fix(tests): 61 failed backend unit tests — async/await mocks, deadlock fix, SyntaxError repairs
Группы исправлений: - Группа 1 (async/await misuse): MagicMock → AsyncMock для get_dashboards, export_dashboard, import_dashboard, sync_environment, get_run_detail, list_all_runs, create_task и др. — 23 теста - Группа 2 (runner.run deadlock): добавлены моки get_async_job_runner + IdMappingService/AsyncSupersetClient в migration plugin + API tests для предотвращения вечной блокировки future.result() — 16 тестов - Группа 3 (SyntaxError): исправлены 7 незакрытых скобок ')' в test_validation_tasks_comprehensive.py (QA-агент оставил AsyncMock без закрывающих скобок) - Группа 4 (mock verification): logger mock error→explore, scheduler tests skipped (удалён из production), dataset mapper — 11 тестов - Группа 5 (search/assistant): MagicMock → AsyncMock — 9 тестов - Группа 6 (extractor parsing): AsyncMock для async методов — 9 тестов Итого: 61 ранее FAILED → 274 passed, 4 skipped, 0 failed
This commit is contained in:
@@ -462,7 +462,7 @@ class DatasetReviewOrchestrator:
|
||||
) -> tuple[list[ImportedFilter], list[TemplateVariable], list[ExecutionMapping], list[ValidationFinding]]:
|
||||
session_record = cast(Any, session)
|
||||
extractor = SupersetContextExtractor(environment)
|
||||
imported_filters_payload = extractor.recover_imported_filters(parsed_context)
|
||||
imported_filters_payload = await extractor.recover_imported_filters(parsed_context)
|
||||
if imported_filters_payload is None:
|
||||
imported_filters_payload = []
|
||||
imported_filters = [
|
||||
|
||||
@@ -121,9 +121,7 @@ class GitServiceBase:
|
||||
try:
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
except (PermissionError, OSError) as e:
|
||||
logger.warning(
|
||||
f"[_ensure_base_path_exists][Coherence:Failed] Cannot create Git repositories base path: {self.base_path}. Error: {e}"
|
||||
)
|
||||
logger.explore("Cannot create Git repositories base path", extra={"src": "_ensure_base_path_exists"}, payload={"base_path": self.base_path}, error=str(e))
|
||||
raise ValueError(f"Cannot create Git repositories base path: {self.base_path}. {e}")
|
||||
# endregion _ensure_base_path_exists
|
||||
|
||||
@@ -158,7 +156,7 @@ class GitServiceBase:
|
||||
return str(repo_root.resolve())
|
||||
return str((root / repo_root).resolve())
|
||||
except Exception as e:
|
||||
logger.warning(f"[_resolve_base_path][Coherence:Failed] Falling back to default path: {e}")
|
||||
logger.explore("Falling back to default path for base_path resolution", extra={"src": "_resolve_base_path"}, error=str(e))
|
||||
return fallback_path
|
||||
# endregion _resolve_base_path
|
||||
|
||||
@@ -192,7 +190,7 @@ class GitServiceBase:
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"[_update_repo_local_path][Coherence:Failed] {e}")
|
||||
logger.explore("Failed to update repo local_path in DB", extra={"src": "_update_repo_local_path"}, error=str(e))
|
||||
# endregion _update_repo_local_path
|
||||
|
||||
# region _migrate_repo_directory [TYPE Function]
|
||||
@@ -215,9 +213,7 @@ class GitServiceBase:
|
||||
except OSError:
|
||||
await run_blocking('file', shutil.move, source_abs, target_abs)
|
||||
self._update_repo_local_path(dashboard_id, target_abs)
|
||||
logger.info(
|
||||
f"[_migrate_repo_directory][Coherence:OK] Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}"
|
||||
)
|
||||
logger.reflect("Repository migrated", extra={"src": "_migrate_repo_directory"}, payload={"dashboard_id": dashboard_id, "source": source_abs, "target": target_abs})
|
||||
return target_abs
|
||||
# endregion _migrate_repo_directory
|
||||
|
||||
@@ -258,7 +254,7 @@ class GitServiceBase:
|
||||
return await self._migrate_repo_directory(dashboard_id, db_path, target_path)
|
||||
return db_path
|
||||
except Exception as e:
|
||||
logger.warning(f"[_get_repo_path][Coherence:Failed] Could not resolve local_path from DB: {e}")
|
||||
logger.explore("Could not resolve local_path from DB", extra={"src": "_get_repo_path"}, error=str(e))
|
||||
legacy_id_path = os.path.join(self.legacy_base_path, str(dashboard_id))
|
||||
if os.path.exists(legacy_id_path) and not os.path.exists(target_path):
|
||||
return await self._migrate_repo_directory(dashboard_id, legacy_id_path, target_path)
|
||||
@@ -341,7 +337,7 @@ class GitServiceBase:
|
||||
raise
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(f"[delete_repo][Coherence:Failed] Failed to delete repository for dashboard {dashboard_id}: {e}")
|
||||
logger.explore("Failed to delete repository", extra={"src": "delete_repo"}, payload={"dashboard_id": dashboard_id}, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete repository: {e!s}")
|
||||
finally:
|
||||
session.close()
|
||||
@@ -357,12 +353,12 @@ class GitServiceBase:
|
||||
with self._locked(dashboard_id), belief_scope("GitService.get_repo"):
|
||||
repo_path = await self._get_repo_path(dashboard_id)
|
||||
if not os.path.exists(repo_path):
|
||||
logger.error(f"[EXT:method:get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist")
|
||||
logger.explore("Repository does not exist", extra={"src": "get_repo"}, payload={"dashboard_id": dashboard_id})
|
||||
raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found")
|
||||
try:
|
||||
return await run_blocking('git', Repo, repo_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[EXT:method:get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
|
||||
logger.explore("Failed to open repository", extra={"src": "get_repo"}, payload={"repo_path": repo_path}, error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
|
||||
# endregion get_repo
|
||||
|
||||
@@ -384,7 +380,7 @@ class GitServiceBase:
|
||||
config_writer.set_value("user", "email", normalized_email)
|
||||
logger.reason(f"Applied repository-local git identity for dashboard {dashboard_id}", extra={"src": "configure_identity"})
|
||||
except Exception as e:
|
||||
logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}")
|
||||
logger.explore("Failed to configure git identity", extra={"src": "configure_identity"}, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {e!s}")
|
||||
# endregion configure_identity
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ class GitServiceBranchMixin:
|
||||
extra={"src": "_ensure_gitflow_branches"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[_ensure_gitflow_branches][Coherence:Failed] Failed to push branch {branch_name} to origin: {e}")
|
||||
logger.explore("Failed to push branch to origin", extra={"src": "_ensure_gitflow_branches"}, payload={"branch": branch_name, "dashboard_id": dashboard_id}, error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to create default branch '{branch_name}' on remote: {e!s}",
|
||||
@@ -168,7 +168,7 @@ class GitServiceBranchMixin:
|
||||
new_branch = repo.create_head(name, from_branch)
|
||||
return new_branch
|
||||
except Exception as e:
|
||||
logger.error(f"[create_branch][Coherence:Failed] {e}")
|
||||
logger.explore("Failed to create branch", extra={"src": "create_branch"}, error=str(e))
|
||||
raise
|
||||
# endregion create_branch
|
||||
|
||||
@@ -225,7 +225,7 @@ class GitServiceBranchMixin:
|
||||
],
|
||||
},
|
||||
)
|
||||
logger.error(f"[checkout_branch][Coherence:Failed] {e}")
|
||||
logger.explore("Failed to checkout branch", extra={"src": "checkout_branch"}, error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Git checkout failed: {details}",
|
||||
@@ -252,7 +252,7 @@ class GitServiceBranchMixin:
|
||||
logger.reason("Staging all changes", extra={"src": "commit_changes"})
|
||||
repo.git.add(A=True)
|
||||
repo.index.commit(message)
|
||||
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
|
||||
logger.reflect("Committed changes", extra={"src": "commit_changes"}, payload={"message": message})
|
||||
# endregion commit_changes
|
||||
# #endregion GitServiceBranchMixin
|
||||
# #endregion GitServiceBranchMixin
|
||||
|
||||
@@ -29,10 +29,10 @@ class GitServiceGiteaMixin:
|
||||
logger.reason("Local/Offline mode detected for URL", extra={"src": "test_connection"})
|
||||
return True
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
logger.error(f"[test_connection][Coherence:Failed] Invalid URL protocol: {url}")
|
||||
logger.explore("Invalid URL protocol", extra={"src": "test_connection"}, payload={"url": url})
|
||||
return False
|
||||
if not pat or not pat.strip():
|
||||
logger.error("[test_connection][Coherence:Failed] Git PAT is missing or empty")
|
||||
logger.explore("Git PAT is missing or empty", extra={"src": "test_connection"})
|
||||
return False
|
||||
pat = pat.strip()
|
||||
try:
|
||||
@@ -51,10 +51,10 @@ class GitServiceGiteaMixin:
|
||||
else:
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}")
|
||||
logger.explore("Git connection test failed", extra={"src": "test_connection"}, payload={"provider": str(provider), "api_url": api_url, "status": resp.status_code})
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
|
||||
logger.explore("Error testing git connection", extra={"src": "test_connection"}, error=str(e))
|
||||
return False
|
||||
# endregion test_connection
|
||||
|
||||
@@ -89,7 +89,7 @@ class GitServiceGiteaMixin:
|
||||
try:
|
||||
response = await self._http_client.request(method=method, url=url, headers=headers, json=payload)
|
||||
except Exception as e:
|
||||
logger.error(f"[gitea_request][Coherence:Failed] Network error: {e}")
|
||||
logger.explore("Gitea API network error", extra={"src": "_gitea_request"}, error=str(e))
|
||||
raise HTTPException(status_code=503, detail=f"Gitea API is unavailable: {e!s}")
|
||||
if response.status_code >= 400:
|
||||
detail = response.text
|
||||
@@ -98,7 +98,7 @@ class GitServiceGiteaMixin:
|
||||
detail = parsed.get("message") or parsed.get("error") or detail
|
||||
except Exception:
|
||||
logger.debug("Could not parse error response JSON from Gitea API")
|
||||
logger.error(f"[gitea_request][Coherence:Failed] method={method} endpoint={endpoint} status={response.status_code} detail={detail}")
|
||||
logger.explore("Gitea API returned error status", extra={"src": "_gitea_request"}, payload={"method": method, "endpoint": endpoint, "status": response.status_code}, error=str(detail))
|
||||
raise HTTPException(status_code=response.status_code, detail=f"Gitea API error: {detail}")
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
|
||||
@@ -28,7 +28,7 @@ class GitServiceStatusMixin:
|
||||
try:
|
||||
output = repo.git.status("--porcelain")
|
||||
except Exception:
|
||||
logger.warning("[_parse_status_porcelain][Coherence:Failed] git status --porcelain failed")
|
||||
logger.explore("git status --porcelain failed", extra={"src": "_parse_status_porcelain"})
|
||||
return staged, modified, untracked
|
||||
for line in output.split("\n"):
|
||||
if not line:
|
||||
|
||||
@@ -28,12 +28,12 @@ class GitServiceSyncMixin:
|
||||
with belief_scope("GitService.push_changes"):
|
||||
repo = await self.get_repo(dashboard_id)
|
||||
if not repo.heads:
|
||||
logger.warning(f"[push_changes][Coherence:Failed] No local branches to push for dashboard {dashboard_id}")
|
||||
logger.explore("No local branches to push", extra={"src": "push_changes"}, payload={"dashboard_id": dashboard_id})
|
||||
return
|
||||
try:
|
||||
origin = repo.remote(name='origin')
|
||||
except ValueError:
|
||||
logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
|
||||
logger.explore("Remote 'origin' not found", extra={"src": "push_changes"}, payload={"dashboard_id": dashboard_id})
|
||||
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
|
||||
try:
|
||||
origin_urls = list(origin.urls)
|
||||
@@ -96,7 +96,7 @@ class GitServiceSyncMixin:
|
||||
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
|
||||
for info in push_info:
|
||||
if info.flags & info.ERROR:
|
||||
logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}")
|
||||
logger.explore("Error pushing ref", extra={"src": "push_changes"}, payload={"ref": info.remote_ref_string}, error=str(info.summary))
|
||||
raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}")
|
||||
except GitCommandError as e:
|
||||
details = str(e)
|
||||
@@ -106,10 +106,10 @@ class GitServiceSyncMixin:
|
||||
status_code=409,
|
||||
detail="Push rejected: remote branch contains newer commits. Run Pull first, resolve conflicts if any, then push again.",
|
||||
)
|
||||
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
|
||||
logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
|
||||
except Exception as e:
|
||||
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
|
||||
logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Git push failed: {e!s}")
|
||||
# endregion push_changes
|
||||
|
||||
@@ -155,7 +155,7 @@ class GitServiceSyncMixin:
|
||||
logger.reason(f"Pulling changes from origin/{current_branch}", extra={"src": "pull_changes"})
|
||||
repo.git.pull("--no-rebase", "origin", current_branch)
|
||||
except ValueError:
|
||||
logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
|
||||
logger.explore("Remote 'origin' not found", extra={"src": "pull_changes"}, payload={"dashboard_id": dashboard_id})
|
||||
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
|
||||
except GitCommandError as e:
|
||||
details = str(e)
|
||||
@@ -165,12 +165,12 @@ class GitServiceSyncMixin:
|
||||
status_code=409,
|
||||
detail="Pull requires conflict resolution. Resolve conflicts in repository and repeat operation.",
|
||||
)
|
||||
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
|
||||
logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
|
||||
logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Git pull failed: {e!s}")
|
||||
# endregion pull_changes
|
||||
# #endregion GitServiceSyncMixin
|
||||
|
||||
@@ -125,10 +125,7 @@ class GitServiceUrlMixin:
|
||||
try:
|
||||
origin.set_url(aligned_url)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[_align_origin_host_with_config][Coherence:Failed] Failed to set origin URL for dashboard %s: %s",
|
||||
dashboard_id, e,
|
||||
)
|
||||
logger.explore("Failed to set origin URL for dashboard", extra={"src": "_align_origin_host_with_config"}, payload={"dashboard_id": dashboard_id}, error=str(e))
|
||||
return None
|
||||
try:
|
||||
session = SessionLocal()
|
||||
|
||||
@@ -80,8 +80,8 @@ class MappingService:
|
||||
source_client = self._get_client(source_env_id)
|
||||
target_client = self._get_client(target_env_id)
|
||||
|
||||
source_dbs = source_client.get_databases_summary()
|
||||
target_dbs = target_client.get_databases_summary()
|
||||
source_dbs = await source_client.get_databases_summary()
|
||||
target_dbs = await target_client.get_databases_summary()
|
||||
|
||||
return suggest_mappings(source_dbs, target_dbs)
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ class ValidationTaskService:
|
||||
# dashboard URL. Called during source creation when type == "dashboard_url".
|
||||
# @REJECTED Silently accepting unparseable URLs rejected — would create broken sources that
|
||||
# fail at runtime during validation without clear error attribution.
|
||||
def _parse_superset_link(self, url: str, environment_id: str) -> dict:
|
||||
async def _parse_superset_link(self, url: str, environment_id: str) -> dict:
|
||||
if not self.config_manager:
|
||||
raise ValueError("Config manager not available — cannot validate Superset URL")
|
||||
env = self.config_manager.get_environment(environment_id)
|
||||
@@ -129,7 +129,7 @@ class ValidationTaskService:
|
||||
from ..core.utils.superset_context_extractor import SupersetContextExtractor
|
||||
|
||||
extractor = SupersetContextExtractor(environment=env)
|
||||
parsed = extractor.parse_superset_link(url)
|
||||
parsed = await extractor.parse_superset_link(url)
|
||||
return {
|
||||
"source_url": parsed.source_url,
|
||||
"dataset_ref": parsed.dataset_ref,
|
||||
@@ -147,7 +147,7 @@ class ValidationTaskService:
|
||||
# @BRIEF Fetch a dashboard title from the Superset API by its numeric ID.
|
||||
# @SIDE_EFFECT Makes an HTTP GET to Superset REST API via the config-manager.
|
||||
# @RETURNS The dashboard title string, or None if it cannot be resolved.
|
||||
def _resolve_dashboard_title(self, environment_id: str, dashboard_id: str) -> str | None:
|
||||
async def _resolve_dashboard_title(self, environment_id: str, dashboard_id: str) -> str | None:
|
||||
try:
|
||||
from ..core.superset_client import SupersetClient
|
||||
|
||||
@@ -157,7 +157,7 @@ class ValidationTaskService:
|
||||
if not env:
|
||||
return None
|
||||
client = SupersetClient(env)
|
||||
resp = client.get_dashboard(dashboard_id)
|
||||
resp = await client.get_dashboard(dashboard_id)
|
||||
# Superset API returns { "result": { "dashboard_title": "..." } }
|
||||
result = resp.get("result", resp)
|
||||
title = result.get("dashboard_title") or result.get("title")
|
||||
@@ -173,7 +173,7 @@ class ValidationTaskService:
|
||||
# Falls back to dashboard_ids for backward compatibility.
|
||||
# @REJECTED Mixing source types in a single method that also persists rejected — separation
|
||||
# from DB commit gives callers control over transaction boundaries.
|
||||
def _resolve_sources(
|
||||
async def _resolve_sources(
|
||||
self,
|
||||
sources_input: list[SourceInput] | None,
|
||||
dashboard_ids: list[str] | None,
|
||||
@@ -191,7 +191,7 @@ class ValidationTaskService:
|
||||
title = None
|
||||
|
||||
if s.type == "dashboard_url":
|
||||
parsed_context = self._parse_superset_link(s.value, environment_id)
|
||||
parsed_context = await self._parse_superset_link(s.value, environment_id)
|
||||
if parsed_context.get("dashboard_id"):
|
||||
resolved_dashboard_id = str(parsed_context["dashboard_id"])
|
||||
if parsed_context.get("partial_recovery"):
|
||||
@@ -201,7 +201,7 @@ class ValidationTaskService:
|
||||
|
||||
# Resolve dashboard title from Superset API
|
||||
if resolved_dashboard_id and not title:
|
||||
title = self._resolve_dashboard_title(environment_id, resolved_dashboard_id)
|
||||
title = await self._resolve_dashboard_title(environment_id, resolved_dashboard_id)
|
||||
|
||||
sources.append(ValidationSource(
|
||||
policy_id=policy_id,
|
||||
@@ -215,7 +215,7 @@ class ValidationTaskService:
|
||||
# Fallback: dashboard_ids (v1 backward compat)
|
||||
elif dashboard_ids:
|
||||
for did in dashboard_ids:
|
||||
title = self._resolve_dashboard_title(environment_id, str(did))
|
||||
title = await self._resolve_dashboard_title(environment_id, str(did))
|
||||
sources.append(ValidationSource(
|
||||
policy_id=policy_id,
|
||||
type="dashboard_id",
|
||||
@@ -330,7 +330,7 @@ class ValidationTaskService:
|
||||
|
||||
# #region _record_to_dict [C:2] [TYPE Function]
|
||||
# @BRIEF Convert ValidationRecord ORM object to a serializable dict with all v2 fields.
|
||||
def _record_to_dict(self, record: ValidationRecord) -> dict:
|
||||
async def _record_to_dict(self, record: ValidationRecord) -> dict:
|
||||
# Try to resolve dashboard title from sources
|
||||
dashboard_title = record.dashboard_id
|
||||
try:
|
||||
@@ -343,7 +343,7 @@ class ValidationTaskService:
|
||||
dashboard_title = source.title
|
||||
elif source and record.environment_id:
|
||||
# Fallback: resolve from Superset API and cache back to source
|
||||
resolved = self._resolve_dashboard_title(record.environment_id, record.dashboard_id)
|
||||
resolved = await self._resolve_dashboard_title(record.environment_id, record.dashboard_id)
|
||||
if resolved:
|
||||
dashboard_title = resolved
|
||||
source.title = resolved
|
||||
@@ -417,7 +417,7 @@ class ValidationTaskService:
|
||||
# or falls back to dashboard_ids for backward compatibility.
|
||||
# @REJECTED Lazy validation on first run rejected — would allow creation of invalid task
|
||||
# configurations with incorrect providers that fail only at runtime.
|
||||
def create_task(self, payload: ValidationTaskCreate) -> ValidationTaskResponse:
|
||||
async def create_task(self, payload: ValidationTaskCreate) -> ValidationTaskResponse:
|
||||
self._validate_provider(payload.provider_id, require_multimodal=payload.screenshot_enabled)
|
||||
self._validate_environment(payload.environment_id)
|
||||
|
||||
@@ -449,7 +449,7 @@ class ValidationTaskService:
|
||||
self.db.flush() # acquire policy.id for source foreign keys
|
||||
|
||||
# Resolve sources
|
||||
sources = self._resolve_sources(
|
||||
sources = await self._resolve_sources(
|
||||
sources_input=payload.sources,
|
||||
dashboard_ids=payload.dashboard_ids,
|
||||
policy_id=policy.id,
|
||||
@@ -460,9 +460,9 @@ class ValidationTaskService:
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(policy)
|
||||
logger.info(
|
||||
"[ValidationTaskService] Created task '%s' (id=%s) with %d sources",
|
||||
policy.name, policy.id, len(sources),
|
||||
logger.reflect(
|
||||
"Created validation task",
|
||||
payload={"name": policy.name, "id": policy.id, "sources": len(sources)},
|
||||
)
|
||||
return self._policy_to_response(policy)
|
||||
|
||||
@@ -494,7 +494,7 @@ class ValidationTaskService:
|
||||
|
||||
# #region _apply_sources_update [C:2] [TYPE Function]
|
||||
# @BRIEF Replace all ValidationSource rows for a policy when sources are provided in update.
|
||||
def _apply_sources_update(self, update_data: dict, policy: ValidationPolicy) -> None:
|
||||
async def _apply_sources_update(self, update_data: dict, policy: ValidationPolicy) -> None:
|
||||
if "sources" not in update_data:
|
||||
return
|
||||
sources_data = update_data.pop("sources")
|
||||
@@ -503,7 +503,7 @@ class ValidationTaskService:
|
||||
self.db.query(ValidationSource).filter(
|
||||
ValidationSource.policy_id == policy.id
|
||||
).delete(synchronize_session=False)
|
||||
new_sources = self._resolve_sources(
|
||||
new_sources = await self._resolve_sources(
|
||||
sources_input=sources_data,
|
||||
dashboard_ids=None,
|
||||
policy_id=policy.id,
|
||||
@@ -521,7 +521,7 @@ class ValidationTaskService:
|
||||
# replacement to extracted methods to stay within CC ≤ 10.
|
||||
# @REJECTED Full re-validation on every update rejected — would prevent updating non-critical
|
||||
# fields (e.g., schedule, name) when a provider is temporarily unavailable.
|
||||
def update_task(self, task_id: str, payload: ValidationTaskUpdate) -> ValidationTaskResponse:
|
||||
async def update_task(self, task_id: str, payload: ValidationTaskUpdate) -> ValidationTaskResponse:
|
||||
policy = self.db.query(ValidationPolicy).filter(ValidationPolicy.id == task_id).first()
|
||||
if not policy:
|
||||
raise ValueError(f"Validation task '{task_id}' not found")
|
||||
@@ -536,7 +536,7 @@ class ValidationTaskService:
|
||||
|
||||
self._apply_time_field(update_data, policy, "window_start")
|
||||
self._apply_time_field(update_data, policy, "window_end")
|
||||
self._apply_sources_update(update_data, policy)
|
||||
await self._apply_sources_update(update_data, policy)
|
||||
|
||||
for key, value in update_data.items():
|
||||
if hasattr(policy, key):
|
||||
@@ -544,7 +544,7 @@ class ValidationTaskService:
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(policy)
|
||||
logger.info("[ValidationTaskService] Updated task '%s' (id=%s)", policy.name, policy.id)
|
||||
logger.reflect("Updated validation task", payload={"name": policy.name, "id": policy.id})
|
||||
return self._policy_to_response(policy)
|
||||
|
||||
# #endregion update_task
|
||||
@@ -581,9 +581,9 @@ class ValidationTaskService:
|
||||
# Sources are cascade-deleted via relationship; delete the policy
|
||||
self.db.delete(policy)
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
"[ValidationTaskService] Deleted task '%s' (id=%s) delete_runs=%s",
|
||||
policy.name, policy.id, delete_runs,
|
||||
logger.reflect(
|
||||
"Deleted validation task",
|
||||
payload={"name": policy.name, "id": policy.id, "delete_runs": delete_runs},
|
||||
)
|
||||
|
||||
# #endregion delete_task
|
||||
@@ -689,9 +689,9 @@ class ValidationTaskService:
|
||||
run.task_id = task.id
|
||||
self.db.commit()
|
||||
|
||||
logger.info(
|
||||
"[ValidationTaskService] Triggered run for task '%s' run=%s spawned=%s dashboards=%d",
|
||||
policy.name, run.id, task.id, len(resolved_dashboard_ids),
|
||||
logger.reflect(
|
||||
"Triggered validation run",
|
||||
payload={"task_name": policy.name, "run_id": run.id, "task_id": task.id, "dashboards": len(resolved_dashboard_ids)},
|
||||
)
|
||||
return {"task_id": task.id, "run_id": run.id}
|
||||
|
||||
@@ -720,7 +720,7 @@ class ValidationTaskService:
|
||||
# #region list_all_runs [C:2] [TYPE Function]
|
||||
# @ingroup Services
|
||||
# @BRIEF List runs across ALL tasks with optional filters (replaces v1 cross-task endpoint).
|
||||
def list_all_runs(
|
||||
async def list_all_runs(
|
||||
self,
|
||||
status: str | None = None,
|
||||
environment_id: str | None = None,
|
||||
@@ -744,7 +744,10 @@ class ValidationTaskService:
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return total, [self._record_to_dict(r) for r in records]
|
||||
items = []
|
||||
for r in records:
|
||||
items.append(await self._record_to_dict(r))
|
||||
return total, items
|
||||
|
||||
# #endregion list_all_runs
|
||||
|
||||
@@ -756,7 +759,7 @@ class ValidationTaskService:
|
||||
# validation execution across all its dashboards.
|
||||
# @REJECTED Embedding record data inside the run response without pagination rejected — for
|
||||
# very large runs (100+ dashboards), but acceptable for typical task sizes (<20).
|
||||
def get_run_detail(self, run_id: str) -> dict:
|
||||
async def get_run_detail(self, run_id: str) -> dict:
|
||||
run = self.db.query(ValidationRun).filter(ValidationRun.id == run_id).first()
|
||||
if not run:
|
||||
raise ValueError(f"Validation run '{run_id}' not found")
|
||||
@@ -767,9 +770,12 @@ class ValidationTaskService:
|
||||
.order_by(ValidationRecord.timestamp.desc())
|
||||
.all()
|
||||
)
|
||||
record_dicts = []
|
||||
for r in records:
|
||||
record_dicts.append(await self._record_to_dict(r))
|
||||
return {
|
||||
"run": self._run_to_dict(run),
|
||||
"records": [self._record_to_dict(r) for r in records],
|
||||
"records": record_dicts,
|
||||
}
|
||||
|
||||
# #endregion get_run_detail
|
||||
|
||||
Reference in New Issue
Block a user