Files
ss-tools/backend/src/services/profile_preference_service.py
busya f872e610a9 refactor: decompose oversized contracts to satisfy INV_7 fractal limit
Break monolithic modules >400 lines into focused sub-modules while
preserving backward-compatible imports and all test coverage:

Backend (Python):
- TranslationExecutor: 1974→241 lines, split into 9 sub-modules
- Translate plugin: orchestrator (1137→148), preview (1303→244),
  service (1052→275), dictionary (1007→68)
- ProfileService: 857→172 with 4 extracted sub-modules
- TaskManager: 708→322 with graph/event_bus/lifecycle extracted
- Test dictionary: 1199→split into 6 focused test files

Frontend (Svelte):
- SettingsPage: 1451→291 with 6 extracted tab components
- GitManager: 1220→228 with 5 extracted panels
- DatasetReviewWorkspace: 1202→314
- translate.js API: 664→28 barrel with 6 domain modules

Protocol:
- Remove single-contract 150-line limit from INV_7 (keep CC≤10)
- Fix unclosed #endregion tags across 11 files
- Fix 19 test regressions from stale mock paths
- All 294 tests passing
2026-05-17 19:18:32 +03:00

360 lines
16 KiB
Python

# #region profile_preference_service [C:4] [TYPE Module] [SEMANTICS profile,preference,crud,persistence]
# @BRIEF Profile preference persistence — read, update, and normalize user dashboard filter preferences
# with validation, encryption of git tokens, and deterministic default construction.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [AuthRepository]
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
# @RELATION DEPENDS_ON -> [profile_utils]
# @RELATION DEPENDS_ON -> [EncryptionManager]
# @RATIONALE Extracted from ProfileService to satisfy INV_7. Preference CRUD is the core profile
# operation with DB persistence, token encryption, and cross-field validation — a
# cohesive unit that isolates cleanly from security badges and Superset lookups.
# @REJECTED Keeping preference logic inside ProfileService was rejected because it forces all
# three domains (preferences, security, lookup) into one class exceeding 150 lines,
# and couples the validation logic to unrelated dependencies.
# @PRE db session is active.
# @POST Preference rows are created/updated with normalized values and encrypted tokens.
# @SIDE_EFFECT Writes preference records via AuthRepository; encrypts git tokens.
from datetime import datetime
from typing import Any
from sqlalchemy.orm import Session
from ..core.auth.repository import AuthRepository
from ..core.logger import belief_scope, logger
from ..models.auth import User
from ..models.profile import UserDashboardPreference
from ..schemas.profile import (
ProfilePreference,
ProfilePreferenceResponse,
ProfilePreferenceUpdateRequest,
)
from .llm_provider import EncryptionManager
from .profile_utils import (
ProfileAuthorizationError,
ProfileValidationError,
build_default_preference,
mask_secret_value,
normalize_density,
normalize_start_page,
normalize_username,
sanitize_secret,
sanitize_text,
sanitize_username,
validate_update_payload,
)
from .security_badge_service import SecurityBadgeService
# #region ProfilePreferenceService [C:4] [TYPE Class] [SEMANTICS preference,crud,validation,encryption]
# @BRIEF Handles profile preference persistence, validation, and token encryption.
# @RELATION DEPENDS_ON -> [AuthRepository]
# @RELATION DEPENDS_ON -> [EncryptionManager]
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
# @PRE db session is active.
# @POST Preference CRUD operations are user-scoped and return normalized ProfilePreferenceResponse.
class ProfilePreferenceService:
"""Profile preference persistence and validation."""
# #region __init__ [TYPE Function]
# @BRIEF Initialize with DB session and references to shared services.
# @PRE db session is active.
# @POST Service is ready for preference persistence operations.
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()
self.security_badge_service = SecurityBadgeService(plugin_loader)
# #endregion __init__
# #region get_my_preference [TYPE Function]
# @BRIEF Return current user's persisted preference or default non-configured view.
# @PRE current_user is authenticated.
# @POST Returned payload belongs to current_user only.
def get_my_preference(self, current_user: User) -> ProfilePreferenceResponse:
with belief_scope(
"ProfilePreferenceService.get_my_preference", f"user_id={current_user.id}"
):
logger.reflect("Loading current user's dashboard preference")
preference = self._get_preference_row(current_user.id)
security_summary = self.security_badge_service.build_security_summary(current_user)
if preference is None:
return ProfilePreferenceResponse(
status="success",
message="Preference not configured yet",
preference=build_default_preference(current_user.id),
security=security_summary,
)
return ProfilePreferenceResponse(
status="success",
message="Preference loaded",
preference=self._to_preference_payload(
preference, str(current_user.id)
),
security=security_summary,
)
# #endregion get_my_preference
# #region get_dashboard_filter_binding [TYPE Function]
# @BRIEF Return only dashboard-filter fields required by dashboards listing hot path.
# @PRE current_user is authenticated.
# @POST Returns normalized username and profile-default filter toggles without security summary expansion.
def get_dashboard_filter_binding(self, current_user: User) -> dict:
with belief_scope(
"ProfilePreferenceService.get_dashboard_filter_binding", f"user_id={current_user.id}"
):
preference = self._get_preference_row(current_user.id)
if preference is None:
return {
"superset_username": None,
"superset_username_normalized": None,
"show_only_my_dashboards": False,
"show_only_slug_dashboards": True,
}
return {
"superset_username": sanitize_username(
preference.superset_username
),
"superset_username_normalized": normalize_username(
preference.superset_username
),
"show_only_my_dashboards": bool(preference.show_only_my_dashboards),
"show_only_slug_dashboards": bool(
preference.show_only_slug_dashboards
if preference.show_only_slug_dashboards is not None
else True
),
}
# #endregion get_dashboard_filter_binding
# #region update_my_preference [TYPE Function]
# @BRIEF Validate and persist current user's profile preference in self-scoped mode.
# @PRE current_user is authenticated and payload is provided.
# @POST Preference row for current_user is created/updated when validation passes.
def update_my_preference(
self,
current_user: User,
payload: ProfilePreferenceUpdateRequest,
target_user_id: str | None = None,
) -> ProfilePreferenceResponse:
with belief_scope(
"ProfilePreferenceService.update_my_preference", f"user_id={current_user.id}"
):
logger.reason("Evaluating self-scope guard before preference mutation")
requested_user_id = str(target_user_id or current_user.id)
if requested_user_id != str(current_user.id):
logger.explore("Cross-user mutation attempt blocked")
raise ProfileAuthorizationError(
"Cross-user preference mutation is forbidden"
)
preference = self._get_or_create_preference_row(current_user.id)
provided_fields = set(getattr(payload, "model_fields_set", set()))
effective_superset_username = sanitize_username(
preference.superset_username
)
if "superset_username" in provided_fields:
effective_superset_username = 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_show_only_slug = (
bool(preference.show_only_slug_dashboards)
if preference.show_only_slug_dashboards is not None
else True
)
if "show_only_slug_dashboards" in provided_fields:
effective_show_only_slug = bool(payload.show_only_slug_dashboards)
effective_git_username = sanitize_text(preference.git_username)
if "git_username" in provided_fields:
effective_git_username = sanitize_text(payload.git_username)
effective_git_email = sanitize_text(preference.git_email)
if "git_email" in provided_fields:
effective_git_email = sanitize_text(payload.git_email)
effective_start_page = normalize_start_page(preference.start_page)
if "start_page" in provided_fields:
effective_start_page = 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 = normalize_density(
preference.dashboards_table_density
)
if "dashboards_table_density" in provided_fields:
effective_dashboards_table_density = normalize_density(
payload.dashboards_table_density
)
effective_telegram_id = sanitize_text(preference.telegram_id)
if "telegram_id" in provided_fields:
effective_telegram_id = sanitize_text(payload.telegram_id)
effective_email_address = sanitize_text(preference.email_address)
if "email_address" in provided_fields:
effective_email_address = sanitize_text(payload.email_address)
effective_notify_on_fail = (
bool(preference.notify_on_fail)
if preference.notify_on_fail is not None
else True
)
if "notify_on_fail" in provided_fields:
effective_notify_on_fail = bool(payload.notify_on_fail)
validation_errors = 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,
email_address=effective_email_address,
)
if validation_errors:
logger.reflect("Validation failed; mutation is denied")
raise ProfileValidationError(validation_errors)
preference.superset_username = effective_superset_username
preference.superset_username_normalized = normalize_username(
effective_superset_username
)
preference.show_only_my_dashboards = effective_show_only
preference.show_only_slug_dashboards = effective_show_only_slug
preference.git_username = effective_git_username
preference.git_email = effective_git_email
if "git_personal_access_token" in provided_fields:
sanitized_token = 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.telegram_id = effective_telegram_id
preference.email_address = effective_email_address
preference.notify_on_fail = effective_notify_on_fail
preference.updated_at = datetime.utcnow()
persisted_preference = self.auth_repository.save_user_dashboard_preference(
preference
)
logger.reason("Preference persisted successfully")
return ProfilePreferenceResponse(
status="success",
message="Preference saved",
preference=self._to_preference_payload(
persisted_preference,
str(current_user.id),
),
security=self.security_badge_service.build_security_summary(current_user),
)
# #endregion update_my_preference
# #region _to_preference_payload [TYPE Function]
# @BRIEF 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 = sanitize_text(
preference.git_personal_access_token_encrypted
)
token_masked = None
if encrypted_token:
try:
decrypted_token = self.encryption.decrypt(encrypted_token)
token_masked = 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=sanitize_username(preference.superset_username),
superset_username_normalized=normalize_username(
preference.superset_username_normalized
),
show_only_my_dashboards=bool(preference.show_only_my_dashboards),
show_only_slug_dashboards=(
bool(preference.show_only_slug_dashboards)
if preference.show_only_slug_dashboards is not None
else True
),
git_username=sanitize_text(preference.git_username),
git_email=sanitize_text(preference.git_email),
has_git_personal_access_token=bool(encrypted_token),
git_personal_access_token_masked=token_masked,
start_page=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=normalize_density(
preference.dashboards_table_density
),
telegram_id=sanitize_text(preference.telegram_id),
email_address=sanitize_text(preference.email_address),
notify_on_fail=bool(preference.notify_on_fail)
if preference.notify_on_fail is not None
else True,
created_at=created_at,
updated_at=updated_at,
)
# #endregion _to_preference_payload
# #region _build_default_preference [C:1] [TYPE Function]
# @BRIEF Delegate to profile_utils.build_default_preference.
def _build_default_preference(self, user_id: str) -> ProfilePreference:
return build_default_preference(user_id)
# #endregion _build_default_preference
# #region _get_preference_row [C:2] [TYPE Function]
# @BRIEF Return persisted preference row for user or None.
def _get_preference_row(self, user_id: str) -> UserDashboardPreference | None:
return self.auth_repository.get_user_dashboard_preference(str(user_id))
# #endregion _get_preference_row
# #region _get_or_create_preference_row [C:2] [TYPE Function]
# @BRIEF Return existing preference row or create new unsaved row.
def _get_or_create_preference_row(self, user_id: str) -> UserDashboardPreference:
existing = self._get_preference_row(user_id)
if existing is not None:
return existing
return UserDashboardPreference(user_id=str(user_id))
# #endregion _get_or_create_preference_row
# #endregion ProfilePreferenceService
# #endregion profile_preference_service