Compare commits

..

136 Commits

Author SHA1 Message Date
36173c0880 test contracts 2026-02-26 19:40:00 +03:00
81d62c1345 new test contracts 2026-02-26 19:29:07 +03:00
a8f7147500 test now STANDARD tier 2026-02-26 18:38:26 +03:00
ce684bc5d1 update test data 2026-02-26 18:38:02 +03:00
484019e750 test semantic harden 2026-02-26 18:26:11 +03:00
4ff6d307f8 +ai update 2026-02-26 17:54:23 +03:00
f4612c0737 Improve dashboard LLM validation UX and report flow 2026-02-26 17:53:41 +03:00
5ec1254336 codex specify 2026-02-25 21:19:48 +03:00
b7d1ee2b71 feat(search): add grouped global results for tasks and reports 2026-02-25 21:09:42 +03:00
87285d8f0a feat(search): implement global navbar search for dashboards and datasets 2026-02-25 21:07:51 +03:00
04b01eadb5 fix(ui): use global environment context on datasets page 2026-02-25 20:59:24 +03:00
4d5b9e88dd fix(auth): defer environment context fetch until token is available 2026-02-25 20:58:14 +03:00
4bad4ab4e2 fix(logging): suppress per-request belief scope spam in API client 2026-02-25 20:52:12 +03:00
3801ca13d9 feat(env): add global production context and safety indicators 2026-02-25 20:46:00 +03:00
999c0c54df + git config 2026-02-25 20:27:29 +03:00
f9ac282596 feat: Implement recursive storage listing and directory browsing for backups, and add a migration option to fix cross-filters. 2026-02-25 20:01:33 +03:00
5d42a6b930 i18 cleanup 2026-02-25 18:31:50 +03:00
99f19ac305 { "verdict": "APPROVED", "rejection_reason": "NONE", "audit_details": { "target_invoked": true, "pre_conditions_tested": true, "post_conditions_tested": true, "test_data_used": true }, "feedback": "The test suite robustly verifies the
MigrationEngine
 contracts. It avoids Tautologies by cleanly substituting IdMappingService without mocking the engine itself. Cross-filter parsing asserts against hard-coded, predefined validation dictionaries (no Logic Mirroring). It successfully addresses @PRE negative cases (e.g. invalid zip paths, missing YAMLs) and rigorously validates @POST file transformations (e.g. in-place UUID substitutions and archive reconstruction)." }
2026-02-25 17:47:55 +03:00
590ba49ddb sync worked 2026-02-25 15:20:26 +03:00
2a5b225800 feat: Enhance ID mapping service robustness, add defensive guards, and expand migration engine and API testing. 2026-02-25 14:44:21 +03:00
33433c3173 ready for test 2026-02-25 13:35:09 +03:00
21e969a769 workflow agy update 2026-02-25 13:29:14 +03:00
783644c6ad tasks ready 2026-02-25 13:28:24 +03:00
d32d85556f +md 2026-02-25 10:34:30 +03:00
bc0367ab72 speckit update 2026-02-25 10:31:48 +03:00
1c362f4092 { "verdict": "APPROVED", "rejection_reason": "NONE", "audit_details": { "target_invoked": true, "pre_conditions_tested": true, "post_conditions_tested": true, "test_data_used": true }, "feedback": "Both test files have successfully passed the audit. The 'task_log_viewer.test.js' suite now correctly imports and mounts the real Svelte component using Test Library, fully eliminating the logic mirror/tautology issue. The 'test_logger.py' suite now properly implements negative tests for the @PRE constraint in 'belief_scope' and fully verifies all @POST effects triggered by 'configure_logger'." } 2026-02-24 21:55:13 +03:00
95ae9c6af1 semantic update 2026-02-24 21:08:12 +03:00
7a12ed0931 chore(gitignore): unignore frontend dashboards routes and track pages 2026-02-24 16:16:41 +03:00
e0c0dd3221 fix(validation): respect settings-bound provider and correct multimodal heuristic 2026-02-24 16:04:14 +03:00
5f6e9c0cc0 fix(llm-validation): accept stepfun multimodal models and return 422 on capability mismatch 2026-02-24 16:00:23 +03:00
4fd9d6b6d5 fix(llm): skip unsupported json_object mode for openrouter stepfun models 2026-02-24 14:22:08 +03:00
7e6bd56488 feat(assistant-chat): add animated thinking loader while waiting for response 2026-02-24 14:15:35 +03:00
5e3c213b92 fix(task-drawer): keep drawer above assistant dim overlay 2026-02-24 14:12:34 +03:00
37b75b5a5c fix(task-drawer): render as side column without modal overlay when opened from assistant 2026-02-24 14:09:34 +03:00
3d42a487f7 fix(assistant): resolve dashboard refs via LLM entities and remove deterministic parser fallback 2026-02-24 13:32:25 +03:00
2e93f5ca63 fix(assistant-chat): prevent stale history response from resetting selected conversation 2026-02-24 13:27:09 +03:00
286167b1d5 generate semantic clean up 2026-02-24 12:51:57 +03:00
7df7b4f98c feat(assistant): add multi-dialog UX, task-aware llm settings, and i18n cleanup 2026-02-23 23:45:01 +03:00
ab1c87ffba feat(assistant): add conversations list, infinite history scroll, and archived tab 2026-02-23 20:27:51 +03:00
40e6d8cd4c chat worked 2026-02-23 20:20:25 +03:00
18e96a58bc feat(assistant): implement spec 021 chat assistant flow with semantic contracts 2026-02-23 19:37:56 +03:00
83e4875097 Merge branch '001-unify-frontend-style' into master 2026-02-23 16:06:12 +03:00
e635bd7e5f Add Apache Superset OpenAPI documentation reference to ROOT.md 2026-02-23 16:04:42 +03:00
43dd97ecbf Новый экранчик для обзора дашей 2026-02-23 15:54:20 +03:00
0685f50ae7 Merge branch '020-task-reports-design' into master 2026-02-23 13:28:31 +03:00
d0ffc2f1df Finalize task-020 reports navigation and stability fixes 2026-02-23 13:28:30 +03:00
26880d2e09 semantic update 2026-02-23 13:15:48 +03:00
008b6d72c9 таски готовы 2026-02-23 10:18:56 +03:00
f0c85e4c03 Fix task API stability and Playwright runtime in Docker 2026-02-21 23:43:46 +03:00
6ffdf5f8a4 feat: restore legacy data and add typed task result views 2026-02-21 23:17:56 +03:00
0cf0ef25f1 db + docker 2026-02-20 20:47:39 +03:00
af74841765 semantic update 2026-02-20 10:41:15 +03:00
d7e4919d54 few shots update 2026-02-20 10:26:01 +03:00
fdcbe32dfa css refactor 2026-02-19 18:24:36 +03:00
4de5b22d57 +Svelte specific 2026-02-19 17:47:24 +03:00
c8029ed309 ai base 2026-02-19 17:43:45 +03:00
c2a4c8062a fix tax log 2026-02-19 16:05:59 +03:00
2c820e103a tests ready 2026-02-19 13:33:20 +03:00
c8b84b7bd7 Coder + fix workflow 2026-02-19 13:33:10 +03:00
fdb944f123 Test logic update 2026-02-19 12:44:31 +03:00
d29bc511a2 task panel 2026-02-19 09:43:01 +03:00
a3a9f0788d docs: amend constitution to v2.3.0 (tailwind css first principle) 2026-02-18 18:29:52 +03:00
77147dc95b refactor 2026-02-18 17:29:46 +03:00
026239e3bf fix 2026-02-15 11:11:30 +03:00
4a0273a604 измененные спеки таски 2026-02-10 15:53:38 +03:00
edb2dd5263 updated tasks 2026-02-10 15:04:43 +03:00
76b98fcf8f linter + новые таски 2026-02-10 12:53:01 +03:00
794cc55fe7 Таски готовы 2026-02-09 12:35:27 +03:00
235b0e3c9f semantic update 2026-02-08 22:53:54 +03:00
e6087bd3c1 таски готовы 2026-02-07 12:42:32 +03:00
0f16bab2b8 Похоже работает 2026-02-07 11:26:06 +03:00
7de96c17c4 feat(llm-plugin): switch to environment API for log retrieval
- Replace local backend.log reading with Superset API /log/ fetch
- Update DashboardValidationPlugin to use SupersetClient
- Filter logs by dashboard_id and last 24 hours
- Update spec FR-006 to reflect API usage
2026-02-06 17:57:25 +03:00
f018b97ed2 Semantic protocol update - add UX 2026-01-30 18:53:52 +03:00
72846aa835 tasks ux-reference 2026-01-30 13:35:03 +03:00
994c0c3e5d feat(speckit): integrate ux reference into workflows
Introduce a UX reference stage to ensure technical plans align with
user experience goals. Adds a new template, a generation step in the
specification workflow, and mandatory validation checks during
planning to prevent technical compromises from degrading the defined
user experience.
2026-01-30 12:31:19 +03:00
252a8601a9 Вроде работает 2026-01-30 11:10:16 +03:00
8044f85ea4 tasks and workflow updated 2026-01-29 10:06:28 +03:00
d4109e5a03 docs: amend constitution to v2.0.0 (delegate semantics to protocol + add async/testability principles) 2026-01-28 18:48:43 +03:00
b2bbd73439 tasks ready 2026-01-28 18:30:23 +03:00
0e0e26e2f7 semantic update 2026-01-28 16:57:19 +03:00
18b42f8dd0 semantic protocol condense + script update 2026-01-28 15:49:39 +03:00
e7b31accd6 tested 2026-01-27 23:49:19 +03:00
d3c3a80ed2 Передаем на тест 2026-01-27 16:32:08 +03:00
cc244c2d86 tasks ready 2026-01-27 13:26:06 +03:00
d10c23e658 Обновил gitignore - убрал логи 2026-01-26 22:15:17 +03:00
1042b35d1b Закончили редизайн, обновили интерфейс бэкапа 2026-01-26 22:12:35 +03:00
16ffeb1ed6 Выполнено, передано на тестирование 2026-01-26 21:17:05 +03:00
da34deac02 tasks ready 2026-01-26 20:58:38 +03:00
51e9ee3fcc semantic update 2026-01-26 11:57:36 +03:00
edf9286071 Файловое хранилище готово 2026-01-26 11:08:18 +03:00
a542e7d2df Передаем на тест 2026-01-25 18:33:00 +03:00
a863807cf2 tasks ready 2026-01-24 16:21:43 +03:00
e2bc68683f Update .gitignore 2026-01-24 11:26:19 +03:00
43cb82697b Update backup scheduler task status 2026-01-24 11:26:05 +03:00
4ba28cf93e semantic cleanup 2026-01-23 21:58:32 +03:00
343f2e29f5 Мультиязночность + причесывание css 2026-01-23 17:53:46 +03:00
c9a53578fd tasks ready 2026-01-23 14:56:05 +03:00
07ec2d9797 Работает создание коммитов и перенос в новый enviroment 2026-01-23 13:57:44 +03:00
e9d3f3c827 tasks ready 2026-01-22 23:59:16 +03:00
26ba015b75 +gitignore 2026-01-22 23:25:29 +03:00
49129d3e86 fix error 2026-01-22 23:18:48 +03:00
d99a13d91f refactor complete 2026-01-22 17:37:17 +03:00
203ce446f4 ашч 2026-01-21 14:00:48 +03:00
c96d50a3f4 fix(backend): standardize superset client init and auth
- Update plugins (debug, mapper, search) to explicitly map environment config to SupersetConfig
- Add authenticate method to SupersetClient for explicit session management
- Add get_environment method to ConfigManager
- Fix navbar dropdown hover stability in frontend with invisible bridge
2026-01-20 19:31:17 +03:00
3bbe320949 TaskLog fix 2026-01-19 17:10:43 +03:00
2d2435642d bug fixs 2026-01-19 00:07:06 +03:00
ec8d67c956 bug fixes 2026-01-18 23:21:00 +03:00
76baeb1038 semantic markup update 2026-01-18 21:29:54 +03:00
11c59fb420 semantic checker script update 2026-01-13 17:33:57 +03:00
b2529973eb constitution update 2026-01-13 15:29:42 +03:00
ae1d630ad6 semantics update 2026-01-13 09:11:27 +03:00
9a9c5879e6 tasks.md status 2026-01-12 12:35:45 +03:00
696aac32e7 1st iter 2026-01-12 12:33:51 +03:00
7a9b1a190a tasks ready 2026-01-07 18:59:49 +03:00
a3dc1fb2b9 docs: amend constitution to v1.6.0 (add 'Everything is a Plugin' principle) and refactor 010 plan 2026-01-07 18:36:38 +03:00
297b29986d Product Manager role 2026-01-07 11:39:44 +03:00
4c6fc8256d project map script | semantic parcer 2026-01-01 16:58:21 +03:00
a747a163c8 backup worked 2025-12-30 22:02:51 +03:00
fce0941e98 docs ready 2025-12-30 21:30:37 +03:00
45c077b928 +api rework 2025-12-30 20:08:48 +03:00
9ed3a5992d cleaned 2025-12-30 18:20:40 +03:00
a032fe8457 Password promt 2025-12-30 17:21:12 +03:00
4c9d554432 TaskManager refactor 2025-12-29 10:13:37 +03:00
6962a78112 mappings+migrate 2025-12-27 10:16:41 +03:00
3d75a21127 tech_lead / coder 2roles 2025-12-27 08:02:59 +03:00
07914c8728 semantic add 2025-12-27 07:14:08 +03:00
cddc259b76 new loggers logic in constitution 2025-12-27 06:51:28 +03:00
dcbf0a7d7f tasks ready 2025-12-27 06:37:03 +03:00
65f61c1f80 Merge branch '001-migration-ui-redesign' into master 2025-12-27 05:58:35 +03:00
cb7386f274 superset_tool logger rework 2025-12-27 05:53:30 +03:00
83e34e1799 feat(logging): implement configurable belief state logging
- Add LoggingConfig model and logging field to GlobalSettings
- Implement belief_scope context manager for structured logging
- Add configure_logger for dynamic level and file rotation settings
- Add logging configuration UI to Settings page
- Update ConfigManager to apply logging settings on initialization and updates
2025-12-27 05:39:33 +03:00
d197303b9f 006 plan ready 2025-12-26 19:36:49 +03:00
a43f8fb021 001-migration-ui-redesign (#3)
Reviewed-on: #3
2025-12-26 18:17:58 +03:00
4aa01b6470 Merge branch 'migration' into 001-migration-ui-redesign 2025-12-26 18:16:24 +03:00
35b423979d spec rules 2025-12-25 22:28:42 +03:00
2ffc3cc68f feat(migration): implement interactive mapping resolution workflow
- Add SQLite database integration for environments and mappings
- Update TaskManager to support pausing tasks (AWAITING_MAPPING)
- Modify MigrationPlugin to detect missing mappings and wait for resolution
- Add frontend UI for handling missing mappings interactively
- Create dedicated migration routes and API endpoints
- Update .gitignore and project documentation
2025-12-25 22:27:29 +03:00
285 changed files with 64945 additions and 81832 deletions

View File

@@ -6,7 +6,7 @@ description: Audit AI-generated unit tests. Your goal is to aggressively search
**OBJECTIVE:** Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
**INPUT:**
1. SOURCE CODE (with GRACE-Poly `[DEF]` Contract: `@PRE`, `@POST`, `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`).
1. SOURCE CODE (with GRACE-Poly `[DEF]` Contract: `@PRE`, `@POST`, `@TEST_`).
2. GENERATED TEST CODE.
### I. CRITICAL ANTI-PATTERNS (REJECT IMMEDIATELY IF FOUND):
@@ -17,7 +17,7 @@ description: Audit AI-generated unit tests. Your goal is to aggressively search
2. **The Logic Mirror (Echoing):**
- *Definition:* The test re-implements the exact same algorithmic logic found in the source code to calculate the `expected_result`. If the original logic is flawed, the test will falsely pass.
- *Rule:* Tests must assert against **static, predefined outcomes** (from `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT` or explicit constants), NOT dynamically calculated outcomes using the same logic as the source.
- *Rule:* Tests must assert against **static, predefined outcomes** (from `@TEST_` or explicit constants), NOT dynamically calculated outcomes using the same logic as the source.
3. **The "Happy Path" Illusion:**
- *Definition:* The test suite only checks successful executions but ignores the `@PRE` conditions (Negative Testing).
@@ -26,78 +26,26 @@ description: Audit AI-generated unit tests. Your goal is to aggressively search
4. **Missing Post-Condition Verification:**
- *Definition:* The test calls the function but only checks the return value, ignoring `@SIDE_EFFECT` or `@POST` state changes (e.g., failing to verify that a DB call was made or a Store was updated).
5. **Missing Edge Case Coverage:**
- *Definition:* The test suite ignores `@TEST_EDGE` scenarios defined in the contract.
- *Rule:* Every `@TEST_EDGE` in the source contract MUST have a corresponding test case.
6. **Missing Invariant Verification:**
- *Definition:* The test suite does not verify `@TEST_INVARIANT` conditions.
- *Rule:* Every `@TEST_INVARIANT` MUST be verified by at least one test that attempts to break it.
7. **Missing UX State Testing (Svelte Components):**
- *Definition:* For Svelte components with `@UX_STATE`, the test suite does not verify state transitions.
- *Rule:* Every `@UX_STATE` transition MUST have a test verifying the visual/behavioral change.
- *Check:* `@UX_FEEDBACK` mechanisms (toast, shake, color) must be tested.
- *Check:* `@UX_RECOVERY` mechanisms (retry, clear input) must be tested.
### II. SEMANTIC PROTOCOL COMPLIANCE
Verify the test file follows GRACE-Poly semantics:
1. **Anchor Integrity:**
- Test file MUST start with `[DEF:__tests__/test_name:Module]`
- Test file MUST end with `[/DEF:__tests__/test_name:Module]`
2. **Required Tags:**
- `@RELATION: VERIFIES -> <path_to_source>` must be present
- `@PURPOSE:` must describe what is being tested
3. **TIER Alignment:**
- If source is `@TIER: CRITICAL`, test MUST cover all `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`
- If source is `@TIER: STANDARD`, test MUST cover `@PRE` and `@POST`
- If source is `@TIER: TRIVIAL`, basic smoke test is acceptable
### III. AUDIT CHECKLIST
### II. AUDIT CHECKLIST
Evaluate the test code against these criteria:
1. **Target Invocation:** Does the test actually import and call the function/component declared in the `@RELATION: VERIFIES` tag?
2. **Contract Alignment:** Does the test suite cover 100% of the `@PRE` (negative tests) and `@POST` (assertions) conditions from the source contract?
3. **Test Contract Compliance:** Does the test follow the interface defined in `@TEST_CONTRACT`?
4. **Data Usage:** Does the test use the exact scenarios defined in `@TEST_FIXTURE`?
5. **Edge Coverage:** Are all `@TEST_EDGE` scenarios tested?
6. **Invariant Coverage:** Are all `@TEST_INVARIANT` conditions verified?
7. **UX Coverage (if applicable):** Are all `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tested?
8. **Mocking Sanity:** Are external dependencies mocked correctly WITHOUT mocking the system under test itself?
9. **Semantic Anchor:** Does the test file have proper `[DEF]` and `[/DEF]` anchors?
3. **Data Usage:** Does the test use the exact scenarios defined in `@TEST_`?
4. **Mocking Sanity:** Are external dependencies mocked correctly WITHOUT mocking the system under test itself?
### IV. OUTPUT FORMAT
### III. OUTPUT FORMAT
You MUST respond strictly in the following JSON format. Do not add markdown blocks outside the JSON.
{
"verdict": "APPROVED" | "REJECTED",
"rejection_reason": "TAUTOLOGY" | "LOGIC_MIRROR" | "WEAK_CONTRACT_COVERAGE" | "OVER_MOCKED" | "MISSING_EDGES" | "MISSING_INVARIANTS" | "MISSING_UX_TESTS" | "SEMANTIC_VIOLATION" | "NONE",
"rejection_reason": "TAUTOLOGY" | "LOGIC_MIRROR" | "WEAK_CONTRACT_COVERAGE" | "OVER_MOCKED" | "NONE",
"audit_details": {
"target_invoked": true/false,
"pre_conditions_tested": true/false,
"post_conditions_tested": true/false,
"test_fixture_used": true/false,
"edges_covered": true/false,
"invariants_verified": true/false,
"ux_states_tested": true/false,
"semantic_anchors_present": true/false
},
"coverage_summary": {
"total_edges": number,
"edges_tested": number,
"total_invariants": number,
"invariants_tested": number,
"total_ux_states": number,
"ux_states_tested": number
},
"tier_compliance": {
"source_tier": "CRITICAL" | "STANDARD" | "TRIVIAL",
"meets_tier_requirements": true/false
"test_data_used": true/false
},
"feedback": "Strict, actionable feedback for the test generator agent. Explain exactly which anti-pattern was detected and how to fix it."
}

View File

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

View File

@@ -63,7 +63,6 @@ Load only the minimal necessary context from each artifact:
**From constitution:**
- Load `.ai/standards/constitution.md` for principle validation
- Load `.ai/standards/semantics.md` for technical standard validation
### 3. Build Semantic Models

View File

@@ -53,15 +53,6 @@ You **MUST** consider the user input before proceeding (if not empty).
- **IF EXISTS**: Read research.md for technical decisions and constraints
- **IF EXISTS**: Read quickstart.md for integration scenarios
3. Load and analyze the implementation context:
- **REQUIRED**: Read `.ai/standards/semantics.md` for strict coding standards and contract requirements
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
- **IF EXISTS**: Read research.md for technical decisions and constraints
- **IF EXISTS**: Read quickstart.md for integration scenarios
4. **Project Setup Verification**:
- **REQUIRED**: Create/verify ignore files based on actual project setup:
@@ -120,13 +111,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules:
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules:
- Every file MUST start with a `[DEF:id:Type]` header and end with a closing `[/DEF:id:Type]` anchor.
- Include `@TIER` and define contracts (`@PRE`, `@POST`).
- For Svelte components, use `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY`, and explicitly declare reactivity with `@UX_REATIVITY: State: $state, Derived: $derived`.
- **Molecular Topology Logging**: Use prefixes `[EXPLORE]`, `[REASON]`, `[REFLECT]` in logs to trace logic.
- **CRITICAL Contracts**: If a task description contains a contract summary (e.g., `CRITICAL: PRE: ..., POST: ...`), these constraints are **MANDATORY** and must be strictly implemented in the code using guards/assertions (if applicable per protocol).
- **Setup first**: Initialize project structure, dependencies, configuration
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints
- **Integration work**: Database connections, middleware, logging, external services

View File

@@ -22,7 +22,7 @@ You **MUST** consider the user input before proceeding (if not empty).
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read `.ai/ROOT.md` and `.ai/PROJECT_MAP.md` to understand the project structure and navigation. Then read required standards: `.ai/standards/constitution.md` and `.ai/standards/semantics.md`. Load IMPL_PLAN template.
2. **Load context**: Read FEATURE_SPEC and `.ai/standards/constitution.md`. Load IMPL_PLAN template (already copied).
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
@@ -64,30 +64,16 @@ You **MUST** consider the user input before proceeding (if not empty).
**Prerequisites:** `research.md` complete
0. **Validate Design against UX Reference**:
- Check if the proposed architecture supports the latency, interactivity, and flow defined in `ux_reference.md`.
- **Linkage**: Ensure key UI states from `ux_reference.md` map to Component Contracts (`@UX_STATE`).
- **CRITICAL**: If the technical plan compromises the UX (e.g. "We can't do real-time validation"), you **MUST STOP** and warn the user.
1. **Extract entities from feature spec** → `data-model.md`:
- Entity name, fields, relationships, validation rules.
- Entity name, fields, relationships
- Validation rules from requirements
- State transitions if applicable
2. **Design & Verify Contracts (Semantic Protocol)**:
- **Drafting**: Define `[DEF:id:Type]` Headers, Contracts, and closing `[/DEF:id:Type]` for all new modules based on `.ai/standards/semantics.md`.
- **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module.
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts. **MUST** also define testing contracts: `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, and `@TEST_INVARIANT`.
- **Self-Review**:
- *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research? Are test contracts present for CRITICAL?
- *Connectivity*: Do `@RELATION` tags form a coherent graph?
- *Compliance*: Does syntax match `[DEF:id:Type]` exactly and is it closed with `[/DEF:id:Type]`?
- **Output**: Write verified contracts to `contracts/modules.md`.
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.
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
- Identify what interfaces the project exposes to users or other systems
- Document the contract format appropriate for the project type
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
- Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Agent context update**:
- Run `.specify/scripts/bash/update-agent-context.sh agy`

View File

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

View File

@@ -249,7 +249,6 @@ component/__tests__/Component.test.js
# [DEF:__tests__/test_module:Module]
# @RELATION: VERIFIES -> ../module.py
# @PURPOSE: Contract testing for module
# [/DEF:__tests__/test_module:Module]
```
---

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,28 +3,76 @@
# @SEMANTICS: Finance, ACID, Transfer, Ledger
# @PURPOSE: Core banking transaction processor with ACID guarantees.
# @LAYER: Domain (Core)
# @RELATION: DEPENDS_ON ->[DEF:Infra:PostgresDB]
# @RELATION: DEPENDS_ON -> [DEF:Infra:PostgresDB]
# @RELATION: DEPENDS_ON -> [DEF:Infra:AuditLog]
#
# @INVARIANT: Total system balance must remain constant (Double-Entry Bookkeeping).
# @INVARIANT: Negative transfers are strictly forbidden.
# @INVARIANT: No partial commit must occur under failure (ACID Atomicity).
# --- Test Specifications (The "What" and "Why", not the "Data") ---
# @TEST_CONTRACT: Input -> TransferInputDTO, Output -> TransferResultDTO
# @TEST_CONTRACT: TransferInput ->
# {
# required_fields: {
# sender_id: str,
# receiver_id: str,
# amount: Decimal
# },
# invariants: [
# "amount > 0",
# "sender_id != receiver_id"
# ],
# constraints: [
# "sender must exist",
# "receiver must exist"
# ]
# }
# Happy Path
# @TEST_SCENARIO: sufficient_funds -> Returns COMPLETED, balances updated.
# @TEST_FIXTURE: sufficient_funds -> file:./__tests__/fixtures/transfers.json#happy_path
# @TEST_CONTRACT: TransferResult ->
# {
# required_fields: {
# tx_id: str,
# status: str,
# new_balance: Decimal
# },
# invariants: [
# "status == COMPLETED implies balance mutation occurred"
# ]
# }
# Edge Cases (CRITICAL)
# @TEST_SCENARIO: insufficient_funds -> Throws BusinessRuleViolation("INSUFFICIENT_FUNDS").
# @TEST_SCENARIO: negative_amount -> Throws BusinessRuleViolation("Transfer amount must be positive.").
# @TEST_SCENARIO: self_transfer -> Throws BusinessRuleViolation("Cannot transfer to self.").
# @TEST_SCENARIO: audit_failure -> Throws RuntimeError("TRANSACTION_ABORTED").
# @TEST_SCENARIO: concurrency_conflict -> Throws DBTransactionError.
# @TEST_FIXTURE: sufficient_funds ->
# {
# sender_balance: 500.00,
# receiver_balance: 100.00,
# amount: 100.00
# }
# Linking Tests to Invariants
# @TEST_INVARIANT: total_balance_constant -> VERIFIED_BY: [sufficient_funds, concurrency_conflict]
# @TEST_INVARIANT: negative_transfer_forbidden -> VERIFIED_BY: [negative_amount]
# @TEST_EDGE: insufficient_funds ->
# {
# sender_balance: 50.00,
# receiver_balance: 100.00,
# amount: 100.00
# }
#
# @TEST_EDGE: negative_amount ->
# {
# sender_balance: 500.00,
# receiver_balance: 100.00,
# amount: -10.00
# }
#
# @TEST_EDGE: self_transfer ->
# {
# sender_id: "acc_A",
# receiver_id: "acc_A",
# amount: 10.00
# }
# @TEST_EDGE: audit_failure -> raises Exception
# @TEST_EDGE: concurrency_conflict -> special: concurrent_execution
# @TEST_INVARIANT: total_balance_constant -> verifies: [sufficient_funds, concurrency_conflict]
# @TEST_INVARIANT: no_partial_commit -> verifies: [audit_failure]
# @TEST_INVARIANT: negative_transfer_forbidden -> verifies: [negative_amount]
from decimal import Decimal

View File

@@ -8,7 +8,7 @@
## 1. CORE PRINCIPLES
### I. Semantic Protocol Compliance
* **Ref:** `[DEF:Std:Semantics]` (`ai/standards/semantic.md`)
* **Ref:** `[DEF:Std:Semantics]` (formerly `semantic_protocol.md`)
* **Law:** All code must adhere to the Axioms (Meaning First, Contract First, etc.).
* **Compliance:** Strict matching of Anchors (`[DEF]`), Tags (`@KEY`), and structures is mandatory.

View File

@@ -5,7 +5,6 @@
#### I. ЗАКОН (АКСИОМЫ)
1. Смысл первичен. Код вторичен.
2.Слепота недопустима. Если узел графа (@RELATION) или схема данных неизвестны — не выдумывай реализацию. Остановись и запроси контекст.
2. Контракт (@PRE/@POST) — источник истины.
**3. UX — это логика, а не декор. Состояния интерфейса — часть контракта.**
4. Структура `[DEF]...[/DEF]` — нерушима.
@@ -48,13 +47,11 @@
@PRE: Входные условия.
@POST: Гарантии выхода.
@SIDE_EFFECT: Мутации, IO.
@DATA_CONTRACT: Ссылка на DTO/Pydantic модель. Заменяет ручное описание @PARAM. Формат: Input -> [Model], Output -> [Model].
**UX Теги (Svelte/Frontend):**
**@UX_STATE:** `[StateName] -> Визуальное поведение` (Idle, Loading, Error).
**@UX_FEEDBACK:** Реакция системы (Toast, Shake, Red Border).
**@UX_RECOVERY:** Механизм исправления ошибки пользователем (Retry, Clear Input).
**@UX_REATIVITY:** Явное указание использования рун. Формат: State: $state, Derived: $derived. Никаких устаревших export let.
**UX Testing Tags (для Tester Agent):**
**@UX_TEST:** Спецификация теста для UX состояния.
@@ -66,26 +63,41 @@
#### V. АДАПТАЦИЯ (TIERS)
Определяется тегом `@TIER` в Header.
### V. УРОВНИ СТРОГОСТИ (TIERS)
Степень контроля задается тегом `@TIER` в Header.
1. **CRITICAL** (Core/Security/**Complex UI**):
- Требование: Полный контракт (включая **все @UX теги**), Граф, Инварианты, Строгие Логи.
```
@TEST_CONTRACT: Обязательное описание структуры входных/выходных данных.
Формат:
@TEST_CONTRACT: Name -> {
required_fields: {field: type},
optional_fields: {field: type},
invariants: [...]
}
**1. CRITICAL** (Ядро / Безопасность / Сложный UI)
- **Закон:** Полный GRACE. Граф, Инварианты, Строгий Лог, все `@UX` теги.
- **Догма Тестирования:** Тесты рождаются из контракта. Голый код без данных — слеп.
- `@TEST_CONTRACT: InputType -> OutputType`. (Строгий интерфейс).
- `@TEST_SCENARIO: name -> Ожидаемое поведение`. (Суть теста).
- `@TEST_FIXTURE: name -> file:PATH | INLINE_JSON`. (Данные для Happy Path).
- `@TEST_EDGE: name -> Описание сбоя`. (Минимум 3 границы).
- *Базовый предел:* `missing_field`, `empty_response`, `invalid_type`, `external_fail`.
- `@TEST_INVARIANT: inv_name -> VERIFIED_BY: [scenario_1, ...]`. (Смыкание логики).
- **Исполнение:** Tester Agent обязан строить проверки строго по этим тегам.
@TEST_FIXTURE: Эталонный корректный пример (happy-path).
Формат:
@TEST_FIXTURE: fixture_name -> {INLINE_JSON | PATH#fragment}
**2. STANDARD** (Бизнес-логика / Формы)
- **Закон:** База. (`@PURPOSE`, `@UX_STATE`, Лог, `@RELATION`).
- **Исключение:** Для сложных форм внедряй `@TEST_SCENARIO` и `@TEST_INVARIANT`.
@TEST_EDGE: Граничные случаи (минимум 3 для CRITICAL).
Формат:
@TEST_EDGE: case_name -> {INLINE_JSON | special_case}
**3. TRIVIAL** (DTO / Атомы UI / Утилиты)
- **Закон:** Каркас. Только якорь `[DEF]` и `@PURPOSE`. Данные и графы не требуются.
@TEST_INVARIANT: Обязательно. Связывает тесты с инвариантами.
Формат:
@TEST_INVARIANT: invariant_name -> verifies: [test_case_1, test_case_2]
Обязательные edge-типы для CRITICAL:
- missing_required_field
- empty_response
- invalid_type
- external_failure (exception)
```
- Tester Agent **ОБЯЗАН** использовать @TEST_CONTRACT, @TEST_FIXTURE и @TEST_EDGE при написании тестов для CRITICAL модулей.
2. **STANDARD** (BizLogic/**Forms**):
- Требование: Базовый контракт (@PURPOSE, @UX_STATE), Логи, @RELATION.
- @TEST_INVARIANT, @TEST_CONTRACT: Рекомендуется для Complex Forms.
3. **TRIVIAL** (DTO/**Atoms**):
- Требование: Только Якоря [DEF] и @PURPOSE.
#### VI. ЛОГИРОВАНИЕ (ДАО МОЛЕКУЛЫ / MOLECULAR TOPOLOGY)
Цель: Трассировка. Самокоррекция. Управление Матрицей Внимания ("Химия мышления").
@@ -117,16 +129,10 @@
**Незыблемое правило:** Всякому логу системы — тавро `source`. Для Внешенго Мира (Svelte) начертай рунами вручную: `console.log("[ID][REFLECT] Msg")`.
#### VIII. АЛГОРИТМ ГЕНЕРАЦИИ И ВЫХОД ИЗ ТУПИКА
1. АНАЛИЗ. Оцени TIER, слой и UX-требования. Чего не хватает? Запроси `[NEED_CONTEXT: id]`.
#### VII. АЛГОРИТМ ГЕНЕРАЦИИ
1. АНАЛИЗ. Оцени TIER, слой и UX-требования.
2. КАРКАС. Создай `[DEF]`, Header и Контракты.
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту (и UX-состояниям). Орошай путь логами `[REASON]` и `[REFLECT]`.
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту (и UX-состояниям).
4. ЗАМЫКАНИЕ. Закрой все `[/DEF]`.
**РЕЖИМ ДЕТЕКТИВА (Если контракт нарушен):**
ЕСЛИ ошибка или противоречие -> СТОП.
1. Выведи `[COHERENCE_CHECK_FAILED]`.
2. Сформулируй гипотезу: `[EXPLORE] Ошибка в I/O, состоянии или зависимости?`
3. Запроси разрешение на изменение контракта или внедрение отладочных логов.
ЕСЛИ ошибка или противоречие -> СТОП. Выведи `[COHERENCE_CHECK_FAILED]`.

View File

@@ -6,7 +6,7 @@ description: Audit AI-generated unit tests. Your goal is to aggressively search
**OBJECTIVE:** Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
**INPUT:**
1. SOURCE CODE (with GRACE-Poly `[DEF]` Contract: `@PRE`, `@POST`, `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`).
1. SOURCE CODE (with GRACE-Poly `[DEF]` Contract: `@PRE`, `@POST`, `@TEST_`).
2. GENERATED TEST CODE.
### I. CRITICAL ANTI-PATTERNS (REJECT IMMEDIATELY IF FOUND):
@@ -17,7 +17,7 @@ description: Audit AI-generated unit tests. Your goal is to aggressively search
2. **The Logic Mirror (Echoing):**
- *Definition:* The test re-implements the exact same algorithmic logic found in the source code to calculate the `expected_result`. If the original logic is flawed, the test will falsely pass.
- *Rule:* Tests must assert against **static, predefined outcomes** (from `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT` or explicit constants), NOT dynamically calculated outcomes using the same logic as the source.
- *Rule:* Tests must assert against **static, predefined outcomes** (from `@TEST_` or explicit constants), NOT dynamically calculated outcomes using the same logic as the source.
3. **The "Happy Path" Illusion:**
- *Definition:* The test suite only checks successful executions but ignores the `@PRE` conditions (Negative Testing).
@@ -26,78 +26,26 @@ description: Audit AI-generated unit tests. Your goal is to aggressively search
4. **Missing Post-Condition Verification:**
- *Definition:* The test calls the function but only checks the return value, ignoring `@SIDE_EFFECT` or `@POST` state changes (e.g., failing to verify that a DB call was made or a Store was updated).
5. **Missing Edge Case Coverage:**
- *Definition:* The test suite ignores `@TEST_EDGE` scenarios defined in the contract.
- *Rule:* Every `@TEST_EDGE` in the source contract MUST have a corresponding test case.
6. **Missing Invariant Verification:**
- *Definition:* The test suite does not verify `@TEST_INVARIANT` conditions.
- *Rule:* Every `@TEST_INVARIANT` MUST be verified by at least one test that attempts to break it.
7. **Missing UX State Testing (Svelte Components):**
- *Definition:* For Svelte components with `@UX_STATE`, the test suite does not verify state transitions.
- *Rule:* Every `@UX_STATE` transition MUST have a test verifying the visual/behavioral change.
- *Check:* `@UX_FEEDBACK` mechanisms (toast, shake, color) must be tested.
- *Check:* `@UX_RECOVERY` mechanisms (retry, clear input) must be tested.
### II. SEMANTIC PROTOCOL COMPLIANCE
Verify the test file follows GRACE-Poly semantics:
1. **Anchor Integrity:**
- Test file MUST start with `[DEF:__tests__/test_name:Module]`
- Test file MUST end with `[/DEF:__tests__/test_name:Module]`
2. **Required Tags:**
- `@RELATION: VERIFIES -> <path_to_source>` must be present
- `@PURPOSE:` must describe what is being tested
3. **TIER Alignment:**
- If source is `@TIER: CRITICAL`, test MUST cover all `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`
- If source is `@TIER: STANDARD`, test MUST cover `@PRE` and `@POST`
- If source is `@TIER: TRIVIAL`, basic smoke test is acceptable
### III. AUDIT CHECKLIST
### II. AUDIT CHECKLIST
Evaluate the test code against these criteria:
1. **Target Invocation:** Does the test actually import and call the function/component declared in the `@RELATION: VERIFIES` tag?
2. **Contract Alignment:** Does the test suite cover 100% of the `@PRE` (negative tests) and `@POST` (assertions) conditions from the source contract?
3. **Test Contract Compliance:** Does the test follow the interface defined in `@TEST_CONTRACT`?
4. **Data Usage:** Does the test use the exact scenarios defined in `@TEST_FIXTURE`?
5. **Edge Coverage:** Are all `@TEST_EDGE` scenarios tested?
6. **Invariant Coverage:** Are all `@TEST_INVARIANT` conditions verified?
7. **UX Coverage (if applicable):** Are all `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tested?
8. **Mocking Sanity:** Are external dependencies mocked correctly WITHOUT mocking the system under test itself?
9. **Semantic Anchor:** Does the test file have proper `[DEF]` and `[/DEF]` anchors?
3. **Data Usage:** Does the test use the exact scenarios defined in `@TEST_`?
4. **Mocking Sanity:** Are external dependencies mocked correctly WITHOUT mocking the system under test itself?
### IV. OUTPUT FORMAT
### III. OUTPUT FORMAT
You MUST respond strictly in the following JSON format. Do not add markdown blocks outside the JSON.
{
"verdict": "APPROVED" | "REJECTED",
"rejection_reason": "TAUTOLOGY" | "LOGIC_MIRROR" | "WEAK_CONTRACT_COVERAGE" | "OVER_MOCKED" | "MISSING_EDGES" | "MISSING_INVARIANTS" | "MISSING_UX_TESTS" | "SEMANTIC_VIOLATION" | "NONE",
"rejection_reason": "TAUTOLOGY" | "LOGIC_MIRROR" | "WEAK_CONTRACT_COVERAGE" | "OVER_MOCKED" | "NONE",
"audit_details": {
"target_invoked": true/false,
"pre_conditions_tested": true/false,
"post_conditions_tested": true/false,
"test_fixture_used": true/false,
"edges_covered": true/false,
"invariants_verified": true/false,
"ux_states_tested": true/false,
"semantic_anchors_present": true/false
},
"coverage_summary": {
"total_edges": number,
"edges_tested": number,
"total_invariants": number,
"invariants_tested": number,
"total_ux_states": number,
"ux_states_tested": number
},
"tier_compliance": {
"source_tier": "CRITICAL" | "STANDARD" | "TRIVIAL",
"meets_tier_requirements": true/false
"test_data_used": true/false
},
"feedback": "Strict, actionable feedback for the test generator agent. Explain exactly which anti-pattern was detected and how to fix it."
}

View File

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

View File

@@ -63,7 +63,6 @@ Load only the minimal necessary context from each artifact:
**From constitution:**
- Load `.ai/standards/constitution.md` for principle validation
- Load `.ai/standards/semantics.md` for technical standard validation
### 3. Build Semantic Models

View File

@@ -53,15 +53,6 @@ You **MUST** consider the user input before proceeding (if not empty).
- **IF EXISTS**: Read research.md for technical decisions and constraints
- **IF EXISTS**: Read quickstart.md for integration scenarios
3. Load and analyze the implementation context:
- **REQUIRED**: Read `.ai/standards/semantics.md` for strict coding standards and contract requirements
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
- **IF EXISTS**: Read research.md for technical decisions and constraints
- **IF EXISTS**: Read quickstart.md for integration scenarios
4. **Project Setup Verification**:
- **REQUIRED**: Create/verify ignore files based on actual project setup:
@@ -120,13 +111,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules:
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules:
- Every file MUST start with a `[DEF:id:Type]` header and end with a closing `[/DEF:id:Type]` anchor.
- Include `@TIER` and define contracts (`@PRE`, `@POST`).
- For Svelte components, use `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY`, and explicitly declare reactivity with `@UX_REATIVITY: State: $state, Derived: $derived`.
- **Molecular Topology Logging**: Use prefixes `[EXPLORE]`, `[REASON]`, `[REFLECT]` in logs to trace logic.
- **CRITICAL Contracts**: If a task description contains a contract summary (e.g., `CRITICAL: PRE: ..., POST: ...`), these constraints are **MANDATORY** and must be strictly implemented in the code using guards/assertions (if applicable per protocol).
- **Setup first**: Initialize project structure, dependencies, configuration
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints
- **Integration work**: Database connections, middleware, logging, external services

View File

@@ -22,7 +22,7 @@ You **MUST** consider the user input before proceeding (if not empty).
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read `.ai/ROOT.md` and `.ai/PROJECT_MAP.md` to understand the project structure and navigation. Then read required standards: `.ai/standards/constitution.md` and `.ai/standards/semantics.md`. Load IMPL_PLAN template.
2. **Load context**: Read FEATURE_SPEC and `.ai/standards/constitution.md`. Load IMPL_PLAN template (already copied).
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
@@ -64,30 +64,16 @@ You **MUST** consider the user input before proceeding (if not empty).
**Prerequisites:** `research.md` complete
0. **Validate Design against UX Reference**:
- Check if the proposed architecture supports the latency, interactivity, and flow defined in `ux_reference.md`.
- **Linkage**: Ensure key UI states from `ux_reference.md` map to Component Contracts (`@UX_STATE`).
- **CRITICAL**: If the technical plan compromises the UX (e.g. "We can't do real-time validation"), you **MUST STOP** and warn the user.
1. **Extract entities from feature spec** → `data-model.md`:
- Entity name, fields, relationships, validation rules.
- Entity name, fields, relationships
- Validation rules from requirements
- State transitions if applicable
2. **Design & Verify Contracts (Semantic Protocol)**:
- **Drafting**: Define `[DEF:id:Type]` Headers, Contracts, and closing `[/DEF:id:Type]` for all new modules based on `.ai/standards/semantics.md`.
- **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module.
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts. **MUST** also define testing contracts: `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, and `@TEST_INVARIANT`.
- **Self-Review**:
- *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research? Are test contracts present for CRITICAL?
- *Connectivity*: Do `@RELATION` tags form a coherent graph?
- *Compliance*: Does syntax match `[DEF:id:Type]` exactly and is it closed with `[/DEF:id:Type]`?
- **Output**: Write verified contracts to `contracts/modules.md`.
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.
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
- Identify what interfaces the project exposes to users or other systems
- Document the contract format appropriate for the project type
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
- Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Agent context update**:
- Run `.specify/scripts/bash/update-agent-context.sh agy`

View File

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

View File

@@ -20,7 +20,7 @@ Execute full testing cycle: analyze code for testable modules, write tests with
1. **NEVER delete existing tests** - Only update if they fail due to bugs in the test or implementation
2. **NEVER duplicate tests** - Check existing tests first before creating new ones
3. **Use TEST_FIXTURE fixtures** - For CRITICAL tier modules, read @TEST_FIXTURE from semantics header
3. **Use TEST_DATA fixtures** - For CRITICAL tier modules, read @TEST_ from semantics header
4. **Co-location required** - Write tests in `__tests__` directories relative to the code being tested
## Execution Steps
@@ -40,7 +40,7 @@ Determine:
- Identify completed implementation tasks (not test tasks)
- Extract file paths that need tests
**From .ai/standards/semantics.md:**
**From .specify/memory/semantics.md:**
- Read @TIER annotations for modules
- For CRITICAL modules: Read @TEST_ fixtures
@@ -52,8 +52,8 @@ Determine:
Create coverage matrix:
| Module | File | Has Tests | TIER | TEST_FIXTURE Available |
|--------|------|-----------|------|----------------------|
| Module | File | Has Tests | TIER | TEST_DATA Available |
|--------|------|-----------|------|-------------------|
| ... | ... | ... | ... | ... |
### 4. Write Tests (TDD Approach)
@@ -61,7 +61,7 @@ Create coverage matrix:
For each module requiring tests:
1. **Check existing tests**: Scan `__tests__/` for duplicates
2. **Read TEST_FIXTURE**: If CRITICAL tier, read @TEST_FIXTURE from semantic header
2. **Read TEST_DATA**: If CRITICAL tier, read @TEST_ from semantic header
3. **Write test**: Follow co-location strategy
- Python: `src/module/__tests__/test_module.py`
- Svelte: `src/lib/components/__tests__/test_component.test.js`
@@ -102,7 +102,6 @@ describe('Component UX States', () => {
// @UX_RECOVERY: Retry on error
it('should allow retry on error', async () => { ... });
});
// [/DEF:__tests__/test_Component:Module]
```
### 5. Test Documentation
@@ -171,7 +170,7 @@ Generate test execution report:
- [ ] Fix failed tests
- [ ] Add more coverage for [module]
- [ ] Review TEST_FIXTURE fixtures
- [ ] Review TEST_DATA fixtures
```
## Context for Testing

View File

@@ -45,10 +45,6 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
- SQLite task/result persistence (existing task DB), filesystem only for existing artifacts (no new primary store required) (020-task-reports-design)
- Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui` (001-unify-frontend-style)
- N/A (UI styling and component behavior only) (001-unify-frontend-style)
- Python 3.9+ (backend scripts/services), Shell (release tooling) + FastAPI stack (existing backend), ConfigManager, TaskManager, файловые утилиты, internal artifact registries (020-clean-repo-enterprise)
- PostgreSQL (конфигурации/метаданные), filesystem (артефакты дистрибутива, отчёты проверки) (020-clean-repo-enterprise)
- Python 3.9+ (backend), Node.js 18+ + SvelteKit (frontend) + FastAPI, SQLAlchemy, Pydantic, existing auth stack (`get_current_user`), existing dashboards route/service, Svelte runes (`$state`, `$derived`, `$effect`), Tailwind CSS, frontend `api` wrapper (024-user-dashboard-filter)
- Existing auth database (`AUTH_DATABASE_URL`) with a dedicated per-user preference entity (024-user-dashboard-filter)
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
@@ -69,9 +65,9 @@ cd src; pytest; ruff check .
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
## Recent Changes
- 024-user-dashboard-filter: Added Python 3.9+ (backend), Node.js 18+ + SvelteKit (frontend) + FastAPI, SQLAlchemy, Pydantic, existing auth stack (`get_current_user`), existing dashboards route/service, Svelte runes (`$state`, `$derived`, `$effect`), Tailwind CSS, frontend `api` wrapper
- 020-clean-repo-enterprise: Added Python 3.9+ (backend scripts/services), Shell (release tooling) + FastAPI stack (existing backend), ConfigManager, TaskManager, файловые утилиты, internal artifact registries
- 001-unify-frontend-style: Added Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui`
- 020-task-reports-design: Added Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy/Pydantic task models, existing task/websocket stack
- 019-superset-ux-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing)
<!-- MANUAL ADDITIONS START -->

View File

@@ -1,103 +0,0 @@
---
description: Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
---
**ROLE:** Elite Quality Assurance Architect and Red Teamer.
**OBJECTIVE:** Audit AI-generated unit tests. Your goal is to aggressively search for "Test Tautologies", "Logic Echoing", and "Contract Negligence". You are the final gatekeeper. If a test is meaningless, you MUST reject it.
**INPUT:**
1. SOURCE CODE (with GRACE-Poly `[DEF]` Contract: `@PRE`, `@POST`, `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`).
2. GENERATED TEST CODE.
### I. CRITICAL ANTI-PATTERNS (REJECT IMMEDIATELY IF FOUND):
1. **The Tautology (Self-Fulfilling Prophecy):**
- *Definition:* The test asserts hardcoded values against hardcoded values without executing the core business logic, or mocks the actual function being tested.
- *Example of Failure:* `assert 2 + 2 == 4` or mocking the class under test so that it returns exactly what the test asserts.
2. **The Logic Mirror (Echoing):**
- *Definition:* The test re-implements the exact same algorithmic logic found in the source code to calculate the `expected_result`. If the original logic is flawed, the test will falsely pass.
- *Rule:* Tests must assert against **static, predefined outcomes** (from `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT` or explicit constants), NOT dynamically calculated outcomes using the same logic as the source.
3. **The "Happy Path" Illusion:**
- *Definition:* The test suite only checks successful executions but ignores the `@PRE` conditions (Negative Testing).
- *Rule:* Every `@PRE` tag in the source contract MUST have a corresponding test that deliberately violates it and asserts the correct Exception/Error state.
4. **Missing Post-Condition Verification:**
- *Definition:* The test calls the function but only checks the return value, ignoring `@SIDE_EFFECT` or `@POST` state changes (e.g., failing to verify that a DB call was made or a Store was updated).
5. **Missing Edge Case Coverage:**
- *Definition:* The test suite ignores `@TEST_EDGE` scenarios defined in the contract.
- *Rule:* Every `@TEST_EDGE` in the source contract MUST have a corresponding test case.
6. **Missing Invariant Verification:**
- *Definition:* The test suite does not verify `@TEST_INVARIANT` conditions.
- *Rule:* Every `@TEST_INVARIANT` MUST be verified by at least one test that attempts to break it.
7. **Missing UX State Testing (Svelte Components):**
- *Definition:* For Svelte components with `@UX_STATE`, the test suite does not verify state transitions.
- *Rule:* Every `@UX_STATE` transition MUST have a test verifying the visual/behavioral change.
- *Check:* `@UX_FEEDBACK` mechanisms (toast, shake, color) must be tested.
- *Check:* `@UX_RECOVERY` mechanisms (retry, clear input) must be tested.
### II. SEMANTIC PROTOCOL COMPLIANCE
Verify the test file follows GRACE-Poly semantics:
1. **Anchor Integrity:**
- Test file MUST start with `[DEF:__tests__/test_name:Module]`
- Test file MUST end with `[/DEF:__tests__/test_name:Module]`
2. **Required Tags:**
- `@RELATION: VERIFIES -> <path_to_source>` must be present
- `@PURPOSE:` must describe what is being tested
3. **TIER Alignment:**
- If source is `@TIER: CRITICAL`, test MUST cover all `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, `@TEST_INVARIANT`
- If source is `@TIER: STANDARD`, test MUST cover `@PRE` and `@POST`
- If source is `@TIER: TRIVIAL`, basic smoke test is acceptable
### III. AUDIT CHECKLIST
Evaluate the test code against these criteria:
1. **Target Invocation:** Does the test actually import and call the function/component declared in the `@RELATION: VERIFIES` tag?
2. **Contract Alignment:** Does the test suite cover 100% of the `@PRE` (negative tests) and `@POST` (assertions) conditions from the source contract?
3. **Test Contract Compliance:** Does the test follow the interface defined in `@TEST_CONTRACT`?
4. **Data Usage:** Does the test use the exact scenarios defined in `@TEST_FIXTURE`?
5. **Edge Coverage:** Are all `@TEST_EDGE` scenarios tested?
6. **Invariant Coverage:** Are all `@TEST_INVARIANT` conditions verified?
7. **UX Coverage (if applicable):** Are all `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tested?
8. **Mocking Sanity:** Are external dependencies mocked correctly WITHOUT mocking the system under test itself?
9. **Semantic Anchor:** Does the test file have proper `[DEF]` and `[/DEF]` anchors?
### IV. OUTPUT FORMAT
You MUST respond strictly in the following JSON format. Do not add markdown blocks outside the JSON.
{
"verdict": "APPROVED" | "REJECTED",
"rejection_reason": "TAUTOLOGY" | "LOGIC_MIRROR" | "WEAK_CONTRACT_COVERAGE" | "OVER_MOCKED" | "MISSING_EDGES" | "MISSING_INVARIANTS" | "MISSING_UX_TESTS" | "SEMANTIC_VIOLATION" | "NONE",
"audit_details": {
"target_invoked": true/false,
"pre_conditions_tested": true/false,
"post_conditions_tested": true/false,
"test_fixture_used": true/false,
"edges_covered": true/false,
"invariants_verified": true/false,
"ux_states_tested": true/false,
"semantic_anchors_present": true/false
},
"coverage_summary": {
"total_edges": number,
"edges_tested": number,
"total_invariants": number,
"invariants_tested": number,
"total_ux_states": number,
"ux_states_tested": number
},
"tier_compliance": {
"source_tier": "CRITICAL" | "STANDARD" | "TRIVIAL",
"meets_tier_requirements": true/false
},
"feedback": "Strict, actionable feedback for the test generator agent. Explain exactly which anti-pattern was detected and how to fix it."
}

View File

@@ -117,11 +117,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules:
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules:
- Every file MUST start with a `[DEF:id:Type]` header and end with a closing `[/DEF:id:Type]` anchor.
- Include `@TIER` and define contracts (`@PRE`, `@POST`).
- For Svelte components, use `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY`, and explicitly declare reactivity with `@UX_REATIVITY: State: $state, Derived: $derived`.
- **Molecular Topology Logging**: Use prefixes `[EXPLORE]`, `[REASON]`, `[REFLECT]` in logs to trace logic.
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules - every file must start with [DEF] header, include @TIER, and define contracts.
- **CRITICAL Contracts**: If a task description contains a contract summary (e.g., `CRITICAL: PRE: ..., POST: ...`), these constraints are **MANDATORY** and must be strictly implemented in the code using guards/assertions (if applicable per protocol).
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios

View File

@@ -73,13 +73,13 @@ You **MUST** consider the user input before proceeding (if not empty).
- Entity name, fields, relationships, validation rules.
2. **Design & Verify Contracts (Semantic Protocol)**:
- **Drafting**: Define `[DEF:id:Type]` Headers, Contracts, and closing `[/DEF:id:Type]` for all new modules based on `.ai/standards/semantics.md`.
- **Drafting**: Define [DEF] Headers and Contracts for all new modules based on `.ai/standards/semantics.md`.
- **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module.
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts. **MUST** also define testing contracts: `@TEST_CONTRACT`, `@TEST_FIXTURE`, `@TEST_EDGE`, and `@TEST_INVARIANT`.
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts.
- **Self-Review**:
- *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research? Are test contracts present for CRITICAL?
- *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 and is it closed with `[/DEF:id:Type]`?
- *Compliance*: Does syntax match `[DEF:id:Type]` exactly?
- **Output**: Write verified contracts to `contracts/modules.md`.
3. **Simulate Contract Usage**:

View File

@@ -121,8 +121,8 @@ Every task MUST strictly follow this format:
2. **From Contracts (CRITICAL TIER)**:
- Identify components marked as `@TIER: CRITICAL` in `contracts/modules.md`.
- For these components, **MUST** append the summary of `@PRE`, `@POST`, `@UX_STATE`, and test contracts (`@TEST_FIXTURE`, `@TEST_EDGE`) directly to the task description.
- Example: `- [ ] T005 [P] [US1] Implement Auth (CRITICAL: PRE: token exists, POST: returns User, TESTS: 2 edges) in src/auth.py`
- For these components, **MUST** append the summary of `@PRE`, `@POST`, and `@UX_STATE` contracts directly to the task description.
- Example: `- [ ] T005 [P] [US1] Implement Auth (CRITICAL: PRE: token exists, POST: returns User) in src/auth.py`
- Map each contract/endpoint → to the user story it serves
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase

View File

@@ -20,7 +20,7 @@ Execute full testing cycle: analyze code for testable modules, write tests with
1. **NEVER delete existing tests** - Only update if they fail due to bugs in the test or implementation
2. **NEVER duplicate tests** - Check existing tests first before creating new ones
3. **Use TEST_FIXTURE fixtures** - For CRITICAL tier modules, read @TEST_FIXTURE from .ai/standards/semantics.md
3. **Use TEST_DATA fixtures** - For CRITICAL tier modules, read @TEST_DATA from .ai/standards/semantics.md
4. **Co-location required** - Write tests in `__tests__` directories relative to the code being tested
## Execution Steps
@@ -52,8 +52,8 @@ Determine:
Create coverage matrix:
| Module | File | Has Tests | TIER | TEST_FIXTURE Available |
|--------|------|-----------|------|----------------------|
| Module | File | Has Tests | TIER | TEST_DATA Available |
|--------|------|-----------|------|-------------------|
| ... | ... | ... | ... | ... |
### 4. Write Tests (TDD Approach)
@@ -61,7 +61,7 @@ Create coverage matrix:
For each module requiring tests:
1. **Check existing tests**: Scan `__tests__/` for duplicates
2. **Read TEST_FIXTURE**: If CRITICAL tier, read @TEST_FIXTURE from semantics header
2. **Read TEST_DATA**: If CRITICAL tier, read @TEST_ from semantics header
3. **Write test**: Follow co-location strategy
- Python: `src/module/__tests__/test_module.py`
- Svelte: `src/lib/components/__tests__/test_component.test.js`
@@ -102,7 +102,6 @@ describe('Component UX States', () => {
// @UX_RECOVERY: Retry on error
it('should allow retry on error', async () => { ... });
});
// [/DEF:__tests__/test_Component:Module]
```
### 5. Test Documentation
@@ -171,7 +170,7 @@ Generate test execution report:
- [ ] Fix failed tests
- [ ] Add more coverage for [module]
- [ ] Review TEST_FIXTURE fixtures
- [ ] Review TEST_DATA fixtures
```
## Context for Testing

388
README.md
View File

@@ -1,331 +1,143 @@
# ss-tools
**Инструменты автоматизации для Apache Superset: миграция, версионирование, аналитика и управление данными**
Инструменты автоматизации для Apache Superset: миграция, маппинг, хранение артефактов, Git-интеграция, отчеты по задачам и LLM-assistant.
## 📋 О проекте
## Возможности
- Миграция дашбордов и датасетов между окружениями.
- Ручной и полуавтоматический маппинг ресурсов.
- Логи фоновых задач и отчеты о выполнении.
- Локальное хранилище файлов и бэкапов.
- Git-операции по Superset-ассетам через UI.
- Модуль LLM-анализа и assistant API.
- Многопользовательская авторизация (RBAC).
ss-tools — это комплексная платформа для автоматизации работы с Apache Superset, предоставляющая инструменты для миграции дашбордов, управления версиями через Git, LLM-анализа данных и многопользовательского контроля доступа. Система построена на модульной архитектуре с плагинной системой расширений.
## Стек
- Backend: Python, FastAPI, SQLAlchemy, APScheduler.
- Frontend: SvelteKit, Vite, Tailwind CSS.
- База данных: PostgreSQL (основная конфигурация), поддержка миграции с legacy SQLite.
### 🎯 Ключевые возможности
## Структура репозитория
- `backend/` — API, плагины, сервисы, скрипты миграции и тесты.
- `frontend/` — SPA-интерфейс (SvelteKit).
- `docs/` — документация по архитектуре и плагинам.
- `specs/` — спецификации и планы реализации.
- `docker/` и `docker-compose.yml` — контейнеризация.
#### 🔄 Миграция данных
- **Миграция дашбордов и датасетов** между окружениями (dev/staging/prod)
- **Dry-run режим** с детальным анализом рисков и предпросмотром изменений
- **Автоматическое маппинг** баз данных и ресурсов между окружениями
- **Поддержка legacy-данных** с миграцией из SQLite в PostgreSQL
#### 🌿 Git-интеграция
- **Версионирование** дашбордов через Git-репозитории
- **Управление ветками** и коммитами с помощью LLM
- **Деплой** дашбордов из Git в целевые окружения
- **История изменений** с детальным diff
#### 🤖 LLM-аналитика
- **Автоматическая валидация** дашбордов с помощью ИИ
- **Генерация документации** для датасетов
- **Assistant API** для natural language команд
- **Интеллектуальное коммитинг** с подсказками сообщений
#### 📊 Управление и мониторинг
- **Многопользовательская авторизация** (RBAC)
- **Фоновые задачи** с реальным логированием через WebSocket
- **Унифицированные отчеты** по выполненным задачам
- **Хранение артефактов** с политиками retention
- **Аудит логирование** всех действий
#### 🔌 Плагины
- **MigrationPlugin** — миграция дашбордов
- **BackupPlugin** — резервное копирование
- **GitPlugin** — управление версиями
- **LLMAnalysisPlugin** — аналитика и документация
- **MapperPlugin** — маппинг колонок
- **DebugPlugin** — диагностика системы
- **SearchPlugin** — поиск по датасетам
## 🏗️ Архитектура
### Технологический стек
**Backend:**
- Python 3.9+ (FastAPI, SQLAlchemy, APScheduler)
- PostgreSQL (основная БД)
- GitPython для Git-операций
- OpenAI API для LLM-функций
- Playwright для скриншотов
**Frontend:**
- SvelteKit (Svelte 5.x)
- Vite
- Tailwind CSS
- WebSocket для реального логирования
**DevOps:**
- Docker & Docker Compose
- PostgreSQL 16
### Модульная структура
```
ss-tools/
├── backend/ # Backend API
│ ├── src/
│ │ ├── api/ # API маршруты
│ │ ├── core/ # Ядро системы
│ │ │ ├── task_manager/ # Управление задачами
│ │ │ ├── auth/ # Авторизация
│ │ │ ├── migration/ # Миграция данных
│ │ │ └── plugins/ # Плагины
│ │ ├── models/ # Модели данных
│ │ ├── services/ # Бизнес-логика
│ │ └── schemas/ # Pydantic схемы
│ └── tests/ # Тесты
├── frontend/ # SvelteKit приложение
│ ├── src/
│ │ ├── routes/ # Страницы
│ │ ├── lib/
│ │ │ ├── components/ # UI компоненты
│ │ │ ├── stores/ # Svelte stores
│ │ │ └── api/ # API клиент
│ │ └── i18n/ # Мультиязычность
│ └── tests/
├── docker/ # Docker конфигурация
├── docs/ # Документация
└── specs/ # Спецификации
```
## 🚀 Быстрый старт
## Быстрый старт (локально)
### Требования
**Локальная разработка:**
- Python 3.9+
- Node.js 18+
- npm
- 2 GB RAM (минимум)
- 5 GB свободного места
**Docker (рекомендуется):**
- Docker Engine 24+
- Docker Compose v2
- 4 GB RAM (для стабильной работы)
### Установка и запуск
#### Вариант 1: Docker (рекомендуется)
### Запуск backend + frontend одним скриптом
```bash
# Клонирование репозитория
git clone <repository-url>
cd ss-tools
# Запуск всех сервисов
docker compose up --build
# После запуска:
# Frontend: http://localhost:8000
# Backend API: http://localhost:8001
# PostgreSQL: localhost:5432
./run.sh
```
#### Вариант 2: Локально
Что делает `run.sh`:
- проверяет версии Python/npm;
- создает `backend/.venv` (если нет);
- устанавливает `backend/requirements.txt` и `frontend` зависимости;
- запускает backend и frontend параллельно.
Опции:
- `./run.sh --skip-install` — пропустить установку зависимостей.
- `./run.sh --help` — показать справку.
Переменные окружения для локального запуска:
- `BACKEND_PORT` (по умолчанию `8000`)
- `FRONTEND_PORT` (по умолчанию `5173`)
- `POSTGRES_URL`
- `DATABASE_URL`
- `TASKS_DATABASE_URL`
- `AUTH_DATABASE_URL`
## Docker
### Запуск
```bash
docker compose up --build
```
После старта сервисы доступны по адресам:
- Frontend: `http://localhost:8000`
- Backend API: `http://localhost:8001`
- PostgreSQL: `localhost:5432` (`postgres/postgres`, БД `ss_tools`)
### Остановка
```bash
docker compose down
```
### Очистка БД-тома
```bash
docker compose down -v
```
### Альтернативный образ PostgreSQL
Если есть проблемы с pull `postgres:16-alpine`:
```bash
POSTGRES_IMAGE=mirror.gcr.io/library/postgres:16-alpine docker compose up -d db
```
или
```bash
POSTGRES_IMAGE=bitnami/postgresql:latest docker compose up -d db
```
Если порт `5432` занят:
```bash
POSTGRES_HOST_PORT=5433 docker compose up -d db
```
## Разработка
### Ручной запуск сервисов
```bash
# Backend
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python3 -m uvicorn src.app:app --reload --port 8000
```
# Frontend (в новом терминале)
В другом терминале:
```bash
cd frontend
npm install
npm run dev -- --port 5173
```
### Первичная настройка
### Тесты
Backend:
```bash
# Инициализация БД
cd backend
source .venv/bin/activate
python src/scripts/init_auth_db.py
# Создание администратора
python src/scripts/create_admin.py --username admin --password admin
```
## 🏢 Enterprise Clean Deployment (internal-only)
Для разворота в корпоративной сети используйте профиль enterprise clean:
- очищенный дистрибутив без test/demo/load-test данных;
- запрет внешних интернет-источников;
- загрузка ресурсов только с внутренних серверов компании;
- обязательная блокирующая проверка clean/compliance перед выпуском.
### Операционный workflow (CLI/API/TUI)
#### 1) Headless flow через CLI (рекомендуется для CI/CD)
```bash
cd backend
# 1. Регистрация кандидата
.venv/bin/python3 -m src.scripts.clean_release_cli candidate-register \
--candidate-id 2026.03.09-rc1 \
--version 1.0.0 \
--source-snapshot-ref git:release/2026.03.09-rc1 \
--created-by release-operator
# 2. Импорт артефактов
.venv/bin/python3 -m src.scripts.clean_release_cli artifact-import \
--candidate-id 2026.03.09-rc1 \
--artifact-id artifact-001 \
--path backend/dist/package.tar.gz \
--sha256 deadbeef \
--size 1024
# 3. Сборка манифеста
.venv/bin/python3 -m src.scripts.clean_release_cli manifest-build \
--candidate-id 2026.03.09-rc1 \
--created-by release-operator
# 4. Запуск compliance
.venv/bin/python3 -m src.scripts.clean_release_cli compliance-run \
--candidate-id 2026.03.09-rc1 \
--actor release-operator
```
#### 2) API flow (автоматизация через сервисы)
- V2 candidate/artifact/manifest API:
- `POST /api/clean-release/candidates`
- `POST /api/clean-release/candidates/{candidate_id}/artifacts`
- `POST /api/clean-release/candidates/{candidate_id}/manifests`
- `GET /api/clean-release/candidates/{candidate_id}/overview`
- Legacy compatibility API (оставлены для миграции клиентов):
- `POST /api/clean-release/candidates/prepare`
- `POST /api/clean-release/checks`
- `GET /api/clean-release/checks/{check_run_id}`
#### 3) TUI flow (тонкий клиент поверх facade)
```bash
cd /home/busya/dev/ss-tools
./run_clean_tui.sh 2026.03.09-rc1
```
Горячие клавиши:
- `F5`: Run Compliance
- `F6`: Build Manifest
- `F7`: Reset Draft
- `F8`: Approve
- `F9`: Publish
- `F10`: Refresh Overview
Важно: TUI требует валидный TTY. Без TTY запуск отклоняется с инструкцией использовать CLI/API.
Типовые внутренние источники:
- `repo.intra.company.local`
- `artifacts.intra.company.local`
- `pypi.intra.company.local`
Если найден внешний endpoint, выпуск получает статус `BLOCKED` до исправления.
## 📖 Документация
- [Установка и настройка](docs/installation.md)
- [Архитектура системы](docs/architecture.md)
- [Разработка плагинов](docs/plugin_dev.md)
- [API документация](http://localhost:8001/docs)
- [Настройка окружений](docs/settings.md)
## 🧪 Тестирование
```bash
# Backend тесты
cd backend
source .venv/bin/activate
pytest
```
# Frontend тесты
Frontend:
```bash
cd frontend
npm run test
# Запуск конкретного теста
pytest tests/test_auth.py::test_create_user
```
## 🔐 Авторизация
Система поддерживает два метода аутентификации:
1. **Локальная аутентификация** (username/password)
2. **ADFS SSO** (Active Directory Federation Services)
### Управление пользователями и ролями
## Инициализация auth (опционально)
```bash
# Получение списка пользователей
GET /api/admin/users
# Создание пользователя
POST /api/admin/users
{
"username": "newuser",
"email": "user@example.com",
"password": "password123",
"roles": ["analyst"]
}
# Создание роли
POST /api/admin/roles
{
"name": "analyst",
"permissions": ["dashboards:read", "dashboards:write"]
}
```
## 📊 Мониторинг
### Отчеты о задачах
```bash
# Список всех отчетов
GET /api/reports?page=1&page_size=20
# Детали отчета
GET /api/reports/{report_id}
# Фильтры
GET /api/reports?status=failed&task_type=validation&date_from=2024-01-01
```
### Активность
- **Dashboard Hub** — управление дашбордами с Git-статусом
- **Dataset Hub** — управление датасетами с прогрессом маппинга
- **Task Drawer** — мониторинг выполнения фоновых задач
- **Unified Reports** — унифицированные отчеты по всем типам задач
## 🔄 Обновление системы
```bash
# Обновление Docker контейнеров
docker compose pull
docker compose up -d
# Обновление зависимостей Python
cd backend
source .venv/bin/activate
pip install -r requirements.txt --upgrade
# Обновление зависимостей Node.js
cd frontend
npm install
python src/scripts/init_auth_db.py
python src/scripts/create_admin.py --username admin --password admin
```
## Миграция legacy-данных (опционально)
```bash
cd backend
source .venv/bin/activate
PYTHONPATH=. python src/scripts/migrate_sqlite_to_postgres.py --sqlite-path tasks.db
```
## Дополнительная документация
- `docs/plugin_dev.md`
- `docs/settings.md`
- `semantic_protocol.md`

View File

@@ -1,14 +0,0 @@
[
{
"path": "src/main.py",
"category": "core"
},
{
"path": "src/api/routes/clean_release.py",
"category": "core"
},
{
"path": "docs/installation.md",
"category": "docs"
}
]

File diff suppressed because it is too large Load Diff

BIN
backend/mappings.db Normal file

Binary file not shown.

View File

@@ -6,7 +6,7 @@
# @RELATION: DEPENDS_ON -> importlib
# @INVARIANT: Only names listed in __all__ are importable via __getattr__.
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports', 'assistant', 'clean_release', 'profile']
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports', 'assistant']
# [DEF:__getattr__:Function]

View File

@@ -76,15 +76,11 @@ class _FakeTaskManager:
class _FakeConfigManager:
def get_environments(self):
return [
SimpleNamespace(id="dev", name="Development", url="http://dev", credentials_id="dev", username="fakeuser", password="fakepassword"),
SimpleNamespace(id="prod", name="Production", url="http://prod", credentials_id="prod", username="fakeuser", password="fakepassword"),
SimpleNamespace(id="dev", name="Development"),
SimpleNamespace(id="prod", name="Production"),
]
def get_config(self):
return SimpleNamespace(
settings=SimpleNamespace(migration_sync_cron="0 0 * * *"),
environments=self.get_environments()
)
# [/DEF:_FakeConfigManager:Class]
# [DEF:_admin_user:Function]
# @TIER: TRIVIAL
@@ -649,49 +645,5 @@ def test_confirm_nonexistent_id_returns_404():
assert exc.value.status_code == 404
# [DEF:test_migration_with_dry_run_includes_summary:Function]
# @PURPOSE: Migration command with dry run flag must return the dry run summary in confirmation text.
# @PRE: user specifies a migration with --dry-run flag.
# @POST: Response state is needs_confirmation and text contains dry-run summary counts.
def test_migration_with_dry_run_includes_summary(monkeypatch):
import src.core.migration.dry_run_orchestrator as dry_run_module
from unittest.mock import MagicMock
_clear_assistant_state()
task_manager = _FakeTaskManager()
db = _FakeDb()
class _FakeDryRunService:
def run(self, selection, source_client, target_client, db_session):
return {
"summary": {
"dashboards": {"create": 1, "update": 0, "delete": 0},
"charts": {"create": 3, "update": 2, "delete": 1},
"datasets": {"create": 0, "update": 1, "delete": 0}
}
}
monkeypatch.setattr(dry_run_module, "MigrationDryRunService", _FakeDryRunService)
import src.core.superset_client as superset_client_module
monkeypatch.setattr(superset_client_module, "SupersetClient", lambda env: MagicMock())
start = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="миграция с dev на prod для дашборда 10 --dry-run"
),
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
assert start.state == "needs_confirmation"
assert "отчет dry-run: ВКЛ" in start.text
assert "Отчет dry-run:" in start.text
assert "создано новых объектов: 4" in start.text
assert "обновлено: 3" in start.text
assert "удалено: 1" in start.text
# [/DEF:test_migration_with_dry_run_includes_summary:Function]
# [/DEF:test_guarded_operation_confirm_roundtrip:Function]
# [/DEF:backend.src.api.routes.__tests__.test_assistant_api:Module]

View File

@@ -1,157 +0,0 @@
# [DEF:backend.tests.api.routes.test_clean_release_api:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, api, clean-release, checks, reports
# @PURPOSE: Contract tests for clean release checks and reports endpoints.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.api.routes.clean_release
# @INVARIANT: API returns deterministic payload shapes for checks and reports.
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from src.app import app
from src.dependencies import get_clean_release_repository
from src.models.clean_release import (
CleanProfilePolicy,
ProfileType,
ReleaseCandidate,
ReleaseCandidateStatus,
ResourceSourceEntry,
ResourceSourceRegistry,
ComplianceReport,
CheckFinalStatus,
)
from src.services.clean_release.repository import CleanReleaseRepository
def _repo_with_seed_data() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
repo.save_candidate(
ReleaseCandidate(
candidate_id="2026.03.03-rc1",
version="2026.03.03",
profile=ProfileType.ENTERPRISE_CLEAN,
created_at=datetime.now(timezone.utc),
created_by="tester",
source_snapshot_ref="git:abc123",
status=ReleaseCandidateStatus.PREPARED,
)
)
repo.save_registry(
ResourceSourceRegistry(
registry_id="registry-internal-v1",
name="Internal",
entries=[
ResourceSourceEntry(
source_id="src-1",
host="repo.intra.company.local",
protocol="https",
purpose="artifact-repo",
enabled=True,
)
],
updated_at=datetime.now(timezone.utc),
updated_by="tester",
status="active",
)
)
repo.save_policy(
CleanProfilePolicy(
policy_id="policy-enterprise-clean-v1",
policy_version="1.0.0",
active=True,
prohibited_artifact_categories=["test-data"],
required_system_categories=["system-init"],
external_source_forbidden=True,
internal_source_registry_ref="registry-internal-v1",
effective_from=datetime.now(timezone.utc),
profile=ProfileType.ENTERPRISE_CLEAN,
)
)
return repo
def test_start_check_and_get_status_contract():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
start = client.post(
"/api/clean-release/checks",
json={
"candidate_id": "2026.03.03-rc1",
"profile": "enterprise-clean",
"execution_mode": "tui",
"triggered_by": "tester",
},
)
assert start.status_code == 202
payload = start.json()
assert set(["check_run_id", "candidate_id", "status", "started_at"]).issubset(payload.keys())
check_run_id = payload["check_run_id"]
status_resp = client.get(f"/api/clean-release/checks/{check_run_id}")
assert status_resp.status_code == 200
status_payload = status_resp.json()
assert status_payload["check_run_id"] == check_run_id
assert "final_status" in status_payload
assert "checks" in status_payload
finally:
app.dependency_overrides.clear()
def test_get_report_not_found_returns_404():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
resp = client.get("/api/clean-release/reports/unknown-report")
assert resp.status_code == 404
finally:
app.dependency_overrides.clear()
def test_get_report_success():
repo = _repo_with_seed_data()
report = ComplianceReport(
report_id="rep-1",
check_run_id="run-1",
candidate_id="2026.03.03-rc1",
generated_at=datetime.now(timezone.utc),
final_status=CheckFinalStatus.COMPLIANT,
operator_summary="all systems go",
structured_payload_ref="manifest-1",
violations_count=0,
blocking_violations_count=0
)
repo.save_report(report)
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
resp = client.get("/api/clean-release/reports/rep-1")
assert resp.status_code == 200
assert resp.json()["report_id"] == "rep-1"
finally:
app.dependency_overrides.clear()
def test_prepare_candidate_api_success():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
response = client.post(
"/api/clean-release/candidates/prepare",
json={
"candidate_id": "2026.03.03-rc1",
"artifacts": [{"path": "file1.txt", "category": "system-init", "reason": "core"}],
"sources": ["repo.intra.company.local"],
"operator_id": "operator-1",
},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "prepared"
assert "manifest_id" in data
finally:
app.dependency_overrides.clear()

View File

@@ -1,165 +0,0 @@
# [DEF:backend.src.api.routes.__tests__.test_clean_release_legacy_compat:Module]
# @TIER: STANDARD
# @PURPOSE: Compatibility tests for legacy clean-release API paths retained during v2 migration.
# @LAYER: Tests
# @RELATION: TESTS -> backend.src.api.routes.clean_release
from __future__ import annotations
import os
from datetime import datetime, timezone
from fastapi.testclient import TestClient
os.environ.setdefault("DATABASE_URL", "sqlite:///./test_clean_release_legacy_compat.db")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///./test_clean_release_legacy_auth.db")
from src.app import app
from src.dependencies import get_clean_release_repository
from src.models.clean_release import (
CleanProfilePolicy,
DistributionManifest,
ProfileType,
ReleaseCandidate,
ReleaseCandidateStatus,
ResourceSourceEntry,
ResourceSourceRegistry,
)
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:_seed_legacy_repo:Function]
# @PURPOSE: Seed in-memory repository with minimum trusted data for legacy endpoint contracts.
# @PRE: Repository is empty.
# @POST: Candidate, policy, registry and manifest are available for legacy checks flow.
def _seed_legacy_repo() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
now = datetime.now(timezone.utc)
repo.save_candidate(
ReleaseCandidate(
id="legacy-rc-001",
version="1.0.0",
source_snapshot_ref="git:legacy-001",
created_at=now,
created_by="compat-tester",
status=ReleaseCandidateStatus.DRAFT,
)
)
registry = ResourceSourceRegistry(
registry_id="legacy-reg-1",
name="Legacy Internal Registry",
entries=[
ResourceSourceEntry(
source_id="legacy-src-1",
host="repo.intra.company.local",
protocol="https",
purpose="artifact-repo",
enabled=True,
)
],
updated_at=now,
updated_by="compat-tester",
status="ACTIVE",
)
setattr(registry, "immutable", True)
setattr(registry, "allowed_hosts", ["repo.intra.company.local"])
setattr(registry, "allowed_schemes", ["https"])
setattr(registry, "allowed_source_types", ["artifact-repo"])
repo.save_registry(registry)
policy = CleanProfilePolicy(
policy_id="legacy-pol-1",
policy_version="1.0.0",
profile=ProfileType.ENTERPRISE_CLEAN,
active=True,
internal_source_registry_ref="legacy-reg-1",
prohibited_artifact_categories=["test-data"],
required_system_categories=["core"],
effective_from=now,
)
setattr(policy, "immutable", True)
setattr(
policy,
"content_json",
{
"profile": "enterprise-clean",
"prohibited_artifact_categories": ["test-data"],
"required_system_categories": ["core"],
"external_source_forbidden": True,
},
)
repo.save_policy(policy)
repo.save_manifest(
DistributionManifest(
id="legacy-manifest-1",
candidate_id="legacy-rc-001",
manifest_version=1,
manifest_digest="sha256:legacy-manifest",
artifacts_digest="sha256:legacy-artifacts",
created_at=now,
created_by="compat-tester",
source_snapshot_ref="git:legacy-001",
content_json={"items": [], "summary": {"included_count": 0, "prohibited_detected_count": 0}},
immutable=True,
)
)
return repo
# [/DEF:_seed_legacy_repo:Function]
def test_legacy_prepare_endpoint_still_available() -> None:
repo = _seed_legacy_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
response = client.post(
"/api/clean-release/candidates/prepare",
json={
"candidate_id": "legacy-rc-001",
"artifacts": [{"path": "src/main.py", "category": "core", "reason": "required"}],
"sources": ["repo.intra.company.local"],
"operator_id": "compat-tester",
},
)
assert response.status_code == 200
payload = response.json()
assert "status" in payload
assert payload["status"] in {"prepared", "blocked", "PREPARED", "BLOCKED"}
finally:
app.dependency_overrides.clear()
def test_legacy_checks_endpoints_still_available() -> None:
repo = _seed_legacy_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
start_response = client.post(
"/api/clean-release/checks",
json={
"candidate_id": "legacy-rc-001",
"profile": "enterprise-clean",
"execution_mode": "api",
"triggered_by": "compat-tester",
},
)
assert start_response.status_code == 202
start_payload = start_response.json()
assert "check_run_id" in start_payload
assert start_payload["candidate_id"] == "legacy-rc-001"
status_response = client.get(f"/api/clean-release/checks/{start_payload['check_run_id']}")
assert status_response.status_code == 200
status_payload = status_response.json()
assert status_payload["check_run_id"] == start_payload["check_run_id"]
assert "final_status" in status_payload
assert "checks" in status_payload
finally:
app.dependency_overrides.clear()
# [/DEF:backend.src.api.routes.__tests__.test_clean_release_legacy_compat:Module]

View File

@@ -1,97 +0,0 @@
# [DEF:backend.tests.api.routes.test_clean_release_source_policy:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, api, clean-release, source-policy
# @PURPOSE: Validate API behavior for source isolation violations in clean release preparation.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.api.routes.clean_release
# @INVARIANT: External endpoints must produce blocking violation entries.
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from src.app import app
from src.dependencies import get_clean_release_repository
from src.models.clean_release import (
CleanProfilePolicy,
ProfileType,
ReleaseCandidate,
ReleaseCandidateStatus,
ResourceSourceEntry,
ResourceSourceRegistry,
)
from src.services.clean_release.repository import CleanReleaseRepository
def _repo_with_seed_data() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
repo.save_candidate(
ReleaseCandidate(
candidate_id="2026.03.03-rc1",
version="2026.03.03",
profile=ProfileType.ENTERPRISE_CLEAN,
created_at=datetime.now(timezone.utc),
created_by="tester",
source_snapshot_ref="git:abc123",
status=ReleaseCandidateStatus.DRAFT,
)
)
repo.save_registry(
ResourceSourceRegistry(
registry_id="registry-internal-v1",
name="Internal",
entries=[
ResourceSourceEntry(
source_id="src-1",
host="repo.intra.company.local",
protocol="https",
purpose="artifact-repo",
enabled=True,
)
],
updated_at=datetime.now(timezone.utc),
updated_by="tester",
status="active",
)
)
repo.save_policy(
CleanProfilePolicy(
policy_id="policy-enterprise-clean-v1",
policy_version="1.0.0",
active=True,
prohibited_artifact_categories=["test-data"],
required_system_categories=["system-init"],
external_source_forbidden=True,
internal_source_registry_ref="registry-internal-v1",
effective_from=datetime.now(timezone.utc),
profile=ProfileType.ENTERPRISE_CLEAN,
)
)
return repo
def test_prepare_candidate_blocks_external_source():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
response = client.post(
"/api/clean-release/candidates/prepare",
json={
"candidate_id": "2026.03.03-rc1",
"artifacts": [
{"path": "cfg/system.yaml", "category": "system-init", "reason": "required"}
],
"sources": ["repo.intra.company.local", "pypi.org"],
"operator_id": "release-manager",
},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "blocked"
assert any(v["category"] == "external-source" for v in data["violations"])
finally:
app.dependency_overrides.clear()

View File

@@ -1,93 +0,0 @@
# [DEF:test_clean_release_v2_api:Module]
# @TIER: STANDARD
# @PURPOSE: API contract tests for redesigned clean release endpoints.
# @LAYER: Domain
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from src.app import app
from src.dependencies import get_clean_release_repository, get_config_manager
from src.models.clean_release import (
CleanPolicySnapshot,
DistributionManifest,
ReleaseCandidate,
SourceRegistrySnapshot,
)
from src.services.clean_release.enums import CandidateStatus
client = TestClient(app)
# [REASON] Implementing API contract tests for candidate/artifact/manifest endpoints (T012).
def test_candidate_registration_contract():
"""
@TEST_SCENARIO: candidate_registration -> Should return 201 and candidate DTO.
@TEST_CONTRACT: POST /api/v2/clean-release/candidates -> CandidateDTO
"""
payload = {
"id": "rc-test-001",
"version": "1.0.0",
"source_snapshot_ref": "git:sha123",
"created_by": "test-user"
}
response = client.post("/api/v2/clean-release/candidates", json=payload)
assert response.status_code == 201
data = response.json()
assert data["id"] == "rc-test-001"
assert data["status"] == CandidateStatus.DRAFT.value
def test_artifact_import_contract():
"""
@TEST_SCENARIO: artifact_import -> Should return 200 and success status.
@TEST_CONTRACT: POST /api/v2/clean-release/candidates/{id}/artifacts -> SuccessDTO
"""
candidate_id = "rc-test-001-art"
bootstrap_candidate = {
"id": candidate_id,
"version": "1.0.0",
"source_snapshot_ref": "git:sha123",
"created_by": "test-user"
}
create_response = client.post("/api/v2/clean-release/candidates", json=bootstrap_candidate)
assert create_response.status_code == 201
payload = {
"artifacts": [
{
"id": "art-1",
"path": "bin/app.exe",
"sha256": "hash123",
"size": 1024
}
]
}
response = client.post(f"/api/v2/clean-release/candidates/{candidate_id}/artifacts", json=payload)
assert response.status_code == 200
assert response.json()["status"] == "success"
def test_manifest_build_contract():
"""
@TEST_SCENARIO: manifest_build -> Should return 201 and manifest DTO.
@TEST_CONTRACT: POST /api/v2/clean-release/candidates/{id}/manifests -> ManifestDTO
"""
candidate_id = "rc-test-001-manifest"
bootstrap_candidate = {
"id": candidate_id,
"version": "1.0.0",
"source_snapshot_ref": "git:sha123",
"created_by": "test-user"
}
create_response = client.post("/api/v2/clean-release/candidates", json=bootstrap_candidate)
assert create_response.status_code == 201
response = client.post(f"/api/v2/clean-release/candidates/{candidate_id}/manifests")
assert response.status_code == 201
data = response.json()
assert "manifest_digest" in data
assert data["candidate_id"] == candidate_id
# [/DEF:test_clean_release_v2_api:Module]

View File

@@ -1,107 +0,0 @@
# [DEF:test_clean_release_v2_release_api:Module]
# @TIER: STANDARD
# @PURPOSE: API contract test scaffolding for clean release approval and publication endpoints.
# @LAYER: Domain
# @RELATION: IMPLEMENTS -> clean_release_v2_release_api_contracts
"""Contract tests for redesigned approval/publication API endpoints."""
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.routes.clean_release_v2 import router as clean_release_v2_router
from src.dependencies import get_clean_release_repository
from src.models.clean_release import ComplianceReport, ReleaseCandidate
from src.services.clean_release.enums import CandidateStatus, ComplianceDecision
test_app = FastAPI()
test_app.include_router(clean_release_v2_router)
client = TestClient(test_app)
def _seed_candidate_and_passed_report() -> tuple[str, str]:
repository = get_clean_release_repository()
candidate_id = f"api-release-candidate-{uuid4()}"
report_id = f"api-release-report-{uuid4()}"
repository.save_candidate(
ReleaseCandidate(
id=candidate_id,
version="1.0.0",
source_snapshot_ref="git:sha-api-release",
created_by="api-test",
created_at=datetime.now(timezone.utc),
status=CandidateStatus.CHECK_PASSED.value,
)
)
repository.save_report(
ComplianceReport(
id=report_id,
run_id=f"run-{uuid4()}",
candidate_id=candidate_id,
final_status=ComplianceDecision.PASSED.value,
summary_json={"operator_summary": "ok", "violations_count": 0, "blocking_violations_count": 0},
generated_at=datetime.now(timezone.utc),
immutable=True,
)
)
return candidate_id, report_id
def test_release_approve_and_publish_revoke_contract() -> None:
"""Contract for approve -> publish -> revoke lifecycle endpoints."""
candidate_id, report_id = _seed_candidate_and_passed_report()
approve_response = client.post(
f"/api/v2/clean-release/candidates/{candidate_id}/approve",
json={"report_id": report_id, "decided_by": "api-test", "comment": "approved"},
)
assert approve_response.status_code == 200
approve_payload = approve_response.json()
assert approve_payload["status"] == "ok"
assert approve_payload["decision"] == "APPROVED"
publish_response = client.post(
f"/api/v2/clean-release/candidates/{candidate_id}/publish",
json={
"report_id": report_id,
"published_by": "api-test",
"target_channel": "stable",
"publication_ref": "rel-api-001",
},
)
assert publish_response.status_code == 200
publish_payload = publish_response.json()
assert publish_payload["status"] == "ok"
assert publish_payload["publication"]["status"] == "ACTIVE"
publication_id = publish_payload["publication"]["id"]
revoke_response = client.post(
f"/api/v2/clean-release/publications/{publication_id}/revoke",
json={"revoked_by": "api-test", "comment": "rollback"},
)
assert revoke_response.status_code == 200
revoke_payload = revoke_response.json()
assert revoke_payload["status"] == "ok"
assert revoke_payload["publication"]["status"] == "REVOKED"
def test_release_reject_contract() -> None:
"""Contract for reject endpoint."""
candidate_id, report_id = _seed_candidate_and_passed_report()
reject_response = client.post(
f"/api/v2/clean-release/candidates/{candidate_id}/reject",
json={"report_id": report_id, "decided_by": "api-test", "comment": "rejected"},
)
assert reject_response.status_code == 200
payload = reject_response.json()
assert payload["status"] == "ok"
assert payload["decision"] == "REJECTED"
# [/DEF:test_clean_release_v2_release_api:Module]

File diff suppressed because it is too large Load Diff

View File

@@ -11,41 +11,6 @@ from unittest.mock import MagicMock, patch, AsyncMock
from fastapi.testclient import TestClient
from src.app import app
from src.api.routes.datasets import DatasetsResponse, DatasetDetailResponse
from src.dependencies import get_current_user, has_permission, get_config_manager, get_task_manager, get_resource_service, get_mapping_service
# Global mock user for get_current_user dependency overrides
mock_user = MagicMock()
mock_user.username = "testuser"
mock_user.roles = []
admin_role = MagicMock()
admin_role.name = "Admin"
mock_user.roles.append(admin_role)
@pytest.fixture(autouse=True)
def mock_deps():
config_manager = MagicMock()
task_manager = MagicMock()
resource_service = MagicMock()
mapping_service = MagicMock()
app.dependency_overrides[get_config_manager] = lambda: config_manager
app.dependency_overrides[get_task_manager] = lambda: task_manager
app.dependency_overrides[get_resource_service] = lambda: resource_service
app.dependency_overrides[get_mapping_service] = lambda: mapping_service
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[has_permission("plugin:migration", "READ")] = lambda: mock_user
app.dependency_overrides[has_permission("plugin:migration", "EXECUTE")] = lambda: mock_user
app.dependency_overrides[has_permission("plugin:backup", "EXECUTE")] = lambda: mock_user
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: mock_user
yield {
"config": config_manager,
"task": task_manager,
"resource": resource_service,
"mapping": mapping_service
}
app.dependency_overrides.clear()
client = TestClient(app)
@@ -55,34 +20,41 @@ client = TestClient(app)
# @TEST: GET /api/datasets returns 200 and valid schema
# @PRE: env_id exists
# @POST: Response matches DatasetsResponse schema
def test_get_datasets_success(mock_deps):
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Mock resource service response
mock_deps["resource"].get_datasets_with_status = AsyncMock(
return_value=[
{
"id": 1,
"table_name": "sales_data",
"schema": "public",
"database": "sales_db",
"mapped_fields": {"total": 10, "mapped": 5},
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
}
]
)
def test_get_datasets_success():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.get_resource_service") as mock_service, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock resource service response
mock_service.return_value.get_datasets_with_status.return_value = AsyncMock()(
return_value=[
{
"id": 1,
"table_name": "sales_data",
"schema": "public",
"database": "sales_db",
"mapped_fields": {"total": 10, "mapped": 5},
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
}
]
)
# Mock permission
mock_perm.return_value = lambda: True
response = client.get("/api/datasets?env_id=prod")
assert response.status_code == 200
data = response.json()
assert "datasets" in data
assert len(data["datasets"]) >= 0
# Validate against Pydantic model
DatasetsResponse(**data)
response = client.get("/api/datasets?env_id=prod")
assert response.status_code == 200
data = response.json()
assert "datasets" in data
assert len(data["datasets"]) >= 0
# Validate against Pydantic model
DatasetsResponse(**data)
# [/DEF:test_get_datasets_success:Function]
@@ -92,13 +64,17 @@ def test_get_datasets_success(mock_deps):
# @TEST: GET /api/datasets returns 404 if env_id missing
# @PRE: env_id does not exist
# @POST: Returns 404 error
def test_get_datasets_env_not_found(mock_deps):
mock_deps["config"].get_environments.return_value = []
def test_get_datasets_env_not_found():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
mock_config.return_value.get_environments.return_value = []
mock_perm.return_value = lambda: True
response = client.get("/api/datasets?env_id=nonexistent")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
response = client.get("/api/datasets?env_id=nonexistent")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_get_datasets_env_not_found:Function]
@@ -108,25 +84,24 @@ def test_get_datasets_env_not_found(mock_deps):
# @TEST: GET /api/datasets returns 400 for invalid page/page_size
# @PRE: page < 1 or page_size > 100
# @POST: Returns 400 error
def test_get_datasets_invalid_pagination(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
def test_get_datasets_invalid_pagination():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
mock_perm.return_value = lambda: True
# Invalid page
response = client.get("/api/datasets?env_id=prod&page=0")
assert response.status_code == 400
assert "Page must be >= 1" in response.json()["detail"]
# Invalid page_size (too small)
response = client.get("/api/datasets?env_id=prod&page_size=0")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# @TEST_EDGE: page_size > 100 exceeds max
response = client.get("/api/datasets?env_id=prod&page_size=101")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# Invalid page
response = client.get("/api/datasets?env_id=prod&page=0")
assert response.status_code == 400
assert "Page must be >= 1" in response.json()["detail"]
# Invalid page_size
response = client.get("/api/datasets?env_id=prod&page_size=0")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# [/DEF:test_get_datasets_invalid_pagination:Function]
@@ -136,31 +111,36 @@ def test_get_datasets_invalid_pagination(mock_deps):
# @TEST: POST /api/datasets/map-columns creates mapping task
# @PRE: Valid env_id, dataset_ids, source_type
# @POST: Returns task_id
def test_map_columns_success(mock_deps):
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-123"
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
def test_map_columns_success():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-123"
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
# Mock permission
mock_perm.return_value = lambda: True
response = client.post(
"/api/datasets/map-columns",
json={
"env_id": "prod",
"dataset_ids": [1, 2, 3],
"source_type": "postgresql"
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once()
response = client.post(
"/api/datasets/map-columns",
json={
"env_id": "prod",
"dataset_ids": [1, 2, 3],
"source_type": "postgresql"
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# [/DEF:test_map_columns_success:Function]
@@ -170,18 +150,21 @@ def test_map_columns_success(mock_deps):
# @TEST: POST /api/datasets/map-columns returns 400 for invalid source_type
# @PRE: source_type is not 'postgresql' or 'xlsx'
# @POST: Returns 400 error
def test_map_columns_invalid_source_type(mock_deps):
response = client.post(
"/api/datasets/map-columns",
json={
"env_id": "prod",
"dataset_ids": [1],
"source_type": "invalid"
}
)
assert response.status_code == 400
assert "Source type must be 'postgresql' or 'xlsx'" in response.json()["detail"]
def test_map_columns_invalid_source_type():
with patch("src.api.routes.datasets.has_permission") as mock_perm:
mock_perm.return_value = lambda: True
response = client.post(
"/api/datasets/map-columns",
json={
"env_id": "prod",
"dataset_ids": [1],
"source_type": "invalid"
}
)
assert response.status_code == 400
assert "Source type must be 'postgresql' or 'xlsx'" in response.json()["detail"]
# [/DEF:test_map_columns_invalid_source_type:Function]
@@ -191,110 +174,39 @@ def test_map_columns_invalid_source_type(mock_deps):
# @TEST: POST /api/datasets/generate-docs creates doc generation task
# @PRE: Valid env_id, dataset_ids, llm_provider
# @POST: Returns task_id
def test_generate_docs_success(mock_deps):
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-456"
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
def test_generate_docs_success():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-456"
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
# Mock permission
mock_perm.return_value = lambda: True
response = client.post(
"/api/datasets/generate-docs",
json={
"env_id": "prod",
"dataset_ids": [1],
"llm_provider": "openai"
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once()
response = client.post(
"/api/datasets/generate-docs",
json={
"env_id": "prod",
"dataset_ids": [1],
"llm_provider": "openai"
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# [/DEF:test_generate_docs_success:Function]
# [DEF:test_map_columns_empty_ids:Function]
# @TEST: POST /api/datasets/map-columns returns 400 for empty dataset_ids
# @PRE: dataset_ids is empty
# @POST: Returns 400 error
def test_map_columns_empty_ids(mock_deps):
"""@PRE: dataset_ids must be non-empty."""
response = client.post(
"/api/datasets/map-columns",
json={
"env_id": "prod",
"dataset_ids": [],
"source_type": "postgresql"
}
)
assert response.status_code == 400
assert "At least one dataset ID must be provided" in response.json()["detail"]
# [/DEF:test_map_columns_empty_ids:Function]
# [DEF:test_generate_docs_empty_ids:Function]
# @TEST: POST /api/datasets/generate-docs returns 400 for empty dataset_ids
# @PRE: dataset_ids is empty
# @POST: Returns 400 error
def test_generate_docs_empty_ids(mock_deps):
"""@PRE: dataset_ids must be non-empty."""
response = client.post(
"/api/datasets/generate-docs",
json={
"env_id": "prod",
"dataset_ids": [],
"llm_provider": "openai"
}
)
assert response.status_code == 400
assert "At least one dataset ID must be provided" in response.json()["detail"]
# [/DEF:test_generate_docs_empty_ids:Function]
# [DEF:test_generate_docs_env_not_found:Function]
# @TEST: POST /api/datasets/generate-docs returns 404 for missing env
# @PRE: env_id does not exist
# @POST: Returns 404 error
def test_generate_docs_env_not_found(mock_deps):
"""@PRE: env_id must be a valid environment."""
mock_deps["config"].get_environments.return_value = []
response = client.post(
"/api/datasets/generate-docs",
json={
"env_id": "ghost",
"dataset_ids": [1],
"llm_provider": "openai"
}
)
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_generate_docs_env_not_found:Function]
# [DEF:test_get_datasets_superset_failure:Function]
# @TEST_EDGE: external_superset_failure -> {status: 503}
def test_get_datasets_superset_failure(mock_deps):
"""@TEST_EDGE: external_superset_failure -> {status: 503}"""
mock_env = MagicMock()
mock_env.id = "bad_conn"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_datasets_with_status = AsyncMock(
side_effect=Exception("Connection refused")
)
response = client.get("/api/datasets?env_id=bad_conn")
assert response.status_code == 503
assert "Failed to fetch datasets" in response.json()["detail"]
# [/DEF:test_get_datasets_superset_failure:Function]
# [/DEF:backend.src.api.routes.__tests__.test_datasets:Module]

View File

@@ -1,310 +0,0 @@
# [DEF:backend.src.api.routes.__tests__.test_git_api:Module]
# @RELATION: VERIFIES -> src.api.routes.git
# @PURPOSE: API tests for Git configurations and repository operations.
import pytest
import asyncio
from unittest.mock import MagicMock
from fastapi import HTTPException
from src.api.routes import git as git_routes
from src.models.git import GitServerConfig, GitProvider, GitStatus, GitRepository
class DbMock:
def __init__(self, data=None):
self._data = data or []
self._deleted = []
self._added = []
def query(self, model):
self._model = model
return self
def filter(self, condition):
# Simplistic mocking for tests, assuming equality checks
for item in self._data:
# We assume condition is an equality expression like GitServerConfig.id == "123"
# It's hard to eval the condition exactly in a mock without complex parsing,
# so we'll just return items where type matches.
pass
return self
def first(self):
for item in self._data:
if hasattr(self, "_model") and isinstance(item, self._model):
return item
return None
def all(self):
return self._data
def add(self, item):
self._added.append(item)
if not hasattr(item, "id") or not item.id:
item.id = "mocked-id"
self._data.append(item)
def delete(self, item):
self._deleted.append(item)
if item in self._data:
self._data.remove(item)
def commit(self):
pass
def refresh(self, item):
if not hasattr(item, "status"):
item.status = GitStatus.CONNECTED
if not hasattr(item, "last_validated"):
item.last_validated = "2026-03-08T00:00:00Z"
def test_get_git_configs_masks_pat():
"""
@PRE: Database session `db` is available.
@POST: Returns a list of all GitServerConfig objects from the database with PAT masked.
"""
db = DbMock([GitServerConfig(
id="config-1", name="Test Server", provider=GitProvider.GITHUB,
url="https://github.com", pat="secret-token",
status=GitStatus.CONNECTED, last_validated="2026-03-08T00:00:00Z"
)])
result = asyncio.run(git_routes.get_git_configs(db=db))
assert len(result) == 1
assert result[0].pat == "********"
assert result[0].name == "Test Server"
def test_create_git_config_persists_config():
"""
@PRE: `config` contains valid GitServerConfigCreate data.
@POST: A new GitServerConfig record is created in the database.
"""
from src.api.routes.git_schemas import GitServerConfigCreate
db = DbMock()
config = GitServerConfigCreate(
name="New Server", provider=GitProvider.GITLAB,
url="https://gitlab.com", pat="new-token",
default_branch="master"
)
result = asyncio.run(git_routes.create_git_config(config=config, db=db))
assert len(db._added) == 1
assert db._added[0].name == "New Server"
assert db._added[0].pat == "new-token"
assert result.name == "New Server"
assert result.pat == "new-token" # Note: route returns unmasked until serialized by FastAPI usually, but in tests schema might catch it or not.
from src.api.routes.git_schemas import GitServerConfigUpdate
def test_update_git_config_modifies_record():
"""
@PRE: `config_id` corresponds to an existing configuration.
@POST: The configuration record is updated in the database, preserving PAT if masked is sent.
"""
existing_config = GitServerConfig(
id="config-1", name="Old Server", provider=GitProvider.GITHUB,
url="https://github.com", pat="old-token",
status=GitStatus.CONNECTED, last_validated="2026-03-08T00:00:00Z"
)
# The monkeypatched query will return existing_config as it's the only one in the list
class SingleConfigDbMock:
def query(self, *args): return self
def filter(self, *args): return self
def first(self): return existing_config
def commit(self): pass
def refresh(self, config): pass
db = SingleConfigDbMock()
update_data = GitServerConfigUpdate(name="Updated Server", pat="********")
result = asyncio.run(git_routes.update_git_config(config_id="config-1", config_update=update_data, db=db))
assert existing_config.name == "Updated Server"
assert existing_config.pat == "old-token" # Ensure PAT is not overwritten with asterisks
assert result.pat == "********"
def test_update_git_config_raises_404_if_not_found():
"""
@PRE: `config_id` corresponds to a missing configuration.
@THROW: HTTPException 404
"""
db = DbMock([]) # Empty db
update_data = GitServerConfigUpdate(name="Updated Server", pat="new-token")
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.update_git_config(config_id="config-1", config_update=update_data, db=db))
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Configuration not found"
def test_delete_git_config_removes_record():
"""
@PRE: `config_id` corresponds to an existing configuration.
@POST: The configuration record is removed from the database.
"""
existing_config = GitServerConfig(id="config-1")
class SingleConfigDbMock:
def query(self, *args): return self
def filter(self, *args): return self
def first(self): return existing_config
def delete(self, config): self.deleted = config
def commit(self): pass
db = SingleConfigDbMock()
result = asyncio.run(git_routes.delete_git_config(config_id="config-1", db=db))
assert db.deleted == existing_config
assert result["status"] == "success"
def test_test_git_config_validates_connection_successfully(monkeypatch):
"""
@PRE: `config` contains provider, url, and pat.
@POST: Returns success if the connection is validated via GitService.
"""
class MockGitService:
async def test_connection(self, provider, url, pat):
return True
monkeypatch.setattr(git_routes, "git_service", MockGitService())
from src.api.routes.git_schemas import GitServerConfigCreate
config = GitServerConfigCreate(
name="Test Server", provider=GitProvider.GITHUB,
url="https://github.com", pat="test-pat"
)
db = DbMock([])
result = asyncio.run(git_routes.test_git_config(config=config, db=db))
assert result["status"] == "success"
def test_test_git_config_fails_validation(monkeypatch):
"""
@PRE: `config` contains provider, url, and pat BUT connection fails.
@THROW: HTTPException 400
"""
class MockGitService:
async def test_connection(self, provider, url, pat):
return False
monkeypatch.setattr(git_routes, "git_service", MockGitService())
from src.api.routes.git_schemas import GitServerConfigCreate
config = GitServerConfigCreate(
name="Test Server", provider=GitProvider.GITHUB,
url="https://github.com", pat="bad-pat"
)
db = DbMock([])
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.test_git_config(config=config, db=db))
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "Connection failed"
def test_list_gitea_repositories_returns_payload(monkeypatch):
"""
@PRE: config_id exists and provider is GITEA.
@POST: Returns repositories visible to PAT user.
"""
class MockGitService:
async def list_gitea_repositories(self, url, pat):
return [{"name": "test-repo", "full_name": "owner/test-repo", "private": True}]
monkeypatch.setattr(git_routes, "git_service", MockGitService())
existing_config = GitServerConfig(
id="config-1", name="Gitea Server", provider=GitProvider.GITEA,
url="https://gitea.local", pat="gitea-token"
)
db = DbMock([existing_config])
result = asyncio.run(git_routes.list_gitea_repositories(config_id="config-1", db=db))
assert len(result) == 1
assert result[0].name == "test-repo"
assert result[0].private is True
def test_list_gitea_repositories_rejects_non_gitea(monkeypatch):
"""
@PRE: config_id exists and provider is NOT GITEA.
@THROW: HTTPException 400
"""
existing_config = GitServerConfig(
id="config-1", name="GitHub Server", provider=GitProvider.GITHUB,
url="https://github.com", pat="token"
)
db = DbMock([existing_config])
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.list_gitea_repositories(config_id="config-1", db=db))
assert exc_info.value.status_code == 400
assert "GITEA provider only" in exc_info.value.detail
def test_create_remote_repository_creates_provider_repo(monkeypatch):
"""
@PRE: config_id exists and PAT has creation permissions.
@POST: Returns normalized remote repository payload.
"""
class MockGitService:
async def create_gitlab_repository(self, server_url, pat, name, private, description, auto_init, default_branch):
return {
"name": name,
"full_name": f"user/{name}",
"private": private,
"clone_url": f"{server_url}/user/{name}.git"
}
monkeypatch.setattr(git_routes, "git_service", MockGitService())
from src.api.routes.git_schemas import RemoteRepoCreateRequest
existing_config = GitServerConfig(
id="config-1", name="GitLab Server", provider=GitProvider.GITLAB,
url="https://gitlab.com", pat="token"
)
db = DbMock([existing_config])
request = RemoteRepoCreateRequest(name="new-repo", private=True, description="desc")
result = asyncio.run(git_routes.create_remote_repository(config_id="config-1", request=request, db=db))
assert result.provider == GitProvider.GITLAB
assert result.name == "new-repo"
assert result.full_name == "user/new-repo"
def test_init_repository_initializes_and_saves_binding(monkeypatch):
"""
@PRE: `dashboard_ref` 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.
"""
from src.api.routes.git_schemas import RepoInitRequest
class MockGitService:
def init_repo(self, dashboard_id, remote_url, pat, repo_key, default_branch):
self.init_called = True
def _get_repo_path(self, dashboard_id, repo_key):
return f"/tmp/repos/{repo_key}"
git_service_mock = MockGitService()
monkeypatch.setattr(git_routes, "git_service", git_service_mock)
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *args, **kwargs: 123)
monkeypatch.setattr(git_routes, "_resolve_repo_key_from_ref", lambda *args, **kwargs: "dashboard-123")
existing_config = GitServerConfig(
id="config-1", name="GitLab Server", provider=GitProvider.GITLAB,
url="https://gitlab.com", pat="token", default_branch="main"
)
db = DbMock([existing_config])
init_data = RepoInitRequest(config_id="config-1", remote_url="https://git.local/repo.git")
result = asyncio.run(git_routes.init_repository(dashboard_ref="123", init_data=init_data, config_manager=MagicMock(), db=db))
assert result["status"] == "success"
assert git_service_mock.init_called is True
assert len(db._added) == 1
assert isinstance(db._added[0], GitRepository)
assert db._added[0].dashboard_id == 123
# [/DEF:backend.src.api.routes.__tests__.test_git_api:Module]

View File

@@ -1,440 +0,0 @@
# [DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, git, api, status, no_repo
# @PURPOSE: Validate status endpoint behavior for missing and error repository states.
# @LAYER: Domain (Tests)
# @RELATION: CALLS -> src.api.routes.git.get_repository_status
from fastapi import HTTPException
import pytest
import asyncio
from unittest.mock import MagicMock
from src.api.routes import git as git_routes
# [DEF:test_get_repository_status_returns_no_repo_payload_for_missing_repo:Function]
# @PURPOSE: Ensure missing local repository is represented as NO_REPO payload instead of an API error.
# @PRE: GitService.get_status raises HTTPException(404).
# @POST: Route returns a deterministic NO_REPO status payload.
def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypatch):
class MissingRepoGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/missing-repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
raise AssertionError("get_status must not be called when repository path is missing")
monkeypatch.setattr(git_routes, "git_service", MissingRepoGitService())
response = asyncio.run(git_routes.get_repository_status(34))
assert response["sync_status"] == "NO_REPO"
assert response["sync_state"] == "NO_REPO"
assert response["has_repo"] is False
assert response["current_branch"] is None
# [/DEF:test_get_repository_status_returns_no_repo_payload_for_missing_repo:Function]
# [DEF:test_get_repository_status_propagates_non_404_http_exception:Function]
# @PURPOSE: Ensure HTTP exceptions other than 404 are not masked.
# @PRE: GitService.get_status raises HTTPException with non-404 status.
# @POST: Raised exception preserves original status and detail.
def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
class ConflictGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/existing-repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
raise HTTPException(status_code=409, detail="Conflict")
monkeypatch.setattr(git_routes, "git_service", ConflictGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.get_repository_status(34))
assert exc_info.value.status_code == 409
assert exc_info.value.detail == "Conflict"
# [/DEF:test_get_repository_status_propagates_non_404_http_exception:Function]
# [DEF:test_get_repository_diff_propagates_http_exception:Function]
# @PURPOSE: Ensure diff endpoint preserves domain HTTP errors from GitService.
# @PRE: GitService.get_diff raises HTTPException.
# @POST: Endpoint raises same HTTPException values.
def test_get_repository_diff_propagates_http_exception(monkeypatch):
class DiffGitService:
def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str:
raise HTTPException(status_code=404, detail="Repository missing")
monkeypatch.setattr(git_routes, "git_service", DiffGitService())
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.get_repository_diff(12))
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Repository missing"
# [/DEF:test_get_repository_diff_propagates_http_exception:Function]
# [DEF:test_get_history_wraps_unexpected_error_as_500:Function]
# @PURPOSE: Ensure non-HTTP exceptions in history endpoint become deterministic 500 errors.
# @PRE: GitService.get_commit_history raises ValueError.
# @POST: Endpoint returns HTTPException with status 500 and route context.
def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
class HistoryGitService:
def get_commit_history(self, dashboard_id: int, limit: int = 50):
raise ValueError("broken parser")
monkeypatch.setattr(git_routes, "git_service", HistoryGitService())
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.get_history(12))
assert exc_info.value.status_code == 500
assert exc_info.value.detail == "get_history failed: broken parser"
# [/DEF:test_get_history_wraps_unexpected_error_as_500:Function]
# [DEF:test_commit_changes_wraps_unexpected_error_as_500:Function]
# @PURPOSE: Ensure commit endpoint does not leak unexpected errors as 400.
# @PRE: GitService.commit_changes raises RuntimeError.
# @POST: Endpoint raises HTTPException(500) with route context.
def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
class CommitGitService:
def commit_changes(self, dashboard_id: int, message: str, files):
raise RuntimeError("index lock")
class CommitPayload:
message = "test"
files = ["dashboards/a.yaml"]
monkeypatch.setattr(git_routes, "git_service", CommitGitService())
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.commit_changes(12, CommitPayload()))
assert exc_info.value.status_code == 500
assert exc_info.value.detail == "commit_changes failed: index lock"
# [/DEF:test_commit_changes_wraps_unexpected_error_as_500:Function]
# [DEF:test_get_repository_status_batch_returns_mixed_statuses:Function]
# @PURPOSE: Ensure batch endpoint returns per-dashboard statuses in one response.
# @PRE: Some repositories are missing and some are initialized.
# @POST: Returned map includes resolved status for each requested dashboard ID.
def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
class BatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
if dashboard_id == 2:
return {"sync_state": "SYNCED", "sync_status": "OK"}
raise HTTPException(status_code=404, detail="not found")
monkeypatch.setattr(git_routes, "git_service", BatchGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda path: path.endswith("/repo-2"))
class BatchRequest:
dashboard_ids = [1, 2]
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
assert response.statuses["1"]["sync_status"] == "NO_REPO"
assert response.statuses["2"]["sync_state"] == "SYNCED"
# [/DEF:test_get_repository_status_batch_returns_mixed_statuses:Function]
# [DEF:test_get_repository_status_batch_marks_item_as_error_on_service_failure:Function]
# @PURPOSE: Ensure batch endpoint marks failed items as ERROR without failing entire request.
# @PRE: GitService raises non-HTTP exception for one dashboard.
# @POST: Failed dashboard status is marked as ERROR.
def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monkeypatch):
class BatchErrorGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
raise RuntimeError("boom")
monkeypatch.setattr(git_routes, "git_service", BatchErrorGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
class BatchRequest:
dashboard_ids = [9]
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
assert response.statuses["9"]["sync_status"] == "ERROR"
assert response.statuses["9"]["sync_state"] == "ERROR"
# [/DEF:test_get_repository_status_batch_marks_item_as_error_on_service_failure:Function]
# [DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
# @PURPOSE: Ensure batch endpoint protects server from oversized payloads.
# @PRE: request includes duplicate IDs and more than MAX_REPOSITORY_STATUS_BATCH entries.
# @POST: Result contains unique IDs up to configured cap.
def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch):
class SafeBatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
return {"sync_state": "SYNCED", "sync_status": "OK"}
monkeypatch.setattr(git_routes, "git_service", SafeBatchGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
class BatchRequest:
dashboard_ids = [1, 1] + list(range(2, 90))
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
assert len(response.statuses) == git_routes.MAX_REPOSITORY_STATUS_BATCH
assert "1" in response.statuses
# [/DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
# [DEF:test_commit_changes_applies_profile_identity_before_commit:Function]
# @PURPOSE: Ensure commit route configures repository identity from profile preferences before commit call.
# @PRE: Profile preference contains git_username/git_email for current user.
# @POST: git_service.configure_identity receives resolved identity and commit proceeds.
def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
class IdentityGitService:
def __init__(self):
self.configured_identity = None
self.commit_payload = None
def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email)
def commit_changes(self, dashboard_id: int, message: str, files):
self.commit_payload = (dashboard_id, message, files)
class PreferenceRow:
git_username = "user_1"
git_email = "user1@mail.ru"
class PreferenceQuery:
def filter(self, *_args, **_kwargs):
return self
def first(self):
return PreferenceRow()
class DbStub:
def query(self, _model):
return PreferenceQuery()
class UserStub:
id = "u-1"
class CommitPayload:
message = "test"
files = ["dashboards/a.yaml"]
identity_service = IdentityGitService()
monkeypatch.setattr(git_routes, "git_service", identity_service)
monkeypatch.setattr(
git_routes,
"_resolve_dashboard_id_from_ref",
lambda *_args, **_kwargs: 12,
)
asyncio.run(
git_routes.commit_changes(
"dashboard-12",
CommitPayload(),
config_manager=MagicMock(),
db=DbStub(),
current_user=UserStub(),
)
)
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
assert identity_service.commit_payload == (12, "test", ["dashboards/a.yaml"])
# [/DEF:test_commit_changes_applies_profile_identity_before_commit:Function]
# [DEF:test_pull_changes_applies_profile_identity_before_pull:Function]
# @PURPOSE: Ensure pull route configures repository identity from profile preferences before pull call.
# @PRE: Profile preference contains git_username/git_email for current user.
# @POST: git_service.configure_identity receives resolved identity and pull proceeds.
def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
class IdentityGitService:
def __init__(self):
self.configured_identity = None
self.pulled_dashboard_id = None
def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email)
def pull_changes(self, dashboard_id: int):
self.pulled_dashboard_id = dashboard_id
class PreferenceRow:
git_username = "user_1"
git_email = "user1@mail.ru"
class PreferenceQuery:
def filter(self, *_args, **_kwargs):
return self
def first(self):
return PreferenceRow()
class DbStub:
def query(self, _model):
return PreferenceQuery()
class UserStub:
id = "u-1"
identity_service = IdentityGitService()
monkeypatch.setattr(git_routes, "git_service", identity_service)
monkeypatch.setattr(
git_routes,
"_resolve_dashboard_id_from_ref",
lambda *_args, **_kwargs: 12,
)
asyncio.run(
git_routes.pull_changes(
"dashboard-12",
config_manager=MagicMock(),
db=DbStub(),
current_user=UserStub(),
)
)
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
assert identity_service.pulled_dashboard_id == 12
# [/DEF:test_pull_changes_applies_profile_identity_before_pull:Function]
# [DEF:test_get_merge_status_returns_service_payload:Function]
# @PURPOSE: Ensure merge status route returns service payload as-is.
# @PRE: git_service.get_merge_status returns unfinished merge payload.
# @POST: Route response contains has_unfinished_merge=True.
def test_get_merge_status_returns_service_payload(monkeypatch):
class MergeStatusGitService:
def get_merge_status(self, dashboard_id: int) -> dict:
return {
"has_unfinished_merge": True,
"repository_path": "/tmp/repo-12",
"git_dir": "/tmp/repo-12/.git",
"current_branch": "dev",
"merge_head": "abc",
"merge_message_preview": "merge msg",
"conflicts_count": 2,
}
monkeypatch.setattr(git_routes, "git_service", MergeStatusGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
response = asyncio.run(
git_routes.get_merge_status(
"dashboard-12",
config_manager=MagicMock(),
)
)
assert response["has_unfinished_merge"] is True
assert response["conflicts_count"] == 2
# [/DEF:test_get_merge_status_returns_service_payload:Function]
# [DEF:test_resolve_merge_conflicts_passes_resolution_items_to_service:Function]
# @PURPOSE: Ensure merge resolve route forwards parsed resolutions to service.
# @PRE: resolve_data has one file strategy.
# @POST: Service receives normalized list and route returns resolved files.
def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch):
captured = {}
class MergeResolveGitService:
def resolve_merge_conflicts(self, dashboard_id: int, resolutions):
captured["dashboard_id"] = dashboard_id
captured["resolutions"] = resolutions
return ["dashboards/a.yaml"]
class ResolveData:
class _Resolution:
def dict(self):
return {"file_path": "dashboards/a.yaml", "resolution": "mine", "content": None}
resolutions = [_Resolution()]
monkeypatch.setattr(git_routes, "git_service", MergeResolveGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
response = asyncio.run(
git_routes.resolve_merge_conflicts(
"dashboard-12",
ResolveData(),
config_manager=MagicMock(),
)
)
assert captured["dashboard_id"] == 12
assert captured["resolutions"][0]["resolution"] == "mine"
assert response["resolved_files"] == ["dashboards/a.yaml"]
# [/DEF:test_resolve_merge_conflicts_passes_resolution_items_to_service:Function]
# [DEF:test_abort_merge_calls_service_and_returns_result:Function]
# @PURPOSE: Ensure abort route delegates to service.
# @PRE: Service abort_merge returns aborted status.
# @POST: Route returns aborted status.
def test_abort_merge_calls_service_and_returns_result(monkeypatch):
class AbortGitService:
def abort_merge(self, dashboard_id: int):
assert dashboard_id == 12
return {"status": "aborted"}
monkeypatch.setattr(git_routes, "git_service", AbortGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
response = asyncio.run(
git_routes.abort_merge(
"dashboard-12",
config_manager=MagicMock(),
)
)
assert response["status"] == "aborted"
# [/DEF:test_abort_merge_calls_service_and_returns_result:Function]
# [DEF:test_continue_merge_passes_message_and_returns_commit:Function]
# @PURPOSE: Ensure continue route passes commit message to service.
# @PRE: continue_data.message is provided.
# @POST: Route returns committed status and hash.
def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
class ContinueGitService:
def continue_merge(self, dashboard_id: int, message: str):
assert dashboard_id == 12
assert message == "Resolve all conflicts"
return {"status": "committed", "commit_hash": "abc123"}
class ContinueData:
message = "Resolve all conflicts"
monkeypatch.setattr(git_routes, "git_service", ContinueGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
response = asyncio.run(
git_routes.continue_merge(
"dashboard-12",
ContinueData(),
config_manager=MagicMock(),
)
)
assert response["status"] == "committed"
assert response["commit_hash"] == "abc123"
# [/DEF:test_continue_merge_passes_message_and_returns_commit:Function]
# [/DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]

View File

@@ -407,104 +407,4 @@ async def test_execute_migration_invalid_env_raises_400(_mock_env):
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_dry_run_migration_returns_diff_and_risk(db_session):
# @TEST_EDGE: missing_target_datasource -> validates high risk item generation
# @TEST_EDGE: breaking_reference -> validates high risk on missing dataset link
from src.api.routes.migration import dry_run_migration
from src.models.dashboard import DashboardSelection
env_source = MagicMock()
env_source.id = "src"
env_source.name = "Source"
env_source.url = "http://source"
env_source.username = "admin"
env_source.password = "admin"
env_source.verify_ssl = False
env_source.timeout = 30
env_target = MagicMock()
env_target.id = "tgt"
env_target.name = "Target"
env_target.url = "http://target"
env_target.username = "admin"
env_target.password = "admin"
env_target.verify_ssl = False
env_target.timeout = 30
cm = _make_sync_config_manager([env_source, env_target])
selection = DashboardSelection(
selected_ids=[42],
source_env_id="src",
target_env_id="tgt",
replace_db_config=False,
fix_cross_filters=True,
)
with patch("src.api.routes.migration.SupersetClient") as MockClient, \
patch("src.api.routes.migration.MigrationDryRunService") as MockService:
source_client = MagicMock()
target_client = MagicMock()
MockClient.side_effect = [source_client, target_client]
service_instance = MagicMock()
service_payload = {
"generated_at": "2026-02-27T00:00:00+00:00",
"selection": selection.model_dump(),
"selected_dashboard_titles": ["Sales"],
"diff": {
"dashboards": {"create": [], "update": [{"uuid": "dash-1"}], "delete": []},
"charts": {"create": [{"uuid": "chart-1"}], "update": [], "delete": []},
"datasets": {"create": [{"uuid": "dataset-1"}], "update": [], "delete": []},
},
"summary": {
"dashboards": {"create": 0, "update": 1, "delete": 0},
"charts": {"create": 1, "update": 0, "delete": 0},
"datasets": {"create": 1, "update": 0, "delete": 0},
"selected_dashboards": 1,
},
"risk": {
"score": 75,
"level": "high",
"items": [
{"code": "missing_datasource"},
{"code": "breaking_reference"},
],
},
}
service_instance.run.return_value = service_payload
MockService.return_value = service_instance
result = await dry_run_migration(selection=selection, config_manager=cm, db=db_session, _=None)
assert result["summary"]["dashboards"]["update"] == 1
assert result["summary"]["charts"]["create"] == 1
assert result["summary"]["datasets"]["create"] == 1
assert result["risk"]["score"] > 0
assert any(item["code"] == "missing_datasource" for item in result["risk"]["items"])
assert any(item["code"] == "breaking_reference" for item in result["risk"]["items"])
@pytest.mark.asyncio
async def test_dry_run_migration_rejects_same_environment(db_session):
from src.api.routes.migration import dry_run_migration
from src.models.dashboard import DashboardSelection
env = MagicMock()
env.id = "same"
env.name = "Same"
env.url = "http://same"
env.username = "admin"
env.password = "admin"
env.verify_ssl = False
env.timeout = 30
cm = _make_sync_config_manager([env])
selection = DashboardSelection(selected_ids=[1], source_env_id="same", target_env_id="same")
with pytest.raises(HTTPException) as exc:
await dry_run_migration(selection=selection, config_manager=cm, db=db_session, _=None)
assert exc.value.status_code == 400
# [/DEF:backend.src.api.routes.__tests__.test_migration_routes:Module]

View File

@@ -1,293 +0,0 @@
# [DEF:backend.src.api.routes.__tests__.test_profile_api:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, profile, api, preferences, lookup, contract
# @PURPOSE: Verifies profile API route contracts for preference read/update and Superset account lookup.
# @LAYER: API
# @RELATION: TESTS -> backend.src.api.routes.profile
# [SECTION: IMPORTS]
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from src.app import app
from src.core.database import get_db
from src.dependencies import get_config_manager, get_current_user
from src.schemas.profile import (
ProfilePermissionState,
ProfilePreference,
ProfilePreferenceResponse,
ProfileSecuritySummary,
SupersetAccountCandidate,
SupersetAccountLookupResponse,
)
from src.services.profile_service import (
EnvironmentNotFoundError,
ProfileAuthorizationError,
ProfileValidationError,
)
# [/SECTION]
client = TestClient(app)
# [DEF:mock_profile_route_dependencies:Function]
# @PURPOSE: Provides deterministic dependency overrides for profile route tests.
# @PRE: App instance is initialized.
# @POST: Dependencies are overridden for current test and restored afterward.
def mock_profile_route_dependencies():
mock_user = MagicMock()
mock_user.id = "u-1"
mock_user.username = "test-user"
mock_db = MagicMock()
mock_config_manager = MagicMock()
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_db] = lambda: mock_db
app.dependency_overrides[get_config_manager] = lambda: mock_config_manager
return mock_user, mock_db, mock_config_manager
# [/DEF:mock_profile_route_dependencies:Function]
# [DEF:profile_route_deps_fixture:Function]
# @PURPOSE: Pytest fixture wrapper for profile route dependency overrides.
# @PRE: None.
# @POST: Yields overridden dependencies and clears overrides after test.
import pytest
@pytest.fixture(autouse=True)
def profile_route_deps_fixture():
yielded = mock_profile_route_dependencies()
yield yielded
app.dependency_overrides.clear()
# [/DEF:profile_route_deps_fixture:Function]
# [DEF:_build_preference_response:Function]
# @PURPOSE: Builds stable profile preference response payload for route tests.
# @PRE: user_id is provided.
# @POST: Returns ProfilePreferenceResponse object with deterministic timestamps.
def _build_preference_response(user_id: str = "u-1") -> ProfilePreferenceResponse:
now = datetime.now(timezone.utc)
return ProfilePreferenceResponse(
status="success",
message="Preference loaded",
preference=ProfilePreference(
user_id=user_id,
superset_username="John_Doe",
superset_username_normalized="john_doe",
show_only_my_dashboards=True,
git_username="ivan.ivanov",
git_email="ivan@company.local",
has_git_personal_access_token=True,
git_personal_access_token_masked="iv***al",
start_page="reports",
auto_open_task_drawer=False,
dashboards_table_density="compact",
created_at=now,
updated_at=now,
),
security=ProfileSecuritySummary(
read_only=True,
auth_source="adfs",
current_role="Data Engineer",
role_source="adfs",
roles=["Data Engineer"],
permissions=[
ProfilePermissionState(key="migration:run", allowed=True),
ProfilePermissionState(key="admin:users", allowed=False),
],
),
)
# [/DEF:_build_preference_response:Function]
# [DEF:test_get_profile_preferences_returns_self_payload:Function]
# @PURPOSE: Verifies GET /api/profile/preferences returns stable self-scoped payload.
# @PRE: Authenticated user context is available.
# @POST: Response status is 200 and payload contains current user preference.
def test_get_profile_preferences_returns_self_payload(profile_route_deps_fixture):
mock_user, _, _ = profile_route_deps_fixture
service = MagicMock()
service.get_my_preference.return_value = _build_preference_response(user_id=mock_user.id)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.get("/api/profile/preferences")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "success"
assert payload["preference"]["user_id"] == mock_user.id
assert payload["preference"]["superset_username_normalized"] == "john_doe"
assert payload["preference"]["git_username"] == "ivan.ivanov"
assert payload["preference"]["git_email"] == "ivan@company.local"
assert payload["preference"]["has_git_personal_access_token"] is True
assert payload["preference"]["git_personal_access_token_masked"] == "iv***al"
assert payload["preference"]["start_page"] == "reports"
assert payload["preference"]["auto_open_task_drawer"] is False
assert payload["preference"]["dashboards_table_density"] == "compact"
assert payload["security"]["read_only"] is True
assert payload["security"]["current_role"] == "Data Engineer"
assert payload["security"]["permissions"][0]["key"] == "migration:run"
service.get_my_preference.assert_called_once_with(mock_user)
# [/DEF:test_get_profile_preferences_returns_self_payload:Function]
# [DEF:test_patch_profile_preferences_success:Function]
# @PURPOSE: Verifies PATCH /api/profile/preferences persists valid payload through route mapping.
# @PRE: Valid request payload and authenticated user.
# @POST: Response status is 200 with saved preference payload.
def test_patch_profile_preferences_success(profile_route_deps_fixture):
mock_user, _, _ = profile_route_deps_fixture
service = MagicMock()
service.update_my_preference.return_value = _build_preference_response(user_id=mock_user.id)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.patch(
"/api/profile/preferences",
json={
"superset_username": "John_Doe",
"show_only_my_dashboards": True,
"git_username": "ivan.ivanov",
"git_email": "ivan@company.local",
"git_personal_access_token": "ghp_1234567890",
"start_page": "reports-logs",
"auto_open_task_drawer": False,
"dashboards_table_density": "free",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "success"
assert payload["preference"]["superset_username"] == "John_Doe"
assert payload["preference"]["show_only_my_dashboards"] is True
assert payload["preference"]["git_username"] == "ivan.ivanov"
assert payload["preference"]["git_email"] == "ivan@company.local"
assert payload["preference"]["start_page"] == "reports"
assert payload["preference"]["auto_open_task_drawer"] is False
assert payload["preference"]["dashboards_table_density"] == "compact"
service.update_my_preference.assert_called_once()
called_kwargs = service.update_my_preference.call_args.kwargs
assert called_kwargs["current_user"] == mock_user
assert called_kwargs["payload"].git_username == "ivan.ivanov"
assert called_kwargs["payload"].git_email == "ivan@company.local"
assert called_kwargs["payload"].git_personal_access_token == "ghp_1234567890"
assert called_kwargs["payload"].start_page == "reports-logs"
assert called_kwargs["payload"].auto_open_task_drawer is False
assert called_kwargs["payload"].dashboards_table_density == "free"
# [/DEF:test_patch_profile_preferences_success:Function]
# [DEF:test_patch_profile_preferences_validation_error:Function]
# @PURPOSE: Verifies route maps domain validation failure to HTTP 422 with actionable details.
# @PRE: Service raises ProfileValidationError.
# @POST: Response status is 422 and includes validation messages.
def test_patch_profile_preferences_validation_error(profile_route_deps_fixture):
service = MagicMock()
service.update_my_preference.side_effect = ProfileValidationError(
["Superset username is required when default filter is enabled."]
)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.patch(
"/api/profile/preferences",
json={
"superset_username": "",
"show_only_my_dashboards": True,
},
)
assert response.status_code == 422
payload = response.json()
assert "detail" in payload
assert "Superset username is required when default filter is enabled." in payload["detail"]
# [/DEF:test_patch_profile_preferences_validation_error:Function]
# [DEF:test_patch_profile_preferences_cross_user_denied:Function]
# @PURPOSE: Verifies route maps domain authorization guard failure to HTTP 403.
# @PRE: Service raises ProfileAuthorizationError.
# @POST: Response status is 403 with denial message.
def test_patch_profile_preferences_cross_user_denied(profile_route_deps_fixture):
service = MagicMock()
service.update_my_preference.side_effect = ProfileAuthorizationError(
"Cross-user preference mutation is forbidden"
)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.patch(
"/api/profile/preferences",
json={
"superset_username": "john_doe",
"show_only_my_dashboards": True,
},
)
assert response.status_code == 403
payload = response.json()
assert payload["detail"] == "Cross-user preference mutation is forbidden"
# [/DEF:test_patch_profile_preferences_cross_user_denied:Function]
# [DEF:test_lookup_superset_accounts_success:Function]
# @PURPOSE: Verifies lookup route returns success payload with normalized candidates.
# @PRE: Valid environment_id and service success response.
# @POST: Response status is 200 and items list is returned.
def test_lookup_superset_accounts_success(profile_route_deps_fixture):
service = MagicMock()
service.lookup_superset_accounts.return_value = SupersetAccountLookupResponse(
status="success",
environment_id="dev",
page_index=0,
page_size=20,
total=1,
warning=None,
items=[
SupersetAccountCandidate(
environment_id="dev",
username="john_doe",
display_name="John Doe",
email="john@example.local",
is_active=True,
)
],
)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.get("/api/profile/superset-accounts?environment_id=dev")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "success"
assert payload["environment_id"] == "dev"
assert payload["total"] == 1
assert payload["items"][0]["username"] == "john_doe"
# [/DEF:test_lookup_superset_accounts_success:Function]
# [DEF:test_lookup_superset_accounts_env_not_found:Function]
# @PURPOSE: Verifies lookup route maps missing environment to HTTP 404.
# @PRE: Service raises EnvironmentNotFoundError.
# @POST: Response status is 404 with explicit message.
def test_lookup_superset_accounts_env_not_found(profile_route_deps_fixture):
service = MagicMock()
service.lookup_superset_accounts.side_effect = EnvironmentNotFoundError(
"Environment 'missing-env' not found"
)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.get("/api/profile/superset-accounts?environment_id=missing-env")
assert response.status_code == 404
payload = response.json()
assert payload["detail"] == "Environment 'missing-env' not found"
# [/DEF:test_lookup_superset_accounts_env_not_found:Function]
# [/DEF:backend.src.api.routes.__tests__.test_profile_api:Module]

View File

@@ -1,73 +0,0 @@
# [DEF:__tests__/test_tasks_logs:Module]
# @RELATION: VERIFIES -> ../tasks.py
# @PURPOSE: Contract testing for task logs API endpoints.
# [/DEF:__tests__/test_tasks_logs:Module]
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from unittest.mock import MagicMock
from src.dependencies import get_task_manager, has_permission
from src.api.routes.tasks import router
# @TEST_FIXTURE: mock_app
@pytest.fixture
def client():
app = FastAPI()
app.include_router(router, prefix="/tasks")
# Mock TaskManager
mock_tm = MagicMock()
app.dependency_overrides[get_task_manager] = lambda: mock_tm
# Mock permissions (bypass for unit test)
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: True
return TestClient(app), mock_tm
# @TEST_CONTRACT: get_task_logs_api -> Invariants
# @TEST_FIXTURE: valid_task_logs_request
def test_get_task_logs_success(client):
tc, tm = client
# Setup mock task
mock_task = MagicMock()
tm.get_task.return_value = mock_task
tm.get_task_logs.return_value = [{"level": "INFO", "message": "msg1"}]
response = tc.get("/tasks/task-1/logs?level=INFO")
assert response.status_code == 200
assert response.json() == [{"level": "INFO", "message": "msg1"}]
tm.get_task.assert_called_with("task-1")
# Verify filter construction inside route
args = tm.get_task_logs.call_args
assert args[0][0] == "task-1"
assert args[0][1].level == "INFO"
# @TEST_EDGE: task_not_found
def test_get_task_logs_not_found(client):
tc, tm = client
tm.get_task.return_value = None
response = tc.get("/tasks/missing/logs")
assert response.status_code == 404
assert response.json()["detail"] == "Task not found"
# @TEST_EDGE: invalid_limit
def test_get_task_logs_invalid_limit(client):
tc, tm = client
# limit=0 is ge=1 in Query
response = tc.get("/tasks/task-1/logs?limit=0")
assert response.status_code == 422
# @TEST_INVARIANT: response_purity
def test_get_task_log_stats_success(client):
tc, tm = client
tm.get_task.return_value = MagicMock()
tm.get_task_log_stats.return_value = {"INFO": 5, "ERROR": 1}
response = tc.get("/tasks/task-1/logs/stats")
assert response.status_code == 200
# response_model=LogStats might wrap this, but let's check basic structure
# assuming tm.get_task_log_stats returns something compatible with LogStats

View File

@@ -22,12 +22,8 @@ from ...schemas.auth import (
ADGroupMappingSchema, ADGroupMappingCreate
)
from ...models.auth import User, Role, ADGroupMapping
from ...dependencies import has_permission, get_plugin_loader
from ...dependencies import has_permission
from ...core.logger import logger, belief_scope
from ...services.rbac_permission_catalog import (
discover_declared_permissions,
sync_permission_catalog,
)
# [/SECTION]
# [DEF:router:Variable]
@@ -274,18 +270,9 @@ async def delete_role(
@router.get("/permissions", response_model=List[PermissionSchema])
async def list_permissions(
db: Session = Depends(get_auth_db),
plugin_loader = Depends(get_plugin_loader),
_ = Depends(has_permission("admin:roles", "READ"))
):
with belief_scope("api.admin.list_permissions"):
declared_permissions = discover_declared_permissions(plugin_loader=plugin_loader)
inserted_count = sync_permission_catalog(db=db, declared_permissions=declared_permissions)
if inserted_count > 0:
logger.info(
"[api.admin.list_permissions][Action] Synchronized %s missing RBAC permissions into auth catalog",
inserted_count,
)
repo = AuthRepository(db)
return repo.list_permissions()
# [/DEF:list_permissions:Function]

View File

@@ -810,9 +810,6 @@ def _parse_command(message: str, config_manager: ConfigManager) -> Dict[str, Any
if any(k in lower for k in ["миграц", "migration", "migrate"]):
src = _extract_id(lower, [r"(?:с|from)\s+([a-z0-9_-]+)"])
tgt = _extract_id(lower, [r"(?:на|to)\s+([a-z0-9_-]+)"])
dry_run = "--dry-run" in lower or "dry run" in lower
replace_db_config = "--replace-db-config" in lower
fix_cross_filters = "--fix-cross-filters" not in lower # Default true usually, but let's say test uses --dry-run
is_dangerous = _is_production_env(tgt, config_manager)
return {
"domain": "migration",
@@ -821,13 +818,10 @@ def _parse_command(message: str, config_manager: ConfigManager) -> Dict[str, Any
"dashboard_id": int(dashboard_id) if dashboard_id else None,
"source_env": src,
"target_env": tgt,
"dry_run": dry_run,
"replace_db_config": replace_db_config,
"fix_cross_filters": True,
},
"confidence": 0.95 if dashboard_id and src and tgt else 0.72,
"risk_level": "dangerous" if is_dangerous else "guarded",
"requires_confirmation": is_dangerous or dry_run,
"requires_confirmation": is_dangerous,
}
# Backup
@@ -1063,7 +1057,7 @@ _SAFE_OPS = {"show_capabilities", "get_task_status"}
# @PURPOSE: Build human-readable confirmation prompt for an intent before execution.
# @PRE: intent contains operation and entities fields.
# @POST: Returns descriptive Russian-language text ending with confirmation prompt.
async def _async_confirmation_summary(intent: Dict[str, Any], config_manager: ConfigManager, db: Session) -> str:
def _confirmation_summary(intent: Dict[str, Any]) -> str:
operation = intent.get("operation", "")
entities = intent.get("entities", {})
descriptions: Dict[str, str] = {
@@ -1091,67 +1085,8 @@ async def _async_confirmation_summary(intent: Dict[str, Any], config_manager: Co
tgt=_label(entities.get("target_env")),
dataset=_label(entities.get("dataset_id")),
)
if operation == "execute_migration":
flags = []
flags.append("маппинг БД: " + ("ВКЛ" if _coerce_query_bool(entities.get("replace_db_config", False)) else "ВЫКЛ"))
flags.append("исправление кроссфильтров: " + ("ВКЛ" if _coerce_query_bool(entities.get("fix_cross_filters", True)) else "ВЫКЛ"))
dry_run_enabled = _coerce_query_bool(entities.get("dry_run", False))
flags.append("отчет dry-run: " + ("ВКЛ" if dry_run_enabled else "ВЫКЛ"))
text += f" ({', '.join(flags)})"
if dry_run_enabled:
try:
from ...core.migration.dry_run_orchestrator import MigrationDryRunService
from ...models.dashboard import DashboardSelection
from ...core.superset_client import SupersetClient
src_token = entities.get("source_env")
tgt_token = entities.get("target_env")
dashboard_id = _resolve_dashboard_id_entity(entities, config_manager, env_hint=src_token)
if dashboard_id and src_token and tgt_token:
src_env_id = _resolve_env_id(src_token, config_manager)
tgt_env_id = _resolve_env_id(tgt_token, config_manager)
if src_env_id and tgt_env_id:
env_map = {env.id: env for env in config_manager.get_environments()}
source_env = env_map.get(src_env_id)
target_env = env_map.get(tgt_env_id)
if source_env and target_env and source_env.id != target_env.id:
selection = DashboardSelection(
source_env_id=source_env.id,
target_env_id=target_env.id,
selected_ids=[dashboard_id],
replace_db_config=_coerce_query_bool(entities.get("replace_db_config", False)),
fix_cross_filters=_coerce_query_bool(entities.get("fix_cross_filters", True))
)
service = MigrationDryRunService()
source_client = SupersetClient(source_env)
target_client = SupersetClient(target_env)
report = service.run(selection, source_client, target_client, db)
s = report.get("summary", {})
dash_s = s.get("dashboards", {})
charts_s = s.get("charts", {})
ds_s = s.get("datasets", {})
# Determine main actions counts
creates = dash_s.get("create", 0) + charts_s.get("create", 0) + ds_s.get("create", 0)
updates = dash_s.get("update", 0) + charts_s.get("update", 0) + ds_s.get("update", 0)
deletes = dash_s.get("delete", 0) + charts_s.get("delete", 0) + ds_s.get("delete", 0)
text += f"\n\nОтчет dry-run:\n- Будет создано новых объектов: {creates}\n- Будет обновлено: {updates}\n- Будет удалено: {deletes}"
else:
text += "\n\n(Не удалось загрузить отчет dry-run: неверные окружения)."
except Exception as e:
import traceback
logger.warning("[assistant.dry_run_summary][failed] Exception: %s\n%s", e, traceback.format_exc())
text += f"\n\n(Не удалось загрузить отчет dry-run: {e})."
return f"Выполнить: {text}. Подтвердите или отмените."
# [/DEF:_async_confirmation_summary:Function]
# [/DEF:_confirmation_summary:Function]
# [DEF:_clarification_text_for_intent:Function]
@@ -1241,8 +1176,7 @@ async def _plan_intent_with_llm(
]
)
except Exception as exc:
import traceback
logger.warning(f"[assistant.planner][fallback] LLM planner unavailable: {exc}\n{traceback.format_exc()}")
logger.warning(f"[assistant.planner][fallback] LLM planner unavailable: {exc}")
return None
if not isinstance(response, dict):
return None
@@ -1646,7 +1580,7 @@ async def send_message(
)
CONFIRMATIONS[confirmation_id] = confirm
_persist_confirmation(db, confirm)
text = await _async_confirmation_summary(intent, config_manager, db)
text = _confirmation_summary(intent)
_append_history(
user_id,
conversation_id,
@@ -1961,39 +1895,6 @@ async def list_conversations(
# [/DEF:list_conversations:Function]
# [DEF:delete_conversation:Function]
# @PURPOSE: Soft-delete or hard-delete a conversation and clear its in-memory trace.
# @PRE: conversation_id belongs to current_user.
# @POST: Conversation records are removed from DB and CONVERSATIONS cache.
@router.delete("/conversations/{conversation_id}")
async def delete_conversation(
conversation_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
with belief_scope("assistant.conversations.delete"):
user_id = current_user.id
# 1. Remove from in-memory cache
key = (user_id, conversation_id)
if key in CONVERSATIONS:
del CONVERSATIONS[key]
# 2. Delete from database
deleted_count = db.query(AssistantMessageRecord).filter(
AssistantMessageRecord.user_id == user_id,
AssistantMessageRecord.conversation_id == conversation_id
).delete()
db.commit()
if deleted_count == 0:
raise HTTPException(status_code=404, detail="Conversation not found or already deleted")
return {"status": "success", "deleted": deleted_count, "conversation_id": conversation_id}
# [/DEF:delete_conversation:Function]
@router.get("/history")
# [DEF:get_history:Function]
# @PURPOSE: Retrieve paginated assistant conversation history for current user.

View File

@@ -1,445 +0,0 @@
# [DEF:backend.src.api.routes.clean_release:Module]
# @TIER: STANDARD
# @SEMANTICS: api, clean-release, candidate-preparation, compliance
# @PURPOSE: Expose clean release endpoints for candidate preparation and subsequent compliance flow.
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.dependencies.get_clean_release_repository
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.preparation_service
# @INVARIANT: API never reports prepared status if preparation errors are present.
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ...core.logger import belief_scope, logger
from ...dependencies import get_clean_release_repository, get_config_manager
from ...services.clean_release.preparation_service import prepare_candidate
from ...services.clean_release.repository import CleanReleaseRepository
from ...services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
from ...services.clean_release.report_builder import ComplianceReportBuilder
from ...services.clean_release.compliance_execution_service import ComplianceExecutionService, ComplianceRunError
from ...services.clean_release.dto import CandidateDTO, ManifestDTO, CandidateOverviewDTO, ComplianceRunDTO
from ...services.clean_release.enums import (
ComplianceDecision,
ComplianceStageName,
ViolationCategory,
ViolationSeverity,
RunStatus,
CandidateStatus,
)
from ...models.clean_release import (
ComplianceRun,
ComplianceStageRun,
ComplianceViolation,
CandidateArtifact,
ReleaseCandidate,
)
router = APIRouter(prefix="/api/clean-release", tags=["Clean Release"])
# [DEF:PrepareCandidateRequest:Class]
# @PURPOSE: Request schema for candidate preparation endpoint.
class PrepareCandidateRequest(BaseModel):
candidate_id: str = Field(min_length=1)
artifacts: List[Dict[str, Any]] = Field(default_factory=list)
sources: List[str] = Field(default_factory=list)
operator_id: str = Field(min_length=1)
# [/DEF:PrepareCandidateRequest:Class]
# [DEF:StartCheckRequest:Class]
# @PURPOSE: Request schema for clean compliance check run startup.
class StartCheckRequest(BaseModel):
candidate_id: str = Field(min_length=1)
profile: str = Field(default="enterprise-clean")
execution_mode: str = Field(default="tui")
triggered_by: str = Field(default="system")
# [/DEF:StartCheckRequest:Class]
# [DEF:RegisterCandidateRequest:Class]
# @PURPOSE: Request schema for candidate registration endpoint.
class RegisterCandidateRequest(BaseModel):
id: str = Field(min_length=1)
version: str = Field(min_length=1)
source_snapshot_ref: str = Field(min_length=1)
created_by: str = Field(min_length=1)
# [/DEF:RegisterCandidateRequest:Class]
# [DEF:ImportArtifactsRequest:Class]
# @PURPOSE: Request schema for candidate artifact import endpoint.
class ImportArtifactsRequest(BaseModel):
artifacts: List[Dict[str, Any]] = Field(default_factory=list)
# [/DEF:ImportArtifactsRequest:Class]
# [DEF:BuildManifestRequest:Class]
# @PURPOSE: Request schema for manifest build endpoint.
class BuildManifestRequest(BaseModel):
created_by: str = Field(default="system")
# [/DEF:BuildManifestRequest:Class]
# [DEF:CreateComplianceRunRequest:Class]
# @PURPOSE: Request schema for compliance run creation with optional manifest pinning.
class CreateComplianceRunRequest(BaseModel):
requested_by: str = Field(min_length=1)
manifest_id: str | None = None
# [/DEF:CreateComplianceRunRequest:Class]
# [DEF:register_candidate_v2_endpoint:Function]
# @PURPOSE: Register a clean-release candidate for headless lifecycle.
# @PRE: Candidate identifier is unique.
# @POST: Candidate is persisted in DRAFT status.
@router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED)
async def register_candidate_v2_endpoint(
payload: RegisterCandidateRequest,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
existing = repository.get_candidate(payload.id)
if existing is not None:
raise HTTPException(status_code=409, detail={"message": "Candidate already exists", "code": "CANDIDATE_EXISTS"})
candidate = ReleaseCandidate(
id=payload.id,
version=payload.version,
source_snapshot_ref=payload.source_snapshot_ref,
created_by=payload.created_by,
created_at=datetime.now(timezone.utc),
status=CandidateStatus.DRAFT.value,
)
repository.save_candidate(candidate)
return CandidateDTO(
id=candidate.id,
version=candidate.version,
source_snapshot_ref=candidate.source_snapshot_ref,
created_at=candidate.created_at,
created_by=candidate.created_by,
status=CandidateStatus(candidate.status),
)
# [/DEF:register_candidate_v2_endpoint:Function]
# [DEF:import_candidate_artifacts_v2_endpoint:Function]
# @PURPOSE: Import candidate artifacts in headless flow.
# @PRE: Candidate exists and artifacts array is non-empty.
# @POST: Artifacts are persisted and candidate advances to PREPARED if it was DRAFT.
@router.post("/candidates/{candidate_id}/artifacts")
async def import_candidate_artifacts_v2_endpoint(
candidate_id: str,
payload: ImportArtifactsRequest,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
candidate = repository.get_candidate(candidate_id)
if candidate is None:
raise HTTPException(status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
if not payload.artifacts:
raise HTTPException(status_code=400, detail={"message": "Artifacts list is required", "code": "ARTIFACTS_EMPTY"})
for artifact in payload.artifacts:
required = ("id", "path", "sha256", "size")
for field_name in required:
if field_name not in artifact:
raise HTTPException(
status_code=400,
detail={"message": f"Artifact missing field '{field_name}'", "code": "ARTIFACT_INVALID"},
)
artifact_model = CandidateArtifact(
id=str(artifact["id"]),
candidate_id=candidate_id,
path=str(artifact["path"]),
sha256=str(artifact["sha256"]),
size=int(artifact["size"]),
detected_category=artifact.get("detected_category"),
declared_category=artifact.get("declared_category"),
source_uri=artifact.get("source_uri"),
source_host=artifact.get("source_host"),
metadata_json=artifact.get("metadata_json", {}),
)
repository.save_artifact(artifact_model)
if candidate.status == CandidateStatus.DRAFT.value:
candidate.transition_to(CandidateStatus.PREPARED)
repository.save_candidate(candidate)
return {"status": "success"}
# [/DEF:import_candidate_artifacts_v2_endpoint:Function]
# [DEF:build_candidate_manifest_v2_endpoint:Function]
# @PURPOSE: Build immutable manifest snapshot for prepared candidate.
# @PRE: Candidate exists and has imported artifacts.
# @POST: Returns created ManifestDTO with incremented version.
@router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED)
async def build_candidate_manifest_v2_endpoint(
candidate_id: str,
payload: BuildManifestRequest,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
from ...services.clean_release.manifest_service import build_manifest_snapshot
try:
manifest = build_manifest_snapshot(
repository=repository,
candidate_id=candidate_id,
created_by=payload.created_by,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail={"message": str(exc), "code": "MANIFEST_BUILD_ERROR"})
return ManifestDTO(
id=manifest.id,
candidate_id=manifest.candidate_id,
manifest_version=manifest.manifest_version,
manifest_digest=manifest.manifest_digest,
artifacts_digest=manifest.artifacts_digest,
created_at=manifest.created_at,
created_by=manifest.created_by,
source_snapshot_ref=manifest.source_snapshot_ref,
content_json=manifest.content_json,
)
# [/DEF:build_candidate_manifest_v2_endpoint:Function]
# [DEF:get_candidate_overview_v2_endpoint:Function]
# @PURPOSE: Return expanded candidate overview DTO for headless lifecycle visibility.
# @PRE: Candidate exists.
# @POST: Returns CandidateOverviewDTO built from the same repository state used by headless US1 endpoints.
@router.get("/candidates/{candidate_id}/overview", response_model=CandidateOverviewDTO)
async def get_candidate_overview_v2_endpoint(
candidate_id: str,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
candidate = repository.get_candidate(candidate_id)
if candidate is None:
raise HTTPException(status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
manifests = repository.get_manifests_by_candidate(candidate_id)
latest_manifest = sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0] if manifests else None
runs = [run for run in repository.check_runs.values() if run.candidate_id == candidate_id]
latest_run = sorted(runs, key=lambda run: run.requested_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)[0] if runs else None
latest_report = None
if latest_run is not None:
latest_report = next((r for r in repository.reports.values() if r.run_id == latest_run.id), None)
latest_policy_snapshot = repository.get_policy(latest_run.policy_snapshot_id) if latest_run else None
latest_registry_snapshot = repository.get_registry(latest_run.registry_snapshot_id) if latest_run else None
approval_decisions = getattr(repository, "approval_decisions", [])
latest_approval = (
sorted(
[item for item in approval_decisions if item.candidate_id == candidate_id],
key=lambda item: item.decided_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)[0]
if approval_decisions
and any(item.candidate_id == candidate_id for item in approval_decisions)
else None
)
publication_records = getattr(repository, "publication_records", [])
latest_publication = (
sorted(
[item for item in publication_records if item.candidate_id == candidate_id],
key=lambda item: item.published_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)[0]
if publication_records
and any(item.candidate_id == candidate_id for item in publication_records)
else None
)
return CandidateOverviewDTO(
candidate_id=candidate.id,
version=candidate.version,
source_snapshot_ref=candidate.source_snapshot_ref,
status=CandidateStatus(candidate.status),
latest_manifest_id=latest_manifest.id if latest_manifest else None,
latest_manifest_digest=latest_manifest.manifest_digest if latest_manifest else None,
latest_run_id=latest_run.id if latest_run else None,
latest_run_status=RunStatus(latest_run.status) if latest_run else None,
latest_report_id=latest_report.id if latest_report else None,
latest_report_final_status=ComplianceDecision(latest_report.final_status) if latest_report else None,
latest_policy_snapshot_id=latest_policy_snapshot.id if latest_policy_snapshot else None,
latest_policy_version=latest_policy_snapshot.policy_version if latest_policy_snapshot else None,
latest_registry_snapshot_id=latest_registry_snapshot.id if latest_registry_snapshot else None,
latest_registry_version=latest_registry_snapshot.registry_version if latest_registry_snapshot else None,
latest_approval_decision=latest_approval.decision if latest_approval else None,
latest_publication_id=latest_publication.id if latest_publication else None,
latest_publication_status=latest_publication.status if latest_publication else None,
)
# [/DEF:get_candidate_overview_v2_endpoint:Function]
# [DEF:prepare_candidate_endpoint:Function]
# @PURPOSE: Prepare candidate with policy evaluation and deterministic manifest generation.
# @PRE: Candidate and active policy exist in repository.
# @POST: Returns preparation result including manifest reference and violations.
@router.post("/candidates/prepare")
async def prepare_candidate_endpoint(
payload: PrepareCandidateRequest,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
try:
result = prepare_candidate(
repository=repository,
candidate_id=payload.candidate_id,
artifacts=payload.artifacts,
sources=payload.sources,
operator_id=payload.operator_id,
)
return result
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"message": str(exc), "code": "CLEAN_PREPARATION_ERROR"},
)
# [/DEF:prepare_candidate_endpoint:Function]
# [DEF:start_check:Function]
# @PURPOSE: Start and finalize a clean compliance check run and persist report artifacts.
# @PRE: Active policy and candidate exist.
# @POST: Returns accepted payload with check_run_id and started_at.
@router.post("/checks", status_code=status.HTTP_202_ACCEPTED)
async def start_check(
payload: StartCheckRequest,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
with belief_scope("clean_release.start_check"):
logger.reason("Starting clean-release compliance check run")
policy = repository.get_active_policy()
if policy is None:
raise HTTPException(status_code=409, detail={"message": "Active policy not found", "code": "POLICY_NOT_FOUND"})
candidate = repository.get_candidate(payload.candidate_id)
if candidate is None:
raise HTTPException(status_code=409, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
manifests = repository.get_manifests_by_candidate(payload.candidate_id)
if not manifests:
raise HTTPException(status_code=409, detail={"message": "No manifest found for candidate", "code": "MANIFEST_NOT_FOUND"})
latest_manifest = sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0]
orchestrator = CleanComplianceOrchestrator(repository)
run = orchestrator.start_check_run(
candidate_id=payload.candidate_id,
policy_id=policy.id,
requested_by=payload.triggered_by,
manifest_id=latest_manifest.id,
)
forced = [
ComplianceStageRun(
id=f"stage-{run.id}-1",
run_id=run.id,
stage_name=ComplianceStageName.DATA_PURITY.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
),
ComplianceStageRun(
id=f"stage-{run.id}-2",
run_id=run.id,
stage_name=ComplianceStageName.INTERNAL_SOURCES_ONLY.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
),
ComplianceStageRun(
id=f"stage-{run.id}-3",
run_id=run.id,
stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
),
ComplianceStageRun(
id=f"stage-{run.id}-4",
run_id=run.id,
stage_name=ComplianceStageName.MANIFEST_CONSISTENCY.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
),
]
run = orchestrator.execute_stages(run, forced_results=forced)
run = orchestrator.finalize_run(run)
if run.final_status == ComplianceDecision.BLOCKED.value:
logger.explore("Run ended as BLOCKED, persisting synthetic external-source violation")
violation = ComplianceViolation(
id=f"viol-{run.id}",
run_id=run.id,
stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value,
code="EXTERNAL_SOURCE_DETECTED",
severity=ViolationSeverity.CRITICAL.value,
message="Replace with approved internal server",
evidence_json={"location": "external.example.com"}
)
repository.save_violation(violation)
builder = ComplianceReportBuilder(repository)
report = builder.build_report_payload(run, repository.get_violations_by_run(run.id))
builder.persist_report(report)
logger.reflect(f"Compliance report persisted for run_id={run.id}")
return {
"check_run_id": run.id,
"candidate_id": run.candidate_id,
"status": "running",
"started_at": run.started_at.isoformat() if run.started_at else None,
}
# [/DEF:start_check:Function]
# [DEF:get_check_status:Function]
# @PURPOSE: Return terminal/intermediate status payload for a check run.
# @PRE: check_run_id references an existing run.
# @POST: Deterministic payload shape includes checks and violations arrays.
@router.get("/checks/{check_run_id}")
async def get_check_status(check_run_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository)):
with belief_scope("clean_release.get_check_status"):
run = repository.get_check_run(check_run_id)
if run is None:
raise HTTPException(status_code=404, detail={"message": "Check run not found", "code": "CHECK_NOT_FOUND"})
logger.reflect(f"Returning check status for check_run_id={check_run_id}")
return {
"check_run_id": run.id,
"candidate_id": run.candidate_id,
"final_status": run.final_status,
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"checks": [], # TODO: Map stages if needed
"violations": [], # TODO: Map violations if needed
}
# [/DEF:get_check_status:Function]
# [DEF:get_report:Function]
# @PURPOSE: Return persisted compliance report by report_id.
# @PRE: report_id references an existing report.
# @POST: Returns serialized report object.
@router.get("/reports/{report_id}")
async def get_report(report_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository)):
with belief_scope("clean_release.get_report"):
report = repository.get_report(report_id)
if report is None:
raise HTTPException(status_code=404, detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"})
logger.reflect(f"Returning compliance report report_id={report_id}")
return report.model_dump()
# [/DEF:get_report:Function]
# [/DEF:backend.src.api.routes.clean_release:Module]

View File

@@ -1,216 +0,0 @@
# [DEF:backend.src.api.routes.clean_release_v2:Module]
# @TIER: STANDARD
# @SEMANTICS: api, clean-release, v2, headless
# @PURPOSE: Redesigned clean release API for headless candidate lifecycle.
# @LAYER: API
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Dict, Any
from datetime import datetime, timezone
from ...services.clean_release.approval_service import approve_candidate, reject_candidate
from ...services.clean_release.publication_service import publish_candidate, revoke_publication
from ...services.clean_release.repository import CleanReleaseRepository
from ...dependencies import get_clean_release_repository
from ...services.clean_release.enums import CandidateStatus
from ...models.clean_release import ReleaseCandidate, CandidateArtifact, DistributionManifest
from ...services.clean_release.dto import CandidateDTO, ManifestDTO
router = APIRouter(prefix="/api/v2/clean-release", tags=["Clean Release V2"])
class ApprovalRequest(dict):
pass
class PublishRequest(dict):
pass
class RevokeRequest(dict):
pass
@router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED)
async def register_candidate(
payload: Dict[str, Any],
repository: CleanReleaseRepository = Depends(get_clean_release_repository)
):
candidate = ReleaseCandidate(
id=payload["id"],
version=payload["version"],
source_snapshot_ref=payload["source_snapshot_ref"],
created_by=payload["created_by"],
created_at=datetime.now(timezone.utc),
status=CandidateStatus.DRAFT.value
)
repository.save_candidate(candidate)
return CandidateDTO(
id=candidate.id,
version=candidate.version,
source_snapshot_ref=candidate.source_snapshot_ref,
created_at=candidate.created_at,
created_by=candidate.created_by,
status=CandidateStatus(candidate.status)
)
@router.post("/candidates/{candidate_id}/artifacts")
async def import_artifacts(
candidate_id: str,
payload: Dict[str, Any],
repository: CleanReleaseRepository = Depends(get_clean_release_repository)
):
candidate = repository.get_candidate(candidate_id)
if not candidate:
raise HTTPException(status_code=404, detail="Candidate not found")
for art_data in payload.get("artifacts", []):
artifact = CandidateArtifact(
id=art_data["id"],
candidate_id=candidate_id,
path=art_data["path"],
sha256=art_data["sha256"],
size=art_data["size"]
)
# In a real repo we'd have save_artifact
# repository.save_artifact(artifact)
pass
return {"status": "success"}
@router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED)
async def build_manifest(
candidate_id: str,
repository: CleanReleaseRepository = Depends(get_clean_release_repository)
):
candidate = repository.get_candidate(candidate_id)
if not candidate:
raise HTTPException(status_code=404, detail="Candidate not found")
manifest = DistributionManifest(
id=f"manifest-{candidate_id}",
candidate_id=candidate_id,
manifest_version=1,
manifest_digest="hash-123",
artifacts_digest="art-hash-123",
created_by="system",
created_at=datetime.now(timezone.utc),
source_snapshot_ref=candidate.source_snapshot_ref,
content_json={"items": [], "summary": {}}
)
repository.save_manifest(manifest)
return ManifestDTO(
id=manifest.id,
candidate_id=manifest.candidate_id,
manifest_version=manifest.manifest_version,
manifest_digest=manifest.manifest_digest,
artifacts_digest=manifest.artifacts_digest,
created_at=manifest.created_at,
created_by=manifest.created_by,
source_snapshot_ref=manifest.source_snapshot_ref,
content_json=manifest.content_json
)
@router.post("/candidates/{candidate_id}/approve")
async def approve_candidate_endpoint(
candidate_id: str,
payload: Dict[str, Any],
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
try:
decision = approve_candidate(
repository=repository,
candidate_id=candidate_id,
report_id=str(payload["report_id"]),
decided_by=str(payload["decided_by"]),
comment=payload.get("comment"),
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"})
return {"status": "ok", "decision": decision.decision, "decision_id": decision.id}
@router.post("/candidates/{candidate_id}/reject")
async def reject_candidate_endpoint(
candidate_id: str,
payload: Dict[str, Any],
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
try:
decision = reject_candidate(
repository=repository,
candidate_id=candidate_id,
report_id=str(payload["report_id"]),
decided_by=str(payload["decided_by"]),
comment=payload.get("comment"),
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"})
return {"status": "ok", "decision": decision.decision, "decision_id": decision.id}
@router.post("/candidates/{candidate_id}/publish")
async def publish_candidate_endpoint(
candidate_id: str,
payload: Dict[str, Any],
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
try:
publication = publish_candidate(
repository=repository,
candidate_id=candidate_id,
report_id=str(payload["report_id"]),
published_by=str(payload["published_by"]),
target_channel=str(payload["target_channel"]),
publication_ref=payload.get("publication_ref"),
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"})
return {
"status": "ok",
"publication": {
"id": publication.id,
"candidate_id": publication.candidate_id,
"report_id": publication.report_id,
"published_by": publication.published_by,
"published_at": publication.published_at.isoformat() if publication.published_at else None,
"target_channel": publication.target_channel,
"publication_ref": publication.publication_ref,
"status": publication.status,
},
}
@router.post("/publications/{publication_id}/revoke")
async def revoke_publication_endpoint(
publication_id: str,
payload: Dict[str, Any],
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
try:
publication = revoke_publication(
repository=repository,
publication_id=publication_id,
revoked_by=str(payload["revoked_by"]),
comment=payload.get("comment"),
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"})
return {
"status": "ok",
"publication": {
"id": publication.id,
"candidate_id": publication.candidate_id,
"report_id": publication.report_id,
"published_by": publication.published_by,
"published_at": publication.published_at.isoformat() if publication.published_at else None,
"target_channel": publication.target_channel,
"publication_ref": publication.publication_ref,
"status": publication.status,
},
}
# [/DEF:backend.src.api.routes.clean_release_v2:Module]

View File

@@ -1,6 +1,6 @@
# [DEF:backend.src.api.routes.dashboards:Module]
#
# @TIER: CRITICAL
# @TIER: STANDARD
# @SEMANTICS: api, dashboards, resources, hub
# @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status
# @LAYER: API
@@ -9,53 +9,18 @@
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
#
# @INVARIANT: All dashboard responses include git_status and last_task metadata
#
# @TEST_CONTRACT: DashboardsAPI -> {
# required_fields: {env_id: string, page: integer, page_size: integer},
# optional_fields: {search: string},
# invariants: ["Pagination must be valid", "Environment must exist"]
# }
#
# @TEST_FIXTURE: dashboard_list_happy -> {
# "env_id": "prod",
# "expected_count": 1,
# "dashboards": [{"id": 1, "title": "Main Revenue"}]
# }
#
# @TEST_EDGE: pagination_zero_page -> {"env_id": "prod", "page": 0, "status": 400}
# @TEST_EDGE: pagination_oversize -> {"env_id": "prod", "page_size": 101, "status": 400}
# @TEST_EDGE: missing_env -> {"env_id": "ghost", "status": 404}
# @TEST_EDGE: empty_dashboards -> {"env_id": "empty_env", "expected_total": 0}
# @TEST_EDGE: external_superset_failure -> {"env_id": "bad_conn", "status": 503}
#
# @TEST_INVARIANT: metadata_consistency -> verifies: [dashboard_list_happy, empty_dashboards]
#
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import JSONResponse
from typing import List, Optional, Dict, Any, Literal
from typing import List, Optional, Dict, Any
import re
from urllib.parse import urlparse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ...dependencies import (
get_config_manager,
get_task_manager,
get_resource_service,
get_mapping_service,
get_current_user,
has_permission,
)
from ...core.database import get_db
from ...core.async_superset_client import AsyncSupersetClient
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, get_mapping_service, has_permission
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
from ...core.superset_profile_lookup import SupersetAccountLookupAdapter
from ...core.utils.network import DashboardNotFoundError
from ...models.auth import User
from ...services.profile_service import ProfileService
from ...services.resource_service import ResourceService
# [/SECTION]
router = APIRouter(prefix="/api/dashboards", tags=["Dashboards"])
@@ -71,11 +36,7 @@ class GitStatus(BaseModel):
# [DEF:LastTask:DataClass]
class LastTask(BaseModel):
task_id: Optional[str] = None
status: Optional[str] = Field(
None,
pattern="^PENDING|RUNNING|SUCCESS|FAILED|ERROR|AWAITING_INPUT|WAITING_INPUT|AWAITING_MAPPING$",
)
validation_status: Optional[str] = Field(None, pattern="^PASS|FAIL|WARN|UNKNOWN$")
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
# [/DEF:LastTask:DataClass]
# [DEF:DashboardItem:DataClass]
@@ -85,22 +46,10 @@ class DashboardItem(BaseModel):
slug: Optional[str] = None
url: Optional[str] = None
last_modified: Optional[str] = None
created_by: Optional[str] = None
modified_by: Optional[str] = None
owners: Optional[List[str]] = None
git_status: Optional[GitStatus] = None
last_task: Optional[LastTask] = None
# [/DEF:DashboardItem:DataClass]
# [DEF:EffectiveProfileFilter:DataClass]
class EffectiveProfileFilter(BaseModel):
applied: bool
source_page: Literal["dashboards_main", "other"] = "dashboards_main"
override_show_all: bool = False
username: Optional[str] = None
match_logic: Optional[Literal["owners_or_modified_by"]] = None
# [/DEF:EffectiveProfileFilter:DataClass]
# [DEF:DashboardsResponse:DataClass]
class DashboardsResponse(BaseModel):
dashboards: List[DashboardItem]
@@ -108,7 +57,6 @@ class DashboardsResponse(BaseModel):
page: int
page_size: int
total_pages: int
effective_profile_filter: Optional[EffectiveProfileFilter] = None
# [/DEF:DashboardsResponse:DataClass]
# [DEF:DashboardChartItem:DataClass]
@@ -178,304 +126,6 @@ class DatabaseMappingsResponse(BaseModel):
mappings: List[DatabaseMapping]
# [/DEF:DatabaseMappingsResponse:DataClass]
# [DEF:_find_dashboard_id_by_slug:Function]
# @PURPOSE: Resolve dashboard numeric ID by slug using Superset list endpoint.
# @PRE: `dashboard_slug` is non-empty.
# @POST: Returns dashboard ID when found, otherwise None.
def _find_dashboard_id_by_slug(
client: SupersetClient,
dashboard_slug: str,
) -> Optional[int]:
query_variants = [
{"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
{"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
]
for query in query_variants:
try:
_count, dashboards = client.get_dashboards_page(query=query)
if dashboards:
resolved_id = dashboards[0].get("id")
if resolved_id is not None:
return int(resolved_id)
except Exception:
continue
return None
# [/DEF:_find_dashboard_id_by_slug:Function]
# [DEF:_resolve_dashboard_id_from_ref:Function]
# @PURPOSE: Resolve dashboard ID from slug-first reference with numeric fallback.
# @PRE: `dashboard_ref` is provided in route path.
# @POST: Returns a valid dashboard ID or raises HTTPException(404).
def _resolve_dashboard_id_from_ref(
dashboard_ref: str,
client: SupersetClient,
) -> int:
normalized_ref = str(dashboard_ref or "").strip()
if not normalized_ref:
raise HTTPException(status_code=404, detail="Dashboard not found")
# Slug-first: even if ref looks numeric, try slug first.
slug_match_id = _find_dashboard_id_by_slug(client, normalized_ref)
if slug_match_id is not None:
return slug_match_id
if normalized_ref.isdigit():
return int(normalized_ref)
raise HTTPException(status_code=404, detail="Dashboard not found")
# [/DEF:_resolve_dashboard_id_from_ref:Function]
# [DEF:_find_dashboard_id_by_slug_async:Function]
# @PURPOSE: Resolve dashboard numeric ID by slug using async Superset list endpoint.
# @PRE: dashboard_slug is non-empty.
# @POST: Returns dashboard ID when found, otherwise None.
async def _find_dashboard_id_by_slug_async(
client: AsyncSupersetClient,
dashboard_slug: str,
) -> Optional[int]:
query_variants = [
{"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
{"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
]
for query in query_variants:
try:
_count, dashboards = await client.get_dashboards_page_async(query=query)
if dashboards:
resolved_id = dashboards[0].get("id")
if resolved_id is not None:
return int(resolved_id)
except Exception:
continue
return None
# [/DEF:_find_dashboard_id_by_slug_async:Function]
# [DEF:_resolve_dashboard_id_from_ref_async:Function]
# @PURPOSE: Resolve dashboard ID from slug-first reference using async Superset client.
# @PRE: dashboard_ref is provided in route path.
# @POST: Returns valid dashboard ID or raises HTTPException(404).
async def _resolve_dashboard_id_from_ref_async(
dashboard_ref: str,
client: AsyncSupersetClient,
) -> int:
normalized_ref = str(dashboard_ref or "").strip()
if not normalized_ref:
raise HTTPException(status_code=404, detail="Dashboard not found")
slug_match_id = await _find_dashboard_id_by_slug_async(client, normalized_ref)
if slug_match_id is not None:
return slug_match_id
if normalized_ref.isdigit():
return int(normalized_ref)
raise HTTPException(status_code=404, detail="Dashboard not found")
# [/DEF:_resolve_dashboard_id_from_ref_async:Function]
# [DEF:_normalize_filter_values:Function]
# @PURPOSE: Normalize query filter values to lower-cased non-empty tokens.
# @PRE: values may be None or list of strings.
# @POST: Returns trimmed normalized list preserving input order.
def _normalize_filter_values(values: Optional[List[str]]) -> List[str]:
if not values:
return []
normalized: List[str] = []
for value in values:
token = str(value or "").strip().lower()
if token:
normalized.append(token)
return normalized
# [/DEF:_normalize_filter_values:Function]
# [DEF:_dashboard_git_filter_value:Function]
# @PURPOSE: Build comparable git status token for dashboards filtering.
# @PRE: dashboard payload may contain git_status or None.
# @POST: Returns one of ok|diff|no_repo|error|pending.
def _dashboard_git_filter_value(dashboard: Dict[str, Any]) -> str:
git_status = dashboard.get("git_status") or {}
sync_status = str(git_status.get("sync_status") or "").strip().upper()
has_repo = git_status.get("has_repo")
if has_repo is False or sync_status == "NO_REPO":
return "no_repo"
if sync_status == "DIFF":
return "diff"
if sync_status == "OK":
return "ok"
if sync_status == "ERROR":
return "error"
return "pending"
# [/DEF:_dashboard_git_filter_value:Function]
# [DEF:_normalize_actor_alias_token:Function]
# @PURPOSE: Normalize actor alias token to comparable trim+lower text.
# @PRE: value can be scalar/None.
# @POST: Returns normalized token or None.
def _normalize_actor_alias_token(value: Any) -> Optional[str]:
token = str(value or "").strip().lower()
return token or None
# [/DEF:_normalize_actor_alias_token:Function]
# [DEF:_normalize_owner_display_token:Function]
# @PURPOSE: Project owner payload value into stable display string for API response contracts.
# @PRE: owner can be scalar, dict or None.
# @POST: Returns trimmed non-empty owner display token or None.
def _normalize_owner_display_token(owner: Any) -> Optional[str]:
if owner is None:
return None
if isinstance(owner, dict):
username = str(owner.get("username") or owner.get("user_name") or owner.get("name") or "").strip()
full_name = str(owner.get("full_name") or "").strip()
first_name = str(owner.get("first_name") or "").strip()
last_name = str(owner.get("last_name") or "").strip()
combined = " ".join(part for part in [first_name, last_name] if part).strip()
email = str(owner.get("email") or "").strip()
for candidate in [username, full_name, combined, email]:
if candidate:
return candidate
return None
normalized = str(owner).strip()
return normalized or None
# [/DEF:_normalize_owner_display_token:Function]
# [DEF:_normalize_dashboard_owner_values:Function]
# @PURPOSE: Normalize dashboard owners payload to optional list of display strings.
# @PRE: owners payload can be None, scalar, or list with mixed values.
# @POST: Returns deduplicated owner labels preserving order, or None when absent.
def _normalize_dashboard_owner_values(owners: Any) -> Optional[List[str]]:
if owners is None:
return None
raw_items: List[Any]
if isinstance(owners, list):
raw_items = owners
else:
raw_items = [owners]
normalized: List[str] = []
for owner in raw_items:
token = _normalize_owner_display_token(owner)
if token and token not in normalized:
normalized.append(token)
return normalized
# [/DEF:_normalize_dashboard_owner_values:Function]
# [DEF:_project_dashboard_response_items:Function]
# @PURPOSE: Project dashboard payloads to response-contract-safe shape.
# @PRE: dashboards is a list of dict-like dashboard payloads.
# @POST: Returned items satisfy DashboardItem owners=list[str]|None contract.
def _project_dashboard_response_items(dashboards: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
projected: List[Dict[str, Any]] = []
for dashboard in dashboards:
projected_dashboard = dict(dashboard)
projected_dashboard["owners"] = _normalize_dashboard_owner_values(
projected_dashboard.get("owners")
)
projected.append(projected_dashboard)
return projected
# [/DEF:_project_dashboard_response_items:Function]
# [DEF:_resolve_profile_actor_aliases:Function]
# @PURPOSE: Resolve stable actor aliases for profile filtering without per-dashboard detail fan-out.
# @PRE: bound username is available and env is valid.
# @POST: Returns at least normalized username; may include Superset display-name alias.
# @SIDE_EFFECT: Performs at most one Superset users-lookup request.
def _resolve_profile_actor_aliases(env: Any, bound_username: str) -> List[str]:
normalized_bound = _normalize_actor_alias_token(bound_username)
if not normalized_bound:
return []
aliases: List[str] = [normalized_bound]
try:
client = SupersetClient(env)
adapter = SupersetAccountLookupAdapter(
network_client=client.network,
environment_id=str(getattr(env, "id", "")),
)
lookup_payload = adapter.get_users_page(
search=normalized_bound,
page_index=0,
page_size=20,
sort_column="username",
sort_order="asc",
)
lookup_items = (
lookup_payload.get("items", [])
if isinstance(lookup_payload, dict)
else []
)
matched_item: Optional[Dict[str, Any]] = None
for item in lookup_items:
if not isinstance(item, dict):
continue
if _normalize_actor_alias_token(item.get("username")) == normalized_bound:
matched_item = item
break
if matched_item is None:
for item in lookup_items:
if isinstance(item, dict):
matched_item = item
break
display_alias = _normalize_actor_alias_token(
(matched_item or {}).get("display_name")
)
if display_alias and display_alias not in aliases:
aliases.append(display_alias)
logger.reflect(
"[REFLECT] Resolved profile actor aliases "
f"(env={getattr(env, 'id', None)}, bound_username={normalized_bound!r}, "
f"lookup_items={len(lookup_items)}, aliases={aliases!r})"
)
except Exception as alias_error:
logger.explore(
"[EXPLORE] Failed to resolve profile actor aliases via Superset users lookup "
f"(env={getattr(env, 'id', None)}, bound_username={normalized_bound!r}): {alias_error}"
)
return aliases
# [/DEF:_resolve_profile_actor_aliases:Function]
# [DEF:_matches_dashboard_actor_aliases:Function]
# @PURPOSE: Apply profile actor matching against multiple aliases (username + optional display name).
# @PRE: actor_aliases contains normalized non-empty tokens.
# @POST: Returns True when any alias matches owners OR modified_by.
def _matches_dashboard_actor_aliases(
profile_service: ProfileService,
actor_aliases: List[str],
owners: Optional[Any],
modified_by: Optional[str],
) -> bool:
for actor_alias in actor_aliases:
if profile_service.matches_dashboard_actor(
bound_username=actor_alias,
owners=owners,
modified_by=modified_by,
):
return True
return False
# [/DEF:_matches_dashboard_actor_aliases:Function]
# [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
@@ -483,7 +133,6 @@ def _matches_dashboard_actor_aliases(
# @PRE: page_size must be between 1 and 100 if provided
# @POST: Returns a list of dashboards with enhanced metadata and pagination info
# @POST: Response includes pagination metadata (page, page_size, total, total_pages)
# @POST: Response includes effective profile filter metadata for main dashboards page context
# @PARAM: env_id (str) - The environment ID to fetch dashboards from
# @PARAM: search (Optional[str]) - Filter by title/slug
# @PARAM: page (Optional[int]) - Page number (default: 1)
@@ -496,258 +145,62 @@ async def get_dashboards(
search: Optional[str] = None,
page: int = 1,
page_size: int = 10,
page_context: Literal["dashboards_main", "other"] = Query(default="dashboards_main"),
apply_profile_default: bool = Query(default=True),
override_show_all: bool = Query(default=False),
filter_title: Optional[List[str]] = Query(default=None),
filter_git_status: Optional[List[str]] = Query(default=None),
filter_llm_status: Optional[List[str]] = Query(default=None),
filter_changed_on: Optional[List[str]] = Query(default=None),
filter_actor: Optional[List[str]] = Query(default=None),
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
resource_service=Depends(get_resource_service),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope(
"get_dashboards",
(
f"env_id={env_id}, search={search}, page={page}, page_size={page_size}, "
f"page_context={page_context}, apply_profile_default={apply_profile_default}, "
f"override_show_all={override_show_all}"
),
):
with belief_scope("get_dashboards", f"env_id={env_id}, search={search}, page={page}, page_size={page_size}"):
# Validate pagination parameters
if page < 1:
logger.error(f"[get_dashboards][Coherence:Failed] Invalid page: {page}")
raise HTTPException(status_code=400, detail="Page must be >= 1")
if page_size < 1 or page_size > 100:
logger.error(f"[get_dashboards][Coherence:Failed] Invalid page_size: {page_size}")
raise HTTPException(status_code=400, detail="Page size must be between 1 and 100")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dashboards][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
profile_service = ProfileService(db=db, config_manager=config_manager)
bound_username: Optional[str] = None
can_apply_profile_filter = False
effective_profile_filter = EffectiveProfileFilter(
applied=False,
source_page=page_context,
override_show_all=bool(override_show_all),
username=None,
match_logic=None,
)
try:
profile_preference = profile_service.get_my_preference(current_user).preference
normalized_username = str(
getattr(profile_preference, "superset_username_normalized", None) or ""
).strip().lower()
raw_username = str(
getattr(profile_preference, "superset_username", None) or ""
).strip().lower()
bound_username = normalized_username or raw_username or None
can_apply_profile_filter = (
page_context == "dashboards_main"
and bool(apply_profile_default)
and not bool(override_show_all)
and bool(getattr(profile_preference, "show_only_my_dashboards", False))
and bool(bound_username)
)
effective_profile_filter = EffectiveProfileFilter(
applied=bool(can_apply_profile_filter),
source_page=page_context,
override_show_all=bool(override_show_all),
username=bound_username if can_apply_profile_filter else None,
match_logic="owners_or_modified_by" if can_apply_profile_filter else None,
)
except Exception as profile_error:
logger.explore(
f"[EXPLORE] Profile preference unavailable; continuing without profile-default filter: {profile_error}"
)
try:
# Get all tasks for status lookup
all_tasks = task_manager.get_all_tasks()
title_filters = _normalize_filter_values(filter_title)
git_filters = _normalize_filter_values(filter_git_status)
llm_filters = _normalize_filter_values(filter_llm_status)
changed_on_filters = _normalize_filter_values(filter_changed_on)
actor_filters = _normalize_filter_values(filter_actor)
has_column_filters = any(
(
title_filters,
git_filters,
llm_filters,
changed_on_filters,
actor_filters,
)
)
needs_full_scan = has_column_filters or bool(can_apply_profile_filter)
if isinstance(resource_service, ResourceService) and not needs_full_scan:
try:
page_payload = await resource_service.get_dashboards_page_with_status(
env,
all_tasks,
page=page,
page_size=page_size,
search=search,
include_git_status=False,
)
paginated_dashboards = page_payload["dashboards"]
total = page_payload["total"]
total_pages = page_payload["total_pages"]
except Exception as page_error:
logger.warning(
"[get_dashboards][Action] Page-based fetch failed; using compatibility fallback: %s",
page_error,
)
dashboards = await resource_service.get_dashboards_with_status(
env,
all_tasks,
include_git_status=False,
)
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()
]
total = len(dashboards)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_dashboards = dashboards[start_idx:end_idx]
else:
dashboards = await resource_service.get_dashboards_with_status(
env,
all_tasks,
include_git_status=bool(git_filters),
)
if can_apply_profile_filter and bound_username:
actor_aliases = _resolve_profile_actor_aliases(env, bound_username)
if not actor_aliases:
actor_aliases = [bound_username]
logger.reason(
"[REASON] Applying profile actor filter "
f"(env={env_id}, bound_username={bound_username}, actor_aliases={actor_aliases!r}, "
f"dashboards_before={len(dashboards)})"
)
filtered_dashboards: List[Dict[str, Any]] = []
max_actor_samples = 15
for index, dashboard in enumerate(dashboards):
owners_value = dashboard.get("owners")
created_by_value = dashboard.get("created_by")
modified_by_value = dashboard.get("modified_by")
matches_actor = _matches_dashboard_actor_aliases(
profile_service=profile_service,
actor_aliases=actor_aliases,
owners=owners_value,
modified_by=modified_by_value,
)
if index < max_actor_samples:
logger.reflect(
"[REFLECT] Profile actor filter sample "
f"(env={env_id}, dashboard_id={dashboard.get('id')}, "
f"bound_username={bound_username!r}, actor_aliases={actor_aliases!r}, "
f"owners={owners_value!r}, created_by={created_by_value!r}, "
f"modified_by={modified_by_value!r}, matches={matches_actor})"
)
if matches_actor:
filtered_dashboards.append(dashboard)
logger.reflect(
"[REFLECT] Profile actor filter summary "
f"(env={env_id}, bound_username={bound_username!r}, "
f"dashboards_before={len(dashboards)}, dashboards_after={len(filtered_dashboards)})"
)
dashboards = filtered_dashboards
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()
]
def _matches_dashboard_filters(dashboard: Dict[str, Any]) -> bool:
title_value = str(dashboard.get("title") or "").strip().lower()
if title_filters and title_value not in title_filters:
return False
if git_filters:
git_value = _dashboard_git_filter_value(dashboard)
if git_value not in git_filters:
return False
llm_value = str(
((dashboard.get("last_task") or {}).get("validation_status"))
or "UNKNOWN"
).strip().lower()
if llm_filters and llm_value not in llm_filters:
return False
changed_on_raw = str(dashboard.get("last_modified") or "").strip().lower()
changed_on_prefix = (
changed_on_raw[:10] if len(changed_on_raw) >= 10 else changed_on_raw
)
if (
changed_on_filters
and changed_on_raw not in changed_on_filters
and changed_on_prefix not in changed_on_filters
):
return False
owners = dashboard.get("owners") or []
if isinstance(owners, list):
actor_value = ", ".join(
str(item).strip() for item in owners if str(item).strip()
).lower()
else:
actor_value = str(owners).strip().lower()
if not actor_value:
actor_value = "-"
if actor_filters and actor_value not in actor_filters:
return False
return True
if has_column_filters:
dashboards = [d for d in dashboards if _matches_dashboard_filters(d)]
total = len(dashboards)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_dashboards = dashboards[start_idx:end_idx]
logger.info(
f"[get_dashboards][Coherence:OK] Returning {len(paginated_dashboards)} dashboards "
f"(page {page}/{total_pages}, total: {total}, profile_filter_applied={effective_profile_filter.applied})"
)
response_dashboards = _project_dashboard_response_items(paginated_dashboards)
# Fetch dashboards with status using ResourceService
dashboards = await resource_service.get_dashboards_with_status(env, all_tasks)
# Apply search filter if provided
if search:
search_lower = search.lower()
dashboards = [
d for d in dashboards
if search_lower in d.get('title', '').lower()
or search_lower in d.get('slug', '').lower()
]
# Calculate pagination
total = len(dashboards)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
# Slice dashboards for current page
paginated_dashboards = dashboards[start_idx:end_idx]
logger.info(f"[get_dashboards][Coherence:OK] Returning {len(paginated_dashboards)} dashboards (page {page}/{total_pages}, total: {total})")
return DashboardsResponse(
dashboards=response_dashboards,
dashboards=paginated_dashboards,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
effective_profile_filter=effective_profile_filter,
total_pages=total_pages
)
except Exception as e:
logger.error(f"[get_dashboards][Coherence:Failed] Failed to fetch dashboards: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboards: {str(e)}")
@@ -766,23 +219,10 @@ async def get_dashboards(
async def get_database_mappings(
source_env_id: str,
target_env_id: str,
config_manager=Depends(get_config_manager),
mapping_service=Depends(get_mapping_service),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_database_mappings", f"source={source_env_id}, target={target_env_id}"):
# Validate environments exist
environments = config_manager.get_environments()
source_env = next((e for e in environments if e.id == source_env_id), None)
target_env = next((e for e in environments if e.id == target_env_id), None)
if not source_env:
logger.error(f"[get_database_mappings][Coherence:Failed] Source environment not found: {source_env_id}")
raise HTTPException(status_code=404, detail="Source environment not found")
if not target_env:
logger.error(f"[get_database_mappings][Coherence:Failed] Target environment not found: {target_env_id}")
raise HTTPException(status_code=404, detail="Target environment not found")
try:
# Get mapping suggestions using MappingService
suggestions = await mapping_service.get_suggestions(source_env_id, target_env_id)
@@ -810,29 +250,28 @@ async def get_database_mappings(
# [DEF:get_dashboard_detail:Function]
# @PURPOSE: Fetch detailed dashboard info with related charts and datasets
# @PRE: env_id must be valid and dashboard ref (slug or id) must exist
# @PRE: env_id must be valid and dashboard_id must exist
# @POST: Returns dashboard detail payload for overview page
# @RELATION: CALLS -> SupersetClient.get_dashboard_detail
@router.get("/{dashboard_ref}", response_model=DashboardDetailResponse)
@router.get("/{dashboard_id:int}", response_model=DashboardDetailResponse)
async def get_dashboard_detail(
dashboard_ref: str,
dashboard_id: int,
env_id: str,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_dashboard_detail", f"dashboard_ref={dashboard_ref}, env_id={env_id}"):
with belief_scope("get_dashboard_detail", f"dashboard_id={dashboard_id}, env_id={env_id}"):
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dashboard_detail][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
client = AsyncSupersetClient(env)
try:
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, client)
detail = await client.get_dashboard_detail_async(dashboard_id)
client = SupersetClient(env)
detail = client.get_dashboard_detail(dashboard_id)
logger.info(
f"[get_dashboard_detail][Coherence:OK] Dashboard ref={dashboard_ref} resolved_id={dashboard_id}: {detail.get('chart_count', 0)} charts, {detail.get('dataset_count', 0)} datasets"
f"[get_dashboard_detail][Coherence:OK] Dashboard {dashboard_id}: {detail.get('chart_count', 0)} charts, {detail.get('dataset_count', 0)} datasets"
)
return DashboardDetailResponse(**detail)
except HTTPException:
@@ -840,8 +279,6 @@ async def get_dashboard_detail(
except Exception as e:
logger.error(f"[get_dashboard_detail][Coherence:Failed] Failed to fetch dashboard detail: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard detail: {str(e)}")
finally:
await client.aclose()
# [/DEF:get_dashboard_detail:Function]
@@ -880,87 +317,61 @@ def _task_matches_dashboard(task: Any, dashboard_id: int, env_id: Optional[str])
# [DEF:get_dashboard_tasks_history:Function]
# @PURPOSE: Returns history of backup and LLM validation tasks for a dashboard.
# @PRE: dashboard ref (slug or id) is valid.
# @PRE: dashboard_id is valid integer.
# @POST: Response contains sorted task history (newest first).
@router.get("/{dashboard_ref}/tasks", response_model=DashboardTaskHistoryResponse)
@router.get("/{dashboard_id:int}/tasks", response_model=DashboardTaskHistoryResponse)
async def get_dashboard_tasks_history(
dashboard_ref: str,
dashboard_id: int,
env_id: Optional[str] = None,
limit: int = Query(20, ge=1, le=100),
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
with belief_scope("get_dashboard_tasks_history", f"dashboard_ref={dashboard_ref}, env_id={env_id}, limit={limit}"):
dashboard_id: Optional[int] = None
client: Optional[AsyncSupersetClient] = None
try:
if dashboard_ref.isdigit():
dashboard_id = int(dashboard_ref)
elif env_id:
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dashboard_tasks_history][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
client = AsyncSupersetClient(env)
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, client)
else:
logger.error(
"[get_dashboard_tasks_history][Coherence:Failed] Non-numeric dashboard ref requires env_id"
with belief_scope("get_dashboard_tasks_history", f"dashboard_id={dashboard_id}, env_id={env_id}, limit={limit}"):
matching_tasks = []
for task in task_manager.get_all_tasks():
if _task_matches_dashboard(task, dashboard_id, env_id):
matching_tasks.append(task)
def _sort_key(task_obj: Any) -> str:
return (
str(getattr(task_obj, "started_at", "") or "")
or str(getattr(task_obj, "finished_at", "") or "")
)
matching_tasks.sort(key=_sort_key, reverse=True)
selected = matching_tasks[:limit]
items = []
for task in selected:
result = getattr(task, "result", None)
summary = None
validation_status = None
if isinstance(result, dict):
raw_validation_status = result.get("status")
if raw_validation_status is not None:
validation_status = str(raw_validation_status)
summary = (
result.get("summary")
or result.get("status")
or result.get("message")
)
raise HTTPException(
status_code=400,
detail="env_id is required when dashboard reference is a slug",
params = getattr(task, "params", {}) or {}
items.append(
DashboardTaskHistoryItem(
id=str(getattr(task, "id", "")),
plugin_id=str(getattr(task, "plugin_id", "")),
status=str(getattr(task, "status", "")),
validation_status=validation_status,
started_at=getattr(task, "started_at", None).isoformat() if getattr(task, "started_at", None) else None,
finished_at=getattr(task, "finished_at", None).isoformat() if getattr(task, "finished_at", None) else None,
env_id=str(params.get("environment_id") or params.get("env")) if (params.get("environment_id") or params.get("env")) else None,
summary=summary,
)
)
matching_tasks = []
for task in task_manager.get_all_tasks():
if _task_matches_dashboard(task, dashboard_id, env_id):
matching_tasks.append(task)
def _sort_key(task_obj: Any) -> str:
return (
str(getattr(task_obj, "started_at", "") or "")
or str(getattr(task_obj, "finished_at", "") or "")
)
matching_tasks.sort(key=_sort_key, reverse=True)
selected = matching_tasks[:limit]
items = []
for task in selected:
result = getattr(task, "result", None)
summary = None
validation_status = None
if isinstance(result, dict):
raw_validation_status = result.get("status")
if raw_validation_status is not None:
validation_status = str(raw_validation_status)
summary = (
result.get("summary")
or result.get("status")
or result.get("message")
)
params = getattr(task, "params", {}) or {}
items.append(
DashboardTaskHistoryItem(
id=str(getattr(task, "id", "")),
plugin_id=str(getattr(task, "plugin_id", "")),
status=str(getattr(task, "status", "")),
validation_status=validation_status,
started_at=getattr(task, "started_at", None).isoformat() if getattr(task, "started_at", None) else None,
finished_at=getattr(task, "finished_at", None).isoformat() if getattr(task, "finished_at", None) else None,
env_id=str(params.get("environment_id") or params.get("env")) if (params.get("environment_id") or params.get("env")) else None,
summary=summary,
)
)
logger.info(f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard_ref={dashboard_ref}, dashboard_id={dashboard_id}")
return DashboardTaskHistoryResponse(dashboard_id=dashboard_id, items=items)
finally:
if client is not None:
await client.aclose()
logger.info(f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard {dashboard_id}")
return DashboardTaskHistoryResponse(dashboard_id=dashboard_id, items=items)
# [/DEF:get_dashboard_tasks_history:Function]
@@ -968,30 +379,29 @@ async def get_dashboard_tasks_history(
# @PURPOSE: Proxies Superset dashboard thumbnail with cache support.
# @PRE: env_id must exist.
# @POST: Returns image bytes or 202 when thumbnail is being prepared by Superset.
@router.get("/{dashboard_ref}/thumbnail")
@router.get("/{dashboard_id:int}/thumbnail")
async def get_dashboard_thumbnail(
dashboard_ref: str,
dashboard_id: int,
env_id: str,
force: bool = Query(False),
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_dashboard_thumbnail", f"dashboard_ref={dashboard_ref}, env_id={env_id}, force={force}"):
with belief_scope("get_dashboard_thumbnail", f"dashboard_id={dashboard_id}, env_id={env_id}, force={force}"):
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
client = AsyncSupersetClient(env)
try:
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, client)
client = SupersetClient(env)
digest = None
thumb_endpoint = None
# Preferred flow (newer Superset): ask server to cache screenshot and return digest/image_url.
try:
screenshot_payload = await client.network.request(
screenshot_payload = client.network.request(
method="POST",
endpoint=f"/dashboard/{dashboard_id}/cache_dashboard_screenshot/",
json={"force": force},
@@ -1009,7 +419,7 @@ async def get_dashboard_thumbnail(
# Fallback flow (older Superset): read thumbnail_url from dashboard payload.
if not digest:
dashboard_payload = await client.network.request(
dashboard_payload = client.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id}",
)
@@ -1028,7 +438,7 @@ async def get_dashboard_thumbnail(
if not thumb_endpoint:
thumb_endpoint = f"/dashboard/{dashboard_id}/thumbnail/{digest or 'latest'}/"
thumb_response = await client.network.request(
thumb_response = client.network.request(
method="GET",
endpoint=thumb_endpoint,
raw_response=True,
@@ -1053,8 +463,6 @@ async def get_dashboard_thumbnail(
except Exception as e:
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Failed to fetch dashboard thumbnail: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard thumbnail: {str(e)}")
finally:
await client.aclose()
# [/DEF:get_dashboard_thumbnail:Function]
# [DEF:MigrateRequest:DataClass]

View File

@@ -20,18 +20,6 @@ from ...core.logger import belief_scope
router = APIRouter(prefix="/api/environments", tags=["Environments"])
# [DEF:_normalize_superset_env_url:Function]
# @PURPOSE: Canonicalize Superset environment URL to base host/path without trailing /api/v1.
# @PRE: raw_url can be empty.
# @POST: Returns normalized base URL.
def _normalize_superset_env_url(raw_url: str) -> str:
normalized = str(raw_url or "").strip().rstrip("/")
if normalized.lower().endswith("/api/v1"):
normalized = normalized[:-len("/api/v1")]
return normalized.rstrip("/")
# [/DEF:_normalize_superset_env_url:Function]
# [DEF:ScheduleSchema:DataClass]
class ScheduleSchema(BaseModel):
enabled: bool = False
@@ -43,7 +31,6 @@ class EnvironmentResponse(BaseModel):
id: str
name: str
url: str
stage: str = "DEV"
is_production: bool = False
backup_schedule: Optional[ScheduleSchema] = None
# [/DEF:EnvironmentResponse:DataClass]
@@ -72,26 +59,18 @@ async def get_environments(
# Ensure envs is a list
if not isinstance(envs, list):
envs = []
response_items = []
for e in envs:
resolved_stage = str(
getattr(e, "stage", "")
or ("PROD" if bool(getattr(e, "is_production", False)) else "DEV")
).upper()
response_items.append(
EnvironmentResponse(
id=e.id,
name=e.name,
url=_normalize_superset_env_url(e.url),
stage=resolved_stage,
is_production=(resolved_stage == "PROD"),
backup_schedule=ScheduleSchema(
enabled=e.backup_schedule.enabled,
cron_expression=e.backup_schedule.cron_expression
) if getattr(e, 'backup_schedule', None) else None
)
)
return response_items
return [
EnvironmentResponse(
id=e.id,
name=e.name,
url=e.url,
is_production=getattr(e, "is_production", False),
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]

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
# @INVARIANT: All schemas must be compatible with the FastAPI router.
from pydantic import BaseModel, Field
from typing import Any, Dict, List, Optional
from typing import List, Optional
from datetime import datetime
from src.models.git import GitProvider, GitStatus, SyncStatus
@@ -21,27 +21,14 @@ class GitServerConfigBase(BaseModel):
provider: GitProvider = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)")
url: str = Field(..., description="Server base URL")
pat: str = Field(..., description="Personal Access Token")
pat: str = Field(..., description="Personal Access Token")
default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)")
default_branch: Optional[str] = Field("main", description="Default branch logic/name")
# [/DEF:GitServerConfigBase:Class]
# [DEF:GitServerConfigUpdate:Class]
# @PURPOSE: Schema for updating an existing Git server configuration.
class GitServerConfigUpdate(BaseModel):
name: Optional[str] = Field(None, description="Display name for the Git server")
provider: Optional[GitProvider] = Field(None, description="Git provider (GITHUB, GITLAB, GITEA)")
url: Optional[str] = Field(None, description="Server base URL")
pat: Optional[str] = Field(None, description="Personal Access Token")
default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)")
default_branch: Optional[str] = Field(None, description="Default branch logic/name")
# [/DEF:GitServerConfigUpdate:Class]
# [DEF:GitServerConfigCreate:Class]
# @PURPOSE: Schema for creating a new Git server configuration.
class GitServerConfigCreate(GitServerConfigBase):
"""Schema for creating a new Git server configuration."""
config_id: Optional[str] = Field(None, description="Optional config ID, useful for testing an existing config without sending its full PAT")
pass
# [/DEF:GitServerConfigCreate:Class]
# [DEF:GitServerConfigSchema:Class]
@@ -126,42 +113,6 @@ class ConflictResolution(BaseModel):
content: Optional[str] = None
# [/DEF:ConflictResolution:Class]
# [DEF:MergeStatusSchema:Class]
# @PURPOSE: Schema representing unfinished merge status for repository.
class MergeStatusSchema(BaseModel):
has_unfinished_merge: bool
repository_path: str
git_dir: str
current_branch: str
merge_head: Optional[str] = None
merge_message_preview: Optional[str] = None
conflicts_count: int = 0
# [/DEF:MergeStatusSchema:Class]
# [DEF:MergeConflictFileSchema:Class]
# @PURPOSE: Schema describing one conflicted file with optional side snapshots.
class MergeConflictFileSchema(BaseModel):
file_path: str
mine: Optional[str] = None
theirs: Optional[str] = None
# [/DEF:MergeConflictFileSchema:Class]
# [DEF:MergeResolveRequest:Class]
# @PURPOSE: Request schema for resolving one or multiple merge conflicts.
class MergeResolveRequest(BaseModel):
resolutions: List[ConflictResolution] = Field(default_factory=list)
# [/DEF:MergeResolveRequest:Class]
# [DEF:MergeContinueRequest:Class]
# @PURPOSE: Request schema for finishing merge with optional explicit commit message.
class MergeContinueRequest(BaseModel):
message: Optional[str] = None
# [/DEF:MergeContinueRequest:Class]
# [DEF:DeploymentEnvironmentSchema:Class]
# @PURPOSE: Schema for representing a target deployment environment.
class DeploymentEnvironmentSchema(BaseModel):
@@ -190,104 +141,4 @@ class RepoInitRequest(BaseModel):
remote_url: str
# [/DEF:RepoInitRequest:Class]
# [DEF:RepositoryBindingSchema:Class]
# @PURPOSE: Schema describing repository-to-config binding and provider metadata.
class RepositoryBindingSchema(BaseModel):
dashboard_id: int
config_id: str
provider: GitProvider
remote_url: str
local_path: str
# [/DEF:RepositoryBindingSchema:Class]
# [DEF:RepoStatusBatchRequest:Class]
# @PURPOSE: Schema for requesting repository statuses for multiple dashboards in a single call.
class RepoStatusBatchRequest(BaseModel):
dashboard_ids: List[int] = Field(default_factory=list, description="Dashboard IDs to resolve repository statuses for")
# [/DEF:RepoStatusBatchRequest:Class]
# [DEF:RepoStatusBatchResponse:Class]
# @PURPOSE: Schema for returning repository statuses keyed by dashboard ID.
class RepoStatusBatchResponse(BaseModel):
statuses: Dict[str, Dict[str, Any]]
# [/DEF:RepoStatusBatchResponse:Class]
# [DEF:GiteaRepoSchema:Class]
# @PURPOSE: Schema describing a Gitea repository.
class GiteaRepoSchema(BaseModel):
name: str
full_name: str
private: bool = False
clone_url: Optional[str] = None
html_url: Optional[str] = None
ssh_url: Optional[str] = None
default_branch: Optional[str] = None
# [/DEF:GiteaRepoSchema:Class]
# [DEF:GiteaRepoCreateRequest:Class]
# @PURPOSE: Request schema for creating a Gitea repository.
class GiteaRepoCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
private: bool = True
description: Optional[str] = None
auto_init: bool = True
default_branch: Optional[str] = "main"
# [/DEF:GiteaRepoCreateRequest:Class]
# [DEF:RemoteRepoSchema:Class]
# @PURPOSE: Provider-agnostic remote repository payload.
class RemoteRepoSchema(BaseModel):
provider: GitProvider
name: str
full_name: str
private: bool = False
clone_url: Optional[str] = None
html_url: Optional[str] = None
ssh_url: Optional[str] = None
default_branch: Optional[str] = None
# [/DEF:RemoteRepoSchema:Class]
# [DEF:RemoteRepoCreateRequest:Class]
# @PURPOSE: Provider-agnostic repository creation request.
class RemoteRepoCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
private: bool = True
description: Optional[str] = None
auto_init: bool = True
default_branch: Optional[str] = "main"
# [/DEF:RemoteRepoCreateRequest:Class]
# [DEF:PromoteRequest:Class]
# @PURPOSE: Request schema for branch promotion workflow.
class PromoteRequest(BaseModel):
from_branch: str = Field(..., min_length=1, max_length=255)
to_branch: str = Field(..., min_length=1, max_length=255)
mode: str = Field(default="mr", pattern="^(mr|direct)$")
title: Optional[str] = None
description: Optional[str] = None
reason: Optional[str] = None
draft: bool = False
remove_source_branch: bool = False
# [/DEF:PromoteRequest:Class]
# [DEF:PromoteResponse:Class]
# @PURPOSE: Response schema for promotion operation result.
class PromoteResponse(BaseModel):
mode: str
from_branch: str
to_branch: str
status: str
url: Optional[str] = None
reference_id: Optional[str] = None
policy_violation: bool = False
# [/DEF:PromoteResponse:Class]
# [/DEF:backend.src.api.routes.git_schemas:Module]
# [/DEF:backend.src.api.routes.git_schemas:Module]

View File

@@ -14,7 +14,6 @@ from ...core.database import get_db
from ...models.dashboard import DashboardMetadata, DashboardSelection
from ...core.superset_client import SupersetClient
from ...core.logger import belief_scope
from ...core.migration.dry_run_orchestrator import MigrationDryRunService
from ...core.mapping_service import IdMappingService
from ...models.mapping import ResourceMapping
@@ -84,44 +83,6 @@ async def execute_migration(
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
# [/DEF:execute_migration:Function]
# [DEF:dry_run_migration:Function]
# @PURPOSE: Build pre-flight diff and risk summary without applying migration.
# @PRE: Selection and environments are valid.
# @POST: Returns deterministic JSON diff and risk scoring.
@router.post("/migration/dry-run", response_model=Dict[str, Any])
async def dry_run_migration(
selection: DashboardSelection,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
with belief_scope("dry_run_migration"):
environments = config_manager.get_environments()
env_map = {env.id: env for env in environments}
source_env = env_map.get(selection.source_env_id)
target_env = env_map.get(selection.target_env_id)
if not source_env or not target_env:
raise HTTPException(status_code=400, detail="Invalid source or target environment")
if selection.source_env_id == selection.target_env_id:
raise HTTPException(status_code=400, detail="Source and target environments must be different")
if not selection.selected_ids:
raise HTTPException(status_code=400, detail="No dashboards selected for dry run")
service = MigrationDryRunService()
source_client = SupersetClient(source_env)
target_client = SupersetClient(target_env)
try:
return service.run(
selection=selection,
source_client=source_client,
target_client=target_client,
db=db,
)
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
# [/DEF:dry_run_migration:Function]
# [DEF:get_migration_settings:Function]
# @PURPOSE: Get current migration Cron string explicitly.
@router.get("/migration/settings", response_model=Dict[str, str])
@@ -260,4 +221,4 @@ async def trigger_sync_now(
}
# [/DEF:trigger_sync_now:Function]
# [/DEF:backend.src.api.routes.migration:Module]
# [/DEF:backend.src.api.routes.migration:Module]

View File

@@ -1,147 +0,0 @@
# [DEF:backend.src.api.routes.profile:Module]
#
# @TIER: CRITICAL
# @SEMANTICS: api, profile, preferences, self-service, account-lookup
# @PURPOSE: Exposes self-scoped profile preference endpoints and environment-based Superset account lookup.
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.services.profile_service
# @RELATION: DEPENDS_ON -> backend.src.dependencies.get_current_user
# @RELATION: DEPENDS_ON -> backend.src.core.database.get_db
#
# @INVARIANT: Endpoints are self-scoped and never mutate another user preference.
# @UX_STATE: ProfileLoad -> Returns stable ProfilePreferenceResponse for authenticated user.
# @UX_STATE: Saving -> Validation errors map to actionable 422 details.
# @UX_STATE: LookupLoading -> Returns success/degraded Superset lookup payload.
# @UX_FEEDBACK: Stable status/message/warning payloads support profile page feedback.
# @UX_RECOVERY: Lookup degradation keeps manual username save path available.
# [SECTION: IMPORTS]
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.logger import logger, belief_scope
from ...dependencies import (
get_config_manager,
get_current_user,
get_plugin_loader,
)
from ...models.auth import User
from ...schemas.profile import (
ProfilePreferenceResponse,
ProfilePreferenceUpdateRequest,
SupersetAccountLookupRequest,
SupersetAccountLookupResponse,
)
from ...services.profile_service import (
EnvironmentNotFoundError,
ProfileAuthorizationError,
ProfileService,
ProfileValidationError,
)
# [/SECTION]
router = APIRouter(prefix="/api/profile", tags=["profile"])
# [DEF:_get_profile_service:Function]
# @PURPOSE: Build profile service for current request scope.
# @PRE: db session and config manager are available.
# @POST: Returns a ready ProfileService instance.
def _get_profile_service(db: Session, config_manager, plugin_loader=None) -> ProfileService:
return ProfileService(
db=db,
config_manager=config_manager,
plugin_loader=plugin_loader,
)
# [/DEF:_get_profile_service:Function]
# [DEF:get_preferences:Function]
# @PURPOSE: Get authenticated user's dashboard filter preference.
# @PRE: Valid JWT and authenticated user context.
# @POST: Returns preference payload for current user only.
@router.get("/preferences", response_model=ProfilePreferenceResponse)
async def get_preferences(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
config_manager=Depends(get_config_manager),
plugin_loader=Depends(get_plugin_loader),
):
with belief_scope("profile.get_preferences", f"user_id={current_user.id}"):
logger.reason("[REASON] Resolving current user preference")
service = _get_profile_service(db, config_manager, plugin_loader)
return service.get_my_preference(current_user)
# [/DEF:get_preferences:Function]
# [DEF:update_preferences:Function]
# @PURPOSE: Update authenticated user's dashboard filter preference.
# @PRE: Valid JWT and valid request payload.
# @POST: Persists normalized preference for current user or raises validation/authorization errors.
@router.patch("/preferences", response_model=ProfilePreferenceResponse)
async def update_preferences(
payload: ProfilePreferenceUpdateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
config_manager=Depends(get_config_manager),
plugin_loader=Depends(get_plugin_loader),
):
with belief_scope("profile.update_preferences", f"user_id={current_user.id}"):
service = _get_profile_service(db, config_manager, plugin_loader)
try:
logger.reason("[REASON] Attempting preference save")
return service.update_my_preference(current_user=current_user, payload=payload)
except ProfileValidationError as exc:
logger.reflect("[REFLECT] Preference validation failed")
raise HTTPException(status_code=422, detail=exc.errors) from exc
except ProfileAuthorizationError as exc:
logger.explore("[EXPLORE] Cross-user mutation guard blocked request")
raise HTTPException(status_code=403, detail=str(exc)) from exc
# [/DEF:update_preferences:Function]
# [DEF:lookup_superset_accounts:Function]
# @PURPOSE: Lookup Superset account candidates in selected environment.
# @PRE: Valid JWT, authenticated context, and environment_id query parameter.
# @POST: Returns success or degraded lookup payload with stable shape.
@router.get("/superset-accounts", response_model=SupersetAccountLookupResponse)
async def lookup_superset_accounts(
environment_id: str = Query(...),
search: Optional[str] = Query(default=None),
page_index: int = Query(default=0, ge=0),
page_size: int = Query(default=20, ge=1, le=100),
sort_column: str = Query(default="username"),
sort_order: str = Query(default="desc"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
config_manager=Depends(get_config_manager),
plugin_loader=Depends(get_plugin_loader),
):
with belief_scope(
"profile.lookup_superset_accounts",
f"user_id={current_user.id}, environment_id={environment_id}",
):
service = _get_profile_service(db, config_manager, plugin_loader)
lookup_request = SupersetAccountLookupRequest(
environment_id=environment_id,
search=search,
page_index=page_index,
page_size=page_size,
sort_column=sort_column,
sort_order=sort_order,
)
try:
logger.reason("[REASON] Executing Superset account lookup")
return service.lookup_superset_accounts(
current_user=current_user,
request=lookup_request,
)
except EnvironmentNotFoundError as exc:
logger.explore("[EXPLORE] Lookup request references unknown environment")
raise HTTPException(status_code=404, detail=str(exc)) from exc
# [/DEF:lookup_superset_accounts:Function]
# [/DEF:backend.src.api.routes.profile:Module]

View File

@@ -13,11 +13,10 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from ...dependencies import get_task_manager, has_permission, get_clean_release_repository
from ...dependencies import get_task_manager, has_permission
from ...core.task_manager import TaskManager
from ...core.logger import belief_scope
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskType
from ...services.clean_release.repository import CleanReleaseRepository
from ...services.reports.report_service import ReportsService
# [/SECTION]
@@ -89,7 +88,6 @@ async def list_reports(
sort_by: str = Query("updated_at"),
sort_order: str = Query("desc"),
task_manager: TaskManager = Depends(get_task_manager),
clean_release_repository: CleanReleaseRepository = Depends(get_clean_release_repository),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope("list_reports"):
@@ -119,7 +117,7 @@ async def list_reports(
},
)
service = ReportsService(task_manager, clean_release_repository=clean_release_repository)
service = ReportsService(task_manager)
return service.list_reports(query)
# [/DEF:list_reports:Function]
@@ -132,11 +130,10 @@ async def list_reports(
async def get_report_detail(
report_id: str,
task_manager: TaskManager = Depends(get_task_manager),
clean_release_repository: CleanReleaseRepository = Depends(get_clean_release_repository),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope("get_report_detail", f"report_id={report_id}"):
service = ReportsService(task_manager, clean_release_repository=clean_release_repository)
service = ReportsService(task_manager)
detail = service.get_report_detail(report_id)
if not detail:
raise HTTPException(

View File

@@ -31,38 +31,7 @@ class LoggingConfigResponse(BaseModel):
enable_belief_state: bool
# [/DEF:LoggingConfigResponse:Class]
router = APIRouter()
# [DEF:_normalize_superset_env_url:Function]
# @PURPOSE: Canonicalize Superset environment URL to base host/path without trailing /api/v1.
# @PRE: raw_url can be empty.
# @POST: Returns normalized base URL.
def _normalize_superset_env_url(raw_url: str) -> str:
normalized = str(raw_url or "").strip().rstrip("/")
if normalized.lower().endswith("/api/v1"):
normalized = normalized[:-len("/api/v1")]
return normalized.rstrip("/")
# [/DEF:_normalize_superset_env_url:Function]
# [DEF:_validate_superset_connection_fast:Function]
# @PURPOSE: Run lightweight Superset connectivity validation without full pagination scan.
# @PRE: env contains valid URL and credentials.
# @POST: Raises on auth/API failures; returns None on success.
def _validate_superset_connection_fast(env: Environment) -> None:
client = SupersetClient(env)
# 1) Explicit auth check
client.authenticate()
# 2) Single lightweight API call to ensure read access
client.get_dashboards_page(
query={
"page": 0,
"page_size": 1,
"columns": ["id"],
}
)
# [/DEF:_validate_superset_connection_fast:Function]
router = APIRouter()
# [DEF:get_settings:Function]
# @PURPOSE: Retrieves all application settings.
@@ -143,18 +112,14 @@ async def update_storage_settings(
# @PRE: Config manager is available.
# @POST: Returns list of environments.
# @RETURN: List[Environment] - List of environments.
@router.get("/environments", response_model=List[Environment])
async def get_environments(
@router.get("/environments", response_model=List[Environment])
async def get_environments(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments")
environments = config_manager.get_environments()
return [
env.copy(update={"url": _normalize_superset_env_url(env.url)})
for env in environments
]
):
with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments")
return config_manager.get_environments()
# [/DEF:get_environments:Function]
# [DEF:add_environment:Function]
@@ -164,21 +129,21 @@ async def get_environments(
# @PARAM: env (Environment) - The environment to add.
# @RETURN: Environment - The added environment.
@router.post("/environments", response_model=Environment)
async def add_environment(
env: Environment,
async def add_environment(
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
):
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
# Validate connection before adding (fast path)
try:
_validate_superset_connection_fast(env)
except Exception as e:
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
# Validate connection before adding
try:
client = SupersetClient(env)
client.get_dashboards(query={"page_size": 1})
except Exception as e:
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
config_manager.add_environment(env)
return env
@@ -192,29 +157,28 @@ async def add_environment(
# @PARAM: env (Environment) - The updated environment data.
# @RETURN: Environment - The updated environment.
@router.put("/environments/{id}", response_model=Environment)
async def update_environment(
async def update_environment(
id: str,
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager)
):
):
with belief_scope("update_environment"):
logger.info(f"[update_environment][Entry] Updating environment {id}")
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
# If password is masked, we need the real one for validation
env_to_validate = env.copy(deep=True)
# If password is masked, we need the real one for validation
env_to_validate = env.copy(deep=True)
if env_to_validate.password == "********":
old_env = next((e for e in config_manager.get_environments() if e.id == id), None)
if old_env:
env_to_validate.password = old_env.password
# Validate connection before updating (fast path)
try:
_validate_superset_connection_fast(env_to_validate)
except Exception as e:
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
# Validate connection before updating
try:
client = SupersetClient(env_to_validate)
client.get_dashboards(query={"page_size": 1})
except Exception as e:
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
if config_manager.update_environment(id, env):
return env
@@ -244,7 +208,7 @@ async def delete_environment(
# @PARAM: id (str) - The ID of the environment to test.
# @RETURN: dict - Success message or error.
@router.post("/environments/{id}/test")
async def test_environment_connection(
async def test_environment_connection(
id: str,
config_manager: ConfigManager = Depends(get_config_manager)
):
@@ -256,11 +220,15 @@ async def test_environment_connection(
if not env:
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
try:
_validate_superset_connection_fast(env)
logger.info(f"[test_environment_connection][Coherence:OK] Connection successful for {id}")
return {"status": "success", "message": "Connection successful"}
try:
# Initialize client (this will trigger authentication)
client = SupersetClient(env)
# Try a simple request to verify
client.get_dashboards(query={"page_size": 1})
logger.info(f"[test_environment_connection][Coherence:OK] Connection successful for {id}")
return {"status": "success", "message": "Connection successful"}
except Exception as e:
logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}")
return {"status": "error", "message": str(e)}

View File

@@ -4,30 +4,30 @@
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
# @LAYER: UI (API)
# @RELATION: Depends on the TaskManager. It is included by the main app.
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel
from ...core.logger import belief_scope
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...core.task_manager.models import LogFilter, LogStats
from ...dependencies import get_task_manager, has_permission, get_current_user, get_config_manager
from ...core.config_manager import ConfigManager
from ...services.llm_prompt_templates import (
is_multimodal_model,
normalize_llm_settings,
resolve_bound_provider_id,
)
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...core.task_manager.models import LogFilter, LogStats
from ...dependencies import get_task_manager, has_permission, get_current_user, get_config_manager
from ...core.config_manager import ConfigManager
from ...services.llm_prompt_templates import (
is_multimodal_model,
normalize_llm_settings,
resolve_bound_provider_id,
)
router = APIRouter()
TASK_TYPE_PLUGIN_MAP = {
"llm_validation": ["llm_dashboard_validation"],
"backup": ["superset-backup"],
"migration": ["superset-migration"],
}
class CreateTaskRequest(BaseModel):
router = APIRouter()
TASK_TYPE_PLUGIN_MAP = {
"llm_validation": ["llm_dashboard_validation"],
"backup": ["superset-backup"],
"migration": ["superset-migration"],
}
class CreateTaskRequest(BaseModel):
plugin_id: str
params: Dict[str, Any]
@@ -45,54 +45,54 @@ class ResumeTaskRequest(BaseModel):
# @PRE: plugin_id must exist and params must be valid for that plugin.
# @POST: A new task is created and started.
# @RETURN: Task - The created task instance.
async def create_task(
request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager),
current_user = Depends(get_current_user),
config_manager: ConfigManager = Depends(get_config_manager),
):
async def create_task(
request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager),
current_user = Depends(get_current_user),
config_manager: ConfigManager = Depends(get_config_manager),
):
# Dynamic permission check based on plugin_id
has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user)
"""
Create and start a new task for a given plugin.
"""
with belief_scope("create_task"):
try:
# Special handling for LLM tasks to resolve provider config by task binding.
if request.plugin_id in {"llm_dashboard_validation", "llm_documentation"}:
from ...core.database import SessionLocal
from ...services.llm_provider import LLMProviderService
db = SessionLocal()
try:
llm_service = LLMProviderService(db)
provider_id = request.params.get("provider_id")
if not provider_id:
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
binding_key = "dashboard_validation" if request.plugin_id == "llm_dashboard_validation" else "documentation"
provider_id = resolve_bound_provider_id(llm_settings, binding_key)
if provider_id:
request.params["provider_id"] = provider_id
if not provider_id:
providers = llm_service.get_all_providers()
active_provider = next((p for p in providers if p.is_active), None)
if active_provider:
provider_id = active_provider.id
request.params["provider_id"] = 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")
if request.plugin_id == "llm_dashboard_validation" and not is_multimodal_model(
db_provider.default_model,
db_provider.provider_type,
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Selected provider model is not multimodal for dashboard validation",
)
finally:
db.close()
try:
# Special handling for LLM tasks to resolve provider config by task binding.
if request.plugin_id in {"llm_dashboard_validation", "llm_documentation"}:
from ...core.database import SessionLocal
from ...services.llm_provider import LLMProviderService
db = SessionLocal()
try:
llm_service = LLMProviderService(db)
provider_id = request.params.get("provider_id")
if not provider_id:
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
binding_key = "dashboard_validation" if request.plugin_id == "llm_dashboard_validation" else "documentation"
provider_id = resolve_bound_provider_id(llm_settings, binding_key)
if provider_id:
request.params["provider_id"] = provider_id
if not provider_id:
providers = llm_service.get_all_providers()
active_provider = next((p for p in providers if p.is_active), None)
if active_provider:
provider_id = active_provider.id
request.params["provider_id"] = 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")
if request.plugin_id == "llm_dashboard_validation" and not is_multimodal_model(
db_provider.default_model,
db_provider.provider_type,
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Selected provider model is not multimodal for dashboard validation",
)
finally:
db.close()
task = await task_manager.create_task(
plugin_id=request.plugin_id,
@@ -113,36 +113,36 @@ async def create_task(
# @PRE: task_manager must be available.
# @POST: Returns a list of tasks.
# @RETURN: List[Task] - List of tasks.
async def list_tasks(
limit: int = 10,
offset: int = 0,
status_filter: Optional[TaskStatus] = Query(None, alias="status"),
task_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"),
plugin_id: Optional[List[str]] = Query(None, description="Filter by plugin_id (repeatable query param)"),
completed_only: bool = Query(False, description="Return only completed tasks (SUCCESS/FAILED)"),
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve a list of tasks with pagination and optional status filter.
"""
with belief_scope("list_tasks"):
plugin_filters = list(plugin_id) if plugin_id else []
if task_type:
if task_type not in TASK_TYPE_PLUGIN_MAP:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported task_type '{task_type}'. Allowed: {', '.join(TASK_TYPE_PLUGIN_MAP.keys())}"
)
plugin_filters.extend(TASK_TYPE_PLUGIN_MAP[task_type])
return task_manager.get_tasks(
limit=limit,
offset=offset,
status=status_filter,
plugin_ids=plugin_filters or None,
completed_only=completed_only
)
async def list_tasks(
limit: int = 10,
offset: int = 0,
status_filter: Optional[TaskStatus] = Query(None, alias="status"),
task_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"),
plugin_id: Optional[List[str]] = Query(None, description="Filter by plugin_id (repeatable query param)"),
completed_only: bool = Query(False, description="Return only completed tasks (SUCCESS/FAILED)"),
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve a list of tasks with pagination and optional status filter.
"""
with belief_scope("list_tasks"):
plugin_filters = list(plugin_id) if plugin_id else []
if task_type:
if task_type not in TASK_TYPE_PLUGIN_MAP:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported task_type '{task_type}'. Allowed: {', '.join(TASK_TYPE_PLUGIN_MAP.keys())}"
)
plugin_filters.extend(TASK_TYPE_PLUGIN_MAP[task_type])
return task_manager.get_tasks(
limit=limit,
offset=offset,
status=status_filter,
plugin_ids=plugin_filters or None,
completed_only=completed_only
)
# [/DEF:list_tasks:Function]
@router.get("/{task_id}", response_model=Task)
@@ -182,23 +182,6 @@ async def get_task(
# @POST: Returns a list of log entries or raises 404.
# @RETURN: List[LogEntry] - List of log entries.
# @TIER: CRITICAL
# @TEST_CONTRACT get_task_logs_api ->
# {
# required_params: {task_id: str},
# optional_params: {level: str, source: str, search: str},
# invariants: ["returns 404 for non-existent task", "applies filters correctly"]
# }
# @TEST_FIXTURE valid_task_logs_request -> {"task_id": "test_1", "level": "INFO"}
# @TEST_EDGE task_not_found -> raises 404
# @TEST_EDGE invalid_limit -> Query(limit=0) returns 422
# @TEST_INVARIANT response_purity -> verifies: [valid_task_logs_request]
# @TEST_CONTRACT: TaskLogQueryInput -> List[LogEntry]
# @TEST_SCENARIO: existing_task_logs_filtered -> Returns filtered logs by level/source/search with pagination.
# @TEST_FIXTURE: valid_task_with_mixed_logs -> backend/tests/fixtures/task_logs/valid_task_with_mixed_logs.json
# @TEST_EDGE: missing_task -> Unknown task_id returns 404 Task not found.
# @TEST_EDGE: invalid_level_type -> Non-string/invalid level query rejected by validation or yields empty result.
# @TEST_EDGE: pagination_bounds -> offset=0 and limit=1000 remain within API bounds and do not overflow.
# @TEST_INVARIANT: logs_only_for_existing_task -> VERIFIED_BY: [existing_task_logs_filtered, missing_task]
async def get_task_logs(
task_id: str,
level: Optional[str] = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
@@ -345,4 +328,4 @@ async def clear_tasks(
task_manager.clear_tasks(status)
return
# [/DEF:clear_tasks:Function]
# [/DEF:TasksRouter:Module]
# [/DEF:TasksRouter:Module]

View File

@@ -21,7 +21,7 @@ import asyncio
from .dependencies import get_task_manager, get_scheduler_service
from .core.utils.network import NetworkError
from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant, clean_release, clean_release_v2, profile
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant
from .api import auth
# [DEF:App:Global]
@@ -133,9 +133,6 @@ app.include_router(dashboards.router)
app.include_router(datasets.router)
app.include_router(reports.router)
app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"])
app.include_router(clean_release.router)
app.include_router(clean_release_v2.router)
app.include_router(profile.router)
# [DEF:api.include_routers:Action]

View File

@@ -1,128 +0,0 @@
# [DEF:backend.src.core.__tests__.test_superset_profile_lookup:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, superset, profile, lookup, fallback, sorting
# @PURPOSE: Verifies Superset profile lookup adapter payload normalization and fallback error precedence.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.core.superset_profile_lookup
# [SECTION: IMPORTS]
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
backend_dir = str(Path(__file__).parent.parent.parent.parent.resolve())
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
from src.core.superset_profile_lookup import SupersetAccountLookupAdapter
from src.core.utils.network import AuthenticationError, SupersetAPIError
# [/SECTION]
# [DEF:_RecordingNetworkClient:Class]
# @PURPOSE: Records request payloads and returns scripted responses for deterministic adapter tests.
class _RecordingNetworkClient:
# [DEF:__init__:Function]
# @PURPOSE: Initializes scripted network responses.
# @PRE: scripted_responses is ordered per expected request sequence.
# @POST: Instance stores response script and captures subsequent request calls.
def __init__(self, scripted_responses: List[Any]):
self._scripted_responses = scripted_responses
self.calls: List[Dict[str, Any]] = []
# [/DEF:__init__:Function]
# [DEF:request:Function]
# @PURPOSE: Mimics APIClient.request while capturing call arguments.
# @PRE: method and endpoint are provided.
# @POST: Returns scripted response or raises scripted exception.
def request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
**kwargs,
) -> Dict[str, Any]:
self.calls.append(
{
"method": method,
"endpoint": endpoint,
"params": params or {},
}
)
index = len(self.calls) - 1
response = self._scripted_responses[index]
if isinstance(response, Exception):
raise response
return response
# [/DEF:request:Function]
# [/DEF:_RecordingNetworkClient:Class]
# [DEF:test_get_users_page_sends_lowercase_order_direction:Function]
# @PURPOSE: Ensures adapter sends lowercase order_direction compatible with Superset rison schema.
# @PRE: Adapter is initialized with recording network client.
# @POST: First request query payload contains order_direction='asc' for asc sort.
def test_get_users_page_sends_lowercase_order_direction():
client = _RecordingNetworkClient(
scripted_responses=[{"result": [{"username": "admin"}], "count": 1}]
)
adapter = SupersetAccountLookupAdapter(network_client=client, environment_id="ss-dev")
adapter.get_users_page(
search="admin",
page_index=0,
page_size=20,
sort_column="username",
sort_order="asc",
)
sent_query = json.loads(client.calls[0]["params"]["q"])
assert sent_query["order_direction"] == "asc"
# [/DEF:test_get_users_page_sends_lowercase_order_direction:Function]
# [DEF:test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error:Function]
# @PURPOSE: Ensures fallback auth error does not mask primary schema/query failure.
# @PRE: Primary endpoint fails with SupersetAPIError and fallback fails with AuthenticationError.
# @POST: Raised exception remains primary SupersetAPIError (non-auth) to preserve root cause.
def test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error():
client = _RecordingNetworkClient(
scripted_responses=[
SupersetAPIError("API Error 400: bad rison schema"),
AuthenticationError(),
]
)
adapter = SupersetAccountLookupAdapter(network_client=client, environment_id="ss-dev")
with pytest.raises(SupersetAPIError) as exc_info:
adapter.get_users_page(sort_order="asc")
assert "API Error 400" in str(exc_info.value)
assert not isinstance(exc_info.value, AuthenticationError)
# [/DEF:test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error:Function]
# [DEF:test_get_users_page_uses_fallback_endpoint_when_primary_fails:Function]
# @PURPOSE: Verifies adapter retries second users endpoint and succeeds when fallback is healthy.
# @PRE: Primary endpoint fails; fallback returns valid users payload.
# @POST: Result status is success and both endpoints were attempted in order.
def test_get_users_page_uses_fallback_endpoint_when_primary_fails():
client = _RecordingNetworkClient(
scripted_responses=[
SupersetAPIError("Primary endpoint failed"),
{"result": [{"username": "admin"}], "count": 1},
]
)
adapter = SupersetAccountLookupAdapter(network_client=client, environment_id="ss-dev")
result = adapter.get_users_page()
assert result["status"] == "success"
assert [call["endpoint"] for call in client.calls] == ["/security/users/", "/security/users"]
# [/DEF:test_get_users_page_uses_fallback_endpoint_when_primary_fails:Function]
# [/DEF:backend.src.core.__tests__.test_superset_profile_lookup:Module]

View File

@@ -1,298 +0,0 @@
# [DEF:backend.src.core.async_superset_client:Module]
#
# @TIER: CRITICAL
# @SEMANTICS: superset, async, client, httpx, dashboards, datasets
# @PURPOSE: Async Superset client for dashboard hot-path requests without blocking FastAPI event loop.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
# @RELATION: DEPENDS_ON -> backend.src.core.utils.async_network.AsyncAPIClient
# @INVARIANT: Async dashboard operations reuse shared auth cache and avoid sync requests in async routes.
# [SECTION: IMPORTS]
import asyncio
import json
import re
from typing import Any, Dict, List, Optional, Tuple, cast
from .config_models import Environment
from .logger import logger as app_logger, belief_scope
from .superset_client import SupersetClient
from .utils.async_network import AsyncAPIClient
# [/SECTION]
# [DEF:AsyncSupersetClient:Class]
# @PURPOSE: Async sibling of SupersetClient for dashboard read paths.
class AsyncSupersetClient(SupersetClient):
# [DEF:__init__:Function]
# @PURPOSE: Initialize async Superset client with AsyncAPIClient transport.
# @PRE: env is valid.
# @POST: Client uses async network transport and inherited projection helpers.
def __init__(self, env: Environment):
self.env = env
auth_payload = {
"username": env.username,
"password": env.password,
"provider": "db",
"refresh": "true",
}
self.network = AsyncAPIClient(
config={"base_url": env.url, "auth": auth_payload},
verify_ssl=env.verify_ssl,
timeout=env.timeout,
)
self.delete_before_reimport = False
# [/DEF:__init__:Function]
# [DEF:aclose:Function]
# @PURPOSE: Close async transport resources.
# @POST: Underlying AsyncAPIClient is closed.
async def aclose(self) -> None:
await self.network.aclose()
# [/DEF:aclose:Function]
# [DEF:get_dashboards_page_async:Function]
# @PURPOSE: Fetch one dashboards page asynchronously.
# @POST: Returns total count and page result list.
async def get_dashboards_page_async(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("AsyncSupersetClient.get_dashboards_page_async"):
validated_query = self._validate_query_params(query or {})
if "columns" not in validated_query:
validated_query["columns"] = [
"slug",
"id",
"url",
"changed_on_utc",
"dashboard_title",
"published",
"created_by",
"changed_by",
"changed_by_name",
"owners",
]
response_json = cast(
Dict[str, Any],
await self.network.request(
method="GET",
endpoint="/dashboard/",
params={"q": json.dumps(validated_query)},
),
)
result = response_json.get("result", [])
total_count = response_json.get("count", len(result))
return total_count, result
# [/DEF:get_dashboards_page_async:Function]
# [DEF:get_dashboard_async:Function]
# @PURPOSE: Fetch one dashboard payload asynchronously.
# @POST: Returns raw dashboard payload from Superset API.
async def get_dashboard_async(self, dashboard_id: int) -> Dict:
with belief_scope("AsyncSupersetClient.get_dashboard_async", f"id={dashboard_id}"):
response = await self.network.request(method="GET", endpoint=f"/dashboard/{dashboard_id}")
return cast(Dict, response)
# [/DEF:get_dashboard_async:Function]
# [DEF:get_chart_async:Function]
# @PURPOSE: Fetch one chart payload asynchronously.
# @POST: Returns raw chart payload from Superset API.
async def get_chart_async(self, chart_id: int) -> Dict:
with belief_scope("AsyncSupersetClient.get_chart_async", f"id={chart_id}"):
response = await self.network.request(method="GET", endpoint=f"/chart/{chart_id}")
return cast(Dict, response)
# [/DEF:get_chart_async:Function]
# [DEF:get_dashboard_detail_async:Function]
# @PURPOSE: Fetch dashboard detail asynchronously with concurrent charts/datasets requests.
# @POST: Returns dashboard detail payload for overview page.
async def get_dashboard_detail_async(self, dashboard_id: int) -> Dict:
with belief_scope("AsyncSupersetClient.get_dashboard_detail_async", f"id={dashboard_id}"):
dashboard_response = await self.get_dashboard_async(dashboard_id)
dashboard_data = dashboard_response.get("result", dashboard_response)
charts: List[Dict] = []
datasets: List[Dict] = []
def extract_dataset_id_from_form_data(form_data: Optional[Dict]) -> Optional[int]:
if not isinstance(form_data, dict):
return None
datasource = form_data.get("datasource")
if isinstance(datasource, str):
matched = re.match(r"^(\d+)__", datasource)
if matched:
try:
return int(matched.group(1))
except ValueError:
return None
if isinstance(datasource, dict):
ds_id = datasource.get("id")
try:
return int(ds_id) if ds_id is not None else None
except (TypeError, ValueError):
return None
ds_id = form_data.get("datasource_id")
try:
return int(ds_id) if ds_id is not None else None
except (TypeError, ValueError):
return None
chart_task = self.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id}/charts",
)
dataset_task = self.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id}/datasets",
)
charts_response, datasets_response = await asyncio.gather(
chart_task,
dataset_task,
return_exceptions=True,
)
if not isinstance(charts_response, Exception):
charts_payload = charts_response.get("result", []) if isinstance(charts_response, dict) else []
for chart_obj in charts_payload:
if not isinstance(chart_obj, dict):
continue
chart_id = chart_obj.get("id")
if chart_id is None:
continue
form_data = chart_obj.get("form_data")
if isinstance(form_data, str):
try:
form_data = json.loads(form_data)
except Exception:
form_data = {}
dataset_id = extract_dataset_id_from_form_data(form_data) or chart_obj.get("datasource_id")
charts.append({
"id": int(chart_id),
"title": chart_obj.get("slice_name") or chart_obj.get("name") or f"Chart {chart_id}",
"viz_type": (form_data.get("viz_type") if isinstance(form_data, dict) else None),
"dataset_id": int(dataset_id) if dataset_id is not None else None,
"last_modified": chart_obj.get("changed_on"),
"overview": chart_obj.get("description") or (form_data.get("viz_type") if isinstance(form_data, dict) else None) or "Chart",
})
else:
app_logger.warning("[get_dashboard_detail_async][Warning] Failed to fetch dashboard charts: %s", charts_response)
if not isinstance(datasets_response, Exception):
datasets_payload = datasets_response.get("result", []) if isinstance(datasets_response, dict) else []
for dataset_obj in datasets_payload:
if not isinstance(dataset_obj, dict):
continue
dataset_id = dataset_obj.get("id")
if dataset_id is None:
continue
db_payload = dataset_obj.get("database")
db_name = db_payload.get("database_name") if isinstance(db_payload, dict) else None
table_name = dataset_obj.get("table_name") or dataset_obj.get("datasource_name") or dataset_obj.get("name") or f"Dataset {dataset_id}"
schema = dataset_obj.get("schema")
fq_name = f"{schema}.{table_name}" if schema else table_name
datasets.append({
"id": int(dataset_id),
"table_name": table_name,
"schema": schema,
"database": db_name or dataset_obj.get("database_name") or "Unknown",
"last_modified": dataset_obj.get("changed_on"),
"overview": fq_name,
})
else:
app_logger.warning("[get_dashboard_detail_async][Warning] Failed to fetch dashboard datasets: %s", datasets_response)
if not charts:
raw_position_json = dashboard_data.get("position_json")
chart_ids_from_position = set()
if isinstance(raw_position_json, str) and raw_position_json:
try:
parsed_position = json.loads(raw_position_json)
chart_ids_from_position.update(self._extract_chart_ids_from_layout(parsed_position))
except Exception:
pass
elif isinstance(raw_position_json, dict):
chart_ids_from_position.update(self._extract_chart_ids_from_layout(raw_position_json))
raw_json_metadata = dashboard_data.get("json_metadata")
if isinstance(raw_json_metadata, str) and raw_json_metadata:
try:
parsed_metadata = json.loads(raw_json_metadata)
chart_ids_from_position.update(self._extract_chart_ids_from_layout(parsed_metadata))
except Exception:
pass
elif isinstance(raw_json_metadata, dict):
chart_ids_from_position.update(self._extract_chart_ids_from_layout(raw_json_metadata))
fallback_chart_tasks = [
self.get_chart_async(int(chart_id))
for chart_id in sorted(chart_ids_from_position)
]
fallback_chart_responses = await asyncio.gather(
*fallback_chart_tasks,
return_exceptions=True,
)
for chart_id, chart_response in zip(sorted(chart_ids_from_position), fallback_chart_responses):
if isinstance(chart_response, Exception):
app_logger.warning("[get_dashboard_detail_async][Warning] Failed to resolve fallback chart %s: %s", chart_id, chart_response)
continue
chart_data = chart_response.get("result", chart_response)
charts.append({
"id": int(chart_id),
"title": chart_data.get("slice_name") or chart_data.get("name") or f"Chart {chart_id}",
"viz_type": chart_data.get("viz_type"),
"dataset_id": chart_data.get("datasource_id"),
"last_modified": chart_data.get("changed_on"),
"overview": chart_data.get("description") or chart_data.get("viz_type") or "Chart",
})
dataset_ids_from_charts = {
c.get("dataset_id")
for c in charts
if c.get("dataset_id") is not None
}
known_dataset_ids = {d.get("id") for d in datasets if d.get("id") is not None}
missing_dataset_ids = sorted(int(item) for item in dataset_ids_from_charts if item not in known_dataset_ids)
if missing_dataset_ids:
dataset_fetch_tasks = [
self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}")
for dataset_id in missing_dataset_ids
]
dataset_fetch_responses = await asyncio.gather(
*dataset_fetch_tasks,
return_exceptions=True,
)
for dataset_id, dataset_response in zip(missing_dataset_ids, dataset_fetch_responses):
if isinstance(dataset_response, Exception):
app_logger.warning("[get_dashboard_detail_async][Warning] Failed to backfill dataset %s: %s", dataset_id, dataset_response)
continue
dataset_data = dataset_response.get("result", dataset_response) if isinstance(dataset_response, dict) else {}
db_payload = dataset_data.get("database")
db_name = db_payload.get("database_name") if isinstance(db_payload, dict) else None
table_name = dataset_data.get("table_name") or dataset_data.get("datasource_name") or dataset_data.get("name") or f"Dataset {dataset_id}"
schema = dataset_data.get("schema")
fq_name = f"{schema}.{table_name}" if schema else table_name
datasets.append({
"id": int(dataset_id),
"table_name": table_name,
"schema": schema,
"database": db_name or dataset_data.get("database_name") or "Unknown",
"last_modified": dataset_data.get("changed_on"),
"overview": fq_name,
})
return {
"id": int(dashboard_data.get("id") or dashboard_id),
"title": dashboard_data.get("dashboard_title") or dashboard_data.get("title") or f"Dashboard {dashboard_id}",
"slug": dashboard_data.get("slug"),
"url": dashboard_data.get("url"),
"description": dashboard_data.get("description"),
"last_modified": dashboard_data.get("changed_on_utc") or dashboard_data.get("changed_on"),
"published": dashboard_data.get("published"),
"charts": charts,
"datasets": datasets,
"chart_count": len(charts),
"dataset_count": len(datasets),
}
# [/DEF:get_dashboard_detail_async:Function]
# [/DEF:AsyncSupersetClient:Class]
# [/DEF:backend.src.core.async_superset_client:Module]

View File

@@ -14,8 +14,6 @@ import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.core.database import Base
# Import all models to ensure they are registered with Base before create_all - must import both auth and mapping to ensure Base knows about all tables
from src.models import mapping, auth, task, report
from src.models.auth import User, Role, Permission, ADGroupMapping
from src.services.auth_service import AuthService
from src.core.auth.repository import AuthRepository
@@ -178,94 +176,4 @@ def test_ad_group_mapping(auth_repo):
assert retrieved_mapping.role_id == role.id
def test_authenticate_user_updates_last_login(auth_service, auth_repo):
"""@SIDE_EFFECT: authenticate_user updates last_login timestamp on success."""
user = User(
username="loginuser",
email="login@example.com",
password_hash=get_password_hash("mypassword"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
assert user.last_login is None
authenticated = auth_service.authenticate_user("loginuser", "mypassword")
assert authenticated is not None
assert authenticated.last_login is not None
def test_authenticate_inactive_user(auth_service, auth_repo):
"""@PRE: User with is_active=False should not authenticate."""
user = User(
username="inactive_user",
email="inactive@example.com",
password_hash=get_password_hash("testpass"),
auth_source="LOCAL",
is_active=False
)
auth_repo.db.add(user)
auth_repo.db.commit()
result = auth_service.authenticate_user("inactive_user", "testpass")
assert result is None
def test_verify_password_empty_hash():
"""@PRE: verify_password with empty/None hash returns False."""
assert verify_password("anypassword", "") is False
assert verify_password("anypassword", None) is False
def test_provision_adfs_user_new(auth_service, auth_repo):
"""@POST: provision_adfs_user creates a new ADFS user with correct roles."""
# Set up a role and AD group mapping
role = Role(name="ADFS_Viewer", description="ADFS viewer role")
auth_repo.db.add(role)
auth_repo.db.commit()
mapping = ADGroupMapping(ad_group="DOMAIN\\Viewers", role_id=role.id)
auth_repo.db.add(mapping)
auth_repo.db.commit()
user_info = {
"upn": "newadfsuser@domain.com",
"email": "newadfsuser@domain.com",
"groups": ["DOMAIN\\Viewers"]
}
user = auth_service.provision_adfs_user(user_info)
assert user is not None
assert user.username == "newadfsuser@domain.com"
assert user.auth_source == "ADFS"
assert user.is_active is True
assert len(user.roles) == 1
assert user.roles[0].name == "ADFS_Viewer"
def test_provision_adfs_user_existing(auth_service, auth_repo):
"""@POST: provision_adfs_user updates roles for existing user."""
# Create existing user
existing = User(
username="existingadfs@domain.com",
email="existingadfs@domain.com",
auth_source="ADFS",
is_active=True
)
auth_repo.db.add(existing)
auth_repo.db.commit()
user_info = {
"upn": "existingadfs@domain.com",
"email": "existingadfs@domain.com",
"groups": []
}
user = auth_service.provision_adfs_user(user_info)
assert user is not None
assert user.username == "existingadfs@domain.com"
assert len(user.roles) == 0 # No matching group mappings
# [/DEF:test_auth:Module]

View File

@@ -12,7 +12,6 @@
from typing import Optional, List
from sqlalchemy.orm import Session
from ...models.auth import User, Role, Permission
from ...models.profile import UserDashboardPreference
from ..logger import belief_scope
# [/SECTION]
@@ -110,38 +109,6 @@ class AuthRepository:
).first()
# [/DEF:get_permission_by_resource_action:Function]
# [DEF:get_user_dashboard_preference:Function]
# @PURPOSE: Retrieves dashboard preference by owner user ID.
# @PRE: user_id is a string.
# @POST: Returns UserDashboardPreference if found, else None.
# @PARAM: user_id (str) - Preference owner identifier.
# @RETURN: Optional[UserDashboardPreference] - Found preference or None.
def get_user_dashboard_preference(self, user_id: str) -> Optional[UserDashboardPreference]:
with belief_scope("AuthRepository.get_user_dashboard_preference"):
return (
self.db.query(UserDashboardPreference)
.filter(UserDashboardPreference.user_id == user_id)
.first()
)
# [/DEF:get_user_dashboard_preference:Function]
# [DEF:save_user_dashboard_preference:Function]
# @PURPOSE: Persists dashboard preference entity and returns refreshed row.
# @PRE: preference is a valid UserDashboardPreference entity.
# @POST: Preference is committed and refreshed in database.
# @PARAM: preference (UserDashboardPreference) - Preference entity to persist.
# @RETURN: UserDashboardPreference - Persisted preference row.
def save_user_dashboard_preference(
self,
preference: UserDashboardPreference,
) -> UserDashboardPreference:
with belief_scope("AuthRepository.save_user_dashboard_preference"):
self.db.add(preference)
self.db.commit()
self.db.refresh(preference)
return preference
# [/DEF:save_user_dashboard_preference:Function]
# [DEF:list_permissions:Function]
# @PURPOSE: Lists all available permissions.
# @POST: Returns a list of all Permission objects.

View File

@@ -24,19 +24,18 @@ class Schedule(BaseModel):
# [DEF:Environment:DataClass]
# @PURPOSE: Represents a Superset environment configuration.
class Environment(BaseModel):
id: str
name: str
url: str
username: str
password: str # Will be masked in UI
stage: str = Field(default="DEV", pattern="^(DEV|PREPROD|PROD)$")
verify_ssl: bool = True
timeout: int = 30
is_default: bool = False
is_production: bool = False
backup_schedule: Schedule = Field(default_factory=Schedule)
# [/DEF:Environment:DataClass]
class Environment(BaseModel):
id: str
name: str
url: str
username: str
password: str # Will be masked in UI
verify_ssl: bool = True
timeout: int = 30
is_default: bool = False
is_production: bool = False
backup_schedule: Schedule = Field(default_factory=Schedule)
# [/DEF:Environment:DataClass]
# [DEF:LoggingConfig:DataClass]
# @PURPOSE: Defines the configuration for the application's logging system.
@@ -49,18 +48,10 @@ class LoggingConfig(BaseModel):
enable_belief_state: bool = True
# [/DEF:LoggingConfig:DataClass]
# [DEF:CleanReleaseConfig:DataClass]
# @PURPOSE: Configuration for clean release compliance subsystem.
class CleanReleaseConfig(BaseModel):
active_policy_id: Optional[str] = None
active_registry_id: Optional[str] = None
# [/DEF:CleanReleaseConfig:DataClass]
# [DEF:GlobalSettings:DataClass]
# @PURPOSE: Represents global application settings.
class GlobalSettings(BaseModel):
storage: StorageConfig = Field(default_factory=StorageConfig)
clean_release: CleanReleaseConfig = Field(default_factory=CleanReleaseConfig)
default_environment_id: Optional[str] = None
logging: LoggingConfig = Field(default_factory=LoggingConfig)
connections: List[dict] = []

View File

@@ -11,7 +11,7 @@
# @INVARIANT: A single engine instance is used for the entire application.
# [SECTION: IMPORTS]
from sqlalchemy import create_engine, inspect, text
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from ..models.mapping import Base
# Import models to ensure they're registered with Base
@@ -20,9 +20,7 @@ from ..models import auth as _auth_models # noqa: F401
from ..models import config as _config_models # noqa: F401
from ..models import llm as _llm_models # noqa: F401
from ..models import assistant as _assistant_models # noqa: F401
from ..models import profile as _profile_models # noqa: F401
from ..models import clean_release as _clean_release_models # noqa: F401
from .logger import belief_scope, logger
from .logger import belief_scope
from .auth.config import auth_config
import os
from pathlib import Path
@@ -96,104 +94,6 @@ TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_e
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
# [/DEF:AuthSessionLocal:Class]
# [DEF:_ensure_user_dashboard_preferences_columns:Function]
# @PURPOSE: Applies additive schema upgrades for user_dashboard_preferences table.
# @PRE: bind_engine points to application database where profile table is stored.
# @POST: Missing columns are added without data loss.
def _ensure_user_dashboard_preferences_columns(bind_engine):
with belief_scope("_ensure_user_dashboard_preferences_columns"):
table_name = "user_dashboard_preferences"
inspector = inspect(bind_engine)
if table_name not in inspector.get_table_names():
return
existing_columns = {
str(column.get("name") or "").strip()
for column in inspector.get_columns(table_name)
}
alter_statements = []
if "git_username" not in existing_columns:
alter_statements.append(
"ALTER TABLE user_dashboard_preferences ADD COLUMN git_username VARCHAR"
)
if "git_email" not in existing_columns:
alter_statements.append(
"ALTER TABLE user_dashboard_preferences ADD COLUMN git_email VARCHAR"
)
if "git_personal_access_token_encrypted" not in existing_columns:
alter_statements.append(
"ALTER TABLE user_dashboard_preferences "
"ADD COLUMN git_personal_access_token_encrypted VARCHAR"
)
if "start_page" not in existing_columns:
alter_statements.append(
"ALTER TABLE user_dashboard_preferences "
"ADD COLUMN start_page VARCHAR NOT NULL DEFAULT 'dashboards'"
)
if "auto_open_task_drawer" not in existing_columns:
alter_statements.append(
"ALTER TABLE user_dashboard_preferences "
"ADD COLUMN auto_open_task_drawer BOOLEAN NOT NULL DEFAULT TRUE"
)
if "dashboards_table_density" not in existing_columns:
alter_statements.append(
"ALTER TABLE user_dashboard_preferences "
"ADD COLUMN dashboards_table_density VARCHAR NOT NULL DEFAULT 'comfortable'"
)
if not alter_statements:
return
try:
with bind_engine.begin() as connection:
for statement in alter_statements:
connection.execute(text(statement))
except Exception as migration_error:
logger.warning(
"[database][EXPLORE] Profile preference additive migration failed: %s",
migration_error,
)
# [/DEF:_ensure_user_dashboard_preferences_columns:Function]
# [DEF:_ensure_git_server_configs_columns:Function]
# @PURPOSE: Applies additive schema upgrades for git_server_configs table.
# @PRE: bind_engine points to application database.
# @POST: Missing columns are added without data loss.
def _ensure_git_server_configs_columns(bind_engine):
with belief_scope("_ensure_git_server_configs_columns"):
table_name = "git_server_configs"
inspector = inspect(bind_engine)
if table_name not in inspector.get_table_names():
return
existing_columns = {
str(column.get("name") or "").strip()
for column in inspector.get_columns(table_name)
}
alter_statements = []
if "default_branch" not in existing_columns:
alter_statements.append(
"ALTER TABLE git_server_configs ADD COLUMN default_branch VARCHAR NOT NULL DEFAULT 'main'"
)
if not alter_statements:
return
try:
with bind_engine.begin() as connection:
for statement in alter_statements:
connection.execute(text(statement))
except Exception as migration_error:
logger.warning(
"[database][EXPLORE] GitServerConfig preference additive migration failed: %s",
migration_error,
)
# [/DEF:_ensure_git_server_configs_columns:Function]
# [DEF:init_db:Function]
# @PURPOSE: Initializes the database by creating all tables.
# @PRE: engine, tasks_engine and auth_engine are initialized.
@@ -204,8 +104,6 @@ def init_db():
Base.metadata.create_all(bind=engine)
Base.metadata.create_all(bind=tasks_engine)
Base.metadata.create_all(bind=auth_engine)
_ensure_user_dashboard_preferences_columns(engine)
_ensure_git_server_configs_columns(engine)
# [/DEF:init_db:Function]
# [DEF:get_db:Function]

View File

@@ -1,12 +0,0 @@
# [DEF:backend.src.core.migration.__init__:Module]
# @TIER: TRIVIAL
# @SEMANTICS: migration, package, exports
# @PURPOSE: Namespace package for migration pre-flight orchestration components.
# @LAYER: Core
from .dry_run_orchestrator import MigrationDryRunService
from .archive_parser import MigrationArchiveParser
__all__ = ["MigrationDryRunService", "MigrationArchiveParser"]
# [/DEF:backend.src.core.migration.__init__:Module]

View File

@@ -1,139 +0,0 @@
# [DEF:backend.src.core.migration.archive_parser:Module]
# @TIER: STANDARD
# @SEMANTICS: migration, zip, parser, yaml, metadata
# @PURPOSE: Parse Superset export ZIP archives into normalized object catalogs for diffing.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> backend.src.core.logger
# @INVARIANT: Parsing is read-only and never mutates archive files.
import json
import tempfile
import zipfile
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from ..logger import logger, belief_scope
# [DEF:MigrationArchiveParser:Class]
# @PURPOSE: Extract normalized dashboards/charts/datasets metadata from ZIP archives.
class MigrationArchiveParser:
# [DEF:extract_objects_from_zip:Function]
# @PURPOSE: Extract object catalogs from Superset archive.
# @PRE: zip_path points to a valid readable ZIP.
# @POST: Returns object lists grouped by resource type.
# @RETURN: Dict[str, List[Dict[str, Any]]]
def extract_objects_from_zip(self, zip_path: str) -> Dict[str, List[Dict[str, Any]]]:
with belief_scope("MigrationArchiveParser.extract_objects_from_zip"):
result: Dict[str, List[Dict[str, Any]]] = {
"dashboards": [],
"charts": [],
"datasets": [],
}
with tempfile.TemporaryDirectory() as temp_dir_str:
temp_dir = Path(temp_dir_str)
with zipfile.ZipFile(zip_path, "r") as zip_file:
zip_file.extractall(temp_dir)
result["dashboards"] = self._collect_yaml_objects(temp_dir, "dashboards")
result["charts"] = self._collect_yaml_objects(temp_dir, "charts")
result["datasets"] = self._collect_yaml_objects(temp_dir, "datasets")
return result
# [/DEF:extract_objects_from_zip:Function]
# [DEF:_collect_yaml_objects:Function]
# @PURPOSE: Read and normalize YAML manifests for one object type.
# @PRE: object_type is one of dashboards/charts/datasets.
# @POST: Returns only valid normalized objects.
def _collect_yaml_objects(self, root_dir: Path, object_type: str) -> List[Dict[str, Any]]:
with belief_scope("MigrationArchiveParser._collect_yaml_objects"):
files = list(root_dir.glob(f"**/{object_type}/**/*.yaml")) + list(root_dir.glob(f"**/{object_type}/*.yaml"))
objects: List[Dict[str, Any]] = []
for file_path in set(files):
try:
with open(file_path, "r") as file_obj:
payload = yaml.safe_load(file_obj) or {}
normalized = self._normalize_object_payload(payload, object_type)
if normalized:
objects.append(normalized)
except Exception as exc:
logger.reflect(
"[MigrationArchiveParser._collect_yaml_objects][REFLECT] skip_invalid_yaml path=%s error=%s",
file_path,
exc,
)
return objects
# [/DEF:_collect_yaml_objects:Function]
# [DEF:_normalize_object_payload:Function]
# @PURPOSE: Convert raw YAML payload to stable diff signature shape.
# @PRE: payload is parsed YAML mapping.
# @POST: Returns normalized descriptor with `uuid`, `title`, and `signature`.
def _normalize_object_payload(self, payload: Dict[str, Any], object_type: str) -> Optional[Dict[str, Any]]:
with belief_scope("MigrationArchiveParser._normalize_object_payload"):
if not isinstance(payload, dict):
return None
uuid = payload.get("uuid")
if not uuid:
return None
if object_type == "dashboards":
title = payload.get("dashboard_title") or payload.get("title")
signature = {
"title": title,
"slug": payload.get("slug"),
"position_json": payload.get("position_json"),
"json_metadata": payload.get("json_metadata"),
"description": payload.get("description"),
"owners": payload.get("owners"),
}
return {
"uuid": str(uuid),
"title": title or f"Dashboard {uuid}",
"signature": json.dumps(signature, sort_keys=True, default=str),
"owners": payload.get("owners") or [],
}
if object_type == "charts":
title = payload.get("slice_name") or payload.get("name")
signature = {
"title": title,
"viz_type": payload.get("viz_type"),
"params": payload.get("params"),
"query_context": payload.get("query_context"),
"datasource_uuid": payload.get("datasource_uuid"),
"dataset_uuid": payload.get("dataset_uuid"),
}
return {
"uuid": str(uuid),
"title": title or f"Chart {uuid}",
"signature": json.dumps(signature, sort_keys=True, default=str),
"dataset_uuid": payload.get("datasource_uuid") or payload.get("dataset_uuid"),
}
if object_type == "datasets":
title = payload.get("table_name") or payload.get("name")
signature = {
"title": title,
"schema": payload.get("schema"),
"database_uuid": payload.get("database_uuid"),
"sql": payload.get("sql"),
"columns": payload.get("columns"),
"metrics": payload.get("metrics"),
}
return {
"uuid": str(uuid),
"title": title or f"Dataset {uuid}",
"signature": json.dumps(signature, sort_keys=True, default=str),
"database_uuid": payload.get("database_uuid"),
}
return None
# [/DEF:_normalize_object_payload:Function]
# [/DEF:MigrationArchiveParser:Class]
# [/DEF:backend.src.core.migration.archive_parser:Module]

View File

@@ -1,235 +0,0 @@
# [DEF:backend.src.core.migration.dry_run_orchestrator:Module]
# @TIER: STANDARD
# @SEMANTICS: migration, dry_run, diff, risk, superset
# @PURPOSE: Compute pre-flight migration diff and risk scoring without apply.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
# @RELATION: DEPENDS_ON -> backend.src.core.migration_engine
# @RELATION: DEPENDS_ON -> backend.src.core.migration.archive_parser
# @RELATION: DEPENDS_ON -> backend.src.core.migration.risk_assessor
# @INVARIANT: Dry run is informative only and must not mutate target environment.
from datetime import datetime, timezone
import json
from typing import Any, Dict, List
from sqlalchemy.orm import Session
from ...models.dashboard import DashboardSelection
from ...models.mapping import DatabaseMapping
from ..logger import logger, belief_scope
from .archive_parser import MigrationArchiveParser
from .risk_assessor import build_risks, score_risks
from ..migration_engine import MigrationEngine
from ..superset_client import SupersetClient
from ..utils.fileio import create_temp_file
# [DEF:MigrationDryRunService:Class]
# @PURPOSE: Build deterministic diff/risk payload for migration pre-flight.
class MigrationDryRunService:
# [DEF:__init__:Function]
# @PURPOSE: Wire parser dependency for archive object extraction.
# @PRE: parser can be omitted to use default implementation.
# @POST: Service is ready to calculate dry-run payload.
def __init__(self, parser: MigrationArchiveParser | None = None):
self.parser = parser or MigrationArchiveParser()
# [/DEF:__init__:Function]
# [DEF:run:Function]
# @PURPOSE: Execute full dry-run computation for selected dashboards.
# @PRE: source/target clients are authenticated and selection validated by caller.
# @POST: Returns JSON-serializable pre-flight payload with summary, diff and risk.
# @SIDE_EFFECT: Reads source export archives and target metadata via network.
def run(
self,
selection: DashboardSelection,
source_client: SupersetClient,
target_client: SupersetClient,
db: Session,
) -> Dict[str, Any]:
with belief_scope("MigrationDryRunService.run"):
logger.explore("[MigrationDryRunService.run][EXPLORE] starting dry-run pipeline")
engine = MigrationEngine()
db_mapping = self._load_db_mapping(db, selection) if selection.replace_db_config else {}
transformed = {"dashboards": {}, "charts": {}, "datasets": {}}
dashboards_preview = source_client.get_dashboards_summary()
selected_preview = {
item["id"]: item
for item in dashboards_preview
if item.get("id") in selection.selected_ids
}
for dashboard_id in selection.selected_ids:
exported_content, _ = source_client.export_dashboard(int(dashboard_id))
with create_temp_file(content=exported_content, suffix=".zip") as source_zip:
with create_temp_file(suffix=".zip") as transformed_zip:
success = engine.transform_zip(
str(source_zip),
str(transformed_zip),
db_mapping,
strip_databases=False,
target_env_id=selection.target_env_id,
fix_cross_filters=selection.fix_cross_filters,
)
if not success:
raise ValueError(f"Failed to transform export archive for dashboard {dashboard_id}")
extracted = self.parser.extract_objects_from_zip(str(transformed_zip))
self._accumulate_objects(transformed, extracted)
source_objects = {key: list(value.values()) for key, value in transformed.items()}
target_objects = self._build_target_signatures(target_client)
diff = {
"dashboards": self._build_object_diff(source_objects["dashboards"], target_objects["dashboards"]),
"charts": self._build_object_diff(source_objects["charts"], target_objects["charts"]),
"datasets": self._build_object_diff(source_objects["datasets"], target_objects["datasets"]),
}
risk = self._build_risks(source_objects, target_objects, diff, target_client)
summary = {
"dashboards": {action: len(diff["dashboards"][action]) for action in ("create", "update", "delete")},
"charts": {action: len(diff["charts"][action]) for action in ("create", "update", "delete")},
"datasets": {action: len(diff["datasets"][action]) for action in ("create", "update", "delete")},
"selected_dashboards": len(selection.selected_ids),
}
selected_titles = [
selected_preview[dash_id]["title"]
for dash_id in selection.selected_ids
if dash_id in selected_preview
]
logger.reason("[MigrationDryRunService.run][REASON] dry-run payload assembled")
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"selection": selection.model_dump(),
"selected_dashboard_titles": selected_titles,
"diff": diff,
"summary": summary,
"risk": score_risks(risk),
}
# [/DEF:run:Function]
# [DEF:_load_db_mapping:Function]
# @PURPOSE: Resolve UUID mapping for optional DB config replacement.
def _load_db_mapping(self, db: Session, selection: DashboardSelection) -> Dict[str, str]:
rows = db.query(DatabaseMapping).filter(
DatabaseMapping.source_env_id == selection.source_env_id,
DatabaseMapping.target_env_id == selection.target_env_id,
).all()
return {row.source_db_uuid: row.target_db_uuid for row in rows}
# [/DEF:_load_db_mapping:Function]
# [DEF:_accumulate_objects:Function]
# @PURPOSE: Merge extracted resources by UUID to avoid duplicates.
def _accumulate_objects(self, target: Dict[str, Dict[str, Dict[str, Any]]], source: Dict[str, List[Dict[str, Any]]]) -> None:
for object_type in ("dashboards", "charts", "datasets"):
for item in source.get(object_type, []):
uuid = item.get("uuid")
if uuid:
target[object_type][str(uuid)] = item
# [/DEF:_accumulate_objects:Function]
# [DEF:_index_by_uuid:Function]
# @PURPOSE: Build UUID-index map for normalized resources.
def _index_by_uuid(self, objects: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
indexed: Dict[str, Dict[str, Any]] = {}
for obj in objects:
uuid = obj.get("uuid")
if uuid:
indexed[str(uuid)] = obj
return indexed
# [/DEF:_index_by_uuid:Function]
# [DEF:_build_object_diff:Function]
# @PURPOSE: Compute create/update/delete buckets by UUID+signature.
def _build_object_diff(self, source_objects: List[Dict[str, Any]], target_objects: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
target_index = self._index_by_uuid(target_objects)
created: List[Dict[str, Any]] = []
updated: List[Dict[str, Any]] = []
deleted: List[Dict[str, Any]] = []
for source_obj in source_objects:
source_uuid = str(source_obj.get("uuid"))
target_obj = target_index.get(source_uuid)
if not target_obj:
created.append({"uuid": source_uuid, "title": source_obj.get("title")})
continue
if source_obj.get("signature") != target_obj.get("signature"):
updated.append({
"uuid": source_uuid,
"title": source_obj.get("title"),
"target_title": target_obj.get("title"),
})
return {"create": created, "update": updated, "delete": deleted}
# [/DEF:_build_object_diff:Function]
# [DEF:_build_target_signatures:Function]
# @PURPOSE: Pull target metadata and normalize it into comparable signatures.
def _build_target_signatures(self, client: SupersetClient) -> Dict[str, List[Dict[str, Any]]]:
_, dashboards = client.get_dashboards(query={
"columns": ["uuid", "dashboard_title", "slug", "position_json", "json_metadata", "description", "owners"],
})
_, datasets = client.get_datasets(query={
"columns": ["uuid", "table_name", "schema", "database_uuid", "sql", "columns", "metrics"],
})
_, charts = client.get_charts(query={
"columns": ["uuid", "slice_name", "viz_type", "params", "query_context", "datasource_uuid", "dataset_uuid"],
})
return {
"dashboards": [{
"uuid": str(item.get("uuid")),
"title": item.get("dashboard_title"),
"owners": item.get("owners") or [],
"signature": json.dumps({
"title": item.get("dashboard_title"),
"slug": item.get("slug"),
"position_json": item.get("position_json"),
"json_metadata": item.get("json_metadata"),
"description": item.get("description"),
"owners": item.get("owners"),
}, sort_keys=True, default=str),
} for item in dashboards if item.get("uuid")],
"datasets": [{
"uuid": str(item.get("uuid")),
"title": item.get("table_name"),
"database_uuid": item.get("database_uuid"),
"signature": json.dumps({
"title": item.get("table_name"),
"schema": item.get("schema"),
"database_uuid": item.get("database_uuid"),
"sql": item.get("sql"),
"columns": item.get("columns"),
"metrics": item.get("metrics"),
}, sort_keys=True, default=str),
} for item in datasets if item.get("uuid")],
"charts": [{
"uuid": str(item.get("uuid")),
"title": item.get("slice_name") or item.get("name"),
"dataset_uuid": item.get("datasource_uuid") or item.get("dataset_uuid"),
"signature": json.dumps({
"title": item.get("slice_name") or item.get("name"),
"viz_type": item.get("viz_type"),
"params": item.get("params"),
"query_context": item.get("query_context"),
"datasource_uuid": item.get("datasource_uuid"),
"dataset_uuid": item.get("dataset_uuid"),
}, sort_keys=True, default=str),
} for item in charts if item.get("uuid")],
}
# [/DEF:_build_target_signatures:Function]
# [DEF:_build_risks:Function]
# @PURPOSE: Build risk items for missing datasource, broken refs, overwrite, owner mismatch.
def _build_risks(
self,
source_objects: Dict[str, List[Dict[str, Any]]],
target_objects: Dict[str, List[Dict[str, Any]]],
diff: Dict[str, Dict[str, List[Dict[str, Any]]]],
target_client: SupersetClient,
) -> List[Dict[str, Any]]:
return build_risks(source_objects, target_objects, diff, target_client)
# [/DEF:_build_risks:Function]
# [/DEF:MigrationDryRunService:Class]
# [/DEF:backend.src.core.migration.dry_run_orchestrator:Module]

View File

@@ -1,119 +0,0 @@
# [DEF:backend.src.core.migration.risk_assessor:Module]
# @TIER: STANDARD
# @SEMANTICS: migration, dry_run, risk, scoring
# @PURPOSE: Risk evaluation helpers for migration pre-flight reporting.
# @LAYER: Core
# @RELATION: USED_BY -> backend.src.core.migration.dry_run_orchestrator
from typing import Any, Dict, List
from ..superset_client import SupersetClient
# [DEF:index_by_uuid:Function]
# @PURPOSE: Build UUID-index from normalized objects.
def index_by_uuid(objects: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
indexed: Dict[str, Dict[str, Any]] = {}
for obj in objects:
uuid = obj.get("uuid")
if uuid:
indexed[str(uuid)] = obj
return indexed
# [/DEF:index_by_uuid:Function]
# [DEF:extract_owner_identifiers:Function]
# @PURPOSE: Normalize owner payloads for stable comparison.
def extract_owner_identifiers(owners: Any) -> List[str]:
if not isinstance(owners, list):
return []
ids: List[str] = []
for owner in owners:
if isinstance(owner, dict):
if owner.get("username"):
ids.append(str(owner["username"]))
elif owner.get("id") is not None:
ids.append(str(owner["id"]))
elif owner is not None:
ids.append(str(owner))
return sorted(set(ids))
# [/DEF:extract_owner_identifiers:Function]
# [DEF:build_risks:Function]
# @PURPOSE: Build risk list from computed diffs and target catalog state.
def build_risks(
source_objects: Dict[str, List[Dict[str, Any]]],
target_objects: Dict[str, List[Dict[str, Any]]],
diff: Dict[str, Dict[str, List[Dict[str, Any]]]],
target_client: SupersetClient,
) -> List[Dict[str, Any]]:
risks: List[Dict[str, Any]] = []
for object_type in ("dashboards", "charts", "datasets"):
for item in diff[object_type]["update"]:
risks.append({
"code": "overwrite_existing",
"severity": "medium",
"object_type": object_type[:-1],
"object_uuid": item["uuid"],
"message": f"Object will be updated in target: {item.get('title') or item['uuid']}",
})
target_dataset_uuids = set(index_by_uuid(target_objects["datasets"]).keys())
_, target_databases = target_client.get_databases(query={"columns": ["uuid"]})
target_database_uuids = {str(item.get("uuid")) for item in target_databases if item.get("uuid")}
for dataset in source_objects["datasets"]:
db_uuid = dataset.get("database_uuid")
if db_uuid and str(db_uuid) not in target_database_uuids:
risks.append({
"code": "missing_datasource",
"severity": "high",
"object_type": "dataset",
"object_uuid": dataset.get("uuid"),
"message": f"Target datasource is missing for dataset {dataset.get('title') or dataset.get('uuid')}",
})
for chart in source_objects["charts"]:
ds_uuid = chart.get("dataset_uuid")
if ds_uuid and str(ds_uuid) not in target_dataset_uuids:
risks.append({
"code": "breaking_reference",
"severity": "high",
"object_type": "chart",
"object_uuid": chart.get("uuid"),
"message": f"Chart references dataset not found on target: {ds_uuid}",
})
source_dash = index_by_uuid(source_objects["dashboards"])
target_dash = index_by_uuid(target_objects["dashboards"])
for item in diff["dashboards"]["update"]:
source_obj = source_dash.get(item["uuid"])
target_obj = target_dash.get(item["uuid"])
if not source_obj or not target_obj:
continue
source_owners = extract_owner_identifiers(source_obj.get("owners"))
target_owners = extract_owner_identifiers(target_obj.get("owners"))
if source_owners and target_owners and source_owners != target_owners:
risks.append({
"code": "owner_mismatch",
"severity": "low",
"object_type": "dashboard",
"object_uuid": item["uuid"],
"message": f"Owner mismatch for dashboard {item.get('title') or item['uuid']}",
})
return risks
# [/DEF:build_risks:Function]
# [DEF:score_risks:Function]
# @PURPOSE: Aggregate risk list into score and level.
def score_risks(risk_items: List[Dict[str, Any]]) -> Dict[str, Any]:
weights = {"high": 25, "medium": 10, "low": 5}
score = min(100, sum(weights.get(item.get("severity", "low"), 5) for item in risk_items))
level = "low" if score < 25 else "medium" if score < 60 else "high"
return {"score": score, "level": level, "items": risk_items}
# [/DEF:score_risks:Function]
# [/DEF:backend.src.core.migration.risk_assessor:Module]

View File

@@ -14,7 +14,7 @@ import json
import re
import zipfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union, cast
from typing import Dict, List, Optional, Tuple, Union, cast
from requests import Response
from datetime import datetime
from .logger import logger as app_logger, belief_scope
@@ -87,18 +87,7 @@ class SupersetClient:
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",
"url",
"changed_on_utc",
"dashboard_title",
"published",
"created_by",
"changed_by",
"changed_by_name",
"owners",
]
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"]
paginated_data = self._fetch_all_pages(
endpoint="/dashboard/",
@@ -109,42 +98,6 @@ class SupersetClient:
return total_count, paginated_data
# [/DEF:get_dashboards:Function]
# [DEF:get_dashboards_page:Function]
# @PURPOSE: Fetches a single dashboards page from Superset without iterating all pages.
# @PARAM: query (Optional[Dict]) - Query with page/page_size and optional columns.
# @PRE: Client is authenticated.
# @POST: Returns total count and one page of dashboards.
# @RETURN: Tuple[int, List[Dict]]
def get_dashboards_page(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_dashboards_page"):
validated_query = self._validate_query_params(query or {})
if "columns" not in validated_query:
validated_query["columns"] = [
"slug",
"id",
"url",
"changed_on_utc",
"dashboard_title",
"published",
"created_by",
"changed_by",
"changed_by_name",
"owners",
]
response_json = cast(
Dict[str, Any],
self.network.request(
method="GET",
endpoint="/dashboard/",
params={"q": json.dumps(validated_query)},
),
)
result = response_json.get("result", [])
total_count = response_json.get("count", len(result))
return total_count, result
# [/DEF:get_dashboards_page:Function]
# [DEF:get_dashboards_summary:Function]
# @PURPOSE: Fetches dashboard metadata optimized for the grid.
# @PRE: Client is authenticated.
@@ -152,204 +105,23 @@ class SupersetClient:
# @RETURN: List[Dict]
def get_dashboards_summary(self) -> List[Dict]:
with belief_scope("SupersetClient.get_dashboards_summary"):
# Rely on list endpoint default projection to stay compatible
# across Superset versions and preserve owners in one request.
query: Dict[str, Any] = {}
query = {
"columns": ["id", "dashboard_title", "changed_on_utc", "published"]
}
_, dashboards = self.get_dashboards(query=query)
# Map fields to DashboardMetadata schema
result = []
max_debug_samples = 12
for index, dash in enumerate(dashboards):
raw_owners = dash.get("owners")
raw_created_by = dash.get("created_by")
raw_changed_by = dash.get("changed_by")
raw_changed_by_name = dash.get("changed_by_name")
owners = self._extract_owner_labels(raw_owners)
# No per-dashboard detail requests here: keep list endpoint O(1).
if not owners:
owners = self._extract_owner_labels(
[raw_created_by, raw_changed_by],
)
projected_created_by = self._extract_user_display(
None,
raw_created_by,
)
projected_modified_by = self._extract_user_display(
raw_changed_by_name,
raw_changed_by,
)
raw_owner_usernames: List[str] = []
if isinstance(raw_owners, list):
for owner_payload in raw_owners:
if isinstance(owner_payload, dict):
owner_username = self._sanitize_user_text(owner_payload.get("username"))
if owner_username:
raw_owner_usernames.append(owner_username)
for dash in dashboards:
result.append({
"id": dash.get("id"),
"slug": dash.get("slug"),
"title": dash.get("dashboard_title"),
"url": dash.get("url"),
"last_modified": dash.get("changed_on_utc"),
"status": "published" if dash.get("published") else "draft",
"created_by": projected_created_by,
"modified_by": projected_modified_by,
"owners": owners,
"status": "published" if dash.get("published") else "draft"
})
if index < max_debug_samples:
app_logger.reflect(
"[REFLECT] Dashboard actor projection sample "
f"(env={getattr(self.env, 'id', None)}, dashboard_id={dash.get('id')}, "
f"raw_owners={raw_owners!r}, raw_owner_usernames={raw_owner_usernames!r}, "
f"raw_created_by={raw_created_by!r}, raw_changed_by={raw_changed_by!r}, "
f"raw_changed_by_name={raw_changed_by_name!r}, projected_owners={owners!r}, "
f"projected_created_by={projected_created_by!r}, projected_modified_by={projected_modified_by!r})"
)
app_logger.reflect(
"[REFLECT] Dashboard actor projection summary "
f"(env={getattr(self.env, 'id', None)}, dashboards={len(result)}, "
f"sampled={min(len(result), max_debug_samples)})"
)
return result
# [/DEF:get_dashboards_summary:Function]
# [DEF:get_dashboards_summary_page:Function]
# @PURPOSE: Fetches one page of dashboard metadata optimized for the grid.
# @PARAM: page (int) - 1-based page number from API route contract.
# @PARAM: page_size (int) - Number of items per page.
# @PRE: page >= 1 and page_size > 0.
# @POST: Returns mapped summaries and total dashboard count.
# @RETURN: Tuple[int, List[Dict]]
def get_dashboards_summary_page(
self,
page: int,
page_size: int,
search: Optional[str] = None,
) -> Tuple[int, List[Dict]]:
with belief_scope("SupersetClient.get_dashboards_summary_page"):
query: Dict[str, Any] = {
"page": max(page - 1, 0),
"page_size": page_size,
}
normalized_search = (search or "").strip()
if normalized_search:
# Superset list API supports filter objects with `opr` operator.
# `ct` -> contains (ILIKE on most Superset backends).
query["filters"] = [
{
"col": "dashboard_title",
"opr": "ct",
"value": normalized_search,
}
]
total_count, dashboards = self.get_dashboards_page(query=query)
result = []
for dash in dashboards:
owners = self._extract_owner_labels(dash.get("owners"))
if not owners:
owners = self._extract_owner_labels(
[dash.get("created_by"), dash.get("changed_by")],
)
result.append({
"id": dash.get("id"),
"slug": dash.get("slug"),
"title": dash.get("dashboard_title"),
"url": dash.get("url"),
"last_modified": dash.get("changed_on_utc"),
"status": "published" if dash.get("published") else "draft",
"created_by": self._extract_user_display(
None,
dash.get("created_by"),
),
"modified_by": self._extract_user_display(
dash.get("changed_by_name"),
dash.get("changed_by"),
),
"owners": owners,
})
return total_count, result
# [/DEF:get_dashboards_summary_page:Function]
# [DEF:_extract_owner_labels:Function]
# @PURPOSE: Normalize dashboard owners payload to stable display labels.
# @PRE: owners payload can be scalar, object or list.
# @POST: Returns deduplicated non-empty owner labels preserving order.
# @RETURN: List[str]
def _extract_owner_labels(self, owners_payload: Any) -> List[str]:
if owners_payload is None:
return []
owners_list: List[Any]
if isinstance(owners_payload, list):
owners_list = owners_payload
else:
owners_list = [owners_payload]
normalized: List[str] = []
for owner in owners_list:
label: Optional[str] = None
if isinstance(owner, dict):
label = self._extract_user_display(None, owner)
else:
label = self._sanitize_user_text(owner)
if label and label not in normalized:
normalized.append(label)
return normalized
# [/DEF:_extract_owner_labels:Function]
# [DEF:_extract_user_display:Function]
# @PURPOSE: Normalize user payload to a stable display name.
# @PRE: user payload can be string, dict or None.
# @POST: Returns compact non-empty display value or None.
# @RETURN: Optional[str]
def _extract_user_display(self, preferred_value: Optional[str], user_payload: Optional[Dict]) -> Optional[str]:
preferred = self._sanitize_user_text(preferred_value)
if preferred:
return preferred
if isinstance(user_payload, dict):
full_name = self._sanitize_user_text(user_payload.get("full_name"))
if full_name:
return full_name
first_name = self._sanitize_user_text(user_payload.get("first_name")) or ""
last_name = self._sanitize_user_text(user_payload.get("last_name")) or ""
combined = " ".join(part for part in [first_name, last_name] if part).strip()
if combined:
return combined
username = self._sanitize_user_text(user_payload.get("username"))
if username:
return username
email = self._sanitize_user_text(user_payload.get("email"))
if email:
return email
return None
# [/DEF:_extract_user_display:Function]
# [DEF:_sanitize_user_text:Function]
# @PURPOSE: Convert scalar value to non-empty user-facing text.
# @PRE: value can be any scalar type.
# @POST: Returns trimmed string or None.
# @RETURN: Optional[str]
def _sanitize_user_text(self, value: Optional[Union[str, int]]) -> Optional[str]:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
return None
return normalized
# [/DEF:_sanitize_user_text:Function]
# [DEF:get_dashboard:Function]
# @PURPOSE: Fetches a single dashboard by ID.
# @PRE: Client is authenticated and dashboard_id exists.
@@ -564,25 +336,6 @@ class SupersetClient:
}
# [/DEF:get_dashboard_detail:Function]
# [DEF:get_charts:Function]
# @PURPOSE: Fetches all charts with pagination support.
# @PARAM: query (Optional[Dict]) - Optional query params/columns/filters.
# @PRE: Client is authenticated.
# @POST: Returns total count and charts list.
# @RETURN: Tuple[int, List[Dict]]
def get_charts(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_charts"):
validated_query = self._validate_query_params(query or {})
if "columns" not in validated_query:
validated_query["columns"] = ["id", "uuid", "slice_name", "viz_type"]
paginated_data = self._fetch_all_pages(
endpoint="/chart/",
pagination_options={"base_query": validated_query, "results_field": "result"},
)
return len(paginated_data), paginated_data
# [/DEF:get_charts:Function]
# [DEF:_extract_chart_ids_from_layout:Function]
# @PURPOSE: Traverses dashboard layout metadata and extracts chart IDs from common keys.
# @PRE: payload can be dict/list/scalar.

View File

@@ -1,238 +0,0 @@
# [DEF:backend.src.core.superset_profile_lookup:Module]
#
# @TIER: STANDARD
# @SEMANTICS: superset, users, lookup, profile, pagination, normalization
# @PURPOSE: Provides environment-scoped Superset account lookup adapter with stable normalized output.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> backend.src.core.utils.network.APIClient
# @RELATION: DEPENDS_ON -> backend.src.core.logger
#
# @INVARIANT: Adapter never leaks raw upstream payload shape to API consumers.
# [SECTION: IMPORTS]
import json
from typing import Any, Dict, List, Optional
from .logger import logger, belief_scope
from .utils.network import APIClient, AuthenticationError, SupersetAPIError
# [/SECTION]
# [DEF:SupersetAccountLookupAdapter:Class]
# @TIER: STANDARD
# @PURPOSE: Lookup Superset users and normalize candidates for profile binding.
class SupersetAccountLookupAdapter:
# [DEF:__init__:Function]
# @PURPOSE: Initializes lookup adapter with authenticated API client and environment context.
# @PRE: network_client supports request(method, endpoint, params=...).
# @POST: Adapter is ready to perform users lookup requests.
def __init__(self, network_client: APIClient, environment_id: str):
self.network_client = network_client
self.environment_id = str(environment_id or "")
# [/DEF:__init__:Function]
# [DEF:get_users_page:Function]
# @PURPOSE: Fetch one users page from Superset with passthrough search/sort parameters.
# @PRE: page_index >= 0 and page_size >= 1.
# @POST: Returns deterministic payload with normalized items and total count.
# @RETURN: Dict[str, Any]
def get_users_page(
self,
search: Optional[str] = None,
page_index: int = 0,
page_size: int = 20,
sort_column: str = "username",
sort_order: str = "desc",
) -> Dict[str, Any]:
with belief_scope("SupersetAccountLookupAdapter.get_users_page"):
normalized_page_index = max(int(page_index), 0)
normalized_page_size = max(int(page_size), 1)
normalized_sort_column = str(sort_column or "username").strip().lower() or "username"
normalized_sort_order = str(sort_order or "desc").strip().lower()
if normalized_sort_order not in {"asc", "desc"}:
normalized_sort_order = "desc"
query: Dict[str, Any] = {
"page": normalized_page_index,
"page_size": normalized_page_size,
"order_column": normalized_sort_column,
"order_direction": normalized_sort_order,
}
normalized_search = str(search or "").strip()
if normalized_search:
query["filters"] = [{"col": "username", "opr": "ct", "value": normalized_search}]
logger.reason(
"[REASON] Lookup Superset users "
f"(env={self.environment_id}, page={normalized_page_index}, page_size={normalized_page_size})"
)
logger.reflect(
"[REFLECT] Prepared Superset users lookup query "
f"(env={self.environment_id}, order_column={normalized_sort_column}, "
f"normalized_sort_order={normalized_sort_order}, "
f"payload_order_direction={query.get('order_direction')})"
)
primary_error: Optional[Exception] = None
last_error: Optional[Exception] = None
for attempt_index, endpoint in enumerate(("/security/users/", "/security/users"), start=1):
try:
logger.reason(
"[REASON] Users lookup request attempt "
f"(env={self.environment_id}, attempt={attempt_index}, endpoint={endpoint})"
)
response = self.network_client.request(
method="GET",
endpoint=endpoint,
params={"q": json.dumps(query)},
)
logger.reflect(
"[REFLECT] Users lookup endpoint succeeded "
f"(env={self.environment_id}, attempt={attempt_index}, endpoint={endpoint})"
)
return self._normalize_lookup_payload(
response=response,
page_index=normalized_page_index,
page_size=normalized_page_size,
)
except Exception as exc:
if primary_error is None:
primary_error = exc
last_error = exc
cause = getattr(exc, "__cause__", None)
cause_response = getattr(cause, "response", None)
status_code = getattr(cause_response, "status_code", None)
logger.explore(
"[EXPLORE] Users lookup endpoint failed "
f"(env={self.environment_id}, attempt={attempt_index}, endpoint={endpoint}, "
f"error_type={type(exc).__name__}, status_code={status_code}, "
f"payload_order_direction={query.get('order_direction')}): {exc}"
)
if last_error is not None:
selected_error: Exception = last_error
if (
primary_error is not None
and primary_error is not last_error
and isinstance(last_error, AuthenticationError)
and not isinstance(primary_error, AuthenticationError)
):
selected_error = primary_error
logger.reflect(
"[REFLECT] Preserving primary lookup failure over fallback auth error "
f"(env={self.environment_id}, primary_error_type={type(primary_error).__name__}, "
f"fallback_error_type={type(last_error).__name__})"
)
logger.explore(
"[EXPLORE] All Superset users lookup endpoints failed "
f"(env={self.environment_id}, payload_order_direction={query.get('order_direction')}, "
f"selected_error_type={type(selected_error).__name__})"
)
raise selected_error
raise SupersetAPIError("Superset users lookup failed without explicit error")
# [/DEF:get_users_page:Function]
# [DEF:_normalize_lookup_payload:Function]
# @PURPOSE: Convert Superset users response variants into stable candidates payload.
# @PRE: response can be dict/list in any supported upstream shape.
# @POST: Output contains canonical keys: status, environment_id, page_index, page_size, total, items.
# @RETURN: Dict[str, Any]
def _normalize_lookup_payload(
self,
response: Any,
page_index: int,
page_size: int,
) -> Dict[str, Any]:
with belief_scope("SupersetAccountLookupAdapter._normalize_lookup_payload"):
payload = response
if isinstance(payload, dict) and isinstance(payload.get("result"), dict):
payload = payload.get("result")
raw_items: List[Any] = []
total = 0
if isinstance(payload, dict):
if isinstance(payload.get("result"), list):
raw_items = payload.get("result") or []
total = int(payload.get("count", len(raw_items)) or 0)
elif isinstance(payload.get("users"), list):
raw_items = payload.get("users") or []
total = int(payload.get("total", len(raw_items)) or 0)
elif isinstance(payload.get("items"), list):
raw_items = payload.get("items") or []
total = int(payload.get("total", len(raw_items)) or 0)
elif isinstance(payload, list):
raw_items = payload
total = len(raw_items)
normalized_items: List[Dict[str, Any]] = []
seen_usernames = set()
for raw_user in raw_items:
candidate = self.normalize_user_payload(raw_user)
username_key = str(candidate.get("username") or "").strip().lower()
if not username_key:
continue
if username_key in seen_usernames:
continue
seen_usernames.add(username_key)
normalized_items.append(candidate)
logger.reflect(
"[REFLECT] Normalized lookup payload "
f"(env={self.environment_id}, items={len(normalized_items)}, total={max(total, len(normalized_items))})"
)
return {
"status": "success",
"environment_id": self.environment_id,
"page_index": max(int(page_index), 0),
"page_size": max(int(page_size), 1),
"total": max(int(total), len(normalized_items)),
"items": normalized_items,
}
# [/DEF:_normalize_lookup_payload:Function]
# [DEF:normalize_user_payload:Function]
# @PURPOSE: Project raw Superset user object to canonical candidate shape.
# @PRE: raw_user may have heterogenous key names between Superset versions.
# @POST: Returns normalized candidate keys (environment_id, username, display_name, email, is_active).
# @RETURN: Dict[str, Any]
def normalize_user_payload(self, raw_user: Any) -> Dict[str, Any]:
if not isinstance(raw_user, dict):
raw_user = {}
username = str(
raw_user.get("username")
or raw_user.get("userName")
or raw_user.get("name")
or ""
).strip()
full_name = str(raw_user.get("full_name") or "").strip()
first_name = str(raw_user.get("first_name") or "").strip()
last_name = str(raw_user.get("last_name") or "").strip()
display_name = full_name or " ".join(
part for part in [first_name, last_name] if part
).strip()
if not display_name:
display_name = username or None
email = str(raw_user.get("email") or "").strip() or None
is_active_raw = raw_user.get("is_active")
is_active = bool(is_active_raw) if is_active_raw is not None else None
return {
"environment_id": self.environment_id,
"username": username,
"display_name": display_name,
"email": email,
"is_active": is_active,
}
# [/DEF:normalize_user_payload:Function]
# [/DEF:SupersetAccountLookupAdapter:Class]
# [/DEF:backend.src.core.superset_profile_lookup:Module]

View File

@@ -1,102 +0,0 @@
# [DEF:__tests__/test_task_logger:Module]
# @RELATION: VERIFIES -> ../task_logger.py
# @PURPOSE: Contract testing for TaskLogger
# [/DEF:__tests__/test_task_logger:Module]
import pytest
from unittest.mock import MagicMock
from src.core.task_manager.task_logger import TaskLogger
# @TEST_FIXTURE: valid_task_logger -> {"task_id": "test_123", "add_log_fn": lambda *args: None, "source": "test_plugin"}
@pytest.fixture
def mock_add_log():
return MagicMock()
@pytest.fixture
def task_logger(mock_add_log):
return TaskLogger(task_id="test_123", add_log_fn=mock_add_log, source="test_plugin")
# @TEST_CONTRACT: TaskLoggerModel -> Invariants
def test_task_logger_initialization(task_logger):
"""Verify TaskLogger is bound to specific task_id and source."""
assert task_logger._task_id == "test_123"
assert task_logger._default_source == "test_plugin"
# @TEST_CONTRACT: invariants -> "All specific log methods (info, error) delegate to _log"
def test_log_methods_delegation(task_logger, mock_add_log):
"""Verify info, error, warning, debug delegate to internal _log."""
task_logger.info("info message", metadata={"k": "v"})
mock_add_log.assert_called_with(
task_id="test_123",
level="INFO",
message="info message",
source="test_plugin",
metadata={"k": "v"}
)
task_logger.error("error message", source="override")
mock_add_log.assert_called_with(
task_id="test_123",
level="ERROR",
message="error message",
source="override",
metadata=None
)
task_logger.warning("warning message")
mock_add_log.assert_called_with(
task_id="test_123",
level="WARNING",
message="warning message",
source="test_plugin",
metadata=None
)
task_logger.debug("debug message")
mock_add_log.assert_called_with(
task_id="test_123",
level="DEBUG",
message="debug message",
source="test_plugin",
metadata=None
)
# @TEST_CONTRACT: invariants -> "with_source creates a new logger with the same task_id"
def test_with_source(task_logger):
"""Verify with_source returns a new instance with updated default source."""
new_logger = task_logger.with_source("new_source")
assert isinstance(new_logger, TaskLogger)
assert new_logger._task_id == "test_123"
assert new_logger._default_source == "new_source"
assert new_logger is not task_logger
# @TEST_EDGE: missing_task_id -> raises TypeError
def test_missing_task_id():
with pytest.raises(TypeError):
TaskLogger(add_log_fn=lambda x: x)
# @TEST_EDGE: invalid_add_log_fn -> raises TypeError
# (Python doesn't strictly enforce this at init, but let's verify it fails on call if not callable)
def test_invalid_add_log_fn():
logger = TaskLogger(task_id="msg", add_log_fn=None)
with pytest.raises(TypeError):
logger.info("test")
# @TEST_INVARIANT: consistent_delegation
def test_progress_log(task_logger, mock_add_log):
"""Verify progress method correctly formats metadata."""
task_logger.progress("Step 1", 45.5)
mock_add_log.assert_called_with(
task_id="test_123",
level="INFO",
message="Step 1",
source="test_plugin",
metadata={"progress": 45.5}
)
# Boundary checks
task_logger.progress("Step high", 150)
assert mock_add_log.call_args[1]["metadata"]["progress"] == 100
task_logger.progress("Step low", -10)
assert mock_add_log.call_args[1]["metadata"]["progress"] == 0

View File

@@ -53,7 +53,6 @@ from ..logger import logger, belief_scope, should_log_task_level
# @TEST_EDGE: create_task_invalid_plugin -> raises ValueError
# @TEST_EDGE: create_task_invalid_params -> raises ValueError
# @TEST_INVARIANT: lifecycle_management -> verifies: [valid_manager]
class TaskManager:
"""
Manages the lifecycle of tasks, including their creation, execution, and state tracking.
"""

View File

@@ -10,7 +10,6 @@
from datetime import datetime
from typing import List, Optional
import json
import re
from sqlalchemy.orm import Session
from ...models.task import TaskRecord, TaskLogRecord
@@ -81,40 +80,18 @@ class TaskPersistenceService:
# [DEF:_resolve_environment_id:Function]
# @TIER: STANDARD
# @PURPOSE: Resolve environment id into existing environments.id value to satisfy FK constraints.
# @PURPOSE: Resolve environment id based on provided value or fallback to default
# @PRE: Session is active
# @POST: Returns existing environments.id or None when unresolved.
# @POST: Environment ID is returned
@staticmethod
def _resolve_environment_id(session: Session, env_id: Optional[str]) -> Optional[str]:
def _resolve_environment_id(session: Session, env_id: Optional[str]) -> str:
with belief_scope("_resolve_environment_id"):
raw_value = str(env_id or "").strip()
if not raw_value:
return None
# 1) Direct match by primary key.
by_id = session.query(Environment).filter(Environment.id == raw_value).first()
if by_id:
return str(by_id.id)
# 2) Exact match by name.
by_name = session.query(Environment).filter(Environment.name == raw_value).first()
if by_name:
return str(by_name.id)
# 3) Slug-like match (e.g. "ss-dev" -> "SS DEV").
def normalize_token(value: str) -> str:
lowered = str(value or "").strip().lower()
return re.sub(r"[^a-z0-9]+", "-", lowered).strip("-")
target_token = normalize_token(raw_value)
if not target_token:
return None
for env in session.query(Environment).all():
if normalize_token(env.id) == target_token or normalize_token(env.name) == target_token:
return str(env.id)
return None
if env_id:
return env_id
repo_env = session.query(Environment).filter_by(name="default").first()
if repo_env:
return str(repo_env.id)
return "default"
# [/DEF:_resolve_environment_id:Function]
# [DEF:__init__:Function]

View File

@@ -1,237 +0,0 @@
# [DEF:backend.src.core.utils.async_network:Module]
#
# @TIER: CRITICAL
# @SEMANTICS: network, httpx, async, superset, authentication, cache
# @PURPOSE: Provides async Superset API client with shared auth-token cache to avoid per-request re-login.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> backend.src.core.utils.network.SupersetAuthCache
# @INVARIANT: Async client reuses cached auth tokens per environment credentials and invalidates on 401.
# [SECTION: IMPORTS]
from typing import Optional, Dict, Any, Union
import asyncio
import httpx
from ..logger import logger as app_logger, belief_scope
from .network import (
AuthenticationError,
DashboardNotFoundError,
NetworkError,
PermissionDeniedError,
SupersetAPIError,
SupersetAuthCache,
)
# [/SECTION]
# [DEF:AsyncAPIClient:Class]
# @PURPOSE: Async Superset API client backed by httpx.AsyncClient with shared auth cache.
class AsyncAPIClient:
DEFAULT_TIMEOUT = 30
_auth_locks: Dict[tuple[str, str, bool], asyncio.Lock] = {}
# [DEF:__init__:Function]
# @PURPOSE: Initialize async API client for one environment.
# @PRE: config contains base_url and auth payload.
# @POST: Client is ready for async request/authentication flow.
def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT):
self.base_url: str = self._normalize_base_url(config.get("base_url", ""))
self.api_base_url: str = f"{self.base_url}/api/v1"
self.auth = config.get("auth")
self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
self._client = httpx.AsyncClient(
verify=verify_ssl,
timeout=httpx.Timeout(timeout),
follow_redirects=True,
)
self._tokens: Dict[str, str] = {}
self._authenticated = False
self._auth_cache_key = SupersetAuthCache.build_key(
self.base_url,
self.auth,
verify_ssl,
)
# [/DEF:__init__:Function]
# [DEF:_normalize_base_url:Function]
# @PURPOSE: Normalize base URL for Superset API root construction.
# @POST: Returns canonical base URL without trailing slash and duplicate /api/v1 suffix.
def _normalize_base_url(self, raw_url: str) -> str:
normalized = str(raw_url or "").strip().rstrip("/")
if normalized.lower().endswith("/api/v1"):
normalized = normalized[:-len("/api/v1")]
return normalized.rstrip("/")
# [/DEF:_normalize_base_url:Function]
# [DEF:_build_api_url:Function]
# @PURPOSE: Build full API URL from relative Superset endpoint.
# @POST: Returns absolute URL for upstream request.
def _build_api_url(self, endpoint: str) -> str:
normalized_endpoint = str(endpoint or "").strip()
if normalized_endpoint.startswith("http://") or normalized_endpoint.startswith("https://"):
return normalized_endpoint
if not normalized_endpoint.startswith("/"):
normalized_endpoint = f"/{normalized_endpoint}"
if normalized_endpoint.startswith("/api/v1/") or normalized_endpoint == "/api/v1":
return f"{self.base_url}{normalized_endpoint}"
return f"{self.api_base_url}{normalized_endpoint}"
# [/DEF:_build_api_url:Function]
# [DEF:_get_auth_lock:Function]
# @PURPOSE: Return per-cache-key async lock to serialize fresh login attempts.
# @POST: Returns stable asyncio.Lock instance.
@classmethod
def _get_auth_lock(cls, cache_key: tuple[str, str, bool]) -> asyncio.Lock:
existing_lock = cls._auth_locks.get(cache_key)
if existing_lock is not None:
return existing_lock
created_lock = asyncio.Lock()
cls._auth_locks[cache_key] = created_lock
return created_lock
# [/DEF:_get_auth_lock:Function]
# [DEF:authenticate:Function]
# @PURPOSE: Authenticate against Superset and cache access/csrf tokens.
# @POST: Client tokens are populated and reusable across requests.
async def authenticate(self) -> Dict[str, str]:
cached_tokens = SupersetAuthCache.get(self._auth_cache_key)
if cached_tokens and cached_tokens.get("access_token") and cached_tokens.get("csrf_token"):
self._tokens = cached_tokens
self._authenticated = True
app_logger.info("[async_authenticate][CacheHit] Reusing cached Superset auth tokens for %s", self.base_url)
return self._tokens
auth_lock = self._get_auth_lock(self._auth_cache_key)
async with auth_lock:
cached_tokens = SupersetAuthCache.get(self._auth_cache_key)
if cached_tokens and cached_tokens.get("access_token") and cached_tokens.get("csrf_token"):
self._tokens = cached_tokens
self._authenticated = True
app_logger.info("[async_authenticate][CacheHitAfterWait] Reusing cached Superset auth tokens for %s", self.base_url)
return self._tokens
with belief_scope("AsyncAPIClient.authenticate"):
app_logger.info("[async_authenticate][Enter] Authenticating to %s", self.base_url)
try:
login_url = f"{self.api_base_url}/security/login"
response = await self._client.post(login_url, json=self.auth)
response.raise_for_status()
access_token = response.json()["access_token"]
csrf_url = f"{self.api_base_url}/security/csrf_token/"
csrf_response = await self._client.get(
csrf_url,
headers={"Authorization": f"Bearer {access_token}"},
)
csrf_response.raise_for_status()
self._tokens = {
"access_token": access_token,
"csrf_token": csrf_response.json()["result"],
}
self._authenticated = True
SupersetAuthCache.set(self._auth_cache_key, self._tokens)
app_logger.info("[async_authenticate][Exit] Authenticated successfully.")
return self._tokens
except httpx.HTTPStatusError as exc:
SupersetAuthCache.invalidate(self._auth_cache_key)
status_code = exc.response.status_code if exc.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 exc
raise AuthenticationError(f"Authentication failed: {exc}") from exc
except (httpx.HTTPError, KeyError) as exc:
SupersetAuthCache.invalidate(self._auth_cache_key)
raise NetworkError(f"Network or parsing error during authentication: {exc}") from exc
# [/DEF:authenticate:Function]
# [DEF:get_headers:Function]
# @PURPOSE: Return authenticated Superset headers for async requests.
# @POST: Headers include Authorization and CSRF tokens.
async def get_headers(self) -> Dict[str, str]:
if not self._authenticated:
await 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:get_headers:Function]
# [DEF:request:Function]
# @PURPOSE: Perform one authenticated async Superset API request.
# @POST: Returns JSON payload or raw httpx.Response when raw_response=true.
async def request(
self,
method: str,
endpoint: str,
headers: Optional[Dict[str, str]] = None,
raw_response: bool = False,
**kwargs,
) -> Union[httpx.Response, Dict[str, Any]]:
full_url = self._build_api_url(endpoint)
request_headers = await self.get_headers()
if headers:
request_headers.update(headers)
if "allow_redirects" in kwargs and "follow_redirects" not in kwargs:
kwargs["follow_redirects"] = bool(kwargs.pop("allow_redirects"))
try:
response = await self._client.request(method, full_url, headers=request_headers, **kwargs)
response.raise_for_status()
return response if raw_response else response.json()
except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code == 401:
self._authenticated = False
self._tokens = {}
SupersetAuthCache.invalidate(self._auth_cache_key)
self._handle_http_error(exc, endpoint)
except httpx.HTTPError as exc:
self._handle_network_error(exc, full_url)
# [/DEF:request:Function]
# [DEF:_handle_http_error:Function]
# @PURPOSE: Translate upstream HTTP errors into stable domain exceptions.
# @POST: Raises domain-specific exception for caller flow control.
def _handle_http_error(self, exc: httpx.HTTPStatusError, endpoint: str) -> None:
with belief_scope("AsyncAPIClient._handle_http_error"):
status_code = exc.response.status_code
if status_code in [502, 503, 504]:
raise NetworkError(f"Environment unavailable (Status {status_code})", status_code=status_code) from exc
if status_code == 404:
raise DashboardNotFoundError(endpoint) from exc
if status_code == 403:
raise PermissionDeniedError() from exc
if status_code == 401:
raise AuthenticationError() from exc
raise SupersetAPIError(f"API Error {status_code}: {exc.response.text}") from exc
# [/DEF:_handle_http_error:Function]
# [DEF:_handle_network_error:Function]
# @PURPOSE: Translate generic httpx errors into NetworkError.
# @POST: Raises NetworkError with URL context.
def _handle_network_error(self, exc: httpx.HTTPError, url: str) -> None:
with belief_scope("AsyncAPIClient._handle_network_error"):
if isinstance(exc, httpx.TimeoutException):
message = "Request timeout"
elif isinstance(exc, httpx.ConnectError):
message = "Connection error"
else:
message = f"Unknown network error: {exc}"
raise NetworkError(message, url=url) from exc
# [/DEF:_handle_network_error:Function]
# [DEF:aclose:Function]
# @PURPOSE: Close underlying httpx client.
# @POST: Client resources are released.
async def aclose(self) -> None:
await self._client.aclose()
# [/DEF:aclose:Function]
# [/DEF:AsyncAPIClient:Class]
# [/DEF:backend.src.core.utils.async_network:Module]

View File

@@ -8,12 +8,10 @@
# @PUBLIC_API: APIClient
# [SECTION: IMPORTS]
from typing import Optional, Dict, Any, List, Union, cast, Tuple
from typing import Optional, Dict, Any, List, Union, cast
import json
import io
from pathlib import Path
import threading
import time
import requests
from requests.adapters import HTTPAdapter
import urllib3
@@ -88,62 +86,6 @@ class NetworkError(Exception):
# [/DEF:__init__:Function]
# [/DEF:NetworkError:Class]
# [DEF:SupersetAuthCache:Class]
# @PURPOSE: Process-local cache for Superset access/csrf tokens keyed by environment credentials.
# @PRE: base_url and username are stable strings.
# @POST: Cached entries expire automatically by TTL and can be reused across requests.
class SupersetAuthCache:
TTL_SECONDS = 300
_lock = threading.Lock()
_entries: Dict[Tuple[str, str, bool], Dict[str, Any]] = {}
@classmethod
def build_key(cls, base_url: str, auth: Optional[Dict[str, Any]], verify_ssl: bool) -> Tuple[str, str, bool]:
username = ""
if isinstance(auth, dict):
username = str(auth.get("username") or "").strip()
return (str(base_url or "").strip(), username, bool(verify_ssl))
@classmethod
def get(cls, key: Tuple[str, str, bool]) -> Optional[Dict[str, str]]:
now = time.time()
with cls._lock:
payload = cls._entries.get(key)
if not payload:
return None
expires_at = float(payload.get("expires_at") or 0)
if expires_at <= now:
cls._entries.pop(key, None)
return None
tokens = payload.get("tokens")
if not isinstance(tokens, dict):
cls._entries.pop(key, None)
return None
return {
"access_token": str(tokens.get("access_token") or ""),
"csrf_token": str(tokens.get("csrf_token") or ""),
}
@classmethod
def set(cls, key: Tuple[str, str, bool], tokens: Dict[str, str], ttl_seconds: Optional[int] = None) -> None:
normalized_ttl = max(int(ttl_seconds or cls.TTL_SECONDS), 1)
with cls._lock:
cls._entries[key] = {
"tokens": {
"access_token": str(tokens.get("access_token") or ""),
"csrf_token": str(tokens.get("csrf_token") or ""),
},
"expires_at": time.time() + normalized_ttl,
}
@classmethod
def invalidate(cls, key: Tuple[str, str, bool]) -> None:
with cls._lock:
cls._entries.pop(key, None)
# [/DEF:SupersetAuthCache:Class]
# [DEF:APIClient:Class]
# @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов.
class APIClient:
@@ -159,17 +101,11 @@ class APIClient:
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 = self._normalize_base_url(config.get("base_url", ""))
self.api_base_url: str = f"{self.base_url}/api/v1"
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._auth_cache_key = SupersetAuthCache.build_key(
self.base_url,
self.auth,
verify_ssl,
)
self._authenticated = False
app_logger.info("[APIClient.__init__][Exit] APIClient initialized.")
# [/DEF:__init__:Function]
@@ -220,34 +156,6 @@ class APIClient:
return session
# [/DEF:_init_session:Function]
# [DEF:_normalize_base_url:Function]
# @PURPOSE: Normalize Superset environment URL to base host/path without trailing slash and /api/v1 suffix.
# @PRE: raw_url can be empty.
# @POST: Returns canonical base URL suitable for building API endpoints.
# @RETURN: str
def _normalize_base_url(self, raw_url: str) -> str:
normalized = str(raw_url or "").strip().rstrip("/")
if normalized.lower().endswith("/api/v1"):
normalized = normalized[:-len("/api/v1")]
return normalized.rstrip("/")
# [/DEF:_normalize_base_url:Function]
# [DEF:_build_api_url:Function]
# @PURPOSE: Build absolute Superset API URL for endpoint using canonical /api/v1 base.
# @PRE: endpoint is relative path or absolute URL.
# @POST: Returns full URL without accidental duplicate slashes.
# @RETURN: str
def _build_api_url(self, endpoint: str) -> str:
normalized_endpoint = str(endpoint or "").strip()
if normalized_endpoint.startswith("http://") or normalized_endpoint.startswith("https://"):
return normalized_endpoint
if not normalized_endpoint.startswith("/"):
normalized_endpoint = f"/{normalized_endpoint}"
if normalized_endpoint.startswith("/api/v1/") or normalized_endpoint == "/api/v1":
return f"{self.base_url}{normalized_endpoint}"
return f"{self.api_base_url}{normalized_endpoint}"
# [/DEF:_build_api_url:Function]
# [DEF:authenticate:Function]
# @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены.
# @PRE: self.auth and self.base_url must be valid.
@@ -257,14 +165,8 @@ class APIClient:
def authenticate(self) -> Dict[str, str]:
with belief_scope("authenticate"):
app_logger.info("[authenticate][Enter] Authenticating to %s", self.base_url)
cached_tokens = SupersetAuthCache.get(self._auth_cache_key)
if cached_tokens and cached_tokens.get("access_token") and cached_tokens.get("csrf_token"):
self._tokens = cached_tokens
self._authenticated = True
app_logger.info("[authenticate][CacheHit] Reusing cached Superset auth tokens for %s", self.base_url)
return self._tokens
try:
login_url = f"{self.api_base_url}/security/login"
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}")
@@ -278,23 +180,20 @@ class APIClient:
response.raise_for_status()
access_token = response.json()["access_token"]
csrf_url = f"{self.api_base_url}/security/csrf_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
SupersetAuthCache.set(self._auth_cache_key, self._tokens)
app_logger.info("[authenticate][Exit] Authenticated successfully.")
return self._tokens
except requests.exceptions.HTTPError as e:
SupersetAuthCache.invalidate(self._auth_cache_key)
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:
SupersetAuthCache.invalidate(self._auth_cache_key)
raise NetworkError(f"Network or parsing error during authentication: {e}") from e
# [/DEF:authenticate:Function]
@@ -325,7 +224,7 @@ class APIClient:
# @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]]:
full_url = self._build_api_url(endpoint)
full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy()
if headers:
_headers.update(headers)
@@ -335,10 +234,6 @@ class APIClient:
response.raise_for_status()
return response if raw_response else response.json()
except requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == 401:
self._authenticated = False
self._tokens = {}
SupersetAuthCache.invalidate(self._auth_cache_key)
self._handle_http_error(e, endpoint)
except requests.exceptions.RequestException as e:
self._handle_network_error(e, full_url)
@@ -393,7 +288,7 @@ class APIClient:
# @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 = self._build_api_url(endpoint)
full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy()
_headers.pop('Content-Type', None)

View File

@@ -14,29 +14,20 @@ from .core.config_manager import ConfigManager
from .core.scheduler import SchedulerService
from .services.resource_service import ResourceService
from .services.mapping_service import MappingService
from .services.clean_release.repositories import (
CandidateRepository, ArtifactRepository, ManifestRepository,
PolicyRepository, ComplianceRepository, ReportRepository,
ApprovalRepository, PublicationRepository, AuditRepository,
CleanReleaseAuditLog
)
from .services.clean_release.repository import CleanReleaseRepository
from .services.clean_release.facade import CleanReleaseFacade
from .services.reports.report_service import ReportsService
from .core.database import init_db, get_auth_db, get_db
from .core.database import init_db, get_auth_db
from .core.logger import logger
from .core.auth.jwt import decode_token
from .core.auth.repository import AuthRepository
from .models.auth import User
# Initialize singletons
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
project_root = Path(__file__).parent.parent.parent
config_path = project_root / "config.json"
# Initialize database before services that use persisted configuration.
init_db()
config_manager = ConfigManager(config_path=str(config_path))
# Initialize singletons
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
project_root = Path(__file__).parent.parent.parent
config_path = project_root / "config.json"
# Initialize database before services that use persisted configuration.
init_db()
config_manager = ConfigManager(config_path=str(config_path))
# [DEF:get_config_manager:Function]
# @PURPOSE: Dependency injector for ConfigManager.
@@ -63,11 +54,6 @@ logger.info("SchedulerService initialized")
resource_service = ResourceService()
logger.info("ResourceService initialized")
# Clean Release Redesign Singletons
# Note: These use get_db() which is a generator, so we need a way to provide a session.
# For singletons in dependencies.py, we might need a different approach or
# initialize them inside the dependency functions.
# [DEF:get_plugin_loader:Function]
# @PURPOSE: Dependency injector for PluginLoader.
# @PRE: Global plugin_loader must be initialized.
@@ -118,46 +104,6 @@ def get_mapping_service() -> MappingService:
return MappingService(config_manager)
# [/DEF:get_mapping_service:Function]
_clean_release_repository = CleanReleaseRepository()
# [DEF:get_clean_release_repository:Function]
# @PURPOSE: Legacy compatibility shim for CleanReleaseRepository.
# @POST: Returns a shared CleanReleaseRepository instance.
def get_clean_release_repository() -> CleanReleaseRepository:
"""Legacy compatibility shim for CleanReleaseRepository."""
return _clean_release_repository
# [/DEF:get_clean_release_repository:Function]
# [DEF:get_clean_release_facade:Function]
# @PURPOSE: Dependency injector for CleanReleaseFacade.
# @POST: Returns a facade instance with a fresh DB session.
def get_clean_release_facade(db = Depends(get_db)) -> CleanReleaseFacade:
candidate_repo = CandidateRepository(db)
artifact_repo = ArtifactRepository(db)
manifest_repo = ManifestRepository(db)
policy_repo = PolicyRepository(db)
compliance_repo = ComplianceRepository(db)
report_repo = ReportRepository(db)
approval_repo = ApprovalRepository(db)
publication_repo = PublicationRepository(db)
audit_repo = AuditRepository(db)
return CleanReleaseFacade(
candidate_repo=candidate_repo,
artifact_repo=artifact_repo,
manifest_repo=manifest_repo,
policy_repo=policy_repo,
compliance_repo=compliance_repo,
report_repo=report_repo,
approval_repo=approval_repo,
publication_repo=publication_repo,
audit_repo=audit_repo,
config_manager=config_manager
)
# [/DEF:get_clean_release_facade:Function]
# [DEF:oauth2_scheme:Variable]
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")

View File

@@ -1,149 +0,0 @@
# [DEF:__tests__/test_clean_release:Module]
# @RELATION: VERIFIES -> ../clean_release.py
# @PURPOSE: Contract testing for Clean Release models
# [/DEF:__tests__/test_clean_release:Module]
import pytest
from datetime import datetime
from pydantic import ValidationError
from src.models.clean_release import (
ReleaseCandidate,
ReleaseCandidateStatus,
ProfileType,
CleanProfilePolicy,
DistributionManifest,
ManifestItem,
ManifestSummary,
ClassificationType,
ComplianceCheckRun,
CheckFinalStatus,
CheckStageResult,
CheckStageName,
CheckStageStatus,
ComplianceReport,
ExecutionMode
)
# @TEST_FIXTURE: valid_enterprise_candidate
@pytest.fixture
def valid_candidate_data():
return {
"candidate_id": "RC-001",
"version": "1.0.0",
"profile": ProfileType.ENTERPRISE_CLEAN,
"created_at": datetime.now(),
"created_by": "admin",
"source_snapshot_ref": "v1.0.0-snapshot"
}
def test_release_candidate_valid(valid_candidate_data):
rc = ReleaseCandidate(**valid_candidate_data)
assert rc.candidate_id == "RC-001"
assert rc.status == ReleaseCandidateStatus.DRAFT
def test_release_candidate_empty_id(valid_candidate_data):
valid_candidate_data["candidate_id"] = " "
with pytest.raises(ValueError, match="candidate_id must be non-empty"):
ReleaseCandidate(**valid_candidate_data)
# @TEST_FIXTURE: valid_enterprise_policy
@pytest.fixture
def valid_policy_data():
return {
"policy_id": "POL-001",
"policy_version": "1",
"active": True,
"prohibited_artifact_categories": ["test-data"],
"required_system_categories": ["core"],
"internal_source_registry_ref": "REG-1",
"effective_from": datetime.now(),
"profile": ProfileType.ENTERPRISE_CLEAN
}
# @TEST_INVARIANT: policy_purity
def test_enterprise_policy_valid(valid_policy_data):
policy = CleanProfilePolicy(**valid_policy_data)
assert policy.external_source_forbidden is True
# @TEST_EDGE: enterprise_policy_missing_prohibited
def test_enterprise_policy_missing_prohibited(valid_policy_data):
valid_policy_data["prohibited_artifact_categories"] = []
with pytest.raises(ValueError, match="enterprise-clean policy requires prohibited_artifact_categories"):
CleanProfilePolicy(**valid_policy_data)
# @TEST_EDGE: enterprise_policy_external_allowed
def test_enterprise_policy_external_allowed(valid_policy_data):
valid_policy_data["external_source_forbidden"] = False
with pytest.raises(ValueError, match="enterprise-clean policy requires external_source_forbidden=true"):
CleanProfilePolicy(**valid_policy_data)
# @TEST_INVARIANT: manifest_consistency
# @TEST_EDGE: manifest_count_mismatch
def test_manifest_count_mismatch():
summary = ManifestSummary(included_count=1, excluded_count=0, prohibited_detected_count=0)
item = ManifestItem(path="p", category="c", classification=ClassificationType.ALLOWED, reason="r")
# Valid
DistributionManifest(
manifest_id="m1", candidate_id="rc1", policy_id="p1",
generated_at=datetime.now(), generated_by="u", items=[item],
summary=summary, deterministic_hash="h"
)
# Invalid count
summary.included_count = 2
with pytest.raises(ValueError, match="manifest summary counts must match items size"):
DistributionManifest(
manifest_id="m1", candidate_id="rc1", policy_id="p1",
generated_at=datetime.now(), generated_by="u", items=[item],
summary=summary, deterministic_hash="h"
)
# @TEST_INVARIANT: run_integrity
# @TEST_EDGE: compliant_run_stage_fail
def test_compliant_run_validation():
base_run = {
"check_run_id": "run1",
"candidate_id": "rc1",
"policy_id": "p1",
"started_at": datetime.now(),
"triggered_by": "u",
"execution_mode": ExecutionMode.TUI,
"final_status": CheckFinalStatus.COMPLIANT,
"checks": [
CheckStageResult(stage=CheckStageName.DATA_PURITY, status=CheckStageStatus.PASS),
CheckStageResult(stage=CheckStageName.INTERNAL_SOURCES_ONLY, status=CheckStageStatus.PASS),
CheckStageResult(stage=CheckStageName.NO_EXTERNAL_ENDPOINTS, status=CheckStageStatus.PASS),
CheckStageResult(stage=CheckStageName.MANIFEST_CONSISTENCY, status=CheckStageStatus.PASS),
]
}
# Valid
ComplianceCheckRun(**base_run)
# One stage fails -> cannot be COMPLIANT
base_run["checks"][0].status = CheckStageStatus.FAIL
with pytest.raises(ValueError, match="compliant run requires PASS on all mandatory stages"):
ComplianceCheckRun(**base_run)
# Missing stage -> cannot be COMPLIANT
base_run["checks"] = base_run["checks"][1:]
with pytest.raises(ValueError, match="compliant run requires all mandatory stages"):
ComplianceCheckRun(**base_run)
def test_report_validation():
# Valid blocked report
ComplianceReport(
report_id="rep1", check_run_id="run1", candidate_id="rc1",
generated_at=datetime.now(), final_status=CheckFinalStatus.BLOCKED,
operator_summary="Blocked", structured_payload_ref="ref",
violations_count=2, blocking_violations_count=2
)
# BLOCKED with 0 blocking violations
with pytest.raises(ValueError, match="blocked report requires blocking violations"):
ComplianceReport(
report_id="rep1", check_run_id="run1", candidate_id="rc1",
generated_at=datetime.now(), final_status=CheckFinalStatus.BLOCKED,
operator_summary="Blocked", structured_payload_ref="ref",
violations_count=2, blocking_violations_count=0
)

View File

@@ -1,436 +0,0 @@
# [DEF:backend.src.models.clean_release:Module]
# @TIER: CRITICAL
# @SEMANTICS: clean-release, models, lifecycle, compliance, evidence, immutability
# @PURPOSE: Define canonical clean release domain entities and lifecycle guards.
# @LAYER: Domain
# @INVARIANT: Immutable snapshots are never mutated; forbidden lifecycle transitions are rejected.
from datetime import datetime
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Dict, Any
from sqlalchemy import Column, String, DateTime, JSON, ForeignKey, Integer, Boolean
from sqlalchemy.orm import relationship
from .mapping import Base
from ..services.clean_release.enums import (
CandidateStatus, RunStatus, ComplianceDecision,
ApprovalDecisionType, PublicationStatus, ClassificationType
)
from ..services.clean_release.exceptions import IllegalTransitionError
# [DEF:CheckFinalStatus:Class]
# @PURPOSE: Backward-compatible final status enum for legacy TUI/orchestrator tests.
class CheckFinalStatus(str, Enum):
COMPLIANT = "COMPLIANT"
BLOCKED = "BLOCKED"
FAILED = "FAILED"
# [/DEF:CheckFinalStatus:Class]
# [DEF:CheckStageName:Class]
# @PURPOSE: Backward-compatible stage name enum for legacy TUI/orchestrator tests.
class CheckStageName(str, Enum):
DATA_PURITY = "DATA_PURITY"
INTERNAL_SOURCES_ONLY = "INTERNAL_SOURCES_ONLY"
NO_EXTERNAL_ENDPOINTS = "NO_EXTERNAL_ENDPOINTS"
MANIFEST_CONSISTENCY = "MANIFEST_CONSISTENCY"
# [/DEF:CheckStageName:Class]
# [DEF:CheckStageStatus:Class]
# @PURPOSE: Backward-compatible stage status enum for legacy TUI/orchestrator tests.
class CheckStageStatus(str, Enum):
PASS = "PASS"
FAIL = "FAIL"
SKIPPED = "SKIPPED"
RUNNING = "RUNNING"
# [/DEF:CheckStageStatus:Class]
# [DEF:CheckStageResult:Class]
# @PURPOSE: Backward-compatible stage result container for legacy TUI/orchestrator tests.
@dataclass
class CheckStageResult:
stage: CheckStageName
status: CheckStageStatus
details: str = ""
# [/DEF:CheckStageResult:Class]
# [DEF:ProfileType:Class]
# @PURPOSE: Backward-compatible profile enum for legacy TUI bootstrap logic.
class ProfileType(str, Enum):
ENTERPRISE_CLEAN = "enterprise-clean"
# [/DEF:ProfileType:Class]
# [DEF:RegistryStatus:Class]
# @PURPOSE: Backward-compatible registry status enum for legacy TUI bootstrap logic.
class RegistryStatus(str, Enum):
ACTIVE = "ACTIVE"
INACTIVE = "INACTIVE"
# [/DEF:RegistryStatus:Class]
# [DEF:ReleaseCandidateStatus:Class]
# @PURPOSE: Backward-compatible release candidate status enum for legacy TUI.
class ReleaseCandidateStatus(str, Enum):
DRAFT = CandidateStatus.DRAFT.value
PREPARED = CandidateStatus.PREPARED.value
MANIFEST_BUILT = CandidateStatus.MANIFEST_BUILT.value
CHECK_PENDING = CandidateStatus.CHECK_PENDING.value
CHECK_RUNNING = CandidateStatus.CHECK_RUNNING.value
CHECK_PASSED = CandidateStatus.CHECK_PASSED.value
CHECK_BLOCKED = CandidateStatus.CHECK_BLOCKED.value
CHECK_ERROR = CandidateStatus.CHECK_ERROR.value
APPROVED = CandidateStatus.APPROVED.value
PUBLISHED = CandidateStatus.PUBLISHED.value
REVOKED = CandidateStatus.REVOKED.value
# [/DEF:ReleaseCandidateStatus:Class]
# [DEF:ResourceSourceEntry:Class]
# @PURPOSE: Backward-compatible source entry model for legacy TUI bootstrap logic.
@dataclass
class ResourceSourceEntry:
source_id: str
host: str
protocol: str
purpose: str
enabled: bool = True
# [/DEF:ResourceSourceEntry:Class]
# [DEF:ResourceSourceRegistry:Class]
# @PURPOSE: Backward-compatible source registry model for legacy TUI bootstrap logic.
@dataclass
class ResourceSourceRegistry:
registry_id: str
name: str
entries: List[ResourceSourceEntry]
updated_at: datetime
updated_by: str
status: str = "ACTIVE"
@property
def id(self) -> str:
return self.registry_id
# [/DEF:ResourceSourceRegistry:Class]
# [DEF:CleanProfilePolicy:Class]
# @PURPOSE: Backward-compatible policy model for legacy TUI bootstrap logic.
@dataclass
class CleanProfilePolicy:
policy_id: str
policy_version: str
profile: str
active: bool
internal_source_registry_ref: str
prohibited_artifact_categories: List[str]
effective_from: datetime
required_system_categories: Optional[List[str]] = None
@property
def id(self) -> str:
return self.policy_id
@property
def registry_snapshot_id(self) -> str:
return self.internal_source_registry_ref
# [/DEF:CleanProfilePolicy:Class]
# [DEF:ComplianceCheckRun:Class]
# @PURPOSE: Backward-compatible run model for legacy TUI typing/import compatibility.
@dataclass
class ComplianceCheckRun:
check_run_id: str
candidate_id: str
policy_id: str
requested_by: str
execution_mode: str
checks: List[CheckStageResult]
final_status: CheckFinalStatus
# [/DEF:ComplianceCheckRun:Class]
# [DEF:ReleaseCandidate:Class]
# @PURPOSE: Represents the release unit being prepared and governed.
# @PRE: id, version, source_snapshot_ref are non-empty.
# @POST: status advances only through legal transitions.
class ReleaseCandidate(Base):
__tablename__ = "clean_release_candidates"
id = Column(String, primary_key=True)
name = Column(String, nullable=True) # Added back for backward compatibility with some legacy DTOs
version = Column(String, nullable=False)
source_snapshot_ref = Column(String, nullable=False)
build_id = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
created_by = Column(String, nullable=False)
status = Column(String, default=CandidateStatus.DRAFT)
@property
def candidate_id(self) -> str:
return self.id
def transition_to(self, new_status: CandidateStatus):
"""
@PURPOSE: Enforce legal state transitions.
@PRE: Transition must be allowed by lifecycle rules.
"""
allowed = {
CandidateStatus.DRAFT: [CandidateStatus.PREPARED],
CandidateStatus.PREPARED: [CandidateStatus.MANIFEST_BUILT],
CandidateStatus.MANIFEST_BUILT: [CandidateStatus.CHECK_PENDING],
CandidateStatus.CHECK_PENDING: [CandidateStatus.CHECK_RUNNING],
CandidateStatus.CHECK_RUNNING: [
CandidateStatus.CHECK_PASSED,
CandidateStatus.CHECK_BLOCKED,
CandidateStatus.CHECK_ERROR
],
CandidateStatus.CHECK_PASSED: [CandidateStatus.APPROVED, CandidateStatus.CHECK_PENDING],
CandidateStatus.CHECK_BLOCKED: [CandidateStatus.CHECK_PENDING],
CandidateStatus.CHECK_ERROR: [CandidateStatus.CHECK_PENDING],
CandidateStatus.APPROVED: [CandidateStatus.PUBLISHED],
CandidateStatus.PUBLISHED: [CandidateStatus.REVOKED],
CandidateStatus.REVOKED: []
}
current_status = CandidateStatus(self.status)
if new_status not in allowed.get(current_status, []):
raise IllegalTransitionError(f"Forbidden transition from {current_status} to {new_status}")
self.status = new_status.value
# [/DEF:ReleaseCandidate:Class]
# [DEF:CandidateArtifact:Class]
# @PURPOSE: Represents one artifact associated with a release candidate.
class CandidateArtifact(Base):
__tablename__ = "clean_release_artifacts"
id = Column(String, primary_key=True)
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
path = Column(String, nullable=False)
sha256 = Column(String, nullable=False)
size = Column(Integer, nullable=False)
detected_category = Column(String, nullable=True)
declared_category = Column(String, nullable=True)
source_uri = Column(String, nullable=True)
source_host = Column(String, nullable=True)
metadata_json = Column(JSON, default=dict)
# [/DEF:CandidateArtifact:Class]
# [DEF:ManifestItem:Class]
@dataclass
class ManifestItem:
path: str
category: str
classification: ClassificationType
reason: str
checksum: Optional[str] = None
# [/DEF:ManifestItem:Class]
# [DEF:ManifestSummary:Class]
@dataclass
class ManifestSummary:
included_count: int
excluded_count: int
prohibited_detected_count: int
# [/DEF:ManifestSummary:Class]
# [DEF:DistributionManifest:Class]
# @PURPOSE: Immutable snapshot of the candidate payload.
# @INVARIANT: Immutable after creation.
class DistributionManifest(Base):
__tablename__ = "clean_release_manifests"
id = Column(String, primary_key=True)
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
manifest_version = Column(Integer, nullable=False)
manifest_digest = Column(String, nullable=False)
artifacts_digest = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
created_by = Column(String, nullable=False)
source_snapshot_ref = Column(String, nullable=False)
content_json = Column(JSON, nullable=False)
immutable = Column(Boolean, default=True)
# Redesign compatibility fields (not persisted directly but used by builder/facade)
def __init__(self, **kwargs):
# Handle fields from manifest_builder.py
if "manifest_id" in kwargs:
kwargs["id"] = kwargs.pop("manifest_id")
if "generated_at" in kwargs:
kwargs["created_at"] = kwargs.pop("generated_at")
if "generated_by" in kwargs:
kwargs["created_by"] = kwargs.pop("generated_by")
if "deterministic_hash" in kwargs:
kwargs["manifest_digest"] = kwargs.pop("deterministic_hash")
# Ensure required DB fields have defaults if missing
if "manifest_version" not in kwargs:
kwargs["manifest_version"] = 1
if "artifacts_digest" not in kwargs:
kwargs["artifacts_digest"] = kwargs.get("manifest_digest", "pending")
if "source_snapshot_ref" not in kwargs:
kwargs["source_snapshot_ref"] = "pending"
# Pack items and summary into content_json if provided
if "items" in kwargs or "summary" in kwargs:
content = kwargs.get("content_json", {})
if "items" in kwargs:
items = kwargs.pop("items")
content["items"] = [
{
"path": i.path,
"category": i.category,
"classification": i.classification.value,
"reason": i.reason,
"checksum": i.checksum
} for i in items
]
if "summary" in kwargs:
summary = kwargs.pop("summary")
content["summary"] = {
"included_count": summary.included_count,
"excluded_count": summary.excluded_count,
"prohibited_detected_count": summary.prohibited_detected_count
}
kwargs["content_json"] = content
super().__init__(**kwargs)
# [/DEF:DistributionManifest:Class]
# [DEF:SourceRegistrySnapshot:Class]
# @PURPOSE: Immutable registry snapshot for allowed sources.
class SourceRegistrySnapshot(Base):
__tablename__ = "clean_release_registry_snapshots"
id = Column(String, primary_key=True)
registry_id = Column(String, nullable=False)
registry_version = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
allowed_hosts = Column(JSON, nullable=False) # List[str]
allowed_schemes = Column(JSON, nullable=False) # List[str]
allowed_source_types = Column(JSON, nullable=False) # List[str]
immutable = Column(Boolean, default=True)
# [/DEF:SourceRegistrySnapshot:Class]
# [DEF:CleanPolicySnapshot:Class]
# @PURPOSE: Immutable policy snapshot used to evaluate a run.
class CleanPolicySnapshot(Base):
__tablename__ = "clean_release_policy_snapshots"
id = Column(String, primary_key=True)
policy_id = Column(String, nullable=False)
policy_version = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
content_json = Column(JSON, nullable=False)
registry_snapshot_id = Column(String, ForeignKey("clean_release_registry_snapshots.id"), nullable=False)
immutable = Column(Boolean, default=True)
# [/DEF:CleanPolicySnapshot:Class]
# [DEF:ComplianceRun:Class]
# @PURPOSE: Operational record for one compliance execution.
class ComplianceRun(Base):
__tablename__ = "clean_release_compliance_runs"
id = Column(String, primary_key=True)
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
manifest_id = Column(String, ForeignKey("clean_release_manifests.id"), nullable=False)
manifest_digest = Column(String, nullable=False)
policy_snapshot_id = Column(String, ForeignKey("clean_release_policy_snapshots.id"), nullable=False)
registry_snapshot_id = Column(String, ForeignKey("clean_release_registry_snapshots.id"), nullable=False)
requested_by = Column(String, nullable=False)
requested_at = Column(DateTime, default=datetime.utcnow)
started_at = Column(DateTime, nullable=True)
finished_at = Column(DateTime, nullable=True)
status = Column(String, default=RunStatus.PENDING)
final_status = Column(String, nullable=True) # ComplianceDecision
failure_reason = Column(String, nullable=True)
task_id = Column(String, nullable=True)
@property
def check_run_id(self) -> str:
return self.id
# [/DEF:ComplianceRun:Class]
# [DEF:ComplianceStageRun:Class]
# @PURPOSE: Stage-level execution record inside a run.
class ComplianceStageRun(Base):
__tablename__ = "clean_release_compliance_stage_runs"
id = Column(String, primary_key=True)
run_id = Column(String, ForeignKey("clean_release_compliance_runs.id"), nullable=False)
stage_name = Column(String, nullable=False)
status = Column(String, nullable=False)
started_at = Column(DateTime, nullable=True)
finished_at = Column(DateTime, nullable=True)
decision = Column(String, nullable=True) # ComplianceDecision
details_json = Column(JSON, default=dict)
# [/DEF:ComplianceStageRun:Class]
# [DEF:ComplianceViolation:Class]
# @PURPOSE: Violation produced by a stage.
class ComplianceViolation(Base):
__tablename__ = "clean_release_compliance_violations"
id = Column(String, primary_key=True)
run_id = Column(String, ForeignKey("clean_release_compliance_runs.id"), nullable=False)
stage_name = Column(String, nullable=False)
code = Column(String, nullable=False)
severity = Column(String, nullable=False)
artifact_path = Column(String, nullable=True)
artifact_sha256 = Column(String, nullable=True)
message = Column(String, nullable=False)
evidence_json = Column(JSON, default=dict)
# [/DEF:ComplianceViolation:Class]
# [DEF:ComplianceReport:Class]
# @PURPOSE: Immutable result derived from a completed run.
# @INVARIANT: Immutable after creation.
class ComplianceReport(Base):
__tablename__ = "clean_release_compliance_reports"
id = Column(String, primary_key=True)
run_id = Column(String, ForeignKey("clean_release_compliance_runs.id"), nullable=False)
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
final_status = Column(String, nullable=False) # ComplianceDecision
summary_json = Column(JSON, nullable=False)
generated_at = Column(DateTime, default=datetime.utcnow)
immutable = Column(Boolean, default=True)
# [/DEF:ComplianceReport:Class]
# [DEF:ApprovalDecision:Class]
# @PURPOSE: Approval or rejection bound to a candidate and report.
class ApprovalDecision(Base):
__tablename__ = "clean_release_approval_decisions"
id = Column(String, primary_key=True)
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
report_id = Column(String, ForeignKey("clean_release_compliance_reports.id"), nullable=False)
decision = Column(String, nullable=False) # ApprovalDecisionType
decided_by = Column(String, nullable=False)
decided_at = Column(DateTime, default=datetime.utcnow)
comment = Column(String, nullable=True)
# [/DEF:ApprovalDecision:Class]
# [DEF:PublicationRecord:Class]
# @PURPOSE: Publication or revocation record.
class PublicationRecord(Base):
__tablename__ = "clean_release_publication_records"
id = Column(String, primary_key=True)
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
report_id = Column(String, ForeignKey("clean_release_compliance_reports.id"), nullable=False)
published_by = Column(String, nullable=False)
published_at = Column(DateTime, default=datetime.utcnow)
target_channel = Column(String, nullable=False)
publication_ref = Column(String, nullable=True)
status = Column(String, default=PublicationStatus.ACTIVE)
# [/DEF:PublicationRecord:Class]
# [DEF:CleanReleaseAuditLog:Class]
# @PURPOSE: Represents a persistent audit log entry for clean release actions.
import uuid
class CleanReleaseAuditLog(Base):
__tablename__ = "clean_release_audit_logs"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
candidate_id = Column(String, index=True, nullable=True)
action = Column(String, nullable=False) # e.g. "TRANSITION", "APPROVE", "PUBLISH"
actor = Column(String, nullable=False)
timestamp = Column(DateTime, default=datetime.utcnow)
details_json = Column(JSON, default=dict)
# [/DEF:CleanReleaseAuditLog:Class]
# [/DEF:backend.src.models.clean_release:Module]

View File

@@ -38,7 +38,6 @@ class GitServerConfig(Base):
url = Column(String(255), nullable=False)
pat = Column(String(255), nullable=False) # PERSONAL ACCESS TOKEN
default_repository = Column(String(255), nullable=True)
default_branch = Column(String(255), default="main")
status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN)
last_validated = Column(DateTime, default=datetime.utcnow)
# [/DEF:GitServerConfig:Class]
@@ -54,7 +53,7 @@ class GitRepository(Base):
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="dev")
current_branch = Column(String(255), default="main")
sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN)
# [/DEF:GitRepository:Class]

View File

@@ -1,55 +0,0 @@
# [DEF:backend.src.models.profile:Module]
#
# @TIER: STANDARD
# @SEMANTICS: profile, preferences, persistence, user, dashboard-filter, git, ui-preferences, sqlalchemy
# @PURPOSE: Defines persistent per-user profile settings for dashboard filter, Git identity/token, and UX preferences.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.src.models.auth
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
#
# @INVARIANT: Exactly one preference row exists per user_id.
# @INVARIANT: Sensitive Git token is stored encrypted and never returned in plaintext.
# [SECTION: IMPORTS]
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from .mapping import Base
# [/SECTION]
# [DEF:UserDashboardPreference:Class]
# @TIER: STANDARD
# @PURPOSE: Stores Superset username binding and default "my dashboards" toggle for one authenticated user.
class UserDashboardPreference(Base):
__tablename__ = "user_dashboard_preferences"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String, ForeignKey("users.id"), nullable=False, unique=True, index=True)
superset_username = Column(String, nullable=True)
superset_username_normalized = Column(String, nullable=True, index=True)
show_only_my_dashboards = Column(Boolean, nullable=False, default=False)
git_username = Column(String, nullable=True)
git_email = Column(String, nullable=True)
git_personal_access_token_encrypted = Column(String, nullable=True)
start_page = Column(String, nullable=False, default="dashboards")
auto_open_task_drawer = Column(Boolean, nullable=False, default=True)
dashboards_table_density = Column(String, nullable=False, default="comfortable")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
)
user = relationship("User")
# [/DEF:UserDashboardPreference:Class]
# [/DEF:backend.src.models.profile:Module]

View File

@@ -25,7 +25,6 @@ class TaskType(str, Enum):
BACKUP = "backup"
MIGRATION = "migration"
DOCUMENTATION = "documentation"
CLEAN_RELEASE = "clean_release"
UNKNOWN = "unknown"
# [/DEF:TaskType:Class]

View File

@@ -228,25 +228,6 @@ class StoragePlugin(PluginBase):
f"[StoragePlugin][Action] Listing files in root: {root}, category: {category}, subpath: {subpath}, recursive: {recursive}"
)
files = []
# Root view contract: show category directories only.
if category is None and not subpath:
for cat in FileCategory:
base_dir = root / cat.value
if not base_dir.exists():
continue
stat = base_dir.stat()
files.append(
StoredFile(
name=cat.value,
path=cat.value,
size=0,
created_at=datetime.fromtimestamp(stat.st_ctime),
category=cat,
mime_type="directory",
)
)
return sorted(files, key=lambda x: x.name)
categories = [category] if category else list(FileCategory)

View File

@@ -1,159 +0,0 @@
# [DEF:backend.src.schemas.profile:Module]
#
# @TIER: STANDARD
# @SEMANTICS: profile, schemas, pydantic, preferences, superset, lookup, security, git, ux
# @PURPOSE: Defines API schemas for profile preference persistence, security read-only snapshot, and Superset account lookup.
# @LAYER: API
# @RELATION: DEPENDS_ON -> pydantic
#
# @INVARIANT: Schema shapes stay stable for profile UI states and backend preference contracts.
# [SECTION: IMPORTS]
from datetime import datetime
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
# [/SECTION]
# [DEF:ProfilePermissionState:Class]
# @TIER: STANDARD
# @PURPOSE: Represents one permission badge state for profile read-only security view.
class ProfilePermissionState(BaseModel):
key: str
allowed: bool
# [/DEF:ProfilePermissionState:Class]
# [DEF:ProfileSecuritySummary:Class]
# @TIER: STANDARD
# @PURPOSE: Read-only security and access snapshot for current user.
class ProfileSecuritySummary(BaseModel):
read_only: bool = True
auth_source: Optional[str] = None
current_role: Optional[str] = None
role_source: Optional[str] = None
roles: List[str] = Field(default_factory=list)
permissions: List[ProfilePermissionState] = Field(default_factory=list)
# [/DEF:ProfileSecuritySummary:Class]
# [DEF:ProfilePreference:Class]
# @TIER: STANDARD
# @PURPOSE: Represents persisted profile preference for a single authenticated user.
class ProfilePreference(BaseModel):
user_id: str
superset_username: Optional[str] = None
superset_username_normalized: Optional[str] = None
show_only_my_dashboards: bool = False
git_username: Optional[str] = None
git_email: Optional[str] = None
has_git_personal_access_token: bool = False
git_personal_access_token_masked: Optional[str] = None
start_page: Literal["dashboards", "datasets", "reports"] = "dashboards"
auto_open_task_drawer: bool = True
dashboards_table_density: Literal["compact", "comfortable"] = "comfortable"
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# [/DEF:ProfilePreference:Class]
# [DEF:ProfilePreferenceUpdateRequest:Class]
# @TIER: STANDARD
# @PURPOSE: Request payload for updating current user's profile settings.
class ProfilePreferenceUpdateRequest(BaseModel):
superset_username: Optional[str] = Field(
default=None,
description="Apache Superset username bound to current user profile.",
)
show_only_my_dashboards: Optional[bool] = Field(
default=None,
description='When true, "/dashboards" can auto-apply profile filter in main context.',
)
git_username: Optional[str] = Field(
default=None,
description="Git author username used for commit signature.",
)
git_email: Optional[str] = Field(
default=None,
description="Git author email used for commit signature.",
)
git_personal_access_token: Optional[str] = Field(
default=None,
description="Personal Access Token value. Empty string clears existing token.",
)
start_page: Optional[
Literal["dashboards", "datasets", "reports", "reports-logs"]
] = Field(
default=None,
description="Preferred start page after login.",
)
auto_open_task_drawer: Optional[bool] = Field(
default=None,
description="Auto-open task drawer when long-running tasks start.",
)
dashboards_table_density: Optional[
Literal["compact", "comfortable", "free"]
] = Field(
default=None,
description="Preferred table density for dashboard listings.",
)
# [/DEF:ProfilePreferenceUpdateRequest:Class]
# [DEF:ProfilePreferenceResponse:Class]
# @TIER: STANDARD
# @PURPOSE: Response envelope for profile preference read/update endpoints.
class ProfilePreferenceResponse(BaseModel):
status: Literal["success", "error"] = "success"
message: Optional[str] = None
validation_errors: List[str] = Field(default_factory=list)
preference: ProfilePreference
security: ProfileSecuritySummary = Field(default_factory=ProfileSecuritySummary)
# [/DEF:ProfilePreferenceResponse:Class]
# [DEF:SupersetAccountLookupRequest:Class]
# @TIER: STANDARD
# @PURPOSE: Query contract for Superset account lookup by selected environment.
class SupersetAccountLookupRequest(BaseModel):
environment_id: str
search: Optional[str] = None
page_index: int = Field(default=0, ge=0)
page_size: int = Field(default=20, ge=1, le=100)
sort_column: str = Field(default="username")
sort_order: str = Field(default="desc")
# [/DEF:SupersetAccountLookupRequest:Class]
# [DEF:SupersetAccountCandidate:Class]
# @TIER: STANDARD
# @PURPOSE: Canonical account candidate projected from Superset users payload.
class SupersetAccountCandidate(BaseModel):
environment_id: str
username: str
display_name: Optional[str] = None
email: Optional[str] = None
is_active: Optional[bool] = None
# [/DEF:SupersetAccountCandidate:Class]
# [DEF:SupersetAccountLookupResponse:Class]
# @TIER: STANDARD
# @PURPOSE: Response envelope for Superset account lookup (success or degraded mode).
class SupersetAccountLookupResponse(BaseModel):
status: Literal["success", "degraded"]
environment_id: str
page_index: int = Field(ge=0)
page_size: int = Field(ge=1, le=100)
total: int = Field(ge=0)
warning: Optional[str] = None
items: List[SupersetAccountCandidate] = Field(default_factory=list)
# [/DEF:SupersetAccountLookupResponse:Class]
# [/DEF:backend.src.schemas.profile:Module]

View File

@@ -1,444 +0,0 @@
# [DEF:backend.src.scripts.clean_release_cli:Module]
# @TIER: STANDARD
# @SEMANTICS: cli, clean-release, candidate, artifacts, manifest
# @PURPOSE: Provide headless CLI commands for candidate registration, artifact import and manifest build.
# @LAYER: Scripts
from __future__ import annotations
import argparse
import json
from datetime import date, datetime, timezone
from typing import Any, Dict, List, Optional
from ..models.clean_release import CandidateArtifact, ReleaseCandidate
from ..services.clean_release.approval_service import approve_candidate, reject_candidate
from ..services.clean_release.compliance_execution_service import ComplianceExecutionService
from ..services.clean_release.enums import CandidateStatus
from ..services.clean_release.publication_service import publish_candidate, revoke_publication
# [DEF:build_parser:Function]
# @PURPOSE: Build argparse parser for clean release CLI.
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="clean-release-cli")
subparsers = parser.add_subparsers(dest="command", required=True)
register = subparsers.add_parser("candidate-register")
register.add_argument("--candidate-id", required=True)
register.add_argument("--version", required=True)
register.add_argument("--source-snapshot-ref", required=True)
register.add_argument("--created-by", default="cli-operator")
artifact_import = subparsers.add_parser("artifact-import")
artifact_import.add_argument("--candidate-id", required=True)
artifact_import.add_argument("--artifact-id", required=True)
artifact_import.add_argument("--path", required=True)
artifact_import.add_argument("--sha256", required=True)
artifact_import.add_argument("--size", type=int, required=True)
manifest_build = subparsers.add_parser("manifest-build")
manifest_build.add_argument("--candidate-id", required=True)
manifest_build.add_argument("--created-by", default="cli-operator")
compliance_run = subparsers.add_parser("compliance-run")
compliance_run.add_argument("--candidate-id", required=True)
compliance_run.add_argument("--manifest-id", required=False, default=None)
compliance_run.add_argument("--actor", default="cli-operator")
compliance_run.add_argument("--json", action="store_true")
compliance_status = subparsers.add_parser("compliance-status")
compliance_status.add_argument("--run-id", required=True)
compliance_status.add_argument("--json", action="store_true")
compliance_report = subparsers.add_parser("compliance-report")
compliance_report.add_argument("--run-id", required=True)
compliance_report.add_argument("--json", action="store_true")
compliance_violations = subparsers.add_parser("compliance-violations")
compliance_violations.add_argument("--run-id", required=True)
compliance_violations.add_argument("--json", action="store_true")
approve = subparsers.add_parser("approve")
approve.add_argument("--candidate-id", required=True)
approve.add_argument("--report-id", required=True)
approve.add_argument("--actor", default="cli-operator")
approve.add_argument("--comment", required=False, default=None)
approve.add_argument("--json", action="store_true")
reject = subparsers.add_parser("reject")
reject.add_argument("--candidate-id", required=True)
reject.add_argument("--report-id", required=True)
reject.add_argument("--actor", default="cli-operator")
reject.add_argument("--comment", required=False, default=None)
reject.add_argument("--json", action="store_true")
publish = subparsers.add_parser("publish")
publish.add_argument("--candidate-id", required=True)
publish.add_argument("--report-id", required=True)
publish.add_argument("--actor", default="cli-operator")
publish.add_argument("--target-channel", required=True)
publish.add_argument("--publication-ref", required=False, default=None)
publish.add_argument("--json", action="store_true")
revoke = subparsers.add_parser("revoke")
revoke.add_argument("--publication-id", required=True)
revoke.add_argument("--actor", default="cli-operator")
revoke.add_argument("--comment", required=False, default=None)
revoke.add_argument("--json", action="store_true")
return parser
# [/DEF:build_parser:Function]
# [DEF:run_candidate_register:Function]
# @PURPOSE: Register candidate in repository via CLI command.
# @PRE: Candidate ID must be unique.
# @POST: Candidate is persisted in DRAFT status.
def run_candidate_register(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository
repository = get_clean_release_repository()
existing = repository.get_candidate(args.candidate_id)
if existing is not None:
print(json.dumps({"status": "error", "message": "candidate already exists"}))
return 1
candidate = ReleaseCandidate(
id=args.candidate_id,
version=args.version,
source_snapshot_ref=args.source_snapshot_ref,
created_by=args.created_by,
created_at=datetime.now(timezone.utc),
status=CandidateStatus.DRAFT.value,
)
repository.save_candidate(candidate)
print(json.dumps({"status": "ok", "candidate_id": candidate.id}))
return 0
# [/DEF:run_candidate_register:Function]
# [DEF:run_artifact_import:Function]
# @PURPOSE: Import single artifact for existing candidate.
# @PRE: Candidate must exist.
# @POST: Artifact is persisted for candidate.
def run_artifact_import(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository
repository = get_clean_release_repository()
candidate = repository.get_candidate(args.candidate_id)
if candidate is None:
print(json.dumps({"status": "error", "message": "candidate not found"}))
return 1
artifact = CandidateArtifact(
id=args.artifact_id,
candidate_id=args.candidate_id,
path=args.path,
sha256=args.sha256,
size=args.size,
)
repository.save_artifact(artifact)
if candidate.status == CandidateStatus.DRAFT.value:
candidate.transition_to(CandidateStatus.PREPARED)
repository.save_candidate(candidate)
print(json.dumps({"status": "ok", "artifact_id": artifact.id}))
return 0
# [/DEF:run_artifact_import:Function]
# [DEF:run_manifest_build:Function]
# @PURPOSE: Build immutable manifest snapshot for candidate.
# @PRE: Candidate must exist.
# @POST: New manifest version is persisted.
def run_manifest_build(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository
from ..services.clean_release.manifest_service import build_manifest_snapshot
repository = get_clean_release_repository()
try:
manifest = build_manifest_snapshot(
repository=repository,
candidate_id=args.candidate_id,
created_by=args.created_by,
)
except ValueError as exc:
print(json.dumps({"status": "error", "message": str(exc)}))
return 1
print(json.dumps({"status": "ok", "manifest_id": manifest.id, "version": manifest.manifest_version}))
return 0
# [/DEF:run_manifest_build:Function]
# [DEF:run_compliance_run:Function]
# @PURPOSE: Execute compliance run for candidate with optional manifest fallback.
# @PRE: Candidate exists and trusted snapshots are configured.
# @POST: Returns run payload and exit code 0 on success.
def run_compliance_run(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository, get_config_manager
repository = get_clean_release_repository()
config_manager = get_config_manager()
service = ComplianceExecutionService(repository=repository, config_manager=config_manager)
try:
result = service.execute_run(
candidate_id=args.candidate_id,
requested_by=args.actor,
manifest_id=args.manifest_id,
)
except Exception as exc: # noqa: BLE001
print(json.dumps({"status": "error", "message": str(exc)}))
return 2
payload = {
"status": "ok",
"run_id": result.run.id,
"candidate_id": result.run.candidate_id,
"run_status": result.run.status,
"final_status": result.run.final_status,
"task_id": getattr(result.run, "task_id", None),
"report_id": getattr(result.run, "report_id", None),
}
print(json.dumps(payload))
return 0
# [/DEF:run_compliance_run:Function]
# [DEF:run_compliance_status:Function]
# @PURPOSE: Read run status by run id.
# @PRE: Run exists.
# @POST: Returns run status payload.
def run_compliance_status(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository
repository = get_clean_release_repository()
run = repository.get_check_run(args.run_id)
if run is None:
print(json.dumps({"status": "error", "message": "run not found"}))
return 2
report = next((item for item in repository.reports.values() if item.run_id == run.id), None)
payload = {
"status": "ok",
"run_id": run.id,
"candidate_id": run.candidate_id,
"run_status": run.status,
"final_status": run.final_status,
"task_id": getattr(run, "task_id", None),
"report_id": getattr(run, "report_id", None) or (report.id if report else None),
}
print(json.dumps(payload))
return 0
# [/DEF:run_compliance_status:Function]
# [DEF:_to_payload:Function]
# @PURPOSE: Serialize domain models for CLI JSON output across SQLAlchemy/Pydantic variants.
# @PRE: value is serializable model or primitive object.
# @POST: Returns dictionary payload without mutating value.
def _to_payload(value: Any) -> Dict[str, Any]:
def _normalize(raw: Any) -> Any:
if isinstance(raw, datetime):
return raw.isoformat()
if isinstance(raw, date):
return raw.isoformat()
if isinstance(raw, dict):
return {str(key): _normalize(item) for key, item in raw.items()}
if isinstance(raw, list):
return [_normalize(item) for item in raw]
if isinstance(raw, tuple):
return [_normalize(item) for item in raw]
return raw
if hasattr(value, "model_dump"):
return _normalize(value.model_dump())
table = getattr(value, "__table__", None)
if table is not None:
row = {column.name: getattr(value, column.name) for column in table.columns}
return _normalize(row)
raise TypeError(f"unsupported payload type: {type(value)!r}")
# [/DEF:_to_payload:Function]
# [DEF:run_compliance_report:Function]
# @PURPOSE: Read immutable report by run id.
# @PRE: Run and report exist.
# @POST: Returns report payload.
def run_compliance_report(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository
repository = get_clean_release_repository()
run = repository.get_check_run(args.run_id)
if run is None:
print(json.dumps({"status": "error", "message": "run not found"}))
return 2
report = next((item for item in repository.reports.values() if item.run_id == run.id), None)
if report is None:
print(json.dumps({"status": "error", "message": "report not found"}))
return 2
print(json.dumps({"status": "ok", "report": _to_payload(report)}))
return 0
# [/DEF:run_compliance_report:Function]
# [DEF:run_compliance_violations:Function]
# @PURPOSE: Read run violations by run id.
# @PRE: Run exists.
# @POST: Returns violations payload.
def run_compliance_violations(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository
repository = get_clean_release_repository()
run = repository.get_check_run(args.run_id)
if run is None:
print(json.dumps({"status": "error", "message": "run not found"}))
return 2
violations = repository.get_violations_by_run(args.run_id)
print(json.dumps({"status": "ok", "items": [_to_payload(item) for item in violations]}))
return 0
# [/DEF:run_compliance_violations:Function]
# [DEF:run_approve:Function]
# @PURPOSE: Approve candidate based on immutable PASSED report.
# @PRE: Candidate and report exist; report is PASSED.
# @POST: Persists APPROVED decision and returns success payload.
def run_approve(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository
repository = get_clean_release_repository()
try:
decision = approve_candidate(
repository=repository,
candidate_id=args.candidate_id,
report_id=args.report_id,
decided_by=args.actor,
comment=args.comment,
)
except Exception as exc: # noqa: BLE001
print(json.dumps({"status": "error", "message": str(exc)}))
return 2
print(json.dumps({"status": "ok", "decision": decision.decision, "decision_id": decision.id}))
return 0
# [/DEF:run_approve:Function]
# [DEF:run_reject:Function]
# @PURPOSE: Reject candidate without mutating compliance evidence.
# @PRE: Candidate and report exist.
# @POST: Persists REJECTED decision and returns success payload.
def run_reject(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository
repository = get_clean_release_repository()
try:
decision = reject_candidate(
repository=repository,
candidate_id=args.candidate_id,
report_id=args.report_id,
decided_by=args.actor,
comment=args.comment,
)
except Exception as exc: # noqa: BLE001
print(json.dumps({"status": "error", "message": str(exc)}))
return 2
print(json.dumps({"status": "ok", "decision": decision.decision, "decision_id": decision.id}))
return 0
# [/DEF:run_reject:Function]
# [DEF:run_publish:Function]
# @PURPOSE: Publish approved candidate to target channel.
# @PRE: Candidate is approved and report belongs to candidate.
# @POST: Appends ACTIVE publication record and returns payload.
def run_publish(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository
repository = get_clean_release_repository()
try:
publication = publish_candidate(
repository=repository,
candidate_id=args.candidate_id,
report_id=args.report_id,
published_by=args.actor,
target_channel=args.target_channel,
publication_ref=args.publication_ref,
)
except Exception as exc: # noqa: BLE001
print(json.dumps({"status": "error", "message": str(exc)}))
return 2
print(json.dumps({"status": "ok", "publication": _to_payload(publication)}))
return 0
# [/DEF:run_publish:Function]
# [DEF:run_revoke:Function]
# @PURPOSE: Revoke active publication record.
# @PRE: Publication id exists and is ACTIVE.
# @POST: Publication record status becomes REVOKED.
def run_revoke(args: argparse.Namespace) -> int:
from ..dependencies import get_clean_release_repository
repository = get_clean_release_repository()
try:
publication = revoke_publication(
repository=repository,
publication_id=args.publication_id,
revoked_by=args.actor,
comment=args.comment,
)
except Exception as exc: # noqa: BLE001
print(json.dumps({"status": "error", "message": str(exc)}))
return 2
print(json.dumps({"status": "ok", "publication": _to_payload(publication)}))
return 0
# [/DEF:run_revoke:Function]
# [DEF:main:Function]
# @PURPOSE: CLI entrypoint for clean release commands.
def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "candidate-register":
return run_candidate_register(args)
if args.command == "artifact-import":
return run_artifact_import(args)
if args.command == "manifest-build":
return run_manifest_build(args)
if args.command == "compliance-run":
return run_compliance_run(args)
if args.command == "compliance-status":
return run_compliance_status(args)
if args.command == "compliance-report":
return run_compliance_report(args)
if args.command == "compliance-violations":
return run_compliance_violations(args)
if args.command == "approve":
return run_approve(args)
if args.command == "reject":
return run_reject(args)
if args.command == "publish":
return run_publish(args)
if args.command == "revoke":
return run_revoke(args)
print(json.dumps({"status": "error", "message": "unknown command"}))
return 2
# [/DEF:main:Function]
if __name__ == "__main__":
raise SystemExit(main())
# [/DEF:backend.src.scripts.clean_release_cli:Module]

View File

@@ -1,589 +0,0 @@
# [DEF:backend.src.scripts.clean_release_tui:Module]
# @TIER: STANDARD
# @SEMANTICS: clean-release, tui, ncurses, interactive-validator
# @PURPOSE: Interactive terminal interface for Enterprise Clean Release compliance validation.
# @LAYER: UI
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.compliance_orchestrator
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
# @INVARIANT: TUI refuses startup in non-TTY environments; headless flow is CLI/API only.
import curses
import json
import os
import sys
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import List, Optional, Any, Dict
# Standardize sys.path for direct execution from project root or scripts dir
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..", ".."))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from backend.src.models.clean_release import (
CandidateArtifact,
CheckFinalStatus,
CheckStageName,
CheckStageStatus,
CleanProfilePolicy,
ComplianceViolation,
ProfileType,
ReleaseCandidate,
ResourceSourceEntry,
ResourceSourceRegistry,
RegistryStatus,
ReleaseCandidateStatus,
)
from backend.src.services.clean_release.approval_service import approve_candidate
from backend.src.services.clean_release.compliance_execution_service import ComplianceExecutionService
from backend.src.services.clean_release.enums import CandidateStatus
from backend.src.services.clean_release.manifest_service import build_manifest_snapshot
from backend.src.services.clean_release.publication_service import publish_candidate
from backend.src.services.clean_release.repository import CleanReleaseRepository
# [DEF:TuiFacadeAdapter:Class]
# @PURPOSE: Thin TUI adapter that routes business mutations through application services.
# @PRE: repository contains candidate and trusted policy/registry snapshots for execution.
# @POST: Business actions return service results/errors without direct TUI-owned mutations.
class TuiFacadeAdapter:
def __init__(self, repository: CleanReleaseRepository):
self.repository = repository
def _build_config_manager(self):
policy = self.repository.get_active_policy()
if policy is None:
raise ValueError("Active policy not found")
clean_release = SimpleNamespace(
active_policy_id=policy.id,
active_registry_id=policy.registry_snapshot_id,
)
settings = SimpleNamespace(clean_release=clean_release)
config = SimpleNamespace(settings=settings)
return SimpleNamespace(get_config=lambda: config)
def run_compliance(self, *, candidate_id: str, actor: str):
manifests = self.repository.get_manifests_by_candidate(candidate_id)
if not manifests:
raise ValueError("Manifest required before compliance run")
latest_manifest = sorted(manifests, key=lambda item: item.manifest_version, reverse=True)[0]
service = ComplianceExecutionService(
repository=self.repository,
config_manager=self._build_config_manager(),
)
return service.execute_run(candidate_id=candidate_id, requested_by=actor, manifest_id=latest_manifest.id)
def approve_latest(self, *, candidate_id: str, actor: str):
reports = [item for item in self.repository.reports.values() if item.candidate_id == candidate_id]
if not reports:
raise ValueError("No compliance report available for approval")
report = sorted(reports, key=lambda item: item.generated_at, reverse=True)[0]
return approve_candidate(
repository=self.repository,
candidate_id=candidate_id,
report_id=report.id,
decided_by=actor,
comment="Approved from TUI",
)
def publish_latest(self, *, candidate_id: str, actor: str):
reports = [item for item in self.repository.reports.values() if item.candidate_id == candidate_id]
if not reports:
raise ValueError("No compliance report available for publication")
report = sorted(reports, key=lambda item: item.generated_at, reverse=True)[0]
return publish_candidate(
repository=self.repository,
candidate_id=candidate_id,
report_id=report.id,
published_by=actor,
target_channel="stable",
publication_ref=None,
)
def build_manifest(self, *, candidate_id: str, actor: str):
return build_manifest_snapshot(
repository=self.repository,
candidate_id=candidate_id,
created_by=actor,
)
def get_overview(self, *, candidate_id: str) -> Dict[str, Any]:
candidate = self.repository.get_candidate(candidate_id)
manifests = self.repository.get_manifests_by_candidate(candidate_id)
latest_manifest = sorted(manifests, key=lambda item: item.manifest_version, reverse=True)[0] if manifests else None
runs = [item for item in self.repository.check_runs.values() if item.candidate_id == candidate_id]
latest_run = sorted(runs, key=lambda item: item.requested_at, reverse=True)[0] if runs else None
latest_report = next((item for item in self.repository.reports.values() if latest_run and item.run_id == latest_run.id), None)
approvals = getattr(self.repository, "approval_decisions", [])
latest_approval = sorted(
[item for item in approvals if item.candidate_id == candidate_id],
key=lambda item: item.decided_at,
reverse=True,
)[0] if any(item.candidate_id == candidate_id for item in approvals) else None
publications = getattr(self.repository, "publication_records", [])
latest_publication = sorted(
[item for item in publications if item.candidate_id == candidate_id],
key=lambda item: item.published_at,
reverse=True,
)[0] if any(item.candidate_id == candidate_id for item in publications) else None
policy = self.repository.get_active_policy()
registry = self.repository.get_registry(policy.internal_source_registry_ref) if policy else None
return {
"candidate": candidate,
"manifest": latest_manifest,
"run": latest_run,
"report": latest_report,
"approval": latest_approval,
"publication": latest_publication,
"policy": policy,
"registry": registry,
}
# [/DEF:TuiFacadeAdapter:Class]
# [DEF:CleanReleaseTUI:Class]
# @PURPOSE: Curses-based application for compliance monitoring.
# @UX_STATE: READY -> Waiting for operator to start checks (F5).
# @UX_STATE: RUNNING -> Executing compliance stages with progress feedback.
# @UX_STATE: COMPLIANT -> Release candidate passed all checks.
# @UX_STATE: BLOCKED -> Violations detected, release forbidden.
# @UX_FEEDBACK: Red alerts for BLOCKED status, Green for COMPLIANT.
class CleanReleaseTUI:
def __init__(self, stdscr: curses.window):
self.stdscr = stdscr
self.mode = os.getenv("CLEAN_TUI_MODE", "demo").strip().lower()
self.repo = self._build_repository(self.mode)
self.facade = TuiFacadeAdapter(self.repo)
self.candidate_id = self._resolve_candidate_id()
self.status: Any = "READY"
self.checks_progress: List[Dict[str, Any]] = []
self.violations_list: List[ComplianceViolation] = []
self.report_id: Optional[str] = None
self.last_error: Optional[str] = None
self.overview: Dict[str, Any] = {}
self.refresh_overview()
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) # Header/Footer
curses.init_pair(2, curses.COLOR_GREEN, -1) # PASS
curses.init_pair(3, curses.COLOR_RED, -1) # FAIL/BLOCKED
curses.init_pair(4, curses.COLOR_YELLOW, -1) # RUNNING
curses.init_pair(5, curses.COLOR_CYAN, -1) # Text
def _build_repository(self, mode: str) -> CleanReleaseRepository:
repo = CleanReleaseRepository()
if mode == "demo":
self._bootstrap_demo_repository(repo)
else:
self._bootstrap_real_repository(repo)
return repo
def _bootstrap_demo_repository(self, repository: CleanReleaseRepository) -> None:
now = datetime.now(timezone.utc)
policy = CleanProfilePolicy(
policy_id="POL-ENT-CLEAN",
policy_version="1",
profile=ProfileType.ENTERPRISE_CLEAN,
active=True,
internal_source_registry_ref="REG-1",
prohibited_artifact_categories=["test-data"],
effective_from=now,
)
setattr(policy, "immutable", True)
repository.save_policy(policy)
registry = ResourceSourceRegistry(
registry_id="REG-1",
name="Default Internal Registry",
entries=[
ResourceSourceEntry(
source_id="S1",
host="internal-repo.company.com",
protocol="https",
purpose="artifactory",
)
],
updated_at=now,
updated_by="system",
)
setattr(registry, "immutable", True)
setattr(registry, "allowed_hosts", ["internal-repo.company.com"])
setattr(registry, "allowed_schemes", ["https"])
setattr(registry, "allowed_source_types", ["artifactory"])
repository.save_registry(registry)
candidate = ReleaseCandidate(
id="2026.03.03-rc1",
version="1.0.0",
source_snapshot_ref="v1.0.0-rc1",
created_at=now,
created_by="system",
status=CandidateStatus.DRAFT.value,
)
candidate.transition_to(CandidateStatus.PREPARED)
repository.save_candidate(candidate)
repository.save_artifact(
CandidateArtifact(
id="demo-art-1",
candidate_id=candidate.id,
path="src/main.py",
sha256="sha256-demo-core",
size=128,
detected_category="core",
)
)
repository.save_artifact(
CandidateArtifact(
id="demo-art-2",
candidate_id=candidate.id,
path="test/data.csv",
sha256="sha256-demo-test",
size=64,
detected_category="test-data",
)
)
manifest = build_manifest_snapshot(
repository=repository,
candidate_id=candidate.id,
created_by="system",
policy_id="POL-ENT-CLEAN",
)
summary = dict(manifest.content_json.get("summary", {}))
summary["prohibited_detected_count"] = 1
manifest.content_json["summary"] = summary
def _bootstrap_real_repository(self, repository: CleanReleaseRepository) -> None:
bootstrap_path = os.getenv("CLEAN_TUI_BOOTSTRAP_JSON", "").strip()
if not bootstrap_path:
return
with open(bootstrap_path, "r", encoding="utf-8") as bootstrap_file:
payload = json.load(bootstrap_file)
now = datetime.now(timezone.utc)
candidate = ReleaseCandidate(
id=payload.get("candidate_id", "candidate-1"),
version=payload.get("version", "1.0.0"),
source_snapshot_ref=payload.get("source_snapshot_ref", "snapshot-ref"),
created_at=now,
created_by=payload.get("created_by", "operator"),
status=ReleaseCandidateStatus.DRAFT,
)
repository.save_candidate(candidate)
registry_id = payload.get("registry_id", "REG-1")
entries = [
ResourceSourceEntry(
source_id=f"S-{index + 1}",
host=host,
protocol="https",
purpose="bootstrap",
enabled=True,
)
for index, host in enumerate(payload.get("allowed_hosts", []))
if str(host).strip()
]
if entries:
repository.save_registry(
ResourceSourceRegistry(
registry_id=registry_id,
name=payload.get("registry_name", "Bootstrap Internal Registry"),
entries=entries,
updated_at=now,
updated_by=payload.get("created_by", "operator"),
status=RegistryStatus.ACTIVE,
)
)
if entries:
repository.save_policy(
CleanProfilePolicy(
policy_id=payload.get("policy_id", "POL-ENT-CLEAN"),
policy_version=payload.get("policy_version", "1"),
profile=ProfileType.ENTERPRISE_CLEAN,
active=True,
internal_source_registry_ref=registry_id,
prohibited_artifact_categories=payload.get(
"prohibited_artifact_categories",
["test-data", "demo", "load-test"],
),
required_system_categories=payload.get("required_system_categories", ["core"]),
effective_from=now,
)
)
def _resolve_candidate_id(self) -> str:
env_candidate = os.getenv("CLEAN_TUI_CANDIDATE_ID", "").strip()
if env_candidate:
return env_candidate
candidate_ids = list(self.repo.candidates.keys())
if candidate_ids:
return candidate_ids[0]
return ""
def draw_header(self, max_y: int, max_x: int):
header_text = " Enterprise Clean Release Validator (TUI) "
self.stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
# Avoid slicing if possible to satisfy Pyre, or use explicit int
centered = header_text.center(max_x)
self.stdscr.addstr(0, 0, centered[:max_x])
self.stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
candidate = self.overview.get("candidate")
candidate_text = self.candidate_id or "not-set"
profile_text = "enterprise-clean"
lifecycle = getattr(candidate, "status", "UNKNOWN")
info_line_text = (
f" │ Candidate: [{candidate_text}] Profile: [{profile_text}] "
f"Lifecycle: [{lifecycle}] Mode: [{self.mode}]"
).ljust(max_x)
self.stdscr.addstr(2, 0, info_line_text[:max_x])
def draw_checks(self):
self.stdscr.addstr(4, 3, "Checks:")
check_defs = [
(CheckStageName.DATA_PURITY, "Data Purity (no test/demo payloads)"),
(CheckStageName.INTERNAL_SOURCES_ONLY, "Internal Sources Only (company servers)"),
(CheckStageName.NO_EXTERNAL_ENDPOINTS, "No External Internet Endpoints"),
(CheckStageName.MANIFEST_CONSISTENCY, "Release Manifest Consistency"),
]
row = 5
drawn_checks = {c["stage"]: c for c in self.checks_progress}
for stage, desc in check_defs:
status_text = " "
color = curses.color_pair(5)
if stage in drawn_checks:
c = drawn_checks[stage]
if c["status"] == "RUNNING":
status_text = "..."
color = curses.color_pair(4)
elif c["status"] == CheckStageStatus.PASS:
status_text = "PASS"
color = curses.color_pair(2)
elif c["status"] == CheckStageStatus.FAIL:
status_text = "FAIL"
color = curses.color_pair(3)
self.stdscr.addstr(row, 4, f"[{status_text:^4}] {desc}")
if status_text != " ":
self.stdscr.addstr(row, 50, f"{status_text:>10}", color | curses.A_BOLD)
row += 1
def draw_sources(self):
self.stdscr.addstr(12, 3, "Allowed Internal Sources:", curses.A_BOLD)
reg = self.overview.get("registry")
row = 13
if reg:
for entry in reg.entries:
self.stdscr.addstr(row, 3, f" - {entry.host}")
row += 1
else:
self.stdscr.addstr(row, 3, " - (none)")
def draw_status(self):
color = curses.color_pair(5)
if self.status == CheckFinalStatus.COMPLIANT: color = curses.color_pair(2)
elif self.status == CheckFinalStatus.BLOCKED: color = curses.color_pair(3)
stat_str = str(self.status.value if hasattr(self.status, "value") else self.status)
self.stdscr.addstr(18, 3, f"FINAL STATUS: {stat_str.upper()}", color | curses.A_BOLD)
if self.report_id:
self.stdscr.addstr(19, 3, f"Report ID: {self.report_id}")
approval = self.overview.get("approval")
publication = self.overview.get("publication")
if approval:
self.stdscr.addstr(20, 3, f"Approval: {approval.decision}")
if publication:
self.stdscr.addstr(20, 32, f"Publication: {publication.status}")
if self.violations_list:
self.stdscr.addstr(21, 3, f"Violations Details ({len(self.violations_list)} total):", curses.color_pair(3) | curses.A_BOLD)
row = 22
for i, v in enumerate(self.violations_list[:5]):
v_cat = str(getattr(v, "code", "VIOLATION"))
msg = str(getattr(v, "message", "Violation detected"))
location = str(
getattr(v, "artifact_path", "")
or getattr(getattr(v, "evidence_json", {}), "get", lambda *_: "")("location", "")
)
msg_text = f"[{v_cat}] {msg} (Loc: {location})"
self.stdscr.addstr(row + i, 5, msg_text[:70], curses.color_pair(3))
if self.last_error:
self.stdscr.addstr(27, 3, f"Error: {self.last_error}"[:100], curses.color_pair(3) | curses.A_BOLD)
def draw_footer(self, max_y: int, max_x: int):
footer_text = " F5 Run F6 Manifest F7 Refresh F8 Approve F9 Publish F10 Exit ".center(max_x)
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(max_y - 1, 0, footer_text[:max_x])
self.stdscr.attroff(curses.color_pair(1))
# [DEF:run_checks:Function]
# @PURPOSE: Execute compliance run via facade adapter and update UI state.
# @PRE: Candidate and policy snapshots are present in repository.
# @POST: UI reflects final run/report/violation state from service result.
def run_checks(self):
self.status = "RUNNING"
self.report_id = None
self.violations_list = []
self.checks_progress = []
self.last_error = None
self.refresh_screen()
try:
result = self.facade.run_compliance(candidate_id=self.candidate_id, actor="operator")
except Exception as exc: # noqa: BLE001
self.status = CheckFinalStatus.FAILED
self.last_error = str(exc)
self.refresh_screen()
return
self.checks_progress = [
{
"stage": stage.stage_name,
"status": CheckStageStatus.PASS if str(stage.decision).upper() == "PASSED" else CheckStageStatus.FAIL,
}
for stage in result.stage_runs
]
self.violations_list = result.violations
self.report_id = result.report.id if result.report is not None else None
final_status = str(result.run.final_status or "").upper()
if final_status in {"BLOCKED", CheckFinalStatus.BLOCKED.value}:
self.status = CheckFinalStatus.BLOCKED
elif final_status in {"COMPLIANT", "PASSED", CheckFinalStatus.COMPLIANT.value}:
self.status = CheckFinalStatus.COMPLIANT
else:
self.status = CheckFinalStatus.FAILED
self.refresh_overview()
self.refresh_screen()
def build_manifest(self):
try:
manifest = self.facade.build_manifest(candidate_id=self.candidate_id, actor="operator")
self.status = "READY"
self.report_id = None
self.violations_list = []
self.checks_progress = []
self.last_error = f"Manifest built: {manifest.id}"
except Exception as exc: # noqa: BLE001
self.last_error = str(exc)
self.refresh_overview()
self.refresh_screen()
def clear_history(self):
self.status = "READY"
self.report_id = None
self.violations_list = []
self.checks_progress = []
self.last_error = None
self.refresh_overview()
self.refresh_screen()
def approve_latest(self):
if not self.report_id:
self.last_error = "F8 disabled: no compliance report available"
self.refresh_screen()
return
try:
self.facade.approve_latest(candidate_id=self.candidate_id, actor="operator")
self.last_error = None
except Exception as exc: # noqa: BLE001
self.last_error = str(exc)
self.refresh_overview()
self.refresh_screen()
def publish_latest(self):
if not self.report_id:
self.last_error = "F9 disabled: no compliance report available"
self.refresh_screen()
return
try:
self.facade.publish_latest(candidate_id=self.candidate_id, actor="operator")
self.last_error = None
except Exception as exc: # noqa: BLE001
self.last_error = str(exc)
self.refresh_overview()
self.refresh_screen()
def refresh_overview(self):
if not self.report_id:
self.last_error = "F9 disabled: no compliance report available"
self.refresh_screen()
return
try:
self.facade.publish_latest(candidate_id=self.candidate_id, actor="operator")
self.last_error = None
except Exception as exc: # noqa: BLE001
self.last_error = str(exc)
self.refresh_overview()
self.refresh_screen()
def refresh_overview(self):
if not self.candidate_id:
self.overview = {}
return
self.overview = self.facade.get_overview(candidate_id=self.candidate_id)
def refresh_screen(self):
max_y, max_x = self.stdscr.getmaxyx()
self.stdscr.clear()
try:
self.draw_header(max_y, max_x)
self.draw_checks()
self.draw_sources()
self.draw_status()
self.draw_footer(max_y, max_x)
except Exception:
pass
self.stdscr.refresh()
def loop(self):
self.refresh_screen()
while True:
char = self.stdscr.getch()
if char == curses.KEY_F10:
break
elif char == curses.KEY_F5:
self.run_checks()
elif char == curses.KEY_F6:
self.build_manifest()
elif char == curses.KEY_F7:
self.clear_history()
elif char == curses.KEY_F8:
self.approve_latest()
elif char == curses.KEY_F9:
self.publish_latest()
# [/DEF:CleanReleaseTUI:Class]
def tui_main(stdscr: curses.window):
curses.curs_set(0) # Hide cursor
app = CleanReleaseTUI(stdscr)
app.loop()
def main() -> int:
# TUI requires interactive terminal; headless mode must use CLI/API flow.
if not sys.stdout.isatty():
print(
"TTY is required for TUI mode. Use CLI/API workflow instead.",
file=sys.stderr,
)
return 2
try:
curses.wrapper(tui_main)
return 0
except Exception as e:
print(f"Error starting TUI: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
# [/DEF:backend.src.scripts.clean_release_tui:Module]

View File

@@ -9,8 +9,8 @@
"last_name": "Admin"
},
"changed_by_name": "Superset Admin",
"changed_on": "2026-02-24T19:24:01.850617",
"changed_on_delta_humanized": "7 days ago",
"changed_on": "2026-02-10T13:39:35.945662",
"changed_on_delta_humanized": "15 days ago",
"charts": [
"TA-0001-001 test_chart"
],
@@ -19,12 +19,12 @@
"id": 1,
"last_name": "Admin"
},
"created_on_delta_humanized": "13 days ago",
"created_on_delta_humanized": "15 days ago",
"css": null,
"dashboard_title": "TA-0001 Test dashboard",
"id": 13,
"is_managed_externally": false,
"json_metadata": "{\"color_scheme_domain\": [], \"shared_label_colors\": [], \"map_label_colors\": {}, \"label_colors\": {}, \"native_filter_configuration\": []}",
"json_metadata": "{\"color_scheme_domain\": [], \"shared_label_colors\": [], \"map_label_colors\": {}, \"label_colors\": {}}",
"owners": [
{
"first_name": "Superset",
@@ -32,13 +32,13 @@
"last_name": "Admin"
}
],
"position_json": "{\"DASHBOARD_VERSION_KEY\": \"v2\", \"ROOT_ID\": {\"children\": [\"GRID_ID\"], \"id\": \"ROOT_ID\", \"type\": \"ROOT\"}, \"GRID_ID\": {\"children\": [\"ROW-N-LH8TG1XX\"], \"id\": \"GRID_ID\", \"parents\": [\"ROOT_ID\"], \"type\": \"GRID\"}, \"HEADER_ID\": {\"id\": \"HEADER_ID\", \"meta\": {\"text\": \"TA-0001 Test dashboard\"}, \"type\": \"HEADER\"}, \"ROW-N-LH8TG1XX\": {\"children\": [\"CHART-1EKC8H7C\"], \"id\": \"ROW-N-LH8TG1XX\", \"meta\": {\"0\": \"ROOT_ID\", \"background\": \"BACKGROUND_TRANSPARENT\"}, \"type\": \"ROW\", \"parents\": [\"ROOT_ID\", \"GRID_ID\"]}, \"CHART-1EKC8H7C\": {\"children\": [], \"id\": \"CHART-1EKC8H7C\", \"meta\": {\"chartId\": 162, \"height\": 50, \"sliceName\": \"TA-0001-001 test_chart\", \"uuid\": \"008cdaa7-21b3-4042-9f55-f15653609ebd\", \"width\": 4}, \"type\": \"CHART\", \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-N-LH8TG1XX\"]}}",
"position_json": null,
"published": true,
"roles": [],
"slug": null,
"tags": [],
"theme": null,
"thumbnail_url": "/api/v1/dashboard/13/thumbnail/97dfd5d8d24f7cf01de45671c9a0699d/",
"thumbnail_url": "/api/v1/dashboard/13/thumbnail/3cfc57e6aea7188b139f94fb437a1426/",
"url": "/superset/dashboard/13/",
"uuid": "124b28d4-d54a-4ade-ade7-2d0473b90686"
}
@@ -53,15 +53,15 @@
"first_name": "Superset",
"last_name": "Admin"
},
"changed_on": "2026-02-18T14:56:04.863722",
"changed_on_humanized": "13 days ago",
"changed_on": "2026-02-10T13:38:26.175551",
"changed_on_humanized": "15 days ago",
"column_formats": {},
"columns": [
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.382289",
"column_name": "has_2fa",
"created_on": "2026-02-18T14:56:05.382138",
"changed_on": "2026-02-10T13:38:26.158196",
"column_name": "color",
"created_on": "2026-02-10T13:38:26.158189",
"description": null,
"expression": null,
"extra": null,
@@ -71,16 +71,16 @@
"is_active": true,
"is_dttm": false,
"python_date_format": null,
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "fe374f2a-9e06-4708-89fd-c3926e3e5faa",
"type": "STRING",
"type_generic": 1,
"uuid": "4fa810ee-99cc-4d1f-8c0d-0f289c3b01f4",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.545701",
"column_name": "is_ultra_restricted",
"created_on": "2026-02-18T14:56:05.545465",
"changed_on": "2026-02-10T13:38:26.158249",
"column_name": "deleted",
"created_on": "2026-02-10T13:38:26.158245",
"description": null,
"expression": null,
"extra": null,
@@ -92,14 +92,14 @@
"python_date_format": null,
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "eac7ecce-d472-4933-9652-d4f2811074fd",
"uuid": "ebc07e82-7250-4eef-8d13-ea61561fa52c",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.683578",
"column_name": "is_primary_owner",
"created_on": "2026-02-18T14:56:05.683257",
"changed_on": "2026-02-10T13:38:26.158289",
"column_name": "has_2fa",
"created_on": "2026-02-10T13:38:26.158285",
"description": null,
"expression": null,
"extra": null,
@@ -111,14 +111,14 @@
"python_date_format": null,
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "94a15acd-ef98-425b-8f0d-1ce038ca95c5",
"uuid": "08e72f4d-3ced-4d9a-9f7d-2f85291ce88b",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.758231",
"column_name": "is_app_user",
"created_on": "2026-02-18T14:56:05.758142",
"changed_on": "2026-02-10T13:38:26.158328",
"column_name": "id",
"created_on": "2026-02-10T13:38:26.158324",
"description": null,
"expression": null,
"extra": null,
@@ -128,16 +128,16 @@
"is_active": true,
"is_dttm": false,
"python_date_format": null,
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "d3fcd712-dc96-4bba-a026-aa82022eccf5",
"type": "STRING",
"type_generic": 1,
"uuid": "fd11955c-0130-4ea1-b3c0-d8b159971789",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.799597",
"changed_on": "2026-02-10T13:38:26.158366",
"column_name": "is_admin",
"created_on": "2026-02-18T14:56:05.799519",
"created_on": "2026-02-10T13:38:26.158362",
"description": null,
"expression": null,
"extra": null,
@@ -149,14 +149,14 @@
"python_date_format": null,
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "5a1c9de5-80f1-4fe8-a91b-e6e530688aae",
"uuid": "13a6c8e1-c9f8-4f08-aa62-05bca7be547b",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.819443",
"column_name": "is_bot",
"created_on": "2026-02-18T14:56:05.819382",
"changed_on": "2026-02-10T13:38:26.158404",
"column_name": "is_app_user",
"created_on": "2026-02-10T13:38:26.158400",
"description": null,
"expression": null,
"extra": null,
@@ -168,14 +168,14 @@
"python_date_format": null,
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "6c93e5de-e0d7-430c-88d7-87158905d60a",
"uuid": "6321ba8a-28d7-4d68-a6b3-5cef6cd681a2",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.827568",
"column_name": "is_restricted",
"created_on": "2026-02-18T14:56:05.827556",
"changed_on": "2026-02-10T13:38:26.158442",
"column_name": "is_bot",
"created_on": "2026-02-10T13:38:26.158438",
"description": null,
"expression": null,
"extra": null,
@@ -187,14 +187,14 @@
"python_date_format": null,
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "2e8e6d32-0124-4e3a-a53f-6f200f852439",
"uuid": "f3ded50e-b1a2-4a88-b805-781d5923e062",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.835380",
"changed_on": "2026-02-10T13:38:26.158480",
"column_name": "is_owner",
"created_on": "2026-02-18T14:56:05.835366",
"created_on": "2026-02-10T13:38:26.158477",
"description": null,
"expression": null,
"extra": null,
@@ -206,14 +206,14 @@
"python_date_format": null,
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "510d651b-a595-4261-98e4-278af0a06594",
"uuid": "8a1408eb-050d-4455-878c-22342df5da3d",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.843802",
"column_name": "deleted",
"created_on": "2026-02-18T14:56:05.843784",
"changed_on": "2026-02-10T13:38:26.158532",
"column_name": "is_primary_owner",
"created_on": "2026-02-10T13:38:26.158528",
"description": null,
"expression": null,
"extra": null,
@@ -225,14 +225,14 @@
"python_date_format": null,
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "2653fd2f-c0ce-484e-a5df-d2515b1e822d",
"uuid": "054b8c16-82fd-480c-82e0-a0975229673a",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.851074",
"column_name": "updated",
"created_on": "2026-02-18T14:56:05.851063",
"changed_on": "2026-02-10T13:38:26.158583",
"column_name": "is_restricted",
"created_on": "2026-02-10T13:38:26.158579",
"description": null,
"expression": null,
"extra": null,
@@ -240,18 +240,18 @@
"groupby": true,
"id": 781,
"is_active": true,
"is_dttm": true,
"is_dttm": false,
"python_date_format": null,
"type": "DATETIME",
"type_generic": 2,
"uuid": "1b1f90c8-2567-49b8-9398-e7246396461e",
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "6932c25f-0273-4595-85c1-29422a801ded",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.857578",
"column_name": "tz_offset",
"created_on": "2026-02-18T14:56:05.857571",
"changed_on": "2026-02-10T13:38:26.158621",
"column_name": "is_ultra_restricted",
"created_on": "2026-02-10T13:38:26.158618",
"description": null,
"expression": null,
"extra": null,
@@ -261,16 +261,16 @@
"is_active": true,
"is_dttm": false,
"python_date_format": null,
"type": "LONGINTEGER",
"type_generic": 0,
"uuid": "e6d19b74-7f5d-447b-8071-951961dc2295",
"type": "BOOLEAN",
"type_generic": 3,
"uuid": "9b14e5f9-3ab4-498e-b1e3-bbf49e9d61fe",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.863101",
"column_name": "channel_name",
"created_on": "2026-02-18T14:56:05.863094",
"changed_on": "2026-02-10T13:38:26.158660",
"column_name": "name",
"created_on": "2026-02-10T13:38:26.158656",
"description": null,
"expression": null,
"extra": null,
@@ -282,14 +282,14 @@
"python_date_format": null,
"type": "STRING",
"type_generic": 1,
"uuid": "e1f34628-ebc1-4e0c-8eea-54c3c9efba1b",
"uuid": "ebee8249-0e10-4157-8a8e-96ae107887a3",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.877136",
"changed_on": "2026-02-10T13:38:26.158697",
"column_name": "real_name",
"created_on": "2026-02-18T14:56:05.877083",
"created_on": "2026-02-10T13:38:26.158694",
"description": null,
"expression": null,
"extra": null,
@@ -301,14 +301,14 @@
"python_date_format": null,
"type": "STRING",
"type_generic": 1,
"uuid": "6cc5ab57-9431-428a-a331-0a5b10e4b074",
"uuid": "553517a0-fe05-4ff5-a4eb-e9d2165d6f64",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.893859",
"column_name": "tz_label",
"created_on": "2026-02-18T14:56:05.893834",
"changed_on": "2026-02-10T13:38:26.158735",
"column_name": "team_id",
"created_on": "2026-02-10T13:38:26.158731",
"description": null,
"expression": null,
"extra": null,
@@ -320,14 +320,14 @@
"python_date_format": null,
"type": "STRING",
"type_generic": 1,
"uuid": "8e6dbd8e-b880-4517-a5f6-64e429bd1bea",
"uuid": "6c207fac-424d-465c-b80a-306b42b55ce8",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.902363",
"column_name": "team_id",
"created_on": "2026-02-18T14:56:05.902352",
"changed_on": "2026-02-10T13:38:26.158773",
"column_name": "tz",
"created_on": "2026-02-10T13:38:26.158769",
"description": null,
"expression": null,
"extra": null,
@@ -339,14 +339,14 @@
"python_date_format": null,
"type": "STRING",
"type_generic": 1,
"uuid": "ba8e225d-221b-4275-aadb-e79557756f89",
"uuid": "6efcc042-0b78-4362-9373-2f684077d574",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.910169",
"column_name": "name",
"created_on": "2026-02-18T14:56:05.910151",
"changed_on": "2026-02-10T13:38:26.158824",
"column_name": "tz_label",
"created_on": "2026-02-10T13:38:26.158820",
"description": null,
"expression": null,
"extra": null,
@@ -358,14 +358,14 @@
"python_date_format": null,
"type": "STRING",
"type_generic": 1,
"uuid": "02a7a026-d9f3-49e9-9586-534ebccdd867",
"uuid": "c6a6ac40-5c60-472d-a878-4b65b8460ccc",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.915366",
"column_name": "color",
"created_on": "2026-02-18T14:56:05.915357",
"changed_on": "2026-02-10T13:38:26.158861",
"column_name": "tz_offset",
"created_on": "2026-02-10T13:38:26.158857",
"description": null,
"expression": null,
"extra": null,
@@ -375,16 +375,16 @@
"is_active": true,
"is_dttm": false,
"python_date_format": null,
"type": "STRING",
"type_generic": 1,
"uuid": "0702fcdf-2d03-45db-8496-697d47b300d6",
"type": "LONGINTEGER",
"type_generic": 0,
"uuid": "cf6da93a-bba9-47df-9154-6cfd0c9922fc",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.919466",
"column_name": "id",
"created_on": "2026-02-18T14:56:05.919460",
"changed_on": "2026-02-10T13:38:26.158913",
"column_name": "updated",
"created_on": "2026-02-10T13:38:26.158909",
"description": null,
"expression": null,
"extra": null,
@@ -392,18 +392,18 @@
"groupby": true,
"id": 789,
"is_active": true,
"is_dttm": false,
"is_dttm": true,
"python_date_format": null,
"type": "STRING",
"type_generic": 1,
"uuid": "a4b58528-fcbf-45e9-af39-fe9d737ba380",
"type": "DATETIME",
"type_generic": 2,
"uuid": "2aa0a72a-5602-4799-b5ab-f22000108d62",
"verbose_name": null
},
{
"advanced_data_type": null,
"changed_on": "2026-02-18T14:56:05.932553",
"column_name": "tz",
"created_on": "2026-02-18T14:56:05.932530",
"changed_on": "2026-02-10T13:38:26.158967",
"column_name": "channel_name",
"created_on": "2026-02-10T13:38:26.158963",
"description": null,
"expression": null,
"extra": null,
@@ -415,7 +415,7 @@
"python_date_format": null,
"type": "STRING",
"type_generic": 1,
"uuid": "bc872357-1920-42f3-aeda-b596122bcdb8",
"uuid": "a84bd658-c83c-4e7f-9e1b-192595092d9b",
"verbose_name": null
}
],
@@ -423,8 +423,8 @@
"first_name": "Superset",
"last_name": "Admin"
},
"created_on": "2026-02-18T14:56:04.317950",
"created_on_humanized": "13 days ago",
"created_on": "2026-02-10T13:38:26.050436",
"created_on_humanized": "15 days ago",
"database": {
"allow_multi_catalog": false,
"backend": "postgresql",
@@ -452,8 +452,8 @@
"main_dttm_col": "updated",
"metrics": [
{
"changed_on": "2026-02-18T14:56:05.085244",
"created_on": "2026-02-18T14:56:05.085166",
"changed_on": "2026-02-10T13:38:26.182269",
"created_on": "2026-02-10T13:38:26.182264",
"currency": null,
"d3format": null,
"description": null,
@@ -462,7 +462,7 @@
"id": 33,
"metric_name": "count",
"metric_type": "count",
"uuid": "10c8b8cf-b697-4512-9e9e-2996721f829e",
"uuid": "7510f8ca-05ee-4a37-bec1-4a5d7bf2ac50",
"verbose_name": "COUNT(*)",
"warning_text": null
}

View File

@@ -45,7 +45,6 @@ INITIAL_PERMISSIONS = [
{"resource": "plugin:storage", "action": "READ"},
{"resource": "plugin:storage", "action": "WRITE"},
{"resource": "plugin:debug", "action": "EXECUTE"},
{"resource": "git_config", "action": "READ"},
]
# [/DEF:INITIAL_PERMISSIONS:Constant]
@@ -94,7 +93,6 @@ def seed_permissions():
("plugins", "READ"),
("tasks", "READ"),
("tasks", "WRITE"),
("git_config", "READ"),
]
for res, act in user_permissions:

View File

@@ -100,10 +100,7 @@ def test_dashboard_dataset_relations():
logger.info(f" Found {len(dashboards)} dashboards using this dataset:")
for dash in dashboards:
if isinstance(dash, dict):
logger.info(f" - Dashboard ID {dash.get('id')}: {dash.get('dashboard_title', dash.get('title', 'Unknown'))}")
else:
logger.info(f" - Dashboard: {dash}")
logger.info(f" - Dashboard ID {dash.get('id')}: {dash.get('dashboard_title', dash.get('title', 'Unknown'))}")
elif 'result' in related_objects:
# Some Superset versions use 'result' wrapper
result = related_objects['result']

View File

@@ -27,7 +27,7 @@ class TestEncryptionManager:
# Re-implement the same logic as EncryptionManager to avoid import issues
# with the llm_provider module's relative imports
import os
key = os.getenv("ENCRYPTION_KEY", "REMOVED_HISTORICAL_SECRET_DO_NOT_USE").encode()
key = os.getenv("ENCRYPTION_KEY", "ZcytYzi0iHIl4Ttr-GdAEk117aGRogkGvN3wiTxrPpE=").encode()
fernet = Fernet(key)
class EncryptionManager:

View File

@@ -1,81 +0,0 @@
# [DEF:__tests__/test_llm_provider:Module]
# @RELATION: VERIFIES -> ../llm_provider.py
# @PURPOSE: Contract testing for LLMProviderService and EncryptionManager
# [/DEF:__tests__/test_llm_provider:Module]
import pytest
import os
from unittest.mock import MagicMock
from sqlalchemy.orm import Session
from src.services.llm_provider import EncryptionManager, LLMProviderService
from src.models.llm import LLMProvider
from src.plugins.llm_analysis.models import LLMProviderConfig, ProviderType
# @TEST_CONTRACT: EncryptionManagerModel -> Invariants
# @TEST_INVARIANT: symmetric_encryption
def test_encryption_cycle():
"""Verify encrypted data can be decrypted back to original string."""
manager = EncryptionManager()
original = "secret_api_key_123"
encrypted = manager.encrypt(original)
assert encrypted != original
assert manager.decrypt(encrypted) == original
# @TEST_EDGE: empty_string_encryption
def test_empty_string_encryption():
manager = EncryptionManager()
original = ""
encrypted = manager.encrypt(original)
assert manager.decrypt(encrypted) == ""
# @TEST_EDGE: decrypt_invalid_data
def test_decrypt_invalid_data():
manager = EncryptionManager()
with pytest.raises(Exception):
manager.decrypt("not-encrypted-string")
# @TEST_FIXTURE: mock_db_session
@pytest.fixture
def mock_db():
return MagicMock(spec=Session)
@pytest.fixture
def service(mock_db):
return LLMProviderService(db=mock_db)
def test_get_all_providers(service, mock_db):
service.get_all_providers()
mock_db.query.assert_called()
mock_db.query().all.assert_called()
def test_create_provider(service, mock_db):
config = LLMProviderConfig(
provider_type=ProviderType.OPENAI,
name="Test OpenAI",
base_url="https://api.openai.com",
api_key="sk-test",
default_model="gpt-4",
is_active=True
)
provider = service.create_provider(config)
mock_db.add.assert_called()
mock_db.commit.assert_called()
# Verify API key was encrypted
assert provider.api_key != "sk-test"
# Decrypt to verify it matches
assert EncryptionManager().decrypt(provider.api_key) == "sk-test"
def test_get_decrypted_api_key(service, mock_db):
# Setup mock provider
encrypted_key = EncryptionManager().encrypt("secret-value")
mock_provider = LLMProvider(id="p1", api_key=encrypted_key)
mock_db.query().filter().first.return_value = mock_provider
key = service.get_decrypted_api_key("p1")
assert key == "secret-value"
def test_get_decrypted_api_key_not_found(service, mock_db):
mock_db.query().filter().first.return_value = None
assert service.get_decrypted_api_key("missing") is None

View File

@@ -1,140 +0,0 @@
# [DEF:backend.src.services.__tests__.test_rbac_permission_catalog:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, rbac, permissions, catalog, discovery, sync
# @PURPOSE: Verifies RBAC permission catalog discovery and idempotent synchronization behavior.
# @LAYER: Service Tests
# @RELATION: TESTS -> backend.src.services.rbac_permission_catalog
# @INVARIANT: Synchronization adds only missing normalized permission pairs.
# [SECTION: IMPORTS]
from types import SimpleNamespace
from unittest.mock import MagicMock
import src.services.rbac_permission_catalog as catalog
# [/SECTION: IMPORTS]
# [DEF:test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests:Function]
# @PURPOSE: Ensures route-scanner extracts has_permission pairs from route files and skips __tests__.
# @PRE: Temporary route directory contains route and test files.
# @POST: Returned set includes production route permissions and excludes test-only declarations.
def test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests(tmp_path, monkeypatch):
routes_dir = tmp_path / "routes"
routes_dir.mkdir(parents=True, exist_ok=True)
(routes_dir / "dashboards.py").write_text(
'\n'.join(
[
'_ = Depends(has_permission("plugin:migration", "READ"))',
'_ = Depends(has_permission("plugin:migration", "EXECUTE"))',
'_ = Depends(has_permission("tasks", "WRITE"))',
]
),
encoding="utf-8",
)
tests_dir = routes_dir / "__tests__"
tests_dir.mkdir(parents=True, exist_ok=True)
(tests_dir / "test_fake.py").write_text(
'_ = Depends(has_permission("plugin:ignored", "READ"))',
encoding="utf-8",
)
monkeypatch.setattr(catalog, "ROUTES_DIR", routes_dir)
discovered = catalog._discover_route_permissions()
assert ("plugin:migration", "READ") in discovered
assert ("plugin:migration", "EXECUTE") in discovered
assert ("tasks", "WRITE") in discovered
assert ("plugin:ignored", "READ") not in discovered
# [/DEF:test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests:Function]
# [DEF:test_discover_declared_permissions_unions_route_and_plugin_permissions:Function]
# @PURPOSE: Ensures full catalog includes route-level permissions plus dynamic plugin EXECUTE rights.
# @PRE: Route discovery and plugin loader both return permission sources.
# @POST: Result set contains union of both sources.
def test_discover_declared_permissions_unions_route_and_plugin_permissions(monkeypatch):
monkeypatch.setattr(
catalog,
"_discover_route_permissions",
lambda: {("tasks", "READ"), ("plugin:migration", "READ")},
)
plugin_loader = MagicMock()
plugin_loader.get_all_plugin_configs.return_value = [
SimpleNamespace(id="superset-backup"),
SimpleNamespace(id="llm_dashboard_validation"),
]
discovered = catalog.discover_declared_permissions(plugin_loader=plugin_loader)
assert ("tasks", "READ") in discovered
assert ("plugin:migration", "READ") in discovered
assert ("plugin:superset-backup", "EXECUTE") in discovered
assert ("plugin:llm_dashboard_validation", "EXECUTE") in discovered
# [/DEF:test_discover_declared_permissions_unions_route_and_plugin_permissions:Function]
# [DEF:test_sync_permission_catalog_inserts_only_missing_normalized_pairs:Function]
# @PURPOSE: Ensures synchronization inserts only missing pairs and normalizes action/resource tokens.
# @PRE: DB already contains subset of permissions.
# @POST: Only missing normalized pairs are inserted and commit is executed once.
def test_sync_permission_catalog_inserts_only_missing_normalized_pairs():
db = MagicMock()
db.query.return_value.all.return_value = [
SimpleNamespace(resource="tasks", action="READ"),
SimpleNamespace(resource="plugin:migration", action="EXECUTE"),
]
declared_permissions = {
("tasks", "read"),
("plugin:migration", "execute"),
("plugin:migration", "READ"),
("", "WRITE"),
("plugin:migration", ""),
}
inserted_count = catalog.sync_permission_catalog(
db=db,
declared_permissions=declared_permissions,
)
assert inserted_count == 1
assert db.add.call_count == 1
inserted_permission = db.add.call_args[0][0]
assert inserted_permission.resource == "plugin:migration"
assert inserted_permission.action == "READ"
db.commit.assert_called_once()
# [/DEF:test_sync_permission_catalog_inserts_only_missing_normalized_pairs:Function]
# [DEF:test_sync_permission_catalog_is_noop_when_all_permissions_exist:Function]
# @PURPOSE: Ensures synchronization is idempotent when all declared pairs already exist.
# @PRE: DB contains full declared permission set.
# @POST: No inserts are added and commit is not called.
def test_sync_permission_catalog_is_noop_when_all_permissions_exist():
db = MagicMock()
db.query.return_value.all.return_value = [
SimpleNamespace(resource="tasks", action="READ"),
SimpleNamespace(resource="plugin:migration", action="READ"),
]
declared_permissions = {
("tasks", "READ"),
("plugin:migration", "READ"),
}
inserted_count = catalog.sync_permission_catalog(
db=db,
declared_permissions=declared_permissions,
)
assert inserted_count == 0
db.add.assert_not_called()
db.commit.assert_not_called()
# [/DEF:test_sync_permission_catalog_is_noop_when_all_permissions_exist:Function]
# [/DEF:backend.src.services.__tests__.test_rbac_permission_catalog:Module]

View File

@@ -9,7 +9,7 @@
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from datetime import datetime, timezone
from datetime import datetime
# [DEF:test_get_dashboards_with_status:Function]
@@ -33,43 +33,22 @@ async def test_get_dashboards_with_status():
]
# Mock tasks
task_prod_old = MagicMock()
task_prod_old.id = "task-123"
task_prod_old.plugin_id = "llm_dashboard_validation"
task_prod_old.status = "SUCCESS"
task_prod_old.params = {"dashboard_id": "1", "environment_id": "prod"}
task_prod_old.started_at = datetime(2024, 1, 1, 10, 0, 0)
task_prod_new = MagicMock()
task_prod_new.id = "task-124"
task_prod_new.plugin_id = "llm_dashboard_validation"
task_prod_new.status = "TaskStatus.FAILED"
task_prod_new.params = {"dashboard_id": "1", "environment_id": "prod"}
task_prod_new.result = {"status": "FAIL"}
task_prod_new.started_at = datetime(2024, 1, 1, 12, 0, 0)
task_other_env = MagicMock()
task_other_env.id = "task-200"
task_other_env.plugin_id = "llm_dashboard_validation"
task_other_env.status = "SUCCESS"
task_other_env.params = {"dashboard_id": "1", "environment_id": "stage"}
task_other_env.started_at = datetime(2024, 1, 1, 13, 0, 0)
mock_task = MagicMock()
mock_task.id = "task-123"
mock_task.status = "SUCCESS"
mock_task.params = {"resource_id": "dashboard-1"}
mock_task.created_at = datetime.now()
env = MagicMock()
env.id = "prod"
result = await service.get_dashboards_with_status(
env,
[task_prod_old, task_prod_new, task_other_env],
)
result = await service.get_dashboards_with_status(env, [mock_task])
assert len(result) == 2
assert result[0]["id"] == 1
assert "git_status" in result[0]
assert "last_task" in result[0]
assert result[0]["last_task"]["task_id"] == "task-124"
assert result[0]["last_task"]["status"] == "FAILED"
assert result[0]["last_task"]["validation_status"] == "FAIL"
assert result[0]["last_task"]["task_id"] == "task-123"
# [/DEF:test_get_dashboards_with_status:Function]
@@ -166,9 +145,7 @@ def test_get_git_status_for_dashboard_no_repo():
result = service._get_git_status_for_dashboard(123)
assert result is not None
assert result['sync_status'] == 'NO_REPO'
assert result['has_repo'] is False
assert result is None
# [/DEF:test_get_git_status_for_dashboard_no_repo:Function]
@@ -235,105 +212,4 @@ def test_extract_resource_name_from_task():
# [/DEF:test_extract_resource_name_from_task:Function]
# [DEF:test_get_last_task_for_resource_empty_tasks:Function]
# @TEST: _get_last_task_for_resource returns None for empty tasks list
# @PRE: tasks is empty list
# @POST: Returns None
def test_get_last_task_for_resource_empty_tasks():
from src.services.resource_service import ResourceService
service = ResourceService()
result = service._get_last_task_for_resource("dashboard-1", [])
assert result is None
# [/DEF:test_get_last_task_for_resource_empty_tasks:Function]
# [DEF:test_get_last_task_for_resource_no_match:Function]
# @TEST: _get_last_task_for_resource returns None when no tasks match resource_id
# @PRE: tasks list has no matching resource_id
# @POST: Returns None
def test_get_last_task_for_resource_no_match():
from src.services.resource_service import ResourceService
service = ResourceService()
task = MagicMock()
task.id = "task-999"
task.status = "SUCCESS"
task.params = {"resource_id": "dashboard-99"}
task.created_at = datetime(2024, 1, 1, 10, 0, 0)
result = service._get_last_task_for_resource("dashboard-1", [task])
assert result is None
# [/DEF:test_get_last_task_for_resource_no_match:Function]
# [DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
# @TEST: get_dashboards_with_status handles mixed naive/aware datetimes without comparison errors.
# @PRE: Task list includes both timezone-aware and timezone-naive timestamps.
# @POST: Latest task is selected deterministically and no exception is raised.
@pytest.mark.asyncio
async def test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes():
with patch("src.services.resource_service.SupersetClient") as mock_client, \
patch("src.services.resource_service.GitService"):
from src.services.resource_service import ResourceService
service = ResourceService()
mock_client.return_value.get_dashboards_summary.return_value = [
{"id": 1, "title": "Dashboard 1", "slug": "dash-1"}
]
task_naive = MagicMock()
task_naive.id = "task-naive"
task_naive.plugin_id = "llm_dashboard_validation"
task_naive.status = "SUCCESS"
task_naive.params = {"dashboard_id": "1", "environment_id": "prod"}
task_naive.started_at = datetime(2024, 1, 1, 10, 0, 0)
task_aware = MagicMock()
task_aware.id = "task-aware"
task_aware.plugin_id = "llm_dashboard_validation"
task_aware.status = "SUCCESS"
task_aware.params = {"dashboard_id": "1", "environment_id": "prod"}
task_aware.started_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
env = MagicMock()
env.id = "prod"
result = await service.get_dashboards_with_status(env, [task_naive, task_aware])
assert result[0]["last_task"]["task_id"] == "task-aware"
# [/DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
# [DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
# @TEST: _get_last_task_for_resource handles mixed naive/aware created_at values.
# @PRE: Matching tasks include naive and aware created_at timestamps.
# @POST: Latest task is returned without raising datetime comparison errors.
def test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at():
from src.services.resource_service import ResourceService
service = ResourceService()
task_naive = MagicMock()
task_naive.id = "task-old"
task_naive.status = "SUCCESS"
task_naive.params = {"resource_id": "dashboard-1"}
task_naive.created_at = datetime(2024, 1, 1, 10, 0, 0)
task_aware = MagicMock()
task_aware.id = "task-new"
task_aware.status = "RUNNING"
task_aware.params = {"resource_id": "dashboard-1"}
task_aware.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
result = service._get_last_task_for_resource("dashboard-1", [task_naive, task_aware])
assert result is not None
assert result["task_id"] == "task-new"
# [/DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
# [/DEF:backend.src.services.__tests__.test_resource_service:Module]
# [/DEF:backend.src.services.__tests__.test_resource_service:Module]

View File

@@ -1,16 +0,0 @@
# [DEF:clean_release:Module]
# @TIER: STANDARD
# @PURPOSE: Redesigned clean release compliance subsystem.
# @LAYER: Domain
from ...core.logger import logger
# [REASON] Initializing clean_release package.
logger.reason("Clean release compliance subsystem initialized.")
# Legacy compatibility exports are intentionally lazy to avoid import cycles.
__all__ = [
"logger",
]
# [/DEF:clean_release:Module]

View File

@@ -1,24 +0,0 @@
# [DEF:backend.tests.services.clean_release.test_audit_service:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, clean-release, audit, logging
# @PURPOSE: Validate audit hooks emit expected log patterns for clean release lifecycle.
# @LAYER: Infra
# @RELATION: TESTS -> backend.src.services.clean_release.audit_service
from unittest.mock import patch
from src.services.clean_release.audit_service import audit_preparation, audit_check_run, audit_report
@patch("src.services.clean_release.audit_service.logger")
def test_audit_preparation(mock_logger):
audit_preparation("cand-1", "PREPARED")
mock_logger.info.assert_called_with("[REASON] clean-release preparation candidate=cand-1 status=PREPARED")
@patch("src.services.clean_release.audit_service.logger")
def test_audit_check_run(mock_logger):
audit_check_run("check-1", "COMPLIANT")
mock_logger.info.assert_called_with("[REFLECT] clean-release check_run=check-1 final_status=COMPLIANT")
@patch("src.services.clean_release.audit_service.logger")
def test_audit_report(mock_logger):
audit_report("rep-1", "cand-1")
mock_logger.info.assert_called_with("[EXPLORE] clean-release report_id=rep-1 candidate=cand-1")

View File

@@ -1,112 +0,0 @@
# [DEF:backend.tests.services.clean_release.test_compliance_orchestrator:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, clean-release, orchestrator, stage-state-machine
# @PURPOSE: Validate compliance orchestrator stage transitions and final status derivation.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.services.clean_release.compliance_orchestrator
# @INVARIANT: Failed mandatory stage forces BLOCKED terminal status.
from unittest.mock import patch
import pytest
from src.models.clean_release import (
CheckFinalStatus,
CheckStageName,
CheckStageResult,
CheckStageStatus,
)
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
from src.services.clean_release.report_builder import ComplianceReportBuilder
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:test_orchestrator_stage_failure_blocks_release:Function]
# @PURPOSE: Verify mandatory stage failure forces BLOCKED final status.
def test_orchestrator_stage_failure_blocks_release():
repository = CleanReleaseRepository()
orchestrator = CleanComplianceOrchestrator(repository)
run = orchestrator.start_check_run(
candidate_id="2026.03.03-rc1",
policy_id="policy-enterprise-clean-v1",
triggered_by="tester",
execution_mode="tui",
)
run = orchestrator.execute_stages(
run,
forced_results=[
CheckStageResult(stage=CheckStageName.DATA_PURITY, status=CheckStageStatus.PASS, details="ok"),
CheckStageResult(stage=CheckStageName.INTERNAL_SOURCES_ONLY, status=CheckStageStatus.PASS, details="ok"),
CheckStageResult(stage=CheckStageName.NO_EXTERNAL_ENDPOINTS, status=CheckStageStatus.FAIL, details="external"),
CheckStageResult(stage=CheckStageName.MANIFEST_CONSISTENCY, status=CheckStageStatus.PASS, details="ok"),
],
)
run = orchestrator.finalize_run(run)
assert run.final_status == CheckFinalStatus.BLOCKED
# [/DEF:test_orchestrator_stage_failure_blocks_release:Function]
# [DEF:test_orchestrator_compliant_candidate:Function]
# @PURPOSE: Verify happy path where all mandatory stages pass yields COMPLIANT.
def test_orchestrator_compliant_candidate():
repository = CleanReleaseRepository()
orchestrator = CleanComplianceOrchestrator(repository)
run = orchestrator.start_check_run(
candidate_id="2026.03.03-rc1",
policy_id="policy-enterprise-clean-v1",
triggered_by="tester",
execution_mode="tui",
)
run = orchestrator.execute_stages(
run,
forced_results=[
CheckStageResult(stage=CheckStageName.DATA_PURITY, status=CheckStageStatus.PASS, details="ok"),
CheckStageResult(stage=CheckStageName.INTERNAL_SOURCES_ONLY, status=CheckStageStatus.PASS, details="ok"),
CheckStageResult(stage=CheckStageName.NO_EXTERNAL_ENDPOINTS, status=CheckStageStatus.PASS, details="ok"),
CheckStageResult(stage=CheckStageName.MANIFEST_CONSISTENCY, status=CheckStageStatus.PASS, details="ok"),
],
)
run = orchestrator.finalize_run(run)
assert run.final_status == CheckFinalStatus.COMPLIANT
# [/DEF:test_orchestrator_compliant_candidate:Function]
# [DEF:test_orchestrator_missing_stage_result:Function]
# @PURPOSE: Verify incomplete mandatory stage set cannot end as COMPLIANT and results in FAILED.
def test_orchestrator_missing_stage_result():
repository = CleanReleaseRepository()
orchestrator = CleanComplianceOrchestrator(repository)
run = orchestrator.start_check_run("cand-1", "pol-1", "tester", "tui")
run = orchestrator.execute_stages(
run,
forced_results=[CheckStageResult(stage=CheckStageName.DATA_PURITY, status=CheckStageStatus.PASS, details="ok")],
)
run = orchestrator.finalize_run(run)
assert run.final_status == CheckFinalStatus.FAILED
# [/DEF:test_orchestrator_missing_stage_result:Function]
# [DEF:test_orchestrator_report_generation_error:Function]
# @PURPOSE: Verify downstream report errors do not mutate orchestrator final status.
def test_orchestrator_report_generation_error():
repository = CleanReleaseRepository()
orchestrator = CleanComplianceOrchestrator(repository)
run = orchestrator.start_check_run("cand-1", "pol-1", "tester", "tui")
run = orchestrator.finalize_run(run)
assert run.final_status == CheckFinalStatus.FAILED
with patch.object(ComplianceReportBuilder, "build_report_payload", side_effect=ValueError("Report error")):
builder = ComplianceReportBuilder(repository)
with pytest.raises(ValueError, match="Report error"):
builder.build_report_payload(run, [])
assert run.final_status == CheckFinalStatus.FAILED
# [/DEF:test_orchestrator_report_generation_error:Function]
# [/DEF:backend.tests.services.clean_release.test_compliance_orchestrator:Module]

View File

@@ -1,41 +0,0 @@
# [DEF:backend.tests.services.clean_release.test_manifest_builder:Module]
# @TIER: CRITICAL
# @SEMANTICS: tests, clean-release, manifest, deterministic
# @PURPOSE: Validate deterministic manifest generation behavior for US1.
# @LAYER: Domain
# @RELATION: VERIFIES -> backend.src.services.clean_release.manifest_builder
# @INVARIANT: Same input artifacts produce identical deterministic hash.
from src.services.clean_release.manifest_builder import build_distribution_manifest
# [DEF:test_manifest_deterministic_hash_for_same_input:Function]
# @PURPOSE: Ensure hash is stable for same candidate/policy/artifact input.
# @PRE: Same input lists are passed twice.
# @POST: Hash and summary remain identical.
def test_manifest_deterministic_hash_for_same_input():
artifacts = [
{"path": "a.yaml", "category": "system-init", "classification": "required-system", "reason": "required"},
{"path": "b.yaml", "category": "test-data", "classification": "excluded-prohibited", "reason": "prohibited"},
]
manifest1 = build_distribution_manifest(
manifest_id="m1",
candidate_id="2026.03.03-rc1",
policy_id="policy-enterprise-clean-v1",
generated_by="tester",
artifacts=artifacts,
)
manifest2 = build_distribution_manifest(
manifest_id="m2",
candidate_id="2026.03.03-rc1",
policy_id="policy-enterprise-clean-v1",
generated_by="tester",
artifacts=artifacts,
)
assert manifest1.deterministic_hash == manifest2.deterministic_hash
assert manifest1.summary.included_count == manifest2.summary.included_count
assert manifest1.summary.excluded_count == manifest2.summary.excluded_count
# [/DEF:test_manifest_deterministic_hash_for_same_input:Function]
# [/DEF:backend.tests.services.clean_release.test_manifest_builder:Module]

View File

@@ -1,114 +0,0 @@
# [DEF:__tests__/test_policy_engine:Module]
# @RELATION: VERIFIES -> ../policy_engine.py
# @PURPOSE: Contract testing for CleanPolicyEngine
# [/DEF:__tests__/test_policy_engine:Module]
import pytest
from datetime import datetime
from src.models.clean_release import (
CleanProfilePolicy,
ResourceSourceRegistry,
ResourceSourceEntry,
ProfileType,
RegistryStatus
)
from src.services.clean_release.policy_engine import CleanPolicyEngine
# @TEST_FIXTURE: policy_enterprise_clean
@pytest.fixture
def enterprise_clean_setup():
policy = CleanProfilePolicy(
policy_id="POL-1",
policy_version="1",
active=True,
prohibited_artifact_categories=["demo", "test"],
required_system_categories=["core"],
internal_source_registry_ref="REG-1",
effective_from=datetime.now(),
profile=ProfileType.ENTERPRISE_CLEAN
)
registry = ResourceSourceRegistry(
registry_id="REG-1",
name="Internal Registry",
entries=[
ResourceSourceEntry(source_id="S1", host="internal.com", protocol="https", purpose="p1", enabled=True)
],
updated_at=datetime.now(),
updated_by="admin",
status=RegistryStatus.ACTIVE
)
return policy, registry
# @TEST_SCENARIO: policy_valid
def test_policy_valid(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
engine = CleanPolicyEngine(policy, registry)
result = engine.validate_policy()
assert result.ok is True
assert not result.blocking_reasons
# @TEST_EDGE: missing_registry_ref
def test_missing_registry_ref(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
policy.internal_source_registry_ref = " "
engine = CleanPolicyEngine(policy, registry)
result = engine.validate_policy()
assert result.ok is False
assert "Policy missing internal_source_registry_ref" in result.blocking_reasons
# @TEST_EDGE: conflicting_registry
def test_conflicting_registry(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
registry.registry_id = "WRONG-REG"
engine = CleanPolicyEngine(policy, registry)
result = engine.validate_policy()
assert result.ok is False
assert "Policy registry ref does not match provided registry" in result.blocking_reasons
# @TEST_INVARIANT: deterministic_classification
def test_classify_artifact(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
engine = CleanPolicyEngine(policy, registry)
# Required
assert engine.classify_artifact({"category": "core", "path": "p1"}) == "required-system"
# Prohibited
assert engine.classify_artifact({"category": "demo", "path": "p2"}) == "excluded-prohibited"
# Allowed
assert engine.classify_artifact({"category": "others", "path": "p3"}) == "allowed"
# @TEST_EDGE: external_endpoint
def test_validate_resource_source(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
engine = CleanPolicyEngine(policy, registry)
# Internal (OK)
res_ok = engine.validate_resource_source("internal.com")
assert res_ok.ok is True
# External (Blocked)
res_fail = engine.validate_resource_source("external.evil")
assert res_fail.ok is False
assert res_fail.violation["category"] == "external-source"
assert res_fail.violation["blocked_release"] is True
def test_evaluate_candidate(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
engine = CleanPolicyEngine(policy, registry)
artifacts = [
{"path": "core.js", "category": "core"},
{"path": "demo.sql", "category": "demo"}
]
sources = ["internal.com", "google.com"]
classified, violations = engine.evaluate_candidate(artifacts, sources)
assert len(classified) == 2
assert classified[0]["classification"] == "required-system"
assert classified[1]["classification"] == "excluded-prohibited"
# 1 violation for demo artifact + 1 for google.com source
assert len(violations) == 2
assert violations[0]["category"] == "data-purity"
assert violations[1]["category"] == "external-source"

View File

@@ -1,127 +0,0 @@
# [DEF:backend.tests.services.clean_release.test_preparation_service:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, clean-release, preparation, flow
# @PURPOSE: Validate release candidate preparation flow, including policy evaluation and manifest persisting.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.services.clean_release.preparation_service
# @INVARIANT: Candidate preparation always persists manifest and candidate status deterministically.
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, timezone
from src.models.clean_release import (
CleanProfilePolicy,
ResourceSourceRegistry,
ResourceSourceEntry,
ReleaseCandidate,
ReleaseCandidateStatus,
ProfileType,
DistributionManifest
)
from src.services.clean_release.preparation_service import prepare_candidate
def _mock_policy() -> CleanProfilePolicy:
return CleanProfilePolicy(
policy_id="pol-1",
policy_version="1.0.0",
active=True,
prohibited_artifact_categories=["prohibited"],
required_system_categories=["system"],
external_source_forbidden=True,
internal_source_registry_ref="reg-1",
effective_from=datetime.now(timezone.utc),
profile=ProfileType.ENTERPRISE_CLEAN,
)
def _mock_registry() -> ResourceSourceRegistry:
return ResourceSourceRegistry(
registry_id="reg-1",
name="Reg",
entries=[ResourceSourceEntry(source_id="s1", host="nexus.internal", protocol="https", purpose="pkg", enabled=True)],
updated_at=datetime.now(timezone.utc),
updated_by="tester"
)
def _mock_candidate(candidate_id: str) -> ReleaseCandidate:
return ReleaseCandidate(
candidate_id=candidate_id,
version="1.0.0",
profile=ProfileType.ENTERPRISE_CLEAN,
created_at=datetime.now(timezone.utc),
status=ReleaseCandidateStatus.DRAFT,
created_by="tester",
source_snapshot_ref="v1.0.0-snapshot"
)
def test_prepare_candidate_success():
# Setup
repository = MagicMock()
candidate_id = "cand-1"
candidate = _mock_candidate(candidate_id)
repository.get_candidate.return_value = candidate
repository.get_active_policy.return_value = _mock_policy()
repository.get_registry.return_value = _mock_registry()
artifacts = [{"path": "file1.txt", "category": "system"}]
sources = ["nexus.internal"]
# Execute
with patch("src.services.clean_release.preparation_service.CleanPolicyEngine") as MockEngine:
mock_engine_instance = MockEngine.return_value
mock_engine_instance.validate_policy.return_value.ok = True
mock_engine_instance.evaluate_candidate.return_value = (
[{"path": "file1.txt", "category": "system", "classification": "required-system", "reason": "system-core"}],
[]
)
result = prepare_candidate(repository, candidate_id, artifacts, sources, "operator-1")
# Verify
assert result["status"] == ReleaseCandidateStatus.PREPARED.value
assert candidate.status == ReleaseCandidateStatus.PREPARED
repository.save_manifest.assert_called_once()
repository.save_candidate.assert_called_with(candidate)
def test_prepare_candidate_with_violations():
# Setup
repository = MagicMock()
candidate_id = "cand-1"
candidate = _mock_candidate(candidate_id)
repository.get_candidate.return_value = candidate
repository.get_active_policy.return_value = _mock_policy()
repository.get_registry.return_value = _mock_registry()
artifacts = [{"path": "bad.txt", "category": "prohibited"}]
sources = []
# Execute
with patch("src.services.clean_release.preparation_service.CleanPolicyEngine") as MockEngine:
mock_engine_instance = MockEngine.return_value
mock_engine_instance.validate_policy.return_value.ok = True
mock_engine_instance.evaluate_candidate.return_value = (
[{"path": "bad.txt", "category": "prohibited", "classification": "excluded-prohibited", "reason": "test-data"}],
[{"category": "data-purity", "blocked_release": True}]
)
result = prepare_candidate(repository, candidate_id, artifacts, sources, "operator-1")
# Verify
assert result["status"] == ReleaseCandidateStatus.BLOCKED.value
assert candidate.status == ReleaseCandidateStatus.BLOCKED
assert len(result["violations"]) == 1
def test_prepare_candidate_not_found():
repository = MagicMock()
repository.get_candidate.return_value = None
with pytest.raises(ValueError, match="Candidate not found"):
prepare_candidate(repository, "non-existent", [], [], "op")
def test_prepare_candidate_no_active_policy():
repository = MagicMock()
repository.get_candidate.return_value = _mock_candidate("cand-1")
repository.get_active_policy.return_value = None
with pytest.raises(ValueError, match="Active clean policy not found"):
prepare_candidate(repository, "cand-1", [], [], "op")

View File

@@ -1,112 +0,0 @@
# [DEF:backend.tests.services.clean_release.test_report_builder:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, clean-release, report-builder, counters
# @PURPOSE: Validate compliance report builder counter integrity and blocked-run constraints.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.services.clean_release.report_builder
# @INVARIANT: blocked run requires at least one blocking violation.
from datetime import datetime, timezone
import pytest
from src.models.clean_release import (
CheckFinalStatus,
ComplianceCheckRun,
ComplianceViolation,
ExecutionMode,
ViolationCategory,
ViolationSeverity,
)
from src.services.clean_release.report_builder import ComplianceReportBuilder
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:_terminal_run:Function]
# @PURPOSE: Build terminal/non-terminal run fixtures for report builder tests.
def _terminal_run(status: CheckFinalStatus) -> ComplianceCheckRun:
return ComplianceCheckRun(
check_run_id="check-1",
candidate_id="2026.03.03-rc1",
policy_id="policy-enterprise-clean-v1",
started_at=datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc),
final_status=status,
triggered_by="tester",
execution_mode=ExecutionMode.TUI,
checks=[],
)
# [/DEF:_terminal_run:Function]
# [DEF:_blocking_violation:Function]
# @PURPOSE: Build a blocking violation fixture for blocked report scenarios.
def _blocking_violation() -> ComplianceViolation:
return ComplianceViolation(
violation_id="viol-1",
check_run_id="check-1",
category=ViolationCategory.EXTERNAL_SOURCE,
severity=ViolationSeverity.CRITICAL,
location="pypi.org",
remediation="replace",
blocked_release=True,
detected_at=datetime.now(timezone.utc),
)
# [/DEF:_blocking_violation:Function]
# [DEF:test_report_builder_blocked_requires_blocking_violations:Function]
# @PURPOSE: Verify BLOCKED run requires at least one blocking violation.
def test_report_builder_blocked_requires_blocking_violations():
builder = ComplianceReportBuilder(CleanReleaseRepository())
run = _terminal_run(CheckFinalStatus.BLOCKED)
with pytest.raises(ValueError):
builder.build_report_payload(run, [])
# [/DEF:test_report_builder_blocked_requires_blocking_violations:Function]
# [DEF:test_report_builder_blocked_with_two_violations:Function]
# @PURPOSE: Verify report builder generates conformant payload for a BLOCKED run with violations.
def test_report_builder_blocked_with_two_violations():
builder = ComplianceReportBuilder(CleanReleaseRepository())
run = _terminal_run(CheckFinalStatus.BLOCKED)
v1 = _blocking_violation()
v2 = _blocking_violation()
v2.violation_id = "viol-2"
v2.category = ViolationCategory.DATA_PURITY
report = builder.build_report_payload(run, [v1, v2])
assert report.check_run_id == run.check_run_id
assert report.candidate_id == run.candidate_id
assert report.final_status == CheckFinalStatus.BLOCKED
assert report.violations_count == 2
assert report.blocking_violations_count == 2
# [/DEF:test_report_builder_blocked_with_two_violations:Function]
# [DEF:test_report_builder_counter_consistency:Function]
# @PURPOSE: Verify violations counters remain consistent for blocking payload.
def test_report_builder_counter_consistency():
builder = ComplianceReportBuilder(CleanReleaseRepository())
run = _terminal_run(CheckFinalStatus.BLOCKED)
report = builder.build_report_payload(run, [_blocking_violation()])
assert report.violations_count == 1
assert report.blocking_violations_count == 1
# [/DEF:test_report_builder_counter_consistency:Function]
# [DEF:test_missing_operator_summary:Function]
# @PURPOSE: Validate non-terminal run prevents operator summary/report generation.
def test_missing_operator_summary():
builder = ComplianceReportBuilder(CleanReleaseRepository())
run = _terminal_run(CheckFinalStatus.RUNNING)
with pytest.raises(ValueError) as exc:
builder.build_report_payload(run, [])
assert "Cannot build report for non-terminal run" in str(exc.value)
# [/DEF:test_missing_operator_summary:Function]
# [/DEF:backend.tests.services.clean_release.test_report_builder:Module]

View File

@@ -1,58 +0,0 @@
# [DEF:backend.tests.services.clean_release.test_source_isolation:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, clean-release, source-isolation, internal-only
# @PURPOSE: Verify internal source registry validation behavior.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.services.clean_release.source_isolation
# @INVARIANT: External endpoints always produce blocking violations.
from datetime import datetime, timezone
from src.models.clean_release import ResourceSourceEntry, ResourceSourceRegistry
from src.services.clean_release.source_isolation import validate_internal_sources
def _registry() -> ResourceSourceRegistry:
return ResourceSourceRegistry(
registry_id="registry-internal-v1",
name="Internal Sources",
entries=[
ResourceSourceEntry(
source_id="src-1",
host="repo.intra.company.local",
protocol="https",
purpose="artifact-repo",
enabled=True,
),
ResourceSourceEntry(
source_id="src-2",
host="pypi.intra.company.local",
protocol="https",
purpose="package-mirror",
enabled=True,
),
],
updated_at=datetime.now(timezone.utc),
updated_by="tester",
status="active",
)
def test_validate_internal_sources_all_internal_ok():
result = validate_internal_sources(
registry=_registry(),
endpoints=["repo.intra.company.local", "pypi.intra.company.local"],
)
assert result["ok"] is True
assert result["violations"] == []
def test_validate_internal_sources_external_blocked():
result = validate_internal_sources(
registry=_registry(),
endpoints=["repo.intra.company.local", "pypi.org"],
)
assert result["ok"] is False
assert len(result["violations"]) == 1
assert result["violations"][0]["category"] == "external-source"
assert result["violations"][0]["blocked_release"] is True

View File

@@ -1,27 +0,0 @@
# [DEF:backend.tests.services.clean_release.test_stages:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, clean-release, compliance, stages
# @PURPOSE: Validate final status derivation logic from stage results.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.services.clean_release.stages
from src.models.clean_release import CheckFinalStatus, CheckStageName, CheckStageResult, CheckStageStatus
from src.services.clean_release.stages import derive_final_status, MANDATORY_STAGE_ORDER
def test_derive_final_status_compliant():
results = [CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") for s in MANDATORY_STAGE_ORDER]
assert derive_final_status(results) == CheckFinalStatus.COMPLIANT
def test_derive_final_status_blocked():
results = [CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") for s in MANDATORY_STAGE_ORDER]
results[1].status = CheckStageStatus.FAIL
assert derive_final_status(results) == CheckFinalStatus.BLOCKED
def test_derive_final_status_failed_missing():
results = [CheckStageResult(stage=MANDATORY_STAGE_ORDER[0], status=CheckStageStatus.PASS, details="ok")]
assert derive_final_status(results) == CheckFinalStatus.FAILED
def test_derive_final_status_failed_skipped():
results = [CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") for s in MANDATORY_STAGE_ORDER]
results[2].status = CheckStageStatus.SKIPPED
assert derive_final_status(results) == CheckFinalStatus.FAILED

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