# #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 -> [ProfileUtils] # @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 UTC, datetime from typing import Any from sqlalchemy.orm import Session from ..core.auth.repository import AuthRepository from ..core.encryption import EncryptionManager 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 .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.now(UTC) 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.now(UTC) 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 ProfileUtils.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