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:
2026-03-08 10:19:38 +03:00
parent 12d17ec35e
commit e864a9e08b
30 changed files with 2041 additions and 211 deletions

View File

@@ -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]

View File

@@ -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]

View File

@@ -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,

View File

@@ -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]

View File

@@ -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,

View File

@@ -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]

View File

@@ -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]

View File

@@ -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,

View File

@@ -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]

View File

@@ -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

View File

@@ -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
View 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())

View File

@@ -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")