Files
ss-tools/backend/src/api/routes/profile.py

136 lines
5.8 KiB
Python

# [DEF:backend.src.api.routes.profile:Module]
#
# @TIER: CRITICAL
# @SEMANTICS: api, profile, preferences, self-service, account-lookup
# @PURPOSE: Exposes self-scoped profile preference endpoints and environment-based Superset account lookup.
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.services.profile_service
# @RELATION: DEPENDS_ON -> backend.src.dependencies.get_current_user
# @RELATION: DEPENDS_ON -> backend.src.core.database.get_db
#
# @INVARIANT: Endpoints are self-scoped and never mutate another user preference.
# @UX_STATE: ProfileLoad -> Returns stable ProfilePreferenceResponse for authenticated user.
# @UX_STATE: Saving -> Validation errors map to actionable 422 details.
# @UX_STATE: LookupLoading -> Returns success/degraded Superset lookup payload.
# @UX_FEEDBACK: Stable status/message/warning payloads support profile page feedback.
# @UX_RECOVERY: Lookup degradation keeps manual username save path available.
# [SECTION: IMPORTS]
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
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 ...models.auth import User
from ...schemas.profile import (
ProfilePreferenceResponse,
ProfilePreferenceUpdateRequest,
SupersetAccountLookupRequest,
SupersetAccountLookupResponse,
)
from ...services.profile_service import (
EnvironmentNotFoundError,
ProfileAuthorizationError,
ProfileService,
ProfileValidationError,
)
# [/SECTION]
router = APIRouter(prefix="/api/profile", tags=["profile"])
# [DEF:_get_profile_service:Function]
# @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:Function]
# [DEF:get_preferences:Function]
# @PURPOSE: Get authenticated user's dashboard filter preference.
# @PRE: Valid JWT and authenticated user context.
# @POST: Returns preference payload for current user only.
@router.get("/preferences", response_model=ProfilePreferenceResponse)
async def get_preferences(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
config_manager=Depends(get_config_manager),
):
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)
return service.get_my_preference(current_user)
# [/DEF:get_preferences:Function]
# [DEF:update_preferences:Function]
# @PURPOSE: Update authenticated user's dashboard filter preference.
# @PRE: Valid JWT and valid request payload.
# @POST: Persists normalized preference for current user or raises validation/authorization errors.
@router.patch("/preferences", response_model=ProfilePreferenceResponse)
async def update_preferences(
payload: ProfilePreferenceUpdateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
config_manager=Depends(get_config_manager),
):
with belief_scope("profile.update_preferences", f"user_id={current_user.id}"):
service = _get_profile_service(db, config_manager)
try:
logger.reason("[REASON] Attempting preference save")
return service.update_my_preference(current_user=current_user, payload=payload)
except ProfileValidationError as exc:
logger.reflect("[REFLECT] Preference validation failed")
raise HTTPException(status_code=422, detail=exc.errors) from exc
except ProfileAuthorizationError as exc:
logger.explore("[EXPLORE] Cross-user mutation guard blocked request")
raise HTTPException(status_code=403, detail=str(exc)) from exc
# [/DEF:update_preferences:Function]
# [DEF:lookup_superset_accounts:Function]
# @PURPOSE: Lookup Superset account candidates in selected environment.
# @PRE: Valid JWT, authenticated context, and environment_id query parameter.
# @POST: Returns success or degraded lookup payload with stable shape.
@router.get("/superset-accounts", response_model=SupersetAccountLookupResponse)
async def lookup_superset_accounts(
environment_id: str = Query(...),
search: Optional[str] = Query(default=None),
page_index: int = Query(default=0, ge=0),
page_size: int = Query(default=20, ge=1, le=100),
sort_column: str = Query(default="username"),
sort_order: str = Query(default="desc"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
config_manager=Depends(get_config_manager),
):
with belief_scope(
"profile.lookup_superset_accounts",
f"user_id={current_user.id}, environment_id={environment_id}",
):
service = _get_profile_service(db, config_manager)
lookup_request = SupersetAccountLookupRequest(
environment_id=environment_id,
search=search,
page_index=page_index,
page_size=page_size,
sort_column=sort_column,
sort_order=sort_order,
)
try:
logger.reason("[REASON] Executing Superset account lookup")
return service.lookup_superset_accounts(
current_user=current_user,
request=lookup_request,
)
except EnvironmentNotFoundError as exc:
logger.explore("[EXPLORE] Lookup request references unknown environment")
raise HTTPException(status_code=404, detail=str(exc)) from exc
# [/DEF:lookup_superset_accounts:Function]
# [/DEF:backend.src.api.routes.profile:Module]