- 10 translate plugin test files (100% coverage on 12 modules) - assistant/handler tools: 85+ tests covering dispatch, registry, resolvers, routes, llm_planner, 13 tool handlers - clean release: artifact_catalog_loader, mappers, approval, publication tests - API routes: translate_helpers, validation_service extensions, datasets to 100% - notifications: providers/service tests - services: profile_preference_service - docs/orthogonal-test-report.md — full speckit.tests audit - Fixes: 3 git_base async mock failures, 4 assistant handler permission-check patches - .gitignore: coverage artifacts
253 lines
9.5 KiB
Plaintext
253 lines
9.5 KiB
Plaintext
# #region ProfileUtils [C:2] [TYPE Module] [SEMANTICS string,normalization,utility,sanitize]
|
|
# @defgroup Services Module group.
|
|
# @BRIEF Pure utility helpers for profile data sanitization, normalization, and secret masking.
|
|
# Also contains shared exception classes to avoid circular imports between profile sub-modules.
|
|
# @LAYER Domain
|
|
# @RATIONALE Extracted from ProfileService to satisfy INV_7 — module length under 400 lines,
|
|
# contract length under 150 lines. These are stateless pure functions that do not
|
|
# require a class or service container.
|
|
# @REJECTED Keeping these as private methods on ProfileService was rejected because they
|
|
# inflate the class beyond 150 lines and mix pure computation with I/O-bound
|
|
# service logic.
|
|
|
|
> from collections.abc import Iterable, Sequence
|
|
> from datetime import UTC, datetime
|
|
> from typing import Any
|
|
|
|
|
|
# #region ProfileValidationError [C:2] [TYPE Class]
|
|
# @defgroup Services Module group.
|
|
# @RELATION INHERITS -> [EXT:Python:Exception]
|
|
# @BRIEF Domain validation error for profile preference update requests.
|
|
> class ProfileValidationError(Exception):
|
|
> def __init__(self, errors: Sequence[str]):
|
|
> self.errors = list(errors)
|
|
> super().__init__("Profile preference validation failed")
|
|
# #endregion ProfileValidationError
|
|
|
|
|
|
# #region EnvironmentNotFoundError [C:2] [TYPE Class]
|
|
# @defgroup Services Module group.
|
|
# @RELATION INHERITS -> [EXT:Python:Exception]
|
|
# @BRIEF Raised when environment_id from lookup request is unknown in app configuration.
|
|
> class EnvironmentNotFoundError(Exception):
|
|
> pass
|
|
# #endregion EnvironmentNotFoundError
|
|
|
|
|
|
# #region ProfileAuthorizationError [C:2] [TYPE Class]
|
|
# @defgroup Services Module group.
|
|
# @RELATION INHERITS -> [EXT:Python:Exception]
|
|
# @BRIEF Raised when caller attempts cross-user preference mutation.
|
|
> class ProfileAuthorizationError(Exception):
|
|
> pass
|
|
# #endregion ProfileAuthorizationError
|
|
|
|
> SUPPORTED_START_PAGES: set[str] = {"dashboards", "datasets", "reports"}
|
|
> SUPPORTED_DENSITIES: set[str] = {"compact", "comfortable"}
|
|
|
|
|
|
# #region sanitize_text [C:1] [TYPE Function]
|
|
# @BRIEF Normalize optional text into trimmed form or None.
|
|
> def sanitize_text(value: str | None) -> str | None:
|
|
> normalized = str(value or "").strip()
|
|
> if not normalized:
|
|
> return None
|
|
> return normalized
|
|
# #endregion sanitize_text
|
|
|
|
|
|
# #region sanitize_secret [C:1] [TYPE Function]
|
|
# @BRIEF Normalize secret input into trimmed form or None.
|
|
> def sanitize_secret(value: str | None) -> str | None:
|
|
> if value is None:
|
|
> return None
|
|
> normalized = str(value).strip()
|
|
> if not normalized:
|
|
> return None
|
|
> return normalized
|
|
# #endregion sanitize_secret
|
|
|
|
|
|
# #region sanitize_username [C:1] [TYPE Function]
|
|
# @BRIEF Normalize raw username into trimmed form or None for empty input.
|
|
> def sanitize_username(value: str | None) -> str | None:
|
|
> return sanitize_text(value)
|
|
# #endregion sanitize_username
|
|
|
|
|
|
# #region normalize_username [C:1] [TYPE Function]
|
|
# @BRIEF Apply deterministic trim+lower normalization for actor matching.
|
|
> def normalize_username(value: str | None) -> str | None:
|
|
> sanitized = sanitize_username(value)
|
|
> if sanitized is None:
|
|
> return None
|
|
> return sanitized.lower()
|
|
# #endregion normalize_username
|
|
|
|
|
|
# #region normalize_start_page [C:1] [TYPE Function]
|
|
# @BRIEF Normalize supported start page aliases to canonical values.
|
|
> def normalize_start_page(value: str | None) -> str:
|
|
> normalized = str(value or "").strip().lower()
|
|
> if normalized == "reports-logs":
|
|
> return "reports"
|
|
> if normalized in SUPPORTED_START_PAGES:
|
|
> return normalized
|
|
> return "dashboards"
|
|
# #endregion normalize_start_page
|
|
|
|
|
|
# #region normalize_density [C:1] [TYPE Function]
|
|
# @BRIEF Normalize supported density aliases to canonical values.
|
|
> def normalize_density(value: str | None) -> str:
|
|
> normalized = str(value or "").strip().lower()
|
|
> if normalized == "free":
|
|
> return "comfortable"
|
|
> if normalized in SUPPORTED_DENSITIES:
|
|
> return normalized
|
|
> return "comfortable"
|
|
# #endregion normalize_density
|
|
|
|
|
|
# #region mask_secret_value [C:1] [TYPE Function]
|
|
# @BRIEF Build a safe display value for sensitive secrets.
|
|
> def mask_secret_value(secret: str | None) -> str | None:
|
|
> sanitized = sanitize_secret(secret)
|
|
> if sanitized is None:
|
|
> return None
|
|
> if len(sanitized) <= 4:
|
|
> return "***"
|
|
> return f"{sanitized[:2]}***{sanitized[-2:]}"
|
|
# #endregion mask_secret_value
|
|
|
|
|
|
# #region validate_update_payload [C:2] [TYPE Function]
|
|
# @ingroup Services
|
|
# @BRIEF Validate username/toggle constraints for preference mutation.
|
|
# @RELATION CALLS -> [sanitize_username]
|
|
# @RELATION CALLS -> [sanitize_text]
|
|
# @RELATION DEPENDS_ON -> [ProfileUtils]
|
|
# @RELATION DEPENDS_ON -> [ProfileUtils]
|
|
# @POST Returns validation errors list; empty list means valid.
|
|
> def validate_update_payload(
|
|
> superset_username: str | None,
|
|
> show_only_my_dashboards: bool,
|
|
> git_email: str | None,
|
|
> start_page: str,
|
|
> dashboards_table_density: str,
|
|
> email_address: str | None = None,
|
|
> ) -> list[str]:
|
|
> errors: list[str] = []
|
|
> sanitized_username = 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 show_only_my_dashboards and not sanitized_username:
|
|
> errors.append(
|
|
> "Superset username is required when default filter is enabled."
|
|
> )
|
|
|
|
> sanitized_git_email = 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.")
|
|
|
|
> sanitized_email = sanitize_text(email_address)
|
|
> if sanitized_email:
|
|
> if (
|
|
> " " in sanitized_email
|
|
> or "@" not in sanitized_email
|
|
> or sanitized_email.startswith("@")
|
|
> or sanitized_email.endswith("@")
|
|
> ):
|
|
> errors.append("Notification email should be a valid email address.")
|
|
|
|
> return errors
|
|
# #endregion validate_update_payload
|
|
|
|
|
|
# #region build_default_preference [C:2] [TYPE Function] [SEMANTICS preference,default,unconfigured]
|
|
# @ingroup Services
|
|
# @BRIEF Build non-persisted default preference DTO for unconfigured users.
|
|
# @RELATION DEPENDS_ON -> [ProfilePreference]
|
|
> def build_default_preference(user_id: str) -> Any:
|
|
> """Build default ProfilePreference with disabled toggles and empty usernames."""
|
|
> from ..schemas.profile import ProfilePreference
|
|
> now = datetime.now(UTC)
|
|
> return ProfilePreference(
|
|
> user_id=str(user_id),
|
|
> superset_username=None,
|
|
> superset_username_normalized=None,
|
|
> show_only_my_dashboards=False,
|
|
> show_only_slug_dashboards=True,
|
|
> 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",
|
|
> telegram_id=None,
|
|
> email_address=None,
|
|
> notify_on_fail=True,
|
|
> created_at=now,
|
|
> updated_at=now,
|
|
> )
|
|
# #endregion build_default_preference
|
|
|
|
|
|
# #region normalize_owner_tokens [C:2] [TYPE Function]
|
|
# @ingroup Services
|
|
# @BRIEF Normalize owners payload into deduplicated lower-cased tokens.
|
|
# @RELATION CALLS -> [normalize_username]
|
|
> def normalize_owner_tokens(owners: Iterable[Any] | None) -> list[str]:
|
|
> if owners is None:
|
|
> return []
|
|
> normalized: list[str] = []
|
|
> for owner in owners:
|
|
> owner_candidates: list[Any]
|
|
> if isinstance(owner, dict):
|
|
> first_name = sanitize_username(str(owner.get("first_name") or ""))
|
|
> last_name = sanitize_username(str(owner.get("last_name") or ""))
|
|
> full_name = " ".join(
|
|
> part for part in [first_name, last_name] if part
|
|
> ).strip()
|
|
> snake_name = "_".join(
|
|
> part for part in [first_name, last_name] if part
|
|
> ).strip("_")
|
|
> owner_candidates = [
|
|
> owner.get("username"),
|
|
> owner.get("user_name"),
|
|
> owner.get("name"),
|
|
> owner.get("full_name"),
|
|
> first_name,
|
|
> last_name,
|
|
> full_name or None,
|
|
> snake_name or None,
|
|
> owner.get("email"),
|
|
> ]
|
|
> else:
|
|
> owner_candidates = [owner]
|
|
|
|
> for candidate in owner_candidates:
|
|
> token = normalize_username(str(candidate or ""))
|
|
> if token and token not in normalized:
|
|
> normalized.append(token)
|
|
> return normalized
|
|
# #endregion normalize_owner_tokens
|
|
# #endregion ProfileUtils
|