Files
ss-tools/backend/src/services/profile_service.py

172 lines
7.5 KiB
Python

# #region profile_service [C:5] [TYPE Module] [SEMANTICS sqlalchemy, profile, preference, superset, lookup]
#
# @BRIEF Composite facade orchestrating profile preference persistence, Superset account lookup,
# security badges, and deterministic actor matching by delegating to focused sub-services.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
# @RELATION DEPENDS_ON -> [ProfilePreferenceResponse]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [AuthRepositoryModule]
# @RELATION DEPENDS_ON -> [User]
# @RELATION DEPENDS_ON -> [EXT:Library:sqlalchemy.orm.Session]
# @RELATION DEPENDS_ON -> [ProfilePreferenceService]
# @RELATION DEPENDS_ON -> [SupersetLookupService]
# @RELATION DEPENDS_ON -> [SecurityBadgeService]
# @RELATION DEPENDS_ON -> [ProfileUtils]
#
# @INVARIANT Profile ID needs to be unique per-user session
#
# @TEST_CONTRACT ProfilePreferenceUpdateRequest -> ProfilePreferenceResponse
# @TEST_FIXTURE valid_profile_update -> {"user_id":"u-1","superset_username":"John_Doe","show_only_my_dashboards":true}
# @TEST_EDGE enable_without_username -> toggle=true with empty username returns validation error
# @TEST_EDGE cross_user_mutation -> attempt to update another user preference returns forbidden
# @TEST_EDGE lookup_env_not_found -> unknown environment_id returns not found
# @TEST_INVARIANT normalization_consistency -> VERIFIED_BY: [valid_profile_update, enable_without_username]
# @DATA_CONTRACT Profile_id -> ProfileInfo; session_id -> valid UUID
# @PRE Session is active and valid
# @POST Profile with updated fields populated and
# @SIDE_EFFECT Database read/write operations
# @RATIONALE Decomposed from monolithic 770-line ProfileService into three focused services
# (ProfilePreferenceService, SupersetLookupService, SecurityBadgeService) plus pure
# utility functions (ProfileUtils) to satisfy INV_7. This module is a thin facade
# that preserves the public API contract for all existing callers.
# @REJECTED Keeping all three domains in a single ProfileService class was rejected — it violated
# INV_7 (770 lines vs 150 max), mixed I/O patterns (DB writes + HTTP calls), and created
# unnecessary coupling between preference persistence and Superset network operations.
from collections.abc import Iterable
from typing import Any
from sqlalchemy.orm import Session
from ..models.auth import User
from ..schemas.profile import (
ProfilePreferenceResponse,
ProfilePreferenceUpdateRequest,
SupersetAccountLookupRequest,
SupersetAccountLookupResponse,
)
from .profile_preference_service import ProfilePreferenceService
from .profile_utils import (
EnvironmentNotFoundError,
ProfileAuthorizationError,
ProfileValidationError,
normalize_owner_tokens,
normalize_username,
)
from .security_badge_service import SecurityBadgeService
from .superset_lookup_service import SupersetLookupService
# Re-export exception classes for backward-compatible imports
__all__ = [
"EnvironmentNotFoundError",
"ProfileAuthorizationError",
"ProfilePreferenceService",
"ProfileService",
"ProfileValidationError",
"SecurityBadgeService",
"SupersetLookupService",
]
# #region ProfileService [C:4] [TYPE Class] [SEMANTICS profile,facade,delegate,coordinator]
# @BRIEF Facade that composes ProfilePreferenceService, SupersetLookupService, and
# SecurityBadgeService into a single interface for backward compatibility.
# @RELATION DEPENDS_ON -> [ProfilePreferenceService]
# @RELATION DEPENDS_ON -> [SupersetLookupService]
# @RELATION DEPENDS_ON -> [SecurityBadgeService]
# @RELATION DEPENDS_ON -> [ProfileUtils]
# @PRE Caller provides authenticated User context for external service methods.
# @POST Delegates to sub-services and returns normalized profile/lookup responses.
# @SIDE_EFFECT Writes preference records and encrypted tokens; performs external account lookups when requested.
# @DATA_CONTRACT Input[User,ProfilePreferenceUpdateRequest|SupersetAccountLookupRequest] -> Output[ProfilePreferenceResponse|SupersetAccountLookupResponse|bool]
# @INVARIANT Profile data integrity maintained, cache consistency with database state
# @RATIONALE Thin coordinator — all sub-10-line delegating methods. Actual business logic lives
# in the injected sub-services.
class ProfileService:
"""Facade composing preference, lookup, and security services."""
# region init [TYPE Function]
# @BRIEF Initialize facade and create sub-services.
# @PRE db session is active and config_manager supports get_environments().
# @POST All sub-services are initialized and ready.
def __init__(self, db: Session, config_manager: Any, plugin_loader: Any = None):
self.preference_service = ProfilePreferenceService(db, config_manager, plugin_loader)
self.lookup_service = SupersetLookupService(config_manager)
self.security_badge_service = SecurityBadgeService(plugin_loader)
self.db = db
self.config_manager = config_manager
self.plugin_loader = plugin_loader
# endregion init
# region get_my_preference [TYPE Function]
# @BRIEF Delegate to ProfilePreferenceService.
def get_my_preference(self, current_user: User) -> ProfilePreferenceResponse:
return self.preference_service.get_my_preference(current_user)
# endregion get_my_preference
# region get_dashboard_filter_binding [TYPE Function]
# @BRIEF Delegate to ProfilePreferenceService.
def get_dashboard_filter_binding(self, current_user: User) -> dict:
return self.preference_service.get_dashboard_filter_binding(current_user)
# endregion get_dashboard_filter_binding
# region update_my_preference [TYPE Function]
# @BRIEF Delegate to ProfilePreferenceService.
def update_my_preference(
self,
current_user: User,
payload: ProfilePreferenceUpdateRequest,
target_user_id: str | None = None,
) -> ProfilePreferenceResponse:
return self.preference_service.update_my_preference(
current_user, payload, target_user_id
)
# endregion update_my_preference
# region lookup_superset_accounts [TYPE Function]
# @BRIEF Delegate to SupersetLookupService.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API.
async def lookup_superset_accounts(
self,
current_user: User,
request: SupersetAccountLookupRequest,
) -> SupersetAccountLookupResponse:
return await self.lookup_service.lookup_superset_accounts(current_user, request)
# endregion lookup_superset_accounts
# region matches_dashboard_actor [TYPE Function]
# @BRIEF Apply trim+case-insensitive actor match across owners OR modified_by.
# @PRE bound_username can be empty; owners may contain mixed payload.
# @POST Returns True when normalized username matches owners or modified_by.
def matches_dashboard_actor(
self,
bound_username: str | None,
owners: Iterable[Any] | None,
modified_by: str | None,
) -> bool:
normalized_actor = normalize_username(bound_username)
if not normalized_actor:
return False
owner_tokens = normalize_owner_tokens(owners)
modified_token = normalize_username(modified_by)
if normalized_actor in owner_tokens:
return True
if modified_token and normalized_actor == modified_token:
return True
return False
# endregion matches_dashboard_actor
# #endregion ProfileService
# #endregion profile_service