feat: Implement user profile preferences for start page, Git identity, and task drawer auto-open, alongside Git server default branch configuration.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.api.routes import git as git_routes
|
||||
|
||||
@@ -195,4 +196,121 @@ def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch)
|
||||
assert "1" in response.statuses
|
||||
# [/DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
|
||||
|
||||
|
||||
# [DEF:test_commit_changes_applies_profile_identity_before_commit:Function]
|
||||
# @PURPOSE: Ensure commit route configures repository identity from profile preferences before commit call.
|
||||
# @PRE: Profile preference contains git_username/git_email for current user.
|
||||
# @POST: git_service.configure_identity receives resolved identity and commit proceeds.
|
||||
def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
|
||||
class IdentityGitService:
|
||||
def __init__(self):
|
||||
self.configured_identity = None
|
||||
self.commit_payload = None
|
||||
|
||||
def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
|
||||
self.configured_identity = (dashboard_id, git_username, git_email)
|
||||
|
||||
def commit_changes(self, dashboard_id: int, message: str, files):
|
||||
self.commit_payload = (dashboard_id, message, files)
|
||||
|
||||
class PreferenceRow:
|
||||
git_username = "user_1"
|
||||
git_email = "user1@mail.ru"
|
||||
|
||||
class PreferenceQuery:
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return PreferenceRow()
|
||||
|
||||
class DbStub:
|
||||
def query(self, _model):
|
||||
return PreferenceQuery()
|
||||
|
||||
class UserStub:
|
||||
id = "u-1"
|
||||
|
||||
class CommitPayload:
|
||||
message = "test"
|
||||
files = ["dashboards/a.yaml"]
|
||||
|
||||
identity_service = IdentityGitService()
|
||||
monkeypatch.setattr(git_routes, "git_service", identity_service)
|
||||
monkeypatch.setattr(
|
||||
git_routes,
|
||||
"_resolve_dashboard_id_from_ref",
|
||||
lambda *_args, **_kwargs: 12,
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
git_routes.commit_changes(
|
||||
"dashboard-12",
|
||||
CommitPayload(),
|
||||
config_manager=MagicMock(),
|
||||
db=DbStub(),
|
||||
current_user=UserStub(),
|
||||
)
|
||||
)
|
||||
|
||||
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
|
||||
assert identity_service.commit_payload == (12, "test", ["dashboards/a.yaml"])
|
||||
# [/DEF:test_commit_changes_applies_profile_identity_before_commit:Function]
|
||||
|
||||
|
||||
# [DEF:test_pull_changes_applies_profile_identity_before_pull:Function]
|
||||
# @PURPOSE: Ensure pull route configures repository identity from profile preferences before pull call.
|
||||
# @PRE: Profile preference contains git_username/git_email for current user.
|
||||
# @POST: git_service.configure_identity receives resolved identity and pull proceeds.
|
||||
def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
|
||||
class IdentityGitService:
|
||||
def __init__(self):
|
||||
self.configured_identity = None
|
||||
self.pulled_dashboard_id = None
|
||||
|
||||
def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
|
||||
self.configured_identity = (dashboard_id, git_username, git_email)
|
||||
|
||||
def pull_changes(self, dashboard_id: int):
|
||||
self.pulled_dashboard_id = dashboard_id
|
||||
|
||||
class PreferenceRow:
|
||||
git_username = "user_1"
|
||||
git_email = "user1@mail.ru"
|
||||
|
||||
class PreferenceQuery:
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return PreferenceRow()
|
||||
|
||||
class DbStub:
|
||||
def query(self, _model):
|
||||
return PreferenceQuery()
|
||||
|
||||
class UserStub:
|
||||
id = "u-1"
|
||||
|
||||
identity_service = IdentityGitService()
|
||||
monkeypatch.setattr(git_routes, "git_service", identity_service)
|
||||
monkeypatch.setattr(
|
||||
git_routes,
|
||||
"_resolve_dashboard_id_from_ref",
|
||||
lambda *_args, **_kwargs: 12,
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
git_routes.pull_changes(
|
||||
"dashboard-12",
|
||||
config_manager=MagicMock(),
|
||||
db=DbStub(),
|
||||
current_user=UserStub(),
|
||||
)
|
||||
)
|
||||
|
||||
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
|
||||
assert identity_service.pulled_dashboard_id == 12
|
||||
# [/DEF:test_pull_changes_applies_profile_identity_before_pull:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]
|
||||
|
||||
@@ -15,8 +15,10 @@ from src.app import app
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager, get_current_user
|
||||
from src.schemas.profile import (
|
||||
ProfilePermissionState,
|
||||
ProfilePreference,
|
||||
ProfilePreferenceResponse,
|
||||
ProfileSecuritySummary,
|
||||
SupersetAccountCandidate,
|
||||
SupersetAccountLookupResponse,
|
||||
)
|
||||
@@ -80,9 +82,27 @@ def _build_preference_response(user_id: str = "u-1") -> ProfilePreferenceRespons
|
||||
superset_username="John_Doe",
|
||||
superset_username_normalized="john_doe",
|
||||
show_only_my_dashboards=True,
|
||||
git_username="ivan.ivanov",
|
||||
git_email="ivan@company.local",
|
||||
has_git_personal_access_token=True,
|
||||
git_personal_access_token_masked="iv***al",
|
||||
start_page="reports",
|
||||
auto_open_task_drawer=False,
|
||||
dashboards_table_density="compact",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
security=ProfileSecuritySummary(
|
||||
read_only=True,
|
||||
auth_source="adfs",
|
||||
current_role="Data Engineer",
|
||||
role_source="adfs",
|
||||
roles=["Data Engineer"],
|
||||
permissions=[
|
||||
ProfilePermissionState(key="migration:run", allowed=True),
|
||||
ProfilePermissionState(key="admin:users", allowed=False),
|
||||
],
|
||||
),
|
||||
)
|
||||
# [/DEF:_build_preference_response:Function]
|
||||
|
||||
@@ -104,6 +124,16 @@ def test_get_profile_preferences_returns_self_payload(profile_route_deps_fixture
|
||||
assert payload["status"] == "success"
|
||||
assert payload["preference"]["user_id"] == mock_user.id
|
||||
assert payload["preference"]["superset_username_normalized"] == "john_doe"
|
||||
assert payload["preference"]["git_username"] == "ivan.ivanov"
|
||||
assert payload["preference"]["git_email"] == "ivan@company.local"
|
||||
assert payload["preference"]["has_git_personal_access_token"] is True
|
||||
assert payload["preference"]["git_personal_access_token_masked"] == "iv***al"
|
||||
assert payload["preference"]["start_page"] == "reports"
|
||||
assert payload["preference"]["auto_open_task_drawer"] is False
|
||||
assert payload["preference"]["dashboards_table_density"] == "compact"
|
||||
assert payload["security"]["read_only"] is True
|
||||
assert payload["security"]["current_role"] == "Data Engineer"
|
||||
assert payload["security"]["permissions"][0]["key"] == "migration:run"
|
||||
service.get_my_preference.assert_called_once_with(mock_user)
|
||||
# [/DEF:test_get_profile_preferences_returns_self_payload:Function]
|
||||
|
||||
@@ -123,6 +153,12 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
|
||||
json={
|
||||
"superset_username": "John_Doe",
|
||||
"show_only_my_dashboards": True,
|
||||
"git_username": "ivan.ivanov",
|
||||
"git_email": "ivan@company.local",
|
||||
"git_personal_access_token": "ghp_1234567890",
|
||||
"start_page": "reports-logs",
|
||||
"auto_open_task_drawer": False,
|
||||
"dashboards_table_density": "free",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -131,7 +167,21 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
|
||||
assert payload["status"] == "success"
|
||||
assert payload["preference"]["superset_username"] == "John_Doe"
|
||||
assert payload["preference"]["show_only_my_dashboards"] is True
|
||||
assert payload["preference"]["git_username"] == "ivan.ivanov"
|
||||
assert payload["preference"]["git_email"] == "ivan@company.local"
|
||||
assert payload["preference"]["start_page"] == "reports"
|
||||
assert payload["preference"]["auto_open_task_drawer"] is False
|
||||
assert payload["preference"]["dashboards_table_density"] == "compact"
|
||||
service.update_my_preference.assert_called_once()
|
||||
|
||||
called_kwargs = service.update_my_preference.call_args.kwargs
|
||||
assert called_kwargs["current_user"] == mock_user
|
||||
assert called_kwargs["payload"].git_username == "ivan.ivanov"
|
||||
assert called_kwargs["payload"].git_email == "ivan@company.local"
|
||||
assert called_kwargs["payload"].git_personal_access_token == "ghp_1234567890"
|
||||
assert called_kwargs["payload"].start_page == "reports-logs"
|
||||
assert called_kwargs["payload"].auto_open_task_drawer is False
|
||||
assert called_kwargs["payload"].dashboards_table_density == "free"
|
||||
# [/DEF:test_patch_profile_preferences_success:Function]
|
||||
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import typing
|
||||
import os
|
||||
from src.dependencies import get_config_manager, has_permission
|
||||
from src.dependencies import get_config_manager, get_current_user, has_permission
|
||||
from src.core.database import get_db
|
||||
from src.models.auth import User
|
||||
from src.models.git import GitServerConfig, GitRepository, GitProvider
|
||||
from src.models.profile import UserDashboardPreference
|
||||
from src.api.routes.git_schemas import (
|
||||
GitServerConfigSchema, GitServerConfigCreate,
|
||||
GitServerConfigSchema, GitServerConfigCreate, GitServerConfigUpdate,
|
||||
BranchSchema, BranchCreate,
|
||||
BranchCheckout, CommitSchema, CommitCreate,
|
||||
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest,
|
||||
@@ -208,6 +210,84 @@ def _resolve_repo_key_from_ref(
|
||||
return f"dashboard-{dashboard_id}"
|
||||
# [/DEF:_resolve_repo_key_from_ref:Function]
|
||||
|
||||
|
||||
# [DEF:_sanitize_optional_identity_value:Function]
|
||||
# @PURPOSE: Normalize optional identity value into trimmed string or None.
|
||||
# @PRE: value may be None or blank.
|
||||
# @POST: Returns sanitized value suitable for git identity configuration.
|
||||
# @RETURN: Optional[str]
|
||||
def _sanitize_optional_identity_value(value: Optional[str]) -> Optional[str]:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized
|
||||
# [/DEF:_sanitize_optional_identity_value:Function]
|
||||
|
||||
|
||||
# [DEF:_resolve_current_user_git_identity:Function]
|
||||
# @PURPOSE: Resolve configured Git username/email from current user's profile preferences.
|
||||
# @PRE: `db` may be stubbed in tests; `current_user` may be absent for direct handler invocations.
|
||||
# @POST: Returns tuple(username, email) only when both values are configured.
|
||||
# @RETURN: Optional[tuple[str, str]]
|
||||
def _resolve_current_user_git_identity(
|
||||
db: Session,
|
||||
current_user: Optional[User],
|
||||
) -> Optional[tuple[str, str]]:
|
||||
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.warning(
|
||||
"[_resolve_current_user_git_identity][Action] Failed to load profile preference for user %s: %s",
|
||||
user_id,
|
||||
resolve_error,
|
||||
)
|
||||
return None
|
||||
|
||||
if not preference:
|
||||
return None
|
||||
|
||||
git_username = _sanitize_optional_identity_value(getattr(preference, "git_username", None))
|
||||
git_email = _sanitize_optional_identity_value(getattr(preference, "git_email", None))
|
||||
if not git_username or not git_email:
|
||||
return None
|
||||
return git_username, git_email
|
||||
# [/DEF:_resolve_current_user_git_identity:Function]
|
||||
|
||||
|
||||
# [DEF:_apply_git_identity_from_profile:Function]
|
||||
# @PURPOSE: Apply user-scoped Git identity to repository-local config before write/pull operations.
|
||||
# @PRE: dashboard_id is resolved; db/current_user may be missing in direct test invocation context.
|
||||
# @POST: git_service.configure_identity is called only when identity and method are available.
|
||||
# @RETURN: None
|
||||
def _apply_git_identity_from_profile(
|
||||
dashboard_id: int,
|
||||
db: Session,
|
||||
current_user: Optional[User],
|
||||
) -> None:
|
||||
identity = _resolve_current_user_git_identity(db, current_user)
|
||||
if not identity:
|
||||
return
|
||||
|
||||
configure_identity = getattr(git_service, "configure_identity", None)
|
||||
if not callable(configure_identity):
|
||||
return
|
||||
|
||||
git_username, git_email = identity
|
||||
configure_identity(dashboard_id, git_username, git_email)
|
||||
# [/DEF:_apply_git_identity_from_profile:Function]
|
||||
|
||||
|
||||
# [DEF:get_git_configs:Function]
|
||||
# @PURPOSE: List all configured Git servers.
|
||||
# @PRE: Database session `db` is available.
|
||||
@@ -219,7 +299,13 @@ async def get_git_configs(
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
with belief_scope("get_git_configs"):
|
||||
return db.query(GitServerConfig).all()
|
||||
configs = db.query(GitServerConfig).all()
|
||||
result = []
|
||||
for config in configs:
|
||||
schema = GitServerConfigSchema.from_orm(config)
|
||||
schema.pat = "********"
|
||||
result.append(schema)
|
||||
return result
|
||||
# [/DEF:get_git_configs:Function]
|
||||
|
||||
# [DEF:create_git_config:Function]
|
||||
@@ -242,6 +328,40 @@ async def create_git_config(
|
||||
return db_config
|
||||
# [/DEF:create_git_config:Function]
|
||||
|
||||
# [DEF:update_git_config:Function]
|
||||
# @PURPOSE: Update an existing Git server configuration.
|
||||
# @PRE: `config_id` corresponds to an existing configuration.
|
||||
# @POST: The configuration record is updated in the database.
|
||||
# @PARAM: config_id (str)
|
||||
# @PARAM: config_update (GitServerConfigUpdate)
|
||||
# @RETURN: GitServerConfigSchema
|
||||
@router.put("/config/{config_id}", response_model=GitServerConfigSchema)
|
||||
async def update_git_config(
|
||||
config_id: str,
|
||||
config_update: GitServerConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||
):
|
||||
with belief_scope("update_git_config"):
|
||||
db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
|
||||
if not db_config:
|
||||
raise HTTPException(status_code=404, detail="Configuration not found")
|
||||
|
||||
update_data = config_update.dict(exclude_unset=True)
|
||||
if update_data.get("pat") == "********":
|
||||
update_data.pop("pat")
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(db_config, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_config)
|
||||
|
||||
result_schema = GitServerConfigSchema.from_orm(db_config)
|
||||
result_schema.pat = "********"
|
||||
return result_schema
|
||||
# [/DEF:update_git_config:Function]
|
||||
|
||||
# [DEF:delete_git_config:Function]
|
||||
# @PURPOSE: Remove a Git server configuration.
|
||||
# @PRE: `config_id` corresponds to an existing configuration.
|
||||
@@ -271,10 +391,22 @@ async def delete_git_config(
|
||||
@router.post("/config/test")
|
||||
async def test_git_config(
|
||||
config: GitServerConfigCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
with belief_scope("test_git_config"):
|
||||
success = await git_service.test_connection(config.provider, config.url, config.pat)
|
||||
pat_to_use = config.pat
|
||||
if pat_to_use == "********":
|
||||
if config.config_id:
|
||||
db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config.config_id).first()
|
||||
if db_config:
|
||||
pat_to_use = db_config.pat
|
||||
else:
|
||||
db_config = db.query(GitServerConfig).filter(GitServerConfig.url == config.url).first()
|
||||
if db_config:
|
||||
pat_to_use = db_config.pat
|
||||
|
||||
success = await git_service.test_connection(config.provider, config.url, pat_to_use)
|
||||
if success:
|
||||
return {"status": "success", "message": "Connection successful"}
|
||||
else:
|
||||
@@ -459,7 +591,7 @@ async def init_repository(
|
||||
try:
|
||||
# 2. Perform Git clone/init
|
||||
logger.info(f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}")
|
||||
git_service.init_repo(dashboard_id, init_data.remote_url, config.pat, repo_key=repo_key)
|
||||
git_service.init_repo(dashboard_id, init_data.remote_url, config.pat, repo_key=repo_key, default_branch=config.default_branch)
|
||||
|
||||
# 3. Save to DB
|
||||
repo_path = git_service._get_repo_path(dashboard_id, repo_key=repo_key)
|
||||
@@ -583,11 +715,14 @@ async def create_branch(
|
||||
branch_data: BranchCreate,
|
||||
env_id: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("create_branch"):
|
||||
try:
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
_apply_git_identity_from_profile(dashboard_id, db, current_user)
|
||||
git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
@@ -633,11 +768,14 @@ async def commit_changes(
|
||||
commit_data: CommitCreate,
|
||||
env_id: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("commit_changes"):
|
||||
try:
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
_apply_git_identity_from_profile(dashboard_id, db, current_user)
|
||||
git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
@@ -679,11 +817,14 @@ async def pull_changes(
|
||||
dashboard_ref: str,
|
||||
env_id: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("pull_changes"):
|
||||
try:
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
_apply_git_identity_from_profile(dashboard_id, db, current_user)
|
||||
git_service.pull_changes(dashboard_id)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
@@ -734,6 +875,7 @@ async def promote_dashboard(
|
||||
env_id: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("promote_dashboard"):
|
||||
@@ -762,6 +904,7 @@ async def promote_dashboard(
|
||||
to_branch,
|
||||
reason,
|
||||
)
|
||||
_apply_git_identity_from_profile(dashboard_id, db, current_user)
|
||||
result = git_service.promote_direct_merge(
|
||||
dashboard_id=dashboard_id,
|
||||
from_branch=from_branch,
|
||||
|
||||
@@ -21,14 +21,27 @@ class GitServerConfigBase(BaseModel):
|
||||
provider: GitProvider = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)")
|
||||
url: str = Field(..., description="Server base URL")
|
||||
pat: str = Field(..., description="Personal Access Token")
|
||||
pat: str = Field(..., description="Personal Access Token")
|
||||
default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)")
|
||||
default_branch: Optional[str] = Field("main", description="Default branch logic/name")
|
||||
# [/DEF:GitServerConfigBase:Class]
|
||||
|
||||
# [DEF:GitServerConfigUpdate:Class]
|
||||
# @PURPOSE: Schema for updating an existing Git server configuration.
|
||||
class GitServerConfigUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Display name for the Git server")
|
||||
provider: Optional[GitProvider] = Field(None, description="Git provider (GITHUB, GITLAB, GITEA)")
|
||||
url: Optional[str] = Field(None, description="Server base URL")
|
||||
pat: Optional[str] = Field(None, description="Personal Access Token")
|
||||
default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)")
|
||||
default_branch: Optional[str] = Field(None, description="Default branch logic/name")
|
||||
# [/DEF:GitServerConfigUpdate:Class]
|
||||
|
||||
# [DEF:GitServerConfigCreate:Class]
|
||||
# @PURPOSE: Schema for creating a new Git server configuration.
|
||||
class GitServerConfigCreate(GitServerConfigBase):
|
||||
"""Schema for creating a new Git server configuration."""
|
||||
pass
|
||||
config_id: Optional[str] = Field(None, description="Optional config ID, useful for testing an existing config without sending its full PAT")
|
||||
# [/DEF:GitServerConfigCreate:Class]
|
||||
|
||||
# [DEF:GitServerConfigSchema:Class]
|
||||
|
||||
@@ -23,7 +23,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...dependencies import get_config_manager, get_current_user
|
||||
from ...dependencies import (
|
||||
get_config_manager,
|
||||
get_current_user,
|
||||
get_plugin_loader,
|
||||
)
|
||||
from ...models.auth import User
|
||||
from ...schemas.profile import (
|
||||
ProfilePreferenceResponse,
|
||||
@@ -46,8 +50,12 @@ router = APIRouter(prefix="/api/profile", tags=["profile"])
|
||||
# @PURPOSE: Build profile service for current request scope.
|
||||
# @PRE: db session and config manager are available.
|
||||
# @POST: Returns a ready ProfileService instance.
|
||||
def _get_profile_service(db: Session, config_manager) -> ProfileService:
|
||||
return ProfileService(db=db, config_manager=config_manager)
|
||||
def _get_profile_service(db: Session, config_manager, plugin_loader=None) -> ProfileService:
|
||||
return ProfileService(
|
||||
db=db,
|
||||
config_manager=config_manager,
|
||||
plugin_loader=plugin_loader,
|
||||
)
|
||||
# [/DEF:_get_profile_service:Function]
|
||||
|
||||
|
||||
@@ -60,10 +68,11 @@ async def get_preferences(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
config_manager=Depends(get_config_manager),
|
||||
plugin_loader=Depends(get_plugin_loader),
|
||||
):
|
||||
with belief_scope("profile.get_preferences", f"user_id={current_user.id}"):
|
||||
logger.reason("[REASON] Resolving current user preference")
|
||||
service = _get_profile_service(db, config_manager)
|
||||
service = _get_profile_service(db, config_manager, plugin_loader)
|
||||
return service.get_my_preference(current_user)
|
||||
# [/DEF:get_preferences:Function]
|
||||
|
||||
@@ -78,9 +87,10 @@ async def update_preferences(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
config_manager=Depends(get_config_manager),
|
||||
plugin_loader=Depends(get_plugin_loader),
|
||||
):
|
||||
with belief_scope("profile.update_preferences", f"user_id={current_user.id}"):
|
||||
service = _get_profile_service(db, config_manager)
|
||||
service = _get_profile_service(db, config_manager, plugin_loader)
|
||||
try:
|
||||
logger.reason("[REASON] Attempting preference save")
|
||||
return service.update_my_preference(current_user=current_user, payload=payload)
|
||||
@@ -108,12 +118,13 @@ async def lookup_superset_accounts(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
config_manager=Depends(get_config_manager),
|
||||
plugin_loader=Depends(get_plugin_loader),
|
||||
):
|
||||
with belief_scope(
|
||||
"profile.lookup_superset_accounts",
|
||||
f"user_id={current_user.id}, environment_id={environment_id}",
|
||||
):
|
||||
service = _get_profile_service(db, config_manager)
|
||||
service = _get_profile_service(db, config_manager, plugin_loader)
|
||||
lookup_request = SupersetAccountLookupRequest(
|
||||
environment_id=environment_id,
|
||||
search=search,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# @INVARIANT: A single engine instance is used for the entire application.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from ..models.mapping import Base
|
||||
# Import models to ensure they're registered with Base
|
||||
@@ -21,7 +21,7 @@ from ..models import config as _config_models # noqa: F401
|
||||
from ..models import llm as _llm_models # noqa: F401
|
||||
from ..models import assistant as _assistant_models # noqa: F401
|
||||
from ..models import profile as _profile_models # noqa: F401
|
||||
from .logger import belief_scope
|
||||
from .logger import belief_scope, logger
|
||||
from .auth.config import auth_config
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -95,6 +95,104 @@ TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_e
|
||||
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
|
||||
# [/DEF:AuthSessionLocal:Class]
|
||||
|
||||
# [DEF:_ensure_user_dashboard_preferences_columns:Function]
|
||||
# @PURPOSE: Applies additive schema upgrades for user_dashboard_preferences table.
|
||||
# @PRE: bind_engine points to application database where profile table is stored.
|
||||
# @POST: Missing columns are added without data loss.
|
||||
def _ensure_user_dashboard_preferences_columns(bind_engine):
|
||||
with belief_scope("_ensure_user_dashboard_preferences_columns"):
|
||||
table_name = "user_dashboard_preferences"
|
||||
inspector = inspect(bind_engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
existing_columns = {
|
||||
str(column.get("name") or "").strip()
|
||||
for column in inspector.get_columns(table_name)
|
||||
}
|
||||
|
||||
alter_statements = []
|
||||
if "git_username" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE user_dashboard_preferences ADD COLUMN git_username VARCHAR"
|
||||
)
|
||||
if "git_email" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE user_dashboard_preferences ADD COLUMN git_email VARCHAR"
|
||||
)
|
||||
if "git_personal_access_token_encrypted" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE user_dashboard_preferences "
|
||||
"ADD COLUMN git_personal_access_token_encrypted VARCHAR"
|
||||
)
|
||||
if "start_page" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE user_dashboard_preferences "
|
||||
"ADD COLUMN start_page VARCHAR NOT NULL DEFAULT 'dashboards'"
|
||||
)
|
||||
if "auto_open_task_drawer" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE user_dashboard_preferences "
|
||||
"ADD COLUMN auto_open_task_drawer BOOLEAN NOT NULL DEFAULT TRUE"
|
||||
)
|
||||
if "dashboards_table_density" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE user_dashboard_preferences "
|
||||
"ADD COLUMN dashboards_table_density VARCHAR NOT NULL DEFAULT 'comfortable'"
|
||||
)
|
||||
|
||||
if not alter_statements:
|
||||
return
|
||||
|
||||
try:
|
||||
with bind_engine.begin() as connection:
|
||||
for statement in alter_statements:
|
||||
connection.execute(text(statement))
|
||||
except Exception as migration_error:
|
||||
logger.warning(
|
||||
"[database][EXPLORE] Profile preference additive migration failed: %s",
|
||||
migration_error,
|
||||
)
|
||||
# [/DEF:_ensure_user_dashboard_preferences_columns:Function]
|
||||
|
||||
|
||||
# [DEF:_ensure_git_server_configs_columns:Function]
|
||||
# @PURPOSE: Applies additive schema upgrades for git_server_configs table.
|
||||
# @PRE: bind_engine points to application database.
|
||||
# @POST: Missing columns are added without data loss.
|
||||
def _ensure_git_server_configs_columns(bind_engine):
|
||||
with belief_scope("_ensure_git_server_configs_columns"):
|
||||
table_name = "git_server_configs"
|
||||
inspector = inspect(bind_engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
existing_columns = {
|
||||
str(column.get("name") or "").strip()
|
||||
for column in inspector.get_columns(table_name)
|
||||
}
|
||||
|
||||
alter_statements = []
|
||||
if "default_branch" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE git_server_configs ADD COLUMN default_branch VARCHAR NOT NULL DEFAULT 'main'"
|
||||
)
|
||||
|
||||
if not alter_statements:
|
||||
return
|
||||
|
||||
try:
|
||||
with bind_engine.begin() as connection:
|
||||
for statement in alter_statements:
|
||||
connection.execute(text(statement))
|
||||
except Exception as migration_error:
|
||||
logger.warning(
|
||||
"[database][EXPLORE] GitServerConfig preference additive migration failed: %s",
|
||||
migration_error,
|
||||
)
|
||||
# [/DEF:_ensure_git_server_configs_columns:Function]
|
||||
|
||||
|
||||
# [DEF:init_db:Function]
|
||||
# @PURPOSE: Initializes the database by creating all tables.
|
||||
# @PRE: engine, tasks_engine and auth_engine are initialized.
|
||||
@@ -105,6 +203,8 @@ def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Base.metadata.create_all(bind=tasks_engine)
|
||||
Base.metadata.create_all(bind=auth_engine)
|
||||
_ensure_user_dashboard_preferences_columns(engine)
|
||||
_ensure_git_server_configs_columns(engine)
|
||||
# [/DEF:init_db:Function]
|
||||
|
||||
# [DEF:get_db:Function]
|
||||
|
||||
@@ -38,6 +38,7 @@ class GitServerConfig(Base):
|
||||
url = Column(String(255), nullable=False)
|
||||
pat = Column(String(255), nullable=False) # PERSONAL ACCESS TOKEN
|
||||
default_repository = Column(String(255), nullable=True)
|
||||
default_branch = Column(String(255), default="main")
|
||||
status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN)
|
||||
last_validated = Column(DateTime, default=datetime.utcnow)
|
||||
# [/DEF:GitServerConfig:Class]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# [DEF:backend.src.models.profile:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: profile, preferences, persistence, user, dashboard-filter, sqlalchemy
|
||||
# @PURPOSE: Defines persistent per-user dashboard filter preferences.
|
||||
# @SEMANTICS: profile, preferences, persistence, user, dashboard-filter, git, ui-preferences, sqlalchemy
|
||||
# @PURPOSE: Defines persistent per-user profile settings for dashboard filter, Git identity/token, and UX preferences.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.auth
|
||||
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
|
||||
#
|
||||
# @INVARIANT: Exactly one preference row exists per user_id.
|
||||
# @INVARIANT: Sensitive Git token is stored encrypted and never returned in plaintext.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import uuid
|
||||
@@ -32,6 +33,14 @@ class UserDashboardPreference(Base):
|
||||
|
||||
show_only_my_dashboards = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
git_username = Column(String, nullable=True)
|
||||
git_email = Column(String, nullable=True)
|
||||
git_personal_access_token_encrypted = Column(String, nullable=True)
|
||||
|
||||
start_page = Column(String, nullable=False, default="dashboards")
|
||||
auto_open_task_drawer = Column(Boolean, nullable=False, default=True)
|
||||
dashboards_table_density = Column(String, nullable=False, default="comfortable")
|
||||
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
updated_at = Column(
|
||||
DateTime,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# [DEF:backend.src.schemas.profile:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: profile, schemas, pydantic, preferences, superset, lookup
|
||||
# @PURPOSE: Defines API schemas for profile preference persistence and Superset account lookup flows.
|
||||
# @SEMANTICS: profile, schemas, pydantic, preferences, superset, lookup, security, git, ux
|
||||
# @PURPOSE: Defines API schemas for profile preference persistence, security read-only snapshot, and Superset account lookup.
|
||||
# @LAYER: API
|
||||
# @RELATION: DEPENDS_ON -> pydantic
|
||||
#
|
||||
# @INVARIANT: Schema shapes stay stable for profile UI states and dashboards filter metadata.
|
||||
# @INVARIANT: Schema shapes stay stable for profile UI states and backend preference contracts.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
@@ -15,6 +15,28 @@ from pydantic import BaseModel, Field
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:ProfilePermissionState:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Represents one permission badge state for profile read-only security view.
|
||||
class ProfilePermissionState(BaseModel):
|
||||
key: str
|
||||
allowed: bool
|
||||
# [/DEF:ProfilePermissionState:Class]
|
||||
|
||||
|
||||
# [DEF:ProfileSecuritySummary:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Read-only security and access snapshot for current user.
|
||||
class ProfileSecuritySummary(BaseModel):
|
||||
read_only: bool = True
|
||||
auth_source: Optional[str] = None
|
||||
current_role: Optional[str] = None
|
||||
role_source: Optional[str] = None
|
||||
roles: List[str] = Field(default_factory=list)
|
||||
permissions: List[ProfilePermissionState] = Field(default_factory=list)
|
||||
# [/DEF:ProfileSecuritySummary:Class]
|
||||
|
||||
|
||||
# [DEF:ProfilePreference:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Represents persisted profile preference for a single authenticated user.
|
||||
@@ -23,6 +45,16 @@ class ProfilePreference(BaseModel):
|
||||
superset_username: Optional[str] = None
|
||||
superset_username_normalized: Optional[str] = None
|
||||
show_only_my_dashboards: bool = False
|
||||
|
||||
git_username: Optional[str] = None
|
||||
git_email: Optional[str] = None
|
||||
has_git_personal_access_token: bool = False
|
||||
git_personal_access_token_masked: Optional[str] = None
|
||||
|
||||
start_page: Literal["dashboards", "datasets", "reports"] = "dashboards"
|
||||
auto_open_task_drawer: bool = True
|
||||
dashboards_table_density: Literal["compact", "comfortable"] = "comfortable"
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -33,16 +65,44 @@ class ProfilePreference(BaseModel):
|
||||
|
||||
# [DEF:ProfilePreferenceUpdateRequest:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Request payload for updating current user's dashboard filter preference.
|
||||
# @PURPOSE: Request payload for updating current user's profile settings.
|
||||
class ProfilePreferenceUpdateRequest(BaseModel):
|
||||
superset_username: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Apache Superset username bound to current user profile.",
|
||||
)
|
||||
show_only_my_dashboards: bool = Field(
|
||||
default=False,
|
||||
show_only_my_dashboards: Optional[bool] = Field(
|
||||
default=None,
|
||||
description='When true, "/dashboards" can auto-apply profile filter in main context.',
|
||||
)
|
||||
git_username: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Git author username used for commit signature.",
|
||||
)
|
||||
git_email: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Git author email used for commit signature.",
|
||||
)
|
||||
git_personal_access_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Personal Access Token value. Empty string clears existing token.",
|
||||
)
|
||||
start_page: Optional[
|
||||
Literal["dashboards", "datasets", "reports", "reports-logs"]
|
||||
] = Field(
|
||||
default=None,
|
||||
description="Preferred start page after login.",
|
||||
)
|
||||
auto_open_task_drawer: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Auto-open task drawer when long-running tasks start.",
|
||||
)
|
||||
dashboards_table_density: Optional[
|
||||
Literal["compact", "comfortable", "free"]
|
||||
] = Field(
|
||||
default=None,
|
||||
description="Preferred table density for dashboard listings.",
|
||||
)
|
||||
# [/DEF:ProfilePreferenceUpdateRequest:Class]
|
||||
|
||||
|
||||
@@ -54,6 +114,7 @@ class ProfilePreferenceResponse(BaseModel):
|
||||
message: Optional[str] = None
|
||||
validation_errors: List[str] = Field(default_factory=list)
|
||||
preference: ProfilePreference
|
||||
security: ProfileSecuritySummary = Field(default_factory=ProfileSecuritySummary)
|
||||
# [/DEF:ProfilePreferenceResponse:Class]
|
||||
|
||||
|
||||
|
||||
@@ -411,6 +411,37 @@ class GitService:
|
||||
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
|
||||
# [/DEF:get_repo:Function]
|
||||
|
||||
# [DEF:configure_identity:Function]
|
||||
# @PURPOSE: Configure repository-local Git committer identity for user-scoped operations.
|
||||
# @PRE: dashboard_id repository exists; git_username/git_email may be empty.
|
||||
# @POST: Repository config has user.name and user.email when both identity values are provided.
|
||||
# @RETURN: None
|
||||
def configure_identity(
|
||||
self,
|
||||
dashboard_id: int,
|
||||
git_username: Optional[str],
|
||||
git_email: Optional[str],
|
||||
) -> None:
|
||||
with belief_scope("GitService.configure_identity"):
|
||||
normalized_username = str(git_username or "").strip()
|
||||
normalized_email = str(git_email or "").strip()
|
||||
if not normalized_username or not normalized_email:
|
||||
return
|
||||
|
||||
repo = self.get_repo(dashboard_id)
|
||||
try:
|
||||
with repo.config_writer(config_level="repository") as config_writer:
|
||||
config_writer.set_value("user", "name", normalized_username)
|
||||
config_writer.set_value("user", "email", normalized_email)
|
||||
logger.info(
|
||||
"[configure_identity][Action] Applied repository-local git identity for dashboard %s",
|
||||
dashboard_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {str(e)}")
|
||||
# [/DEF:configure_identity:Function]
|
||||
|
||||
# [DEF:list_branches:Function]
|
||||
# @PURPOSE: List all branches for a dashboard's repository.
|
||||
# @PRE: Repository for dashboard_id exists.
|
||||
@@ -601,6 +632,21 @@ class GitService:
|
||||
def pull_changes(self, dashboard_id: int):
|
||||
with belief_scope("GitService.pull_changes"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
|
||||
# Check for unfinished merge (MERGE_HEAD exists)
|
||||
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
|
||||
if os.path.exists(merge_head_path):
|
||||
logger.warning(f"[pull_changes][Action] Unfinished merge detected for dashboard {dashboard_id}")
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"В репозитории есть незавершённое слияние. "
|
||||
"Пожалуйста, завершите или отмените слияние вручную: "
|
||||
"1) Разрешите конфликты и выполните коммит, или "
|
||||
"2) Выполните 'git merge --abort' для отмены слияния."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
origin = repo.remote(name='origin')
|
||||
current_branch = repo.active_branch.name
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable, List, Optional, Sequence
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Set, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.auth.repository import AuthRepository
|
||||
@@ -32,16 +32,23 @@ from ..core.superset_client import SupersetClient
|
||||
from ..core.superset_profile_lookup import SupersetAccountLookupAdapter
|
||||
from ..models.auth import User
|
||||
from ..models.profile import UserDashboardPreference
|
||||
from .llm_provider import EncryptionManager
|
||||
from .rbac_permission_catalog import discover_declared_permissions
|
||||
from ..schemas.profile import (
|
||||
ProfilePermissionState,
|
||||
ProfilePreference,
|
||||
ProfilePreferenceResponse,
|
||||
ProfilePreferenceUpdateRequest,
|
||||
ProfileSecuritySummary,
|
||||
SupersetAccountLookupRequest,
|
||||
SupersetAccountLookupResponse,
|
||||
SupersetAccountCandidate,
|
||||
)
|
||||
# [/SECTION]
|
||||
|
||||
SUPPORTED_START_PAGES = {"dashboards", "datasets", "reports"}
|
||||
SUPPORTED_DENSITIES = {"compact", "comfortable"}
|
||||
|
||||
|
||||
# [DEF:ProfileValidationError:Class]
|
||||
# @TIER: STANDARD
|
||||
@@ -77,10 +84,12 @@ class ProfileService:
|
||||
# @PURPOSE: Initialize service with DB session and config manager.
|
||||
# @PRE: db session is active and config_manager supports get_environments().
|
||||
# @POST: Service is ready for preference persistence and lookup operations.
|
||||
def __init__(self, db: Session, config_manager: Any):
|
||||
def __init__(self, db: Session, config_manager: Any, plugin_loader: Any = None):
|
||||
self.db = db
|
||||
self.config_manager = config_manager
|
||||
self.plugin_loader = plugin_loader
|
||||
self.auth_repository = AuthRepository(db)
|
||||
self.encryption = EncryptionManager()
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:get_my_preference:Function]
|
||||
@@ -91,16 +100,20 @@ class ProfileService:
|
||||
with belief_scope("ProfileService.get_my_preference", f"user_id={current_user.id}"):
|
||||
logger.reflect("[REFLECT] Loading current user's dashboard preference")
|
||||
preference = self._get_preference_row(current_user.id)
|
||||
security_summary = self._build_security_summary(current_user)
|
||||
|
||||
if preference is None:
|
||||
return ProfilePreferenceResponse(
|
||||
status="success",
|
||||
message="Preference not configured yet",
|
||||
preference=self._build_default_preference(current_user.id),
|
||||
security=security_summary,
|
||||
)
|
||||
return ProfilePreferenceResponse(
|
||||
status="success",
|
||||
message="Preference loaded",
|
||||
preference=ProfilePreference.model_validate(preference, from_attributes=True),
|
||||
preference=self._to_preference_payload(preference, str(current_user.id)),
|
||||
security=security_summary,
|
||||
)
|
||||
# [/DEF:get_my_preference:Function]
|
||||
|
||||
@@ -121,27 +134,90 @@ class ProfileService:
|
||||
logger.explore("[EXPLORE] Cross-user mutation attempt blocked")
|
||||
raise ProfileAuthorizationError("Cross-user preference mutation is forbidden")
|
||||
|
||||
validation_errors = self._validate_update_payload(payload)
|
||||
preference = self._get_or_create_preference_row(current_user.id)
|
||||
provided_fields = set(getattr(payload, "model_fields_set", set()))
|
||||
|
||||
effective_superset_username = self._sanitize_username(preference.superset_username)
|
||||
if "superset_username" in provided_fields:
|
||||
effective_superset_username = self._sanitize_username(payload.superset_username)
|
||||
|
||||
effective_show_only = bool(preference.show_only_my_dashboards)
|
||||
if "show_only_my_dashboards" in provided_fields:
|
||||
effective_show_only = bool(payload.show_only_my_dashboards)
|
||||
|
||||
effective_git_username = self._sanitize_text(preference.git_username)
|
||||
if "git_username" in provided_fields:
|
||||
effective_git_username = self._sanitize_text(payload.git_username)
|
||||
|
||||
effective_git_email = self._sanitize_text(preference.git_email)
|
||||
if "git_email" in provided_fields:
|
||||
effective_git_email = self._sanitize_text(payload.git_email)
|
||||
|
||||
effective_start_page = self._normalize_start_page(preference.start_page)
|
||||
if "start_page" in provided_fields:
|
||||
effective_start_page = self._normalize_start_page(payload.start_page)
|
||||
|
||||
effective_auto_open_task_drawer = (
|
||||
bool(preference.auto_open_task_drawer)
|
||||
if preference.auto_open_task_drawer is not None
|
||||
else True
|
||||
)
|
||||
if "auto_open_task_drawer" in provided_fields:
|
||||
effective_auto_open_task_drawer = bool(payload.auto_open_task_drawer)
|
||||
|
||||
effective_dashboards_table_density = self._normalize_density(
|
||||
preference.dashboards_table_density
|
||||
)
|
||||
if "dashboards_table_density" in provided_fields:
|
||||
effective_dashboards_table_density = self._normalize_density(
|
||||
payload.dashboards_table_density
|
||||
)
|
||||
|
||||
validation_errors = self._validate_update_payload(
|
||||
superset_username=effective_superset_username,
|
||||
show_only_my_dashboards=effective_show_only,
|
||||
git_email=effective_git_email,
|
||||
start_page=effective_start_page,
|
||||
dashboards_table_density=effective_dashboards_table_density,
|
||||
)
|
||||
if validation_errors:
|
||||
logger.reflect("[REFLECT] Validation failed; mutation is denied")
|
||||
raise ProfileValidationError(validation_errors)
|
||||
|
||||
normalized_username = self._normalize_username(payload.superset_username)
|
||||
raw_username = self._sanitize_username(payload.superset_username)
|
||||
preference.superset_username = effective_superset_username
|
||||
preference.superset_username_normalized = self._normalize_username(
|
||||
effective_superset_username
|
||||
)
|
||||
preference.show_only_my_dashboards = effective_show_only
|
||||
|
||||
preference = self._get_or_create_preference_row(current_user.id)
|
||||
preference.superset_username = raw_username
|
||||
preference.superset_username_normalized = normalized_username
|
||||
preference.show_only_my_dashboards = bool(payload.show_only_my_dashboards)
|
||||
preference.git_username = effective_git_username
|
||||
preference.git_email = effective_git_email
|
||||
|
||||
if "git_personal_access_token" in provided_fields:
|
||||
sanitized_token = self._sanitize_secret(payload.git_personal_access_token)
|
||||
if sanitized_token is None:
|
||||
preference.git_personal_access_token_encrypted = None
|
||||
else:
|
||||
preference.git_personal_access_token_encrypted = self.encryption.encrypt(
|
||||
sanitized_token
|
||||
)
|
||||
|
||||
preference.start_page = effective_start_page
|
||||
preference.auto_open_task_drawer = effective_auto_open_task_drawer
|
||||
preference.dashboards_table_density = effective_dashboards_table_density
|
||||
preference.updated_at = datetime.utcnow()
|
||||
|
||||
self.auth_repository.save_user_dashboard_preference(preference)
|
||||
persisted_preference = self.auth_repository.save_user_dashboard_preference(preference)
|
||||
|
||||
logger.reason("[REASON] Preference persisted successfully")
|
||||
return ProfilePreferenceResponse(
|
||||
status="success",
|
||||
message="Preference saved",
|
||||
preference=ProfilePreference.model_validate(preference, from_attributes=True),
|
||||
preference=self._to_preference_payload(
|
||||
persisted_preference,
|
||||
str(current_user.id),
|
||||
),
|
||||
security=self._build_security_summary(current_user),
|
||||
)
|
||||
# [/DEF:update_my_preference:Function]
|
||||
|
||||
@@ -245,6 +321,206 @@ class ProfileService:
|
||||
return False
|
||||
# [/DEF:matches_dashboard_actor:Function]
|
||||
|
||||
# [DEF:_build_security_summary:Function]
|
||||
# @PURPOSE: Build read-only security snapshot with role and permission badges.
|
||||
# @PRE: current_user is authenticated.
|
||||
# @POST: Returns deterministic security projection for profile UI.
|
||||
def _build_security_summary(self, current_user: User) -> ProfileSecuritySummary:
|
||||
role_names_set: Set[str] = set()
|
||||
roles = getattr(current_user, "roles", []) or []
|
||||
for role in roles:
|
||||
normalized_role_name = self._sanitize_text(getattr(role, "name", None))
|
||||
if normalized_role_name:
|
||||
role_names_set.add(normalized_role_name)
|
||||
role_names = sorted(role_names_set)
|
||||
|
||||
is_admin = any(str(role_name).lower() == "admin" for role_name in role_names)
|
||||
user_permission_pairs = self._collect_user_permission_pairs(current_user)
|
||||
|
||||
declared_permission_pairs: Set[Tuple[str, str]] = set()
|
||||
try:
|
||||
discovered_permissions = discover_declared_permissions(
|
||||
plugin_loader=self.plugin_loader
|
||||
)
|
||||
for resource, action in discovered_permissions:
|
||||
normalized_resource = self._sanitize_text(resource)
|
||||
normalized_action = str(action or "").strip().upper()
|
||||
if normalized_resource and normalized_action:
|
||||
declared_permission_pairs.add((normalized_resource, normalized_action))
|
||||
except Exception as discovery_error:
|
||||
logger.warning(
|
||||
"[ProfileService][EXPLORE] Failed to build declared permission catalog: %s",
|
||||
discovery_error,
|
||||
)
|
||||
|
||||
if not declared_permission_pairs:
|
||||
declared_permission_pairs = set(user_permission_pairs)
|
||||
|
||||
sorted_permission_pairs = sorted(
|
||||
declared_permission_pairs,
|
||||
key=lambda pair: (pair[0], pair[1]),
|
||||
)
|
||||
permission_states = [
|
||||
ProfilePermissionState(
|
||||
key=self._format_permission_key(resource, action),
|
||||
allowed=bool(is_admin or (resource, action) in user_permission_pairs),
|
||||
)
|
||||
for resource, action in sorted_permission_pairs
|
||||
]
|
||||
|
||||
auth_source = self._sanitize_text(getattr(current_user, "auth_source", None))
|
||||
current_role = "Admin" if is_admin else (role_names[0] if role_names else None)
|
||||
|
||||
return ProfileSecuritySummary(
|
||||
read_only=True,
|
||||
auth_source=auth_source,
|
||||
current_role=current_role,
|
||||
role_source=auth_source,
|
||||
roles=role_names,
|
||||
permissions=permission_states,
|
||||
)
|
||||
# [/DEF:_build_security_summary:Function]
|
||||
|
||||
# [DEF:_collect_user_permission_pairs:Function]
|
||||
# @PURPOSE: Collect effective permission tuples from current user's roles.
|
||||
# @PRE: current_user can include role/permission graph.
|
||||
# @POST: Returns unique normalized (resource, ACTION) tuples.
|
||||
def _collect_user_permission_pairs(self, current_user: User) -> Set[Tuple[str, str]]:
|
||||
collected: Set[Tuple[str, str]] = set()
|
||||
roles = getattr(current_user, "roles", []) or []
|
||||
for role in roles:
|
||||
permissions = getattr(role, "permissions", []) or []
|
||||
for permission in permissions:
|
||||
resource = self._sanitize_text(getattr(permission, "resource", None))
|
||||
action = str(getattr(permission, "action", "") or "").strip().upper()
|
||||
if resource and action:
|
||||
collected.add((resource, action))
|
||||
return collected
|
||||
# [/DEF:_collect_user_permission_pairs:Function]
|
||||
|
||||
# [DEF:_format_permission_key:Function]
|
||||
# @PURPOSE: Convert normalized permission pair to compact UI key.
|
||||
# @PRE: resource and action are normalized.
|
||||
# @POST: Returns user-facing badge key.
|
||||
def _format_permission_key(self, resource: str, action: str) -> str:
|
||||
normalized_resource = self._sanitize_text(resource) or ""
|
||||
normalized_action = str(action or "").strip().upper()
|
||||
if normalized_action == "READ":
|
||||
return normalized_resource
|
||||
return f"{normalized_resource}:{normalized_action.lower()}"
|
||||
# [/DEF:_format_permission_key:Function]
|
||||
|
||||
# [DEF:_to_preference_payload:Function]
|
||||
# @PURPOSE: Map ORM preference row to API DTO with token metadata.
|
||||
# @PRE: preference row can contain nullable optional fields.
|
||||
# @POST: Returns normalized ProfilePreference object.
|
||||
def _to_preference_payload(
|
||||
self,
|
||||
preference: UserDashboardPreference,
|
||||
user_id: str,
|
||||
) -> ProfilePreference:
|
||||
encrypted_token = self._sanitize_text(
|
||||
preference.git_personal_access_token_encrypted
|
||||
)
|
||||
token_masked = None
|
||||
if encrypted_token:
|
||||
try:
|
||||
decrypted_token = self.encryption.decrypt(encrypted_token)
|
||||
token_masked = self._mask_secret_value(decrypted_token)
|
||||
except Exception:
|
||||
token_masked = "***"
|
||||
|
||||
created_at = getattr(preference, "created_at", None) or datetime.utcnow()
|
||||
updated_at = getattr(preference, "updated_at", None) or created_at
|
||||
|
||||
return ProfilePreference(
|
||||
user_id=str(user_id),
|
||||
superset_username=self._sanitize_username(preference.superset_username),
|
||||
superset_username_normalized=self._normalize_username(
|
||||
preference.superset_username_normalized
|
||||
),
|
||||
show_only_my_dashboards=bool(preference.show_only_my_dashboards),
|
||||
git_username=self._sanitize_text(preference.git_username),
|
||||
git_email=self._sanitize_text(preference.git_email),
|
||||
has_git_personal_access_token=bool(encrypted_token),
|
||||
git_personal_access_token_masked=token_masked,
|
||||
start_page=self._normalize_start_page(preference.start_page),
|
||||
auto_open_task_drawer=(
|
||||
bool(preference.auto_open_task_drawer)
|
||||
if preference.auto_open_task_drawer is not None
|
||||
else True
|
||||
),
|
||||
dashboards_table_density=self._normalize_density(
|
||||
preference.dashboards_table_density
|
||||
),
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
# [/DEF:_to_preference_payload:Function]
|
||||
|
||||
# [DEF:_mask_secret_value:Function]
|
||||
# @PURPOSE: Build a safe display value for sensitive secrets.
|
||||
# @PRE: secret may be None or plaintext.
|
||||
# @POST: Returns masked representation or None.
|
||||
def _mask_secret_value(self, secret: Optional[str]) -> Optional[str]:
|
||||
sanitized_secret = self._sanitize_secret(secret)
|
||||
if sanitized_secret is None:
|
||||
return None
|
||||
if len(sanitized_secret) <= 4:
|
||||
return "***"
|
||||
return f"{sanitized_secret[:2]}***{sanitized_secret[-2:]}"
|
||||
# [/DEF:_mask_secret_value:Function]
|
||||
|
||||
# [DEF:_sanitize_text:Function]
|
||||
# @PURPOSE: Normalize optional text into trimmed form or None.
|
||||
# @PRE: value may be empty or None.
|
||||
# @POST: Returns trimmed value or None.
|
||||
def _sanitize_text(self, value: Optional[str]) -> Optional[str]:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized
|
||||
# [/DEF:_sanitize_text:Function]
|
||||
|
||||
# [DEF:_sanitize_secret:Function]
|
||||
# @PURPOSE: Normalize secret input into trimmed form or None.
|
||||
# @PRE: value may be None or blank.
|
||||
# @POST: Returns trimmed secret or None.
|
||||
def _sanitize_secret(self, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized
|
||||
# [/DEF:_sanitize_secret:Function]
|
||||
|
||||
# [DEF:_normalize_start_page:Function]
|
||||
# @PURPOSE: Normalize supported start page aliases to canonical values.
|
||||
# @PRE: value may be None or alias.
|
||||
# @POST: Returns one of SUPPORTED_START_PAGES.
|
||||
def _normalize_start_page(self, value: Optional[str]) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized == "reports-logs":
|
||||
return "reports"
|
||||
if normalized in SUPPORTED_START_PAGES:
|
||||
return normalized
|
||||
return "dashboards"
|
||||
# [/DEF:_normalize_start_page:Function]
|
||||
|
||||
# [DEF:_normalize_density:Function]
|
||||
# @PURPOSE: Normalize supported density aliases to canonical values.
|
||||
# @PRE: value may be None or alias.
|
||||
# @POST: Returns one of SUPPORTED_DENSITIES.
|
||||
def _normalize_density(self, value: Optional[str]) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized == "free":
|
||||
return "comfortable"
|
||||
if normalized in SUPPORTED_DENSITIES:
|
||||
return normalized
|
||||
return "comfortable"
|
||||
# [/DEF:_normalize_density:Function]
|
||||
|
||||
# [DEF:_resolve_environment:Function]
|
||||
# @PURPOSE: Resolve environment model from configured environments by id.
|
||||
# @PRE: environment_id is provided.
|
||||
@@ -287,6 +563,13 @@ class ProfileService:
|
||||
superset_username=None,
|
||||
superset_username_normalized=None,
|
||||
show_only_my_dashboards=False,
|
||||
git_username=None,
|
||||
git_email=None,
|
||||
has_git_personal_access_token=False,
|
||||
git_personal_access_token_masked=None,
|
||||
start_page="dashboards",
|
||||
auto_open_task_drawer=True,
|
||||
dashboards_table_density="comfortable",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -298,17 +581,38 @@ class ProfileService:
|
||||
# @POST: Returns validation errors list; empty list means valid.
|
||||
def _validate_update_payload(
|
||||
self,
|
||||
payload: ProfilePreferenceUpdateRequest,
|
||||
superset_username: Optional[str],
|
||||
show_only_my_dashboards: bool,
|
||||
git_email: Optional[str],
|
||||
start_page: str,
|
||||
dashboards_table_density: str,
|
||||
) -> List[str]:
|
||||
errors: List[str] = []
|
||||
sanitized_username = self._sanitize_username(payload.superset_username)
|
||||
sanitized_username = self._sanitize_username(superset_username)
|
||||
|
||||
if sanitized_username and any(ch.isspace() for ch in sanitized_username):
|
||||
errors.append(
|
||||
"Username should not contain spaces. Please enter a valid Apache Superset username."
|
||||
)
|
||||
if payload.show_only_my_dashboards and not sanitized_username:
|
||||
if show_only_my_dashboards and not sanitized_username:
|
||||
errors.append("Superset username is required when default filter is enabled.")
|
||||
|
||||
sanitized_git_email = self._sanitize_text(git_email)
|
||||
if sanitized_git_email:
|
||||
if (
|
||||
" " in sanitized_git_email
|
||||
or "@" not in sanitized_git_email
|
||||
or sanitized_git_email.startswith("@")
|
||||
or sanitized_git_email.endswith("@")
|
||||
):
|
||||
errors.append("Git email should be a valid email address.")
|
||||
|
||||
if start_page not in SUPPORTED_START_PAGES:
|
||||
errors.append("Start page value is not supported.")
|
||||
|
||||
if dashboards_table_density not in SUPPORTED_DENSITIES:
|
||||
errors.append("Dashboards table density value is not supported.")
|
||||
|
||||
return errors
|
||||
# [/DEF:_validate_update_payload:Function]
|
||||
|
||||
@@ -317,10 +621,7 @@ class ProfileService:
|
||||
# @PRE: value can be empty or None.
|
||||
# @POST: Returns trimmed username or None.
|
||||
def _sanitize_username(self, value: Optional[str]) -> Optional[str]:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized
|
||||
return self._sanitize_text(value)
|
||||
# [/DEF:_sanitize_username:Function]
|
||||
|
||||
# [DEF:_normalize_username:Function]
|
||||
|
||||
20
backend/test_pat_api.py
Normal file
20
backend/test_pat_api.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
class GitProvider(str):
|
||||
pass
|
||||
|
||||
class GitServerConfigBase(BaseModel):
|
||||
name: str = Field(..., description="Display name for the Git server")
|
||||
provider: str = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)")
|
||||
url: str = Field(..., description="Server base URL")
|
||||
pat: str = Field(..., description="Personal Access Token")
|
||||
pat: str = Field(..., description="Personal Access Token")
|
||||
default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)")
|
||||
default_branch: Optional[str] = Field("main", description="Default branch logic/name")
|
||||
|
||||
class GitServerConfigSchema(GitServerConfigBase):
|
||||
id: str
|
||||
status: str
|
||||
|
||||
print(GitServerConfigSchema.model_fields.keys())
|
||||
@@ -113,3 +113,20 @@ def test_git_service_ensure_gitflow_branches_creates_and_pushes_missing_defaults
|
||||
assert {"main", "dev", "preprod"}.issubset(local_branch_names)
|
||||
assert "dev:dev" in repo.origin.pushed
|
||||
assert "preprod:preprod" in repo.origin.pushed
|
||||
|
||||
|
||||
def test_git_service_configure_identity_updates_repo_local_config():
|
||||
"""Verify configure_identity writes repository-local user.name/user.email."""
|
||||
service = GitService(base_path="test_repos_identity")
|
||||
|
||||
config_writer_context = MagicMock()
|
||||
config_writer = config_writer_context.__enter__.return_value
|
||||
fake_repo = MagicMock()
|
||||
fake_repo.config_writer.return_value = config_writer_context
|
||||
|
||||
with patch.object(service, "get_repo", return_value=fake_repo):
|
||||
service.configure_identity(42, "user_1", "user1@mail.ru")
|
||||
|
||||
fake_repo.config_writer.assert_called_once_with(config_level="repository")
|
||||
config_writer.set_value.assert_any_call("user", "name", "user_1")
|
||||
config_writer.set_value.assert_any_call("user", "email", "user1@mail.ru")
|
||||
|
||||
Reference in New Issue
Block a user