chore: commit remaining working changes

Backend:
- agent: confirmation, persistence, app, langgraph_setup updates
- routes: agent_superset_explore, environments, git helpers/operations
- services: git sync refactoring
- tests: git_status_route expanded

Frontend:
- Navbar: minor cleanup
- Profile: i18n (en/ru), page enhancements, integration tests
- New: _llm_params.py
This commit is contained in:
2026-07-05 09:24:45 +03:00
parent 708b7b5815
commit dbc6314d2e
17 changed files with 1030 additions and 203 deletions

View File

@@ -12,6 +12,7 @@ from typing import Any
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from src.agent._llm_params import chat_openai_kwargs
from src.agent._tool_resolver import ( from src.agent._tool_resolver import (
extract_tool_call_from_state, extract_tool_call_from_state,
find_tool, find_tool,
@@ -284,13 +285,12 @@ async def _format_tool_output_via_llm(
config = await _fetch_llm_config() config = await _fetch_llm_config()
if config and config.get("configured"): if config and config.get("configured"):
try: try:
llm = ChatOpenAI( llm = ChatOpenAI(**chat_openai_kwargs(
model=config.get("default_model", "gpt-4o-mini"), model=config.get("default_model", "gpt-4o-mini"),
base_url=config.get("base_url", "https://api.openai.com/v1"), base_url=config.get("base_url", "https://api.openai.com/v1"),
api_key=config["api_key"], api_key=config["api_key"],
temperature=0,
max_tokens=1024, max_tokens=1024,
) ))
prompt = ( prompt = (
f"Tool '{tool_name}' returned this data:\n\n{text}\n\n" f"Tool '{tool_name}' returned this data:\n\n{text}\n\n"
"Summarize this data in a concise, human-readable format. " "Summarize this data in a concise, human-readable format. "

View File

@@ -0,0 +1,70 @@
# backend/src/agent/_llm_params.py
# #region AgentChat.LlmParams [C:3] [TYPE Module] [SEMANTICS agent-chat,llm,openai,compatibility]
# @defgroup AgentChat Shared LLM parameter compatibility helpers.
# @LAYER Service
# @BRIEF Build provider-safe ChatOpenAI kwargs and raw OpenAI payloads.
# @POST Unsupported sampling parameters are omitted for reasoning/codex models.
# @RATIONALE Some OpenAI-compatible gateways reject temperature for reasoning/codex
# models. Centralising the guard prevents resume, health-check, and title
# generation paths from diverging.
from typing import Any
_TEMPERATURE_UNSUPPORTED_PREFIXES = (
"codex/",
"omni/codex/",
"gpt-5",
"o1",
"o3",
"o4",
)
def _canonical_model_name(model: str | None) -> str:
"""Return provider-stripped, lowercase model name for compatibility checks."""
name = (model or "").strip().lower()
if name.startswith(("codex/", "omni/codex/")):
return name
if "/" in name:
return name.rsplit("/", 1)[-1]
return name
def supports_temperature(model: str | None) -> bool:
"""Return False for model families whose APIs reject temperature."""
name = _canonical_model_name(model)
return not any(name.startswith(prefix) for prefix in _TEMPERATURE_UNSUPPORTED_PREFIXES)
def chat_openai_kwargs(
*,
model: str,
base_url: str | None,
api_key: str,
max_tokens: int,
temperature: float = 0,
) -> dict[str, Any]:
"""Build ChatOpenAI kwargs without unsupported temperature for reasoning models."""
kwargs: dict[str, Any] = {
"model": model,
"base_url": base_url,
"api_key": api_key,
"max_tokens": max_tokens,
}
if supports_temperature(model):
kwargs["temperature"] = temperature
return kwargs
def add_temperature_if_supported(
payload: dict[str, Any],
*,
model: str | None,
temperature: float = 0,
) -> dict[str, Any]:
"""Mutate and return payload with temperature only when the model supports it."""
if supports_temperature(model):
payload["temperature"] = temperature
return payload
# #endregion AgentChat.LlmParams

View File

@@ -16,6 +16,7 @@ import uuid
import httpx import httpx
from src.agent._config import AGENT_PREFETCH_DASHBOARD_LIMIT as _PREFETCH_LIMIT, FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT from src.agent._config import AGENT_PREFETCH_DASHBOARD_LIMIT as _PREFETCH_LIMIT, FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT
from src.agent._llm_params import add_temperature_if_supported
from src.core.logger import logger from src.core.logger import logger
SAVE_API_URL = FASTAPI_URL + "/api/agent/conversations/save" SAVE_API_URL = FASTAPI_URL + "/api/agent/conversations/save"
@@ -195,8 +196,8 @@ async def _call_llm_for_title(user_text: str) -> str | None:
"model": model, "model": model,
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
"max_tokens": 15, "max_tokens": 15,
"temperature": 0,
} }
add_temperature_if_supported(payload, model=model)
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -34,6 +34,7 @@ from langchain_openai import ChatOpenAI
from openai import APIConnectionError, APITimeoutError, AuthenticationError from openai import APIConnectionError, APITimeoutError, AuthenticationError
from src.agent._config import GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, STORAGE_ROOT as _STORAGE_ROOT from src.agent._config import GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, STORAGE_ROOT as _STORAGE_ROOT
from src.agent._llm_params import chat_openai_kwargs
from src.agent._confirmation import ( from src.agent._confirmation import (
_pending_confirmations, _pending_confirmations,
confirmation_payload, confirmation_payload,
@@ -216,13 +217,12 @@ async def _check_llm_provider_health() -> str:
if not config or not config.get("configured"): if not config or not config.get("configured"):
return _llm_status["status"] return _llm_status["status"]
llm = ChatOpenAI( llm = ChatOpenAI(**chat_openai_kwargs(
model=config.get("default_model", "gpt-4o-mini"), model=config.get("default_model", "gpt-4o-mini"),
base_url=config.get("base_url", "https://api.openai.com/v1"), base_url=config.get("base_url", "https://api.openai.com/v1"),
api_key=config.get("api_key", ""), api_key=config.get("api_key", ""),
temperature=0,
max_tokens=1, max_tokens=1,
) ))
await llm.ainvoke([HumanMessage(content="ping")]) await llm.ainvoke([HumanMessage(content="ping")])
_llm_status["status"] = "ok" _llm_status["status"] = "ok"
_llm_status["last_error"] = "" _llm_status["last_error"] = ""
@@ -244,6 +244,11 @@ async def _check_llm_provider_health() -> str:
_llm_status["last_check_ts"] = time.time() _llm_status["last_check_ts"] = time.time()
return "auth_error" return "auth_error"
except Exception as exc: except Exception as exc:
if "usage_metadata.total_tokens" in str(exc):
_llm_status["status"] = "ok"
_llm_status["last_error"] = ""
_llm_status["last_check_ts"] = time.time()
return "ok"
logger.explore("LLM health check failed", logger.explore("LLM health check failed",
error=str(exc), error=str(exc),
extra={"src": "AgentChat.GradioApp.LlmHealthCheck"}) extra={"src": "AgentChat.GradioApp.LlmHealthCheck"})
@@ -535,11 +540,24 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
if kind == "on_chat_model_stream": if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"] chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content: if hasattr(chunk, "content") and chunk.content:
content = chunk.content
if isinstance(content, str):
token_text = content
elif isinstance(content, list):
token_text = "".join(
str(item.get("text") or item.get("content") or "")
if isinstance(item, dict) else str(item)
for item in content
)
else:
token_text = str(content)
if not token_text:
continue
emitted_any = True emitted_any = True
assistant_parts.append(chunk.content) assistant_parts.append(token_text)
yield json.dumps({ yield json.dumps({
"content": chunk.content, "content": token_text,
"metadata": {"type": "stream_token", "token": chunk.content}, "metadata": {"type": "stream_token", "token": token_text},
}) })
elif kind == "on_tool_start": elif kind == "on_tool_start":
tool_name = event["name"] tool_name = event["name"]
@@ -677,14 +695,15 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"content": f"❌ Ошибка: {exc}", "content": f"❌ Ошибка: {exc}",
"metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)}, "metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)},
}) })
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(assistant_parts)) await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(str(part) for part in assistant_parts))
return return
await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(assistant_parts)) assistant_text = "".join(str(part) for part in assistant_parts)
await save_conversation(conv_id, visible_user_text, user_id, assistant_text=assistant_text)
await _generate_title_best_effort(conv_id, visible_user_text) await _generate_title_best_effort(conv_id, visible_user_text)
logger.reflect( logger.reflect(
"Agent handler completed", "Agent handler completed",
payload={"conv_id": conv_id, "assistant_len": len("".join(assistant_parts))}, payload={"conv_id": conv_id, "assistant_len": len(assistant_text)},
extra={"src": "AgentChat.GradioApp.Handler"}, extra={"src": "AgentChat.GradioApp.Handler"},
) )

View File

@@ -21,6 +21,7 @@ from langgraph.prebuilt import create_react_agent
from psycopg.rows import dict_row from psycopg.rows import dict_row
from src.agent._config import FASTAPI_URL, AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE from src.agent._config import FASTAPI_URL, AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE
from src.agent._llm_params import chat_openai_kwargs
from src.core.logger import logger from src.core.logger import logger
# ── Monkey-patch: OpenAI SDK for Pydantic BaseModel classes ── # ── Monkey-patch: OpenAI SDK for Pydantic BaseModel classes ──
@@ -167,13 +168,12 @@ async def create_agent(
extra={"src": "AgentChat.LangGraph.Setup"}, extra={"src": "AgentChat.LangGraph.Setup"},
) )
llm = ChatOpenAI( llm = ChatOpenAI(**chat_openai_kwargs(
model=model, model=model,
base_url=base_url, base_url=base_url,
api_key=api_key, api_key=api_key,
temperature=0,
max_tokens=2048, max_tokens=2048,
) ))
# System prompt — env_id injected deterministically, not in user message # System prompt — env_id injected deterministically, not in user message
prompt = ( prompt = (

View File

@@ -9,6 +9,12 @@ from unittest.mock import MagicMock
from fastapi import HTTPException from fastapi import HTTPException
from src.api.routes import git as git_routes from src.api.routes import git as git_routes
from src.api.routes.git import _helpers as git_helpers
from src.services.git._sync import GitServiceSyncMixin
async def _resolved_dashboard_id(*_args, **_kwargs):
return 12
# #region test_get_repository_status_returns_no_repo_payload_for_missing_repo [TYPE Function] # #region test_get_repository_status_returns_no_repo_payload_for_missing_repo [TYPE Function]
@@ -18,9 +24,9 @@ from src.api.routes import git as git_routes
# @POST Route returns a deterministic NO_REPO status payload. # @POST Route returns a deterministic NO_REPO status payload.
def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypatch): def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypatch):
class MissingRepoGitService: class MissingRepoGitService:
def _get_repo_path(self, dashboard_id: int) -> str: async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/missing-repo-{dashboard_id}" return f"/tmp/missing-repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict: async def get_status(self, dashboard_id: int) -> dict:
raise AssertionError("get_status must not be called when repository path is missing") raise AssertionError("get_status must not be called when repository path is missing")
monkeypatch.setattr(git_routes, "git_service", MissingRepoGitService()) monkeypatch.setattr(git_routes, "git_service", MissingRepoGitService())
response = asyncio.run(git_routes.get_repository_status(34)) response = asyncio.run(git_routes.get_repository_status(34))
@@ -36,9 +42,9 @@ def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypa
# @POST Raised exception preserves original status and detail. # @POST Raised exception preserves original status and detail.
def test_get_repository_status_propagates_non_404_http_exception(monkeypatch): def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
class ConflictGitService: class ConflictGitService:
def _get_repo_path(self, dashboard_id: int) -> str: async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/existing-repo-{dashboard_id}" return f"/tmp/existing-repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict: async def get_status(self, dashboard_id: int) -> dict:
raise HTTPException(status_code=409, detail="Conflict") raise HTTPException(status_code=409, detail="Conflict")
monkeypatch.setattr(git_routes, "git_service", ConflictGitService()) monkeypatch.setattr(git_routes, "git_service", ConflictGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True) monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
@@ -54,7 +60,7 @@ def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
# @POST Endpoint raises same HTTPException values. # @POST Endpoint raises same HTTPException values.
def test_get_repository_diff_propagates_http_exception(monkeypatch): def test_get_repository_diff_propagates_http_exception(monkeypatch):
class DiffGitService: class DiffGitService:
def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str: async def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str:
raise HTTPException(status_code=404, detail="Repository missing") raise HTTPException(status_code=404, detail="Repository missing")
monkeypatch.setattr(git_routes, "git_service", DiffGitService()) monkeypatch.setattr(git_routes, "git_service", DiffGitService())
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
@@ -69,7 +75,7 @@ def test_get_repository_diff_propagates_http_exception(monkeypatch):
# @POST Endpoint returns HTTPException with status 500 and route context. # @POST Endpoint returns HTTPException with status 500 and route context.
def test_get_history_wraps_unexpected_error_as_500(monkeypatch): def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
class HistoryGitService: class HistoryGitService:
def get_commit_history(self, dashboard_id: int, limit: int = 50): async def get_commit_history(self, dashboard_id: int, limit: int = 50):
raise ValueError("broken parser") raise ValueError("broken parser")
monkeypatch.setattr(git_routes, "git_service", HistoryGitService()) monkeypatch.setattr(git_routes, "git_service", HistoryGitService())
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
@@ -84,7 +90,7 @@ def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
# @POST Endpoint raises HTTPException(500) with route context. # @POST Endpoint raises HTTPException(500) with route context.
def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch): def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
class CommitGitService: class CommitGitService:
def commit_changes(self, dashboard_id: int, message: str, files): async def commit_changes(self, dashboard_id: int, message: str, files):
raise RuntimeError("index lock") raise RuntimeError("index lock")
class CommitPayload: class CommitPayload:
message = "test" message = "test"
@@ -102,9 +108,9 @@ def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
# @POST Returned map includes resolved status for each requested dashboard ID. # @POST Returned map includes resolved status for each requested dashboard ID.
def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch): def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
class BatchGitService: class BatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str: async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}" return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict: async def get_status(self, dashboard_id: int) -> dict:
if dashboard_id == 2: if dashboard_id == 2:
return {"sync_state": "SYNCED", "sync_status": "OK"} return {"sync_state": "SYNCED", "sync_status": "OK"}
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
@@ -123,9 +129,9 @@ def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
# @POST Failed dashboard status is marked as ERROR. # @POST Failed dashboard status is marked as ERROR.
def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monkeypatch): def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monkeypatch):
class BatchErrorGitService: class BatchErrorGitService:
def _get_repo_path(self, dashboard_id: int) -> str: async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}" return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict: async def get_status(self, dashboard_id: int) -> dict:
raise RuntimeError("boom") raise RuntimeError("boom")
monkeypatch.setattr(git_routes, "git_service", BatchErrorGitService()) monkeypatch.setattr(git_routes, "git_service", BatchErrorGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True) monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
@@ -142,9 +148,9 @@ def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monk
# @POST Result contains unique IDs up to configured cap. # @POST Result contains unique IDs up to configured cap.
def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch): def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch):
class SafeBatchGitService: class SafeBatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str: async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}" return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict: async def get_status(self, dashboard_id: int) -> dict:
return {"sync_state": "SYNCED", "sync_status": "OK"} return {"sync_state": "SYNCED", "sync_status": "OK"}
monkeypatch.setattr(git_routes, "git_service", SafeBatchGitService()) monkeypatch.setattr(git_routes, "git_service", SafeBatchGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True) monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
@@ -164,9 +170,9 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
def __init__(self): def __init__(self):
self.configured_identity = None self.configured_identity = None
self.commit_payload = None self.commit_payload = None
def configure_identity(self, dashboard_id: int, git_username: str, git_email: str): async def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email) self.configured_identity = (dashboard_id, git_username, git_email)
def commit_changes(self, dashboard_id: int, message: str, files): async def commit_changes(self, dashboard_id: int, message: str, files):
self.commit_payload = (dashboard_id, message, files) self.commit_payload = (dashboard_id, message, files)
class PreferenceRow: class PreferenceRow:
git_username = "user_1" git_username = "user_1"
@@ -189,7 +195,7 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
git_routes, git_routes,
"_resolve_dashboard_id_from_ref", "_resolve_dashboard_id_from_ref",
lambda *_args, **_kwargs: 12, _resolved_dashboard_id,
) )
asyncio.run( asyncio.run(
git_routes.commit_changes( git_routes.commit_changes(
@@ -213,10 +219,10 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
def __init__(self): def __init__(self):
self.configured_identity = None self.configured_identity = None
self.pulled_dashboard_id = None self.pulled_dashboard_id = None
def configure_identity(self, dashboard_id: int, git_username: str, git_email: str): async def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email) self.configured_identity = (dashboard_id, git_username, git_email)
def pull_changes(self, dashboard_id: int): async def pull_changes(self, dashboard_id: int, pat: str | None = None):
self.pulled_dashboard_id = dashboard_id self.pulled_payload = (dashboard_id, pat)
class PreferenceRow: class PreferenceRow:
git_username = "user_1" git_username = "user_1"
git_email = "user1@mail.ru" git_email = "user1@mail.ru"
@@ -235,7 +241,7 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
git_routes, git_routes,
"_resolve_dashboard_id_from_ref", "_resolve_dashboard_id_from_ref",
lambda *_args, **_kwargs: 12, _resolved_dashboard_id,
) )
asyncio.run( asyncio.run(
git_routes.pull_changes( git_routes.pull_changes(
@@ -246,8 +252,127 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
) )
) )
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru") assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
assert identity_service.pulled_dashboard_id == 12 assert identity_service.pulled_payload == (12, None)
# #endregion test_pull_changes_applies_profile_identity_before_pull # #endregion test_pull_changes_applies_profile_identity_before_pull
# #region test_push_changes_passes_decrypted_profile_pat [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure push route decrypts current user's profile PAT and passes it to GitService.
# @PRE Profile preference contains encrypted git_personal_access_token.
# @POST git_service.push_changes receives the decrypted token via pat keyword.
def test_push_changes_passes_decrypted_profile_pat(monkeypatch):
class PushGitService:
def __init__(self):
self.pushed_payload = None
async def push_changes(self, dashboard_id: int, pat: str | None = None):
self.pushed_payload = (dashboard_id, pat)
class PreferenceRow:
git_personal_access_token_encrypted = "encrypted-token"
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 EncryptionStub:
def decrypt(self, encrypted_data: str) -> str:
assert encrypted_data == "encrypted-token"
return "profile-pat"
push_service = PushGitService()
monkeypatch.setattr(git_routes, "git_service", push_service)
monkeypatch.setattr(git_helpers, "EncryptionManager", lambda: EncryptionStub())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
asyncio.run(
git_routes.push_changes(
"dashboard-12",
config_manager=MagicMock(),
db=DbStub(),
current_user=UserStub(),
)
)
assert push_service.pushed_payload == (12, "profile-pat")
# #endregion test_push_changes_passes_decrypted_profile_pat
# #region test_pull_changes_passes_decrypted_profile_pat [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure pull route decrypts current user's profile PAT and passes it to GitService.
# @PRE Profile preference contains git identity and encrypted PAT.
# @POST git_service.pull_changes receives decrypted token after identity configuration.
def test_pull_changes_passes_decrypted_profile_pat(monkeypatch):
class PullGitService:
def __init__(self):
self.configured_identity = None
self.pulled_payload = None
async def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email)
async def pull_changes(self, dashboard_id: int, pat: str | None = None):
self.pulled_payload = (dashboard_id, pat)
class PreferenceRow:
git_username = "user_1"
git_email = "user1@mail.ru"
git_personal_access_token_encrypted = "encrypted-token"
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 EncryptionStub:
def decrypt(self, encrypted_data: str) -> str:
assert encrypted_data == "encrypted-token"
return "profile-pat"
pull_service = PullGitService()
monkeypatch.setattr(git_routes, "git_service", pull_service)
monkeypatch.setattr(git_helpers, "EncryptionManager", lambda: EncryptionStub())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
asyncio.run(
git_routes.pull_changes(
"dashboard-12",
config_manager=MagicMock(),
db=DbStub(),
current_user=UserStub(),
)
)
assert pull_service.configured_identity == (12, "user_1", "user1@mail.ru")
assert pull_service.pulled_payload == (12, "profile-pat")
# #endregion test_pull_changes_passes_decrypted_profile_pat
# #region test_git_sync_mixin_embeds_and_redacts_pat [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure PAT embedding URL-encodes secrets and redaction removes them from messages.
# @PRE Origin remote uses an HTTPS URL and PAT contains URL-sensitive characters.
# @POST Origin URL receives encoded PAT; redaction hides raw and URL-shaped tokens.
def test_git_sync_mixin_embeds_and_redacts_pat():
class OriginStub:
def __init__(self):
self.urls = ["https://github.com/org/repo.git"]
self.set_urls = []
def set_url(self, url: str):
self.set_urls.append(url)
self.urls = [url]
class RepoStub:
def __init__(self):
self.origin = OriginStub()
def remote(self, name: str):
assert name == "origin"
return self.origin
repo = RepoStub()
original_url = GitServiceSyncMixin._embed_pat_in_origin_url(repo, "tok@:/secret")
assert original_url == "https://github.com/org/repo.git"
assert repo.origin.set_urls == ["https://git-user:tok%40%3A%2Fsecret@github.com/org/repo.git"]
redacted = GitServiceSyncMixin._redact_pat_from_message(
"fatal https://git-user:tok%40%3A%2Fsecret@github.com/org/repo.git tok@:/secret",
"tok@:/secret",
)
assert "tok@:/secret" not in redacted
assert "tok%40%3A%2Fsecret" not in redacted
assert "***" in redacted
# #endregion test_git_sync_mixin_embeds_and_redacts_pat
# #region test_get_merge_status_returns_service_payload [TYPE Function] # #region test_get_merge_status_returns_service_payload [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute # @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure merge status route returns service payload as-is. # @BRIEF Ensure merge status route returns service payload as-is.
@@ -255,7 +380,7 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
# @POST Route response contains has_unfinished_merge=True. # @POST Route response contains has_unfinished_merge=True.
def test_get_merge_status_returns_service_payload(monkeypatch): def test_get_merge_status_returns_service_payload(monkeypatch):
class MergeStatusGitService: class MergeStatusGitService:
def get_merge_status(self, dashboard_id: int) -> dict: async def get_merge_status(self, dashboard_id: int) -> dict:
return { return {
"has_unfinished_merge": True, "has_unfinished_merge": True,
"repository_path": "/tmp/repo-12", "repository_path": "/tmp/repo-12",
@@ -266,7 +391,7 @@ def test_get_merge_status_returns_service_payload(monkeypatch):
"conflicts_count": 2, "conflicts_count": 2,
} }
monkeypatch.setattr(git_routes, "git_service", MergeStatusGitService()) monkeypatch.setattr(git_routes, "git_service", MergeStatusGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12) monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run( response = asyncio.run(
git_routes.get_merge_status( git_routes.get_merge_status(
"dashboard-12", "dashboard-12",
@@ -284,7 +409,7 @@ def test_get_merge_status_returns_service_payload(monkeypatch):
def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch): def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch):
captured = {} captured = {}
class MergeResolveGitService: class MergeResolveGitService:
def resolve_merge_conflicts(self, dashboard_id: int, resolutions): async def resolve_merge_conflicts(self, dashboard_id: int, resolutions):
captured["dashboard_id"] = dashboard_id captured["dashboard_id"] = dashboard_id
captured["resolutions"] = resolutions captured["resolutions"] = resolutions
return ["dashboards/a.yaml"] return ["dashboards/a.yaml"]
@@ -294,7 +419,7 @@ def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch)
return {"file_path": "dashboards/a.yaml", "resolution": "mine", "content": None} return {"file_path": "dashboards/a.yaml", "resolution": "mine", "content": None}
resolutions = [_Resolution()] resolutions = [_Resolution()]
monkeypatch.setattr(git_routes, "git_service", MergeResolveGitService()) monkeypatch.setattr(git_routes, "git_service", MergeResolveGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12) monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run( response = asyncio.run(
git_routes.resolve_merge_conflicts( git_routes.resolve_merge_conflicts(
"dashboard-12", "dashboard-12",
@@ -313,11 +438,11 @@ def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch)
# @POST Route returns aborted status. # @POST Route returns aborted status.
def test_abort_merge_calls_service_and_returns_result(monkeypatch): def test_abort_merge_calls_service_and_returns_result(monkeypatch):
class AbortGitService: class AbortGitService:
def abort_merge(self, dashboard_id: int): async def abort_merge(self, dashboard_id: int):
assert dashboard_id == 12 assert dashboard_id == 12
return {"status": "aborted"} return {"status": "aborted"}
monkeypatch.setattr(git_routes, "git_service", AbortGitService()) monkeypatch.setattr(git_routes, "git_service", AbortGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12) monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run( response = asyncio.run(
git_routes.abort_merge( git_routes.abort_merge(
"dashboard-12", "dashboard-12",
@@ -333,14 +458,14 @@ def test_abort_merge_calls_service_and_returns_result(monkeypatch):
# @POST Route returns committed status and hash. # @POST Route returns committed status and hash.
def test_continue_merge_passes_message_and_returns_commit(monkeypatch): def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
class ContinueGitService: class ContinueGitService:
def continue_merge(self, dashboard_id: int, message: str): async def continue_merge(self, dashboard_id: int, message: str):
assert dashboard_id == 12 assert dashboard_id == 12
assert message == "Resolve all conflicts" assert message == "Resolve all conflicts"
return {"status": "committed", "commit_hash": "abc123"} return {"status": "committed", "commit_hash": "abc123"}
class ContinueData: class ContinueData:
message = "Resolve all conflicts" message = "Resolve all conflicts"
monkeypatch.setattr(git_routes, "git_service", ContinueGitService()) monkeypatch.setattr(git_routes, "git_service", ContinueGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12) monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run( response = asyncio.run(
git_routes.continue_merge( git_routes.continue_merge(
"dashboard-12", "dashboard-12",

View File

@@ -17,11 +17,13 @@ router = APIRouter(prefix="/api/agent/superset", tags=["Agent Superset"])
async def _get_superset_client(environment_id: str) -> SupersetClient: async def _get_superset_client(environment_id: str) -> SupersetClient:
"""Resolve a SupersetClient for the given environment_id.""" """Resolve a SupersetClient for the given environment_id."""
from ..api.routes.environments import _get_environment_by_id as _resolve_env from src.dependencies import get_config_manager
env_data = await _resolve_env(environment_id)
if not env_data: env = get_config_manager().get_environment(environment_id)
if env is None:
raise HTTPException(status_code=404, detail=f"Environment '{environment_id}' not found.") raise HTTPException(status_code=404, detail=f"Environment '{environment_id}' not found.")
env = Environment(**env_data) if not isinstance(env, Environment):
env = Environment.model_validate(env)
return SupersetClient(env) return SupersetClient(env)

View File

@@ -56,6 +56,25 @@ class DatabaseResponse(BaseModel):
engine: str | None engine: str | None
# #endregion DatabaseResponse # #endregion DatabaseResponse
# #region _get_environment_by_id [TYPE Function]
# @BRIEF Legacy resolver for agent Superset routes that still import environment by id.
# @PRE Environment id can be an id or configured environment name.
# @POST Returns full Environment-compatible dict or None when missing.
# @RATIONALE Keeps hot-reloaded/stale agent route code from failing while the direct
# ConfigManager resolver path is deployed in agent_superset_explore.py.
async def _get_environment_by_id(environment_id: str) -> dict | None:
env = get_config_manager().get_environment(environment_id)
if env is None:
return None
if hasattr(env, "model_dump"):
return env.model_dump()
if hasattr(env, "dict"):
return env.dict()
return dict(env)
# #endregion _get_environment_by_id
# #region get_environments [TYPE Function] [SEMANTICS list, environments, config] # #region get_environments [TYPE Function] [SEMANTICS list, environments, config]
# @ingroup Api # @ingroup Api
# @BRIEF List all configured environments. # @BRIEF List all configured environments.

View File

@@ -11,6 +11,7 @@ import os
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.encryption import EncryptionManager
from src.core.logger import logger from src.core.logger import logger
from src.core.superset_client import SupersetClient from src.core.superset_client import SupersetClient
from src.models.auth import User from src.models.auth import User
@@ -313,6 +314,51 @@ def _resolve_current_user_git_identity(
# #endregion _resolve_current_user_git_identity # #endregion _resolve_current_user_git_identity
# #region _resolve_current_user_git_token [C:2] [TYPE Function]
# @BRIEF Resolve and decrypt the Git personal access token from current user's profile preferences.
def _resolve_current_user_git_token(
db: Session,
current_user: User | None,
) -> str | None:
if db is None or not hasattr(db, "query"):
return None
user_id = _sanitize_optional_identity_value(getattr(current_user, "id", None))
if not user_id:
return None
try:
preference = (
db.query(UserDashboardPreference)
.filter(UserDashboardPreference.user_id == user_id)
.first()
)
except Exception as resolve_error:
logger.explore(
"Failed to load profile preference for resolving git PAT",
extra={"src": "_resolve_current_user_git_token", "payload": {"user_id": user_id}, "error": str(resolve_error)},
)
return None
if not preference:
return None
encrypted_token = getattr(preference, "git_personal_access_token_encrypted", None)
if not encrypted_token:
return None
try:
encryption = EncryptionManager()
return encryption.decrypt(encrypted_token)
except Exception as decrypt_error:
logger.explore(
"Failed to decrypt git PAT from profile",
extra={"src": "_resolve_current_user_git_token", "user_id": user_id, "error": str(decrypt_error)},
)
return None
# #endregion _resolve_current_user_git_token
# #region _apply_git_identity_from_profile [C:2] [TYPE Function] # #region _apply_git_identity_from_profile [C:2] [TYPE Function]
# @BRIEF Apply user-scoped Git identity to repository-local config before write/pull operations. # @BRIEF Apply user-scoped Git identity to repository-local config before write/pull operations.
async def _apply_git_identity_from_profile( async def _apply_git_identity_from_profile(

View File

@@ -25,6 +25,7 @@ from ._helpers import (
_apply_git_identity_from_profile, _apply_git_identity_from_profile,
_build_no_repo_status_payload, _build_no_repo_status_payload,
_handle_unexpected_git_route_error, _handle_unexpected_git_route_error,
_resolve_current_user_git_token,
_resolve_repository_status, _resolve_repository_status,
) )
from ._router import router from ._router import router
@@ -105,6 +106,8 @@ async def push_changes(
dashboard_ref: str, dashboard_ref: str,
env_id: str | None = None, env_id: str | None = None,
config_manager=Depends(get_config_manager), config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")), _=Depends(has_permission("plugin:git", "EXECUTE")),
): ):
_gs = get_git_service() _gs = get_git_service()
@@ -115,7 +118,8 @@ async def push_changes(
dashboard_id = await _resolve_dashboard_id_from_ref( dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id dashboard_ref, config_manager, env_id
) )
await _gs.push_changes(dashboard_id) pat = _resolve_current_user_git_token(db, current_user)
await _gs.push_changes(dashboard_id, pat=pat)
return {"status": "success"} return {"status": "success"}
except HTTPException: except HTTPException:
raise raise
@@ -178,7 +182,8 @@ async def pull_changes(
extra={"src": "pull_changes"}, extra={"src": "pull_changes"},
) )
await _apply_git_identity_from_profile(dashboard_id, db, current_user) await _apply_git_identity_from_profile(dashboard_id, db, current_user)
await _gs.pull_changes(dashboard_id) pat = _resolve_current_user_git_token(db, current_user)
await _gs.pull_changes(dashboard_id, pat=pat)
return {"status": "success"} return {"status": "success"}
except HTTPException: except HTTPException:
raise raise

View File

@@ -6,6 +6,7 @@
# @RELATION DEPENDS_ON -> [GitServiceUrlMixin] # @RELATION DEPENDS_ON -> [GitServiceUrlMixin]
import os import os
import re
from fastapi import HTTPException from fastapi import HTTPException
from git.exc import GitCommandError from git.exc import GitCommandError
@@ -19,11 +20,49 @@ from src.models.git import GitRepository, GitServerConfig
# @defgroup Services Module group. # @defgroup Services Module group.
# @BRIEF Mixin providing push and pull operations with origin host alignment. # @BRIEF Mixin providing push and pull operations with origin host alignment.
class GitServiceSyncMixin: class GitServiceSyncMixin:
@staticmethod
def _redact_pat_from_message(message: str, pat: str | None = None) -> str:
redacted = str(message or "")
if pat:
redacted = redacted.replace(pat, "***")
return re.sub(r"(https?://[^\s:/@]+:)[^\s@]+(@)", r"\1***\2", redacted)
# region push_changes [C:4] [TYPE Function] [SEMANTICS git,push,lock] # region push_changes [C:4] [TYPE Function] [SEMANTICS git,push,lock]
# @PURPOSE: Push local commits to remote (concurrent-safe). # @PURPOSE: Push local commits to remote (concurrent-safe).
# @PRE Repository exists and has an 'origin' remote. # @PRE Repository exists and has an 'origin' remote.
# @POST Local branch commits are pushed to origin. # @POST Local branch commits are pushed to origin.
async def push_changes(self, dashboard_id: int): # region _embed_pat_in_origin_url [C:2] [TYPE Function] [SEMANTICS git,auth,pat,url]
# @BRIEF Temporarily embed a personal access token into the origin remote URL.
# @POST Origin URL is updated with embedded PAT; returns the original URL for restoration.
@staticmethod
def _embed_pat_in_origin_url(repo, pat: str) -> str | None:
from urllib.parse import urlparse, urlunparse, quote
try:
origin = repo.remote(name="origin")
original_url = list(origin.urls)[0]
except Exception:
return None
parsed = urlparse(original_url)
if parsed.scheme not in ("https", "http"):
return None
username = parsed.username or "git-user"
encoded_pat = quote(pat, safe="")
auth_part = f"{quote(username, safe='')}:{encoded_pat}@"
host_part = parsed.hostname or ""
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
new_url = urlunparse(
(parsed.scheme, f"{auth_part}{host_part}", parsed.path, parsed.params, parsed.query, parsed.fragment)
)
try:
origin.set_url(new_url)
return original_url
except Exception:
return None
# endregion _embed_pat_in_origin_url
async def push_changes(self, dashboard_id: int, pat: str | None = None):
with self._locked(dashboard_id): with self._locked(dashboard_id):
with belief_scope("GitService.push_changes"): with belief_scope("GitService.push_changes"):
repo = await self.get_repo(dashboard_id) repo = await self.get_repo(dashboard_id)
@@ -82,42 +121,56 @@ class GitServiceSyncMixin:
f"Push diagnostics dashboard={dashboard_id} config_id={binding_config_id} config_url={binding_config_url} binding_remote_url={binding_remote_url} origin_urls={origin_urls} origin_realigned={bool(realigned_origin_url)}", f"Push diagnostics dashboard={dashboard_id} config_id={binding_config_id} config_url={binding_config_url} binding_remote_url={binding_remote_url} origin_urls={origin_urls} origin_realigned={bool(realigned_origin_url)}",
extra={"src": "push_changes"}, extra={"src": "push_changes"},
) )
# ── Embed PAT credential if provided ──────────────────────────────
_original_push_url: str | None = None
if pat:
_original_push_url = self._embed_pat_in_origin_url(repo, pat)
# ──────────────────────────────────────────────────────────────────
try: try:
current_branch = repo.active_branch
logger.reason(f"Pushing branch {current_branch.name} to origin", extra={"src": "push_changes"})
tracking_branch = None
try: try:
tracking_branch = current_branch.tracking_branch() current_branch = repo.active_branch
except Exception: logger.reason(f"Pushing branch {current_branch.name} to origin", extra={"src": "push_changes"})
tracking_branch = None tracking_branch = None
if tracking_branch is None: try:
repo.git.push("--set-upstream", "origin", f"{current_branch.name}:{current_branch.name}") tracking_branch = current_branch.tracking_branch()
else: except Exception:
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}') tracking_branch = None
for info in push_info: if tracking_branch is None:
if info.flags & info.ERROR: repo.git.push("--set-upstream", "origin", f"{current_branch.name}:{current_branch.name}")
logger.explore("Error pushing ref", extra={"src": "push_changes"}, payload={"ref": info.remote_ref_string}, error=str(info.summary)) else:
raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}") push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
except GitCommandError as e: for info in push_info:
details = str(e) if info.flags & info.ERROR:
lowered = details.lower() safe_summary = self._redact_pat_from_message(str(info.summary), pat)
if "non-fast-forward" in lowered or "rejected" in lowered: logger.explore("Error pushing ref", extra={"src": "push_changes"}, payload={"ref": info.remote_ref_string}, error=safe_summary)
raise HTTPException( raise Exception(f"Git push error for {info.remote_ref_string}: {safe_summary}")
status_code=409, except GitCommandError as e:
detail="Push rejected: remote branch contains newer commits. Run Pull first, resolve conflicts if any, then push again.", details = self._redact_pat_from_message(str(e), pat)
) lowered = details.lower()
logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=str(e)) if "non-fast-forward" in lowered or "rejected" in lowered:
raise HTTPException(status_code=500, detail=f"Git push failed: {details}") raise HTTPException(
except Exception as e: status_code=409,
logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=str(e)) detail="Push rejected: remote branch contains newer commits. Run Pull first, resolve conflicts if any, then push again.",
raise HTTPException(status_code=500, detail=f"Git push failed: {e!s}") )
logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=details)
raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
except Exception as e:
details = self._redact_pat_from_message(str(e), pat)
logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=details)
raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
finally:
if _original_push_url is not None:
try:
origin.set_url(_original_push_url)
except Exception:
pass
# endregion push_changes # endregion push_changes
# region pull_changes [C:4] [TYPE Function] [SEMANTICS git,pull,lock] # region pull_changes [C:4] [TYPE Function] [SEMANTICS git,pull,lock]
# @PURPOSE: Pull changes from remote (concurrent-safe). # @PURPOSE: Pull changes from remote (concurrent-safe).
# @PRE Repository exists and has an 'origin' remote. # @PRE Repository exists and has an 'origin' remote.
# @POST Changes from origin are pulled and merged into the active branch. # @POST Changes from origin are pulled and merged into the active branch.
async def pull_changes(self, dashboard_id: int): async def pull_changes(self, dashboard_id: int, pat: str | None = None):
with self._locked(dashboard_id): with self._locked(dashboard_id):
with belief_scope("GitService.pull_changes"): with belief_scope("GitService.pull_changes"):
repo = await self.get_repo(dashboard_id) repo = await self.get_repo(dashboard_id)
@@ -129,49 +182,63 @@ class GitServiceSyncMixin:
extra={"src": "pull_changes"}, extra={"src": "pull_changes"},
) )
raise HTTPException(status_code=409, detail=payload) raise HTTPException(status_code=409, detail=payload)
# ── Embed PAT credential if provided ──────────────────────────────
_original_pull_url: str | None = None
if pat:
_original_pull_url = self._embed_pat_in_origin_url(repo, pat)
# ──────────────────────────────────────────────────────────────────
origin = None
try: try:
origin = repo.remote(name='origin')
current_branch = repo.active_branch.name
try: try:
origin_urls = list(origin.urls) origin = repo.remote(name='origin')
except Exception: current_branch = repo.active_branch.name
origin_urls = [] try:
logger.reason( origin_urls = [self._redact_pat_from_message(url, pat) for url in origin.urls]
f"Pull diagnostics dashboard={dashboard_id} repo_path={repo.working_tree_dir} branch={current_branch} origin_urls={origin_urls}", except Exception:
extra={"src": "pull_changes"}, origin_urls = []
) logger.reason(
origin.fetch(prune=True) f"Pull diagnostics dashboard={dashboard_id} repo_path={repo.working_tree_dir} branch={current_branch} origin_urls={origin_urls}",
remote_ref = f"origin/{current_branch}" extra={"src": "pull_changes"},
has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
logger.reason(
f"Pull remote branch check dashboard={dashboard_id} branch={current_branch} remote_ref={remote_ref} exists={has_remote_branch}",
extra={"src": "pull_changes"},
)
if not has_remote_branch:
raise HTTPException(
status_code=409,
detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.",
) )
logger.reason(f"Pulling changes from origin/{current_branch}", extra={"src": "pull_changes"}) origin.fetch(prune=True)
repo.git.pull("--no-rebase", "origin", current_branch) remote_ref = f"origin/{current_branch}"
except ValueError: has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
logger.explore("Remote 'origin' not found", extra={"src": "pull_changes"}, payload={"dashboard_id": dashboard_id}) logger.reason(
raise HTTPException(status_code=400, detail="Remote 'origin' not configured") f"Pull remote branch check dashboard={dashboard_id} branch={current_branch} remote_ref={remote_ref} exists={has_remote_branch}",
except GitCommandError as e: extra={"src": "pull_changes"},
details = str(e)
lowered = details.lower()
if "conflict" in lowered or "not possible to fast-forward" in lowered:
raise HTTPException(
status_code=409,
detail="Pull requires conflict resolution. Resolve conflicts in repository and repeat operation.",
) )
logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=str(e)) if not has_remote_branch:
raise HTTPException(status_code=500, detail=f"Git pull failed: {details}") raise HTTPException(
except HTTPException: status_code=409,
raise detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.",
except Exception as e: )
logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=str(e)) logger.reason(f"Pulling changes from origin/{current_branch}", extra={"src": "pull_changes"})
raise HTTPException(status_code=500, detail=f"Git pull failed: {e!s}") repo.git.pull("--no-rebase", "origin", current_branch)
except ValueError:
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 = self._redact_pat_from_message(str(e), pat)
lowered = details.lower()
if "conflict" in lowered or "not possible to fast-forward" in lowered:
raise HTTPException(
status_code=409,
detail="Pull requires conflict resolution. Resolve conflicts in repository and repeat operation.",
)
logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=details)
raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
except HTTPException:
raise
except Exception as e:
details = self._redact_pat_from_message(str(e), pat)
logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=details)
raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
finally:
if _original_pull_url is not None and origin is not None:
try:
origin.set_url(_original_pull_url)
except Exception:
pass
# endregion pull_changes # endregion pull_changes
# #endregion GitServiceSyncMixin # #endregion GitServiceSyncMixin
# #endregion GitServiceSyncMixin # #endregion GitServiceSyncMixin

View File

@@ -8,8 +8,6 @@
@LAYER UI @LAYER UI
@RELATION BINDS_TO -> [EXT:frontend:authStore] @RELATION BINDS_TO -> [EXT:frontend:authStore]
@RELATION BINDS_TO -> [EXT:frontend:i18n] @RELATION BINDS_TO -> [EXT:frontend:i18n]
@RELATION DEPENDS_ON -> [LanguageSwitcher]
@UX_STATE: Idle -> Navigation links and settings menus are visible. @UX_STATE: Idle -> Navigation links and settings menus are visible.
@UX_STATE: Authenticated -> User identity and logout action are rendered. @UX_STATE: Authenticated -> User identity and logout action are rendered.
--> -->
@@ -17,7 +15,6 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { t } from '$lib/i18n/index.svelte.js'; import { t } from '$lib/i18n/index.svelte.js';
import { LanguageSwitcher } from '$lib/ui';
import { auth } from '$lib/auth/store.svelte.js'; import { auth } from '$lib/auth/store.svelte.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { ROUTES } from '$lib/routes'; import { ROUTES } from '$lib/routes';
@@ -77,8 +74,6 @@
</div> </div>
</div> </div>
{/if} {/if}
<LanguageSwitcher />
{#if _authState.isAuthenticated} {#if _authState.isAuthenticated}
<div class="flex items-center space-x-2 border-l pl-4 ml-4"> <div class="flex items-center space-x-2 border-l pl-4 ml-4">
<span class="text-sm text-text-muted">{_authState.user?.username}</span> <span class="text-sm text-text-muted">{_authState.user?.username}</span>

View File

@@ -4,6 +4,9 @@
"dashboard_preferences": "Dashboard Preferences", "dashboard_preferences": "Dashboard Preferences",
"security_access": "Security & Access", "security_access": "Security & Access",
"read_only": "Read-only", "read_only": "Read-only",
"preferences": "Preferences",
"saved": "Preferences saved",
"language": "Language",
"security_read_only_note": "This section is read-only. Role changes are managed in Admin → Users.", "security_read_only_note": "This section is read-only. Role changes are managed in Admin → Users.",
"current_role": "Current Role", "current_role": "Current Role",
"role_source": "Role Source", "role_source": "Role Source",
@@ -43,6 +46,12 @@
"table_density_compact": "Compact", "table_density_compact": "Compact",
"table_density_comfortable": "Comfortable", "table_density_comfortable": "Comfortable",
"auto_open_task_drawer": "Automatically open task drawer for long-running tasks", "auto_open_task_drawer": "Automatically open task drawer for long-running tasks",
"notifications": "Notifications",
"notification_email": "Notification email",
"notification_email_placeholder": "Enter notification email",
"telegram_id": "Telegram ID",
"telegram_id_placeholder": "Enter Telegram ID",
"notify_on_fail": "Notify on validation failure",
"filter_badge_active": "Profile filters active", "filter_badge_active": "Profile filters active",
"filter_badge_override": "Showing all dashboards temporarily", "filter_badge_override": "Showing all dashboards temporarily",
"filter_empty_state": "No dashboards found for active profile filters. Try adjusting your filter settings.", "filter_empty_state": "No dashboards found for active profile filters. Try adjusting your filter settings.",

View File

@@ -4,6 +4,9 @@
"dashboard_preferences": "Настройки дашбордов", "dashboard_preferences": "Настройки дашбордов",
"security_access": "Безопасность и доступ", "security_access": "Безопасность и доступ",
"read_only": "Только чтение", "read_only": "Только чтение",
"preferences": "Настройки",
"saved": "Настройки сохранены",
"language": "Язык",
"security_read_only_note": "Этот раздел только для чтения. Изменение ролей выполняется в Админ → Users.", "security_read_only_note": "Этот раздел только для чтения. Изменение ролей выполняется в Админ → Users.",
"current_role": "Текущая роль", "current_role": "Текущая роль",
"role_source": "Источник роли", "role_source": "Источник роли",
@@ -43,6 +46,12 @@
"table_density_compact": "Компактная", "table_density_compact": "Компактная",
"table_density_comfortable": "Комфортная", "table_density_comfortable": "Комфортная",
"auto_open_task_drawer": "Автоматически открывать Task Drawer для долгих задач", "auto_open_task_drawer": "Автоматически открывать Task Drawer для долгих задач",
"notifications": "Уведомления",
"notification_email": "Email для уведомлений",
"notification_email_placeholder": "Введите email для уведомлений",
"telegram_id": "Telegram ID",
"telegram_id_placeholder": "Введите Telegram ID",
"notify_on_fail": "Уведомлять при ошибках валидации",
"filter_badge_active": "Активны фильтры профиля", "filter_badge_active": "Активны фильтры профиля",
"filter_badge_override": "Временно показаны все дашборды", "filter_badge_override": "Временно показаны все дашборды",
"filter_empty_state": "По активным фильтрам профиля дашборды не найдены. Попробуйте изменить настройки фильтра.", "filter_empty_state": "По активным фильтрам профиля дашборды не найдены. Попробуйте изменить настройки фильтра.",

View File

@@ -1,29 +1,171 @@
<!-- #region ProfilePage [C:3] [TYPE Page] [SEMANTICS sveltekit, profile, preferences, user, settings] --> <!-- #region ProfilePage [C:4] [TYPE Page] [SEMANTICS sveltekit, profile, preferences, user, settings] -->
<!-- @ingroup Routes --> <!-- @ingroup Routes -->
<!-- @BRIEF User profile page for viewing and editing personal preferences and settings. --> <!-- @BRIEF User profile page for viewing and editing personal preferences, integrations, notifications, and read-only access state. -->
<!-- @LAYER Page --> <!-- @LAYER Page -->
<!-- @RELATION BINDS_TO -> [EXT:frontend:api_module] --> <!-- @RELATION BINDS_TO -> [EXT:frontend:api_module] -->
<!-- @RELATION BINDS_TO -> [EXT:frontend:taskDrawerStore] -->
<!-- @UX_STATE Loading -> Profile data loading. --> <!-- @UX_STATE Loading -> Profile data loading. -->
<!-- @UX_STATE Loaded -> Profile form displayed. --> <!-- @UX_STATE Loaded -> Profile form displayed. -->
<!-- @UX_STATE Error -> Error message shown. --> <!-- @UX_STATE Error -> Error toast shown while preserving defaults. -->
<!-- @UX_FEEDBACK Success toast on profile update. --> <!-- @UX_FEEDBACK Success toast on profile update. -->
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from '$lib/i18n/index.svelte.js'; import { t } from '$lib/i18n/index.svelte.js';
import { PageHeader, Button, Card } from '$lib/ui'; import { PageHeader, Button, Card, Input, Select, LanguageSwitcher } from '$lib/ui';
import { api } from '$lib/api.js'; import { api } from '$lib/api.js';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { setTaskDrawerAutoOpenPreference } from '$lib/stores/taskDrawer.svelte.js';
let preferences = $state<any>({}); type StartPage = 'dashboards' | 'datasets' | 'reports';
type TableDensity = 'compact' | 'comfortable';
type ProfilePermissionState = {
key: string;
allowed: boolean;
};
type ProfileSecuritySummary = {
read_only?: boolean;
auth_source?: string | null;
current_role?: string | null;
role_source?: string | null;
roles?: string[];
permissions?: ProfilePermissionState[];
};
type ProfilePreferences = {
user_id?: string;
superset_username: string;
show_only_my_dashboards: boolean;
show_only_slug_dashboards: boolean;
git_username: string;
git_email: string;
git_personal_access_token?: string | null;
has_git_personal_access_token?: boolean;
git_personal_access_token_masked?: string | null;
start_page: StartPage;
auto_open_task_drawer: boolean;
dashboards_table_density: TableDensity;
telegram_id: string;
email_address: string;
notify_on_fail: boolean;
};
type ProfileResponse = {
status?: string;
message?: string | null;
validation_errors?: string[];
preference?: Partial<ProfilePreferences>;
security?: ProfileSecuritySummary;
};
const defaultPreferences: ProfilePreferences = {
superset_username: '',
show_only_my_dashboards: false,
show_only_slug_dashboards: true,
git_username: '',
git_email: '',
git_personal_access_token: undefined,
has_git_personal_access_token: false,
git_personal_access_token_masked: null,
start_page: 'dashboards',
auto_open_task_drawer: true,
dashboards_table_density: 'comfortable',
telegram_id: '',
email_address: '',
notify_on_fail: true,
};
let preferences = $state<ProfilePreferences>({ ...defaultPreferences });
let security = $state<ProfileSecuritySummary>({ read_only: true, roles: [], permissions: [] });
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let tokenInput = $state('');
let tokenTouched = $state(false);
const startPageOptions = $derived([
{ value: 'dashboards', label: $t.profile?.start_page_dashboards || $t.nav?.dashboards || 'Dashboards' },
{ value: 'datasets', label: $t.profile?.start_page_datasets || $t.nav?.datasets || 'Datasets' },
{ value: 'reports', label: $t.profile?.start_page_reports || $t.nav?.reports || 'Reports' },
]);
const tableDensityOptions = $derived([
{ value: 'comfortable', label: $t.profile?.table_density_comfortable || 'Comfortable' },
{ value: 'compact', label: $t.profile?.table_density_compact || 'Compact' },
]);
const saveLabel = $derived(
saving
? ($t.profile?.saving || 'Saving...')
: ($t.profile?.save_preferences || $t.common?.save || 'Save'),
);
function normalizePreferences(raw: Partial<ProfilePreferences> | undefined): ProfilePreferences {
return {
...defaultPreferences,
...raw,
superset_username: raw?.superset_username || '',
git_username: raw?.git_username || '',
git_email: raw?.git_email || '',
telegram_id: raw?.telegram_id || '',
email_address: raw?.email_address || '',
start_page: (raw?.start_page || defaultPreferences.start_page) as StartPage,
auto_open_task_drawer: raw?.auto_open_task_drawer !== false,
dashboards_table_density: (raw?.dashboards_table_density || defaultPreferences.dashboards_table_density) as TableDensity,
notify_on_fail: raw?.notify_on_fail !== false,
show_only_my_dashboards: raw?.show_only_my_dashboards === true,
show_only_slug_dashboards: raw?.show_only_slug_dashboards !== false,
};
}
function applyProfileResponse(response: ProfileResponse | undefined) {
preferences = normalizePreferences(response?.preference);
security = response?.security || { read_only: true, roles: [], permissions: [] };
tokenInput = '';
tokenTouched = false;
setTaskDrawerAutoOpenPreference(preferences.auto_open_task_drawer !== false);
}
function buildSavePayload() {
const payload: Record<string, unknown> = {
superset_username: preferences.superset_username || null,
show_only_my_dashboards: preferences.show_only_my_dashboards,
show_only_slug_dashboards: preferences.show_only_slug_dashboards,
git_username: preferences.git_username || null,
git_email: preferences.git_email || null,
start_page: preferences.start_page,
auto_open_task_drawer: preferences.auto_open_task_drawer,
dashboards_table_density: preferences.dashboards_table_density,
telegram_id: preferences.telegram_id || null,
email_address: preferences.email_address || null,
notify_on_fail: preferences.notify_on_fail,
};
if (tokenTouched) {
payload.git_personal_access_token = tokenInput || null;
}
return payload;
}
function handleTokenInput(event: Event) {
tokenInput = (event.currentTarget as HTMLInputElement).value;
tokenTouched = true;
}
function handleTokenClear() {
tokenInput = '';
tokenTouched = true;
preferences.has_git_personal_access_token = false;
preferences.git_personal_access_token_masked = null;
}
onMount(async () => { onMount(async () => {
try { try {
const response = await api.getProfilePreferences(); const response = await api.getProfilePreferences<ProfileResponse>();
preferences = response?.preference || {}; applyProfileResponse(response);
} catch (e) { } catch (e) {
addToast(String(e?.message || 'Failed to load preferences'), 'error'); addToast(e instanceof Error ? e.message : 'Failed to load preferences', 'error');
} finally { } finally {
loading = false; loading = false;
} }
@@ -32,46 +174,214 @@
async function handleSave() { async function handleSave() {
saving = true; saving = true;
try { try {
await api.postApi('/profile/preferences', { preference: preferences }); const response = await api.updateProfilePreferences<ProfileResponse>(buildSavePayload());
addToast($t.profile?.saved || 'Preferences saved', 'success'); applyProfileResponse(response);
addToast($t.profile?.save_success || $t.profile?.saved || 'Preferences saved', 'success');
} catch (e) { } catch (e) {
addToast(String(e?.message || 'Failed to save preferences'), 'error'); addToast(e instanceof Error ? e.message : ($t.profile?.save_error || 'Failed to save preferences'), 'error');
} finally { } finally {
saving = false; saving = false;
} }
} }
</script> </script>
<div class="max-w-2xl mx-auto px-4 py-8"> <div class="mx-auto max-w-4xl px-4 py-8">
<PageHeader title={$t.nav?.profile || 'Profile'} /> <PageHeader title={$t.profile?.title || $t.nav?.profile || 'Profile'} />
{#if $t.profile?.description}
<p class="-mt-6 mb-8 text-sm text-text-muted">{$t.profile.description}</p>
{/if}
{#if loading} {#if loading}
<div class="animate-pulse space-y-4 mt-6"> <div class="mt-6 animate-pulse space-y-4">
<div class="h-10 bg-surface-muted rounded w-full"></div> <div class="h-40 rounded-lg bg-surface-muted"></div>
<div class="h-10 bg-surface-muted rounded w-full"></div> <div class="h-56 rounded-lg bg-surface-muted"></div>
<div class="h-10 bg-surface-muted rounded w-1/3"></div> <div class="h-40 rounded-lg bg-surface-muted"></div>
</div> </div>
{:else} {:else}
<Card title={$t.profile?.preferences || 'Preferences'} padding="lg"> <div class="mt-6 space-y-6">
<div class="space-y-4"> <Card title={$t.profile?.user_preferences || $t.profile?.preferences || 'User Preferences'} padding="lg">
<div> <div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm font-medium text-text mb-1">{$t.profile?.start_page || 'Start page'}</label> <div>
<select <label class="mb-1 block text-sm font-medium text-text" for="profile-language">
class="w-full px-3 py-2 border border-border-strong rounded-md" {$t.profile?.language || 'Language'}
</label>
<div id="profile-language">
<LanguageSwitcher />
</div>
</div>
<Select
label={$t.profile?.start_page || 'Start Page'}
bind:value={preferences.start_page} bind:value={preferences.start_page}
> options={startPageOptions}
<option value="dashboards">{$t.nav?.dashboards}</option> id="profile-start-page"
<option value="datasets">{$t.nav?.datasets}</option> name="start_page"
<option value="reports">{$t.nav?.reports}</option> />
</select>
<Select
label={$t.profile?.table_density || 'Table Density'}
bind:value={preferences.dashboards_table_density}
options={tableDensityOptions}
id="profile-table-density"
name="dashboards_table_density"
/>
<label class="flex min-h-10 items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
bind:checked={preferences.auto_open_task_drawer}
name="auto_open_task_drawer"
/>
<span>{$t.profile?.auto_open_task_drawer || 'Automatically open task drawer for long-running tasks'}</span>
</label>
</div> </div>
<div class="pt-4"> </Card>
<Button onclick={handleSave} isLoading={saving}>
{$t.common?.save || 'Save'} <Card title={$t.profile?.dashboard_preferences || 'Dashboard Preferences'} padding="lg">
</Button> <div class="space-y-4">
<Input
label={$t.profile?.superset_account || 'Your Apache Superset Account'}
placeholder={$t.profile?.superset_account_placeholder || 'Enter your Apache Superset username'}
bind:value={preferences.superset_username}
id="profile-superset-username"
name="superset_username"
/>
<div class="grid gap-3 md:grid-cols-2">
<label class="flex items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
bind:checked={preferences.show_only_my_dashboards}
name="show_only_my_dashboards"
/>
<span>{$t.profile?.show_only_my_dashboards || 'Show only my dashboards by default'}</span>
</label>
<label class="flex items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
bind:checked={preferences.show_only_slug_dashboards}
name="show_only_slug_dashboards"
/>
<span>{$t.profile?.show_only_slug_dashboards || 'Show only dashboards with slug by default'}</span>
</label>
</div>
</div> </div>
</Card>
<Card title={$t.profile?.git_integration || 'Git Integration'} padding="lg">
<div class="grid gap-4 md:grid-cols-2">
<Input
label={$t.profile?.git_username || 'Git Username'}
placeholder={$t.profile?.git_username_placeholder || 'Enter git username'}
bind:value={preferences.git_username}
id="profile-git-username"
name="git_username"
/>
<Input
label={$t.profile?.git_email || 'Git Email'}
placeholder={$t.profile?.git_email_placeholder || 'Enter git email'}
bind:value={preferences.git_email}
id="profile-git-email"
name="git_email"
type="email"
/>
<div class="md:col-span-2 space-y-2">
<Input
label={$t.profile?.git_token || 'GitLab / GitHub Token'}
placeholder={$t.profile?.git_token_placeholder || 'Enter new personal access token'}
value={tokenInput}
oninput={handleTokenInput}
id="profile-git-token"
name="git_personal_access_token"
type="password"
/>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-text-muted">
<span>
{$t.profile?.git_token_hint || 'Token is never returned in plain text. Leave empty to keep current token.'}
</span>
<span class="text-text">
{$t.profile?.git_token_masked_label || 'Current token'}:
{preferences.git_personal_access_token_masked || $t.profile?.git_token_not_set || 'Token is not set'}
</span>
</div>
<Button variant="secondary" size="sm" onclick={handleTokenClear}>
{$t.profile?.git_token_clear || 'Clear token'}
</Button>
</div>
</div>
</Card>
<Card title={$t.profile?.notifications || 'Notifications'} padding="lg">
<div class="grid gap-4 md:grid-cols-2">
<Input
label={$t.profile?.notification_email || 'Notification email'}
placeholder={$t.profile?.notification_email_placeholder || 'Enter notification email'}
bind:value={preferences.email_address}
id="profile-email-address"
name="email_address"
type="email"
/>
<Input
label={$t.profile?.telegram_id || 'Telegram ID'}
placeholder={$t.profile?.telegram_id_placeholder || 'Enter Telegram ID'}
bind:value={preferences.telegram_id}
id="profile-telegram-id"
name="telegram_id"
/>
<label class="flex items-center gap-3 rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text md:col-span-2">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring"
bind:checked={preferences.notify_on_fail}
name="notify_on_fail"
/>
<span>{$t.profile?.notify_on_fail || 'Notify on validation failure'}</span>
</label>
</div>
</Card>
<Card title={$t.profile?.security_access || 'Security & Access'} padding="lg">
<div class="grid gap-4 md:grid-cols-2">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-text-subtle">{$t.profile?.current_role || 'Current Role'}</p>
<p class="mt-1 text-sm text-text">{security.current_role || security.roles?.[0] || '-'}</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-text-subtle">{$t.profile?.role_source || 'Role Source'}</p>
<p class="mt-1 text-sm text-text">{security.role_source || security.auth_source || '-'}</p>
</div>
<div class="md:col-span-2">
<p class="text-xs font-semibold uppercase tracking-wide text-text-subtle">{$t.profile?.permissions || 'Permissions'}</p>
{#if security.permissions?.length}
<div class="mt-2 flex flex-wrap gap-2">
{#each security.permissions as permission}
<span class="rounded-full border border-border bg-surface-muted px-2.5 py-1 text-xs text-text-muted">
{permission.key}: {permission.allowed ? 'allowed' : 'denied'}
</span>
{/each}
</div>
{:else}
<p class="mt-1 text-sm text-text-muted">{$t.profile?.permission_none || 'No permissions available'}</p>
{/if}
</div>
<p class="md:col-span-2 text-xs text-text-muted">
{$t.profile?.security_read_only_note || 'This section is read-only. Role changes are managed in Admin -> Users.'}
</p>
</div>
</Card>
<div class="flex justify-end">
<Button onclick={handleSave} isLoading={saving}>{saveLabel}</Button>
</div> </div>
</Card> </div>
{/if} {/if}
</div> </div>
<!-- #endregion ProfilePage --> <!-- #endregion ProfilePage -->

View File

@@ -1,7 +1,7 @@
// #region ProfilePreferencesIntegrationTest [C:2] [TYPE Module] // #region ProfilePreferencesIntegrationTest [C:2] [TYPE Module]
// @COMPLEXITY: 3 // @COMPLEXITY: 3
// @SEMANTICS: tests, profile, integration, load, save // @SEMANTICS: tests, profile, integration, load, save
// @PURPOSE: Verifies profile page loads preferences and saves them. // @PURPOSE: Verifies profile page loads expanded preferences and saves them through PATCH contract.
// @LAYER UI (Tests) // @LAYER UI (Tests)
// @RELATION DEPENDS_ON -> [ProfilePage] // @RELATION DEPENDS_ON -> [ProfilePage]
@@ -10,17 +10,49 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import ProfilePage from "../+page.svelte"; import ProfilePage from "../+page.svelte";
import { api } from "$lib/api.js"; import { api } from "$lib/api.js";
import { addToast } from "$lib/toasts.svelte.js"; import { addToast } from "$lib/toasts.svelte.js";
import { setTaskDrawerAutoOpenPreference } from "$lib/stores/taskDrawer.svelte.js";
const mockedApi = /** @type {any} */ (api); const mockedApi = /** @type {any} */ (api);
const mockedAddToast = /** @type {any} */ (addToast); const mockedAddToast = /** @type {any} */ (addToast);
const mockedSetTaskDrawerAutoOpenPreference = /** @type {any} */ (setTaskDrawerAutoOpenPreference);
const profileResponse = {
status: "success",
preference: {
user_id: "u-1",
superset_username: "superset_admin",
show_only_my_dashboards: true,
show_only_slug_dashboards: true,
git_username: "git-admin",
git_email: "git@example.test",
has_git_personal_access_token: true,
git_personal_access_token_masked: "ghp_***",
start_page: "dashboards",
auto_open_task_drawer: true,
dashboards_table_density: "comfortable",
telegram_id: "12345",
email_address: "notify@example.test",
notify_on_fail: true,
},
security: {
read_only: true,
current_role: "Admin",
role_source: "local",
permissions: [{ key: "admin:settings", allowed: true }],
},
};
vi.mock("$lib/api.js", () => ({ vi.mock("$lib/api.js", () => ({
api: { api: {
getProfilePreferences: vi.fn(), getProfilePreferences: vi.fn(),
postApi: vi.fn(), updateProfilePreferences: vi.fn(),
}, },
})); }));
vi.mock("$lib/stores/taskDrawer.svelte.js", () => ({
setTaskDrawerAutoOpenPreference: vi.fn(),
}));
vi.mock('$lib/toasts.svelte.js', () => ({ vi.mock('$lib/toasts.svelte.js', () => ({
addToast: vi.fn(), addToast: vi.fn(),
})); }));
@@ -30,15 +62,54 @@ vi.mock("$lib/auth/store.svelte.js", () => ({
})); }));
vi.mock('$lib/i18n/index.svelte.js', () => ({ vi.mock('$lib/i18n/index.svelte.js', () => ({
locale: {
subscribe: (run) => { run('en'); return () => {}; },
set: vi.fn(),
update: vi.fn(),
},
t: { t: {
subscribe: (run) => { subscribe: (run) => {
run({ run({
common: { save: "Save", cancel: "Cancel" }, common: { save: "Save", cancel: "Cancel" },
nav: { profile: "Profile", dashboards: "Dashboards", datasets: "Datasets", reports: "Reports" }, nav: { profile: "Profile", dashboards: "Dashboards", datasets: "Datasets", reports: "Reports" },
profile: { profile: {
title: "Profile",
description: "Manage your profile preferences",
saved: "Preferences saved", saved: "Preferences saved",
preferences: "Preferences", preferences: "Preferences",
start_page: "Start page", user_preferences: "User Preferences",
dashboard_preferences: "Dashboard Preferences",
git_integration: "Git Integration",
notifications: "Notifications",
security_access: "Security & Access",
start_page: "Start Page",
start_page_dashboards: "Dashboards",
start_page_datasets: "Datasets",
start_page_reports: "Reports / Logs",
language: "Language",
table_density: "Table Density",
table_density_comfortable: "Comfortable",
table_density_compact: "Compact",
auto_open_task_drawer: "Automatically open task drawer for long-running tasks",
superset_account: "Your Apache Superset Account",
show_only_my_dashboards: "Show only my dashboards by default",
show_only_slug_dashboards: "Show only dashboards with slug by default",
git_username: "Git Username",
git_email: "Git Email",
git_token: "GitLab / GitHub Token",
git_token_clear: "Clear token",
git_token_masked_label: "Current token",
git_token_not_set: "Token is not set",
notification_email: "Notification email",
telegram_id: "Telegram ID",
notify_on_fail: "Notify on validation failure",
save_preferences: "Save Preferences",
save_success: "Preferences saved",
current_role: "Current Role",
role_source: "Role Source",
permissions: "Permissions",
permission_none: "No permissions available",
security_read_only_note: "This section is read-only.",
}, },
}); });
return () => {}; return () => {};
@@ -50,16 +121,11 @@ vi.mock('$lib/i18n/index.svelte.js', () => ({
describe("profile-preferences.integration", () => { describe("profile-preferences.integration", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockedApi.getProfilePreferences.mockResolvedValue({ mockedApi.getProfilePreferences.mockResolvedValue(profileResponse);
status: "success", mockedApi.updateProfilePreferences.mockResolvedValue(profileResponse);
preference: {
user_id: "u-1",
start_page: "dashboards",
},
});
}); });
it("loads preferences and renders the profile form", async () => { it("loads preferences and renders expanded profile sections", async () => {
render(ProfilePage); render(ProfilePage);
await waitFor(() => { await waitFor(() => {
@@ -67,30 +133,45 @@ describe("profile-preferences.integration", () => {
}); });
expect(screen.getByText("Profile")).toBeDefined(); expect(screen.getByText("Profile")).toBeDefined();
expect(screen.getByText("Preferences")).toBeDefined(); expect(screen.getByText("User Preferences")).toBeDefined();
expect(screen.getByText("Start page")).toBeDefined(); expect(screen.getByText("Dashboard Preferences")).toBeDefined();
const select = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox")); expect(screen.getByText("Git Integration")).toBeDefined();
expect(select.value).toBe("dashboards"); expect(screen.getByText("Notifications")).toBeDefined();
expect(screen.getByText("Security & Access")).toBeDefined();
const startPage = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox", { name: "Start Page" }));
expect(startPage.value).toBe("dashboards");
expect(screen.getByRole("combobox", { name: "Language" })).toBeDefined();
expect(screen.getByDisplayValue("superset_admin")).toBeDefined();
expect(screen.getByDisplayValue("git@example.test")).toBeDefined();
expect(screen.getAllByText((_content, element) => Boolean(element?.textContent?.includes("ghp_***"))).length).toBeGreaterThan(0);
expect(screen.getByText("Admin")).toBeDefined();
expect(mockedSetTaskDrawerAutoOpenPreference).toHaveBeenCalledWith(true);
}); });
it("saves preferences via API and shows success toast", async () => { it("saves preferences via PATCH API and shows success toast", async () => {
mockedApi.postApi.mockResolvedValue({ status: "success" });
render(ProfilePage); render(ProfilePage);
await waitFor(() => { await waitFor(() => {
expect(mockedApi.getProfilePreferences).toHaveBeenCalledTimes(1); expect(mockedApi.getProfilePreferences).toHaveBeenCalledTimes(1);
}); });
const select = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox")); const startPage = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox", { name: "Start Page" }));
await fireEvent.change(select, { target: { value: "datasets" } }); await fireEvent.change(startPage, { target: { value: "datasets" } });
await fireEvent.click(screen.getByText("Save")); const autoOpen = /** @type {HTMLInputElement} */ (screen.getByRole("checkbox", { name: "Automatically open task drawer for long-running tasks" }));
await fireEvent.click(autoOpen);
await fireEvent.click(screen.getByRole("button", { name: "Save Preferences" }));
await waitFor(() => { await waitFor(() => {
expect(mockedApi.postApi).toHaveBeenCalledWith("/profile/preferences", { expect(mockedApi.updateProfilePreferences).toHaveBeenCalledWith(expect.objectContaining({
preference: expect.objectContaining({ start_page: "datasets" }), start_page: "datasets",
}); auto_open_task_drawer: false,
superset_username: "superset_admin",
git_email: "git@example.test",
notify_on_fail: true,
}));
}); });
expect(mockedAddToast).toHaveBeenCalledWith("Preferences saved", "success"); expect(mockedAddToast).toHaveBeenCalledWith("Preferences saved", "success");

View File

@@ -1,7 +1,7 @@
// #region ProfileSettingsStateIntegrationTest [C:2] [TYPE Module] // #region ProfileSettingsStateIntegrationTest [C:2] [TYPE Module]
// @COMPLEXITY: 3 // @COMPLEXITY: 3
// @SEMANTICS: tests, profile, integration, load, change, save // @SEMANTICS: tests, profile, integration, load, change, save
// @PURPOSE: Verifies profile loads preferences, allows changes, and saves correctly. // @PURPOSE: Verifies profile state changes for filters, notifications, and token clearing.
// @LAYER UI (Tests) // @LAYER UI (Tests)
// @RELATION DEPENDS_ON -> [ProfilePage] // @RELATION DEPENDS_ON -> [ProfilePage]
@@ -14,13 +14,38 @@ import { addToast } from "$lib/toasts.svelte.js";
const mockedApi = /** @type {any} */ (api); const mockedApi = /** @type {any} */ (api);
const mockedAddToast = /** @type {any} */ (addToast); const mockedAddToast = /** @type {any} */ (addToast);
const profileResponse = {
status: "success",
preference: {
user_id: "u-1",
superset_username: "",
show_only_my_dashboards: false,
show_only_slug_dashboards: true,
git_username: "",
git_email: "",
has_git_personal_access_token: true,
git_personal_access_token_masked: "***",
start_page: "dashboards",
auto_open_task_drawer: true,
dashboards_table_density: "comfortable",
telegram_id: "",
email_address: "",
notify_on_fail: true,
},
security: { read_only: true, roles: [], permissions: [] },
};
vi.mock("$lib/api.js", () => ({ vi.mock("$lib/api.js", () => ({
api: { api: {
getProfilePreferences: vi.fn(), getProfilePreferences: vi.fn(),
postApi: vi.fn(), updateProfilePreferences: vi.fn(),
}, },
})); }));
vi.mock("$lib/stores/taskDrawer.svelte.js", () => ({
setTaskDrawerAutoOpenPreference: vi.fn(),
}));
vi.mock('$lib/toasts.svelte.js', () => ({ vi.mock('$lib/toasts.svelte.js', () => ({
addToast: vi.fn(), addToast: vi.fn(),
})); }));
@@ -30,15 +55,54 @@ vi.mock("$lib/auth/store.svelte.js", () => ({
})); }));
vi.mock('$lib/i18n/index.svelte.js', () => ({ vi.mock('$lib/i18n/index.svelte.js', () => ({
locale: {
subscribe: (run) => { run('en'); return () => {}; },
set: vi.fn(),
update: vi.fn(),
},
t: { t: {
subscribe: (run) => { subscribe: (run) => {
run({ run({
common: { save: "Save", cancel: "Cancel" }, common: { save: "Save", cancel: "Cancel" },
nav: { profile: "Profile", dashboards: "Dashboards", datasets: "Datasets", reports: "Reports" }, nav: { profile: "Profile", dashboards: "Dashboards", datasets: "Datasets", reports: "Reports" },
profile: { profile: {
title: "Profile",
description: "Manage your profile preferences",
saved: "Preferences saved", saved: "Preferences saved",
preferences: "Preferences", preferences: "Preferences",
start_page: "Start page", user_preferences: "User Preferences",
dashboard_preferences: "Dashboard Preferences",
git_integration: "Git Integration",
notifications: "Notifications",
security_access: "Security & Access",
start_page: "Start Page",
start_page_dashboards: "Dashboards",
start_page_datasets: "Datasets",
start_page_reports: "Reports / Logs",
language: "Language",
table_density: "Table Density",
table_density_comfortable: "Comfortable",
table_density_compact: "Compact",
auto_open_task_drawer: "Automatically open task drawer for long-running tasks",
superset_account: "Your Apache Superset Account",
show_only_my_dashboards: "Show only my dashboards by default",
show_only_slug_dashboards: "Show only dashboards with slug by default",
git_username: "Git Username",
git_email: "Git Email",
git_token: "GitLab / GitHub Token",
git_token_clear: "Clear token",
git_token_masked_label: "Current token",
git_token_not_set: "Token is not set",
notification_email: "Notification email",
telegram_id: "Telegram ID",
notify_on_fail: "Notify on validation failure",
save_preferences: "Save Preferences",
save_success: "Preferences saved",
current_role: "Current Role",
role_source: "Role Source",
permissions: "Permissions",
permission_none: "No permissions available",
security_read_only_note: "This section is read-only.",
}, },
}); });
return () => {}; return () => {};
@@ -50,45 +114,50 @@ vi.mock('$lib/i18n/index.svelte.js', () => ({
describe("profile-settings-state.integration", () => { describe("profile-settings-state.integration", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockedApi.getProfilePreferences.mockResolvedValue({ mockedApi.getProfilePreferences.mockResolvedValue(profileResponse);
status: "success", mockedApi.updateProfilePreferences.mockResolvedValue(profileResponse);
preference: {
user_id: "u-1",
start_page: "dashboards",
},
});
}); });
it("loads saved preferences and displays them", async () => { it("loads saved preferences and displays them by label", async () => {
render(ProfilePage); render(ProfilePage);
await waitFor(() => { await waitFor(() => {
expect(mockedApi.getProfilePreferences).toHaveBeenCalledTimes(1); expect(mockedApi.getProfilePreferences).toHaveBeenCalledTimes(1);
}); });
const select = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox")); const startPage = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox", { name: "Start Page" }));
expect(select.value).toBe("dashboards"); expect(startPage.value).toBe("dashboards");
expect(screen.getByText("Profile")).toBeDefined(); expect(screen.getByRole("combobox", { name: "Table Density" })).toBeDefined();
expect(screen.getByText("No permissions available")).toBeDefined();
}); });
it("saves changed preferences and shows success toast", async () => { it("saves changed filter, notification, and token-clear state", async () => {
mockedApi.postApi.mockResolvedValue({ status: "success" });
render(ProfilePage); render(ProfilePage);
await waitFor(() => { await waitFor(() => {
expect(mockedApi.getProfilePreferences).toHaveBeenCalledTimes(1); expect(mockedApi.getProfilePreferences).toHaveBeenCalledTimes(1);
}); });
const select = /** @type {HTMLSelectElement} */ (screen.getByRole("combobox")); await fireEvent.input(screen.getByRole("textbox", { name: "Your Apache Superset Account" }), {
await fireEvent.change(select, { target: { value: "reports" } }); target: { value: "owner_name" },
});
await fireEvent.click(screen.getByRole("checkbox", { name: "Show only my dashboards by default" }));
await fireEvent.input(screen.getByRole("textbox", { name: "Notification email" }), {
target: { value: "owner@example.test" },
});
await fireEvent.click(screen.getByRole("checkbox", { name: "Notify on validation failure" }));
await fireEvent.click(screen.getByRole("button", { name: "Clear token" }));
await fireEvent.click(screen.getByText("Save")); await fireEvent.click(screen.getByRole("button", { name: "Save Preferences" }));
await waitFor(() => { await waitFor(() => {
expect(mockedApi.postApi).toHaveBeenCalledWith("/profile/preferences", { expect(mockedApi.updateProfilePreferences).toHaveBeenCalledWith(expect.objectContaining({
preference: expect.objectContaining({ start_page: "reports" }), superset_username: "owner_name",
}); show_only_my_dashboards: true,
email_address: "owner@example.test",
notify_on_fail: false,
git_personal_access_token: null,
}));
}); });
expect(mockedAddToast).toHaveBeenCalledWith("Preferences saved", "success"); expect(mockedAddToast).toHaveBeenCalledWith("Preferences saved", "success");