"""Profile management API routes.""" from fastapi import APIRouter, Depends, HTTPException from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( get_picture_target_store, get_profile_engine, get_profile_store, ) from wled_controller.api.schemas.profiles import ( ConditionSchema, ProfileCreate, ProfileListResponse, ProfileResponse, ProfileUpdate, ) from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.profile import ApplicationCondition, Condition from wled_controller.storage.profile_store import ProfileStore from wled_controller.utils import get_logger logger = get_logger(__name__) router = APIRouter() # ===== Helpers ===== def _condition_from_schema(s: ConditionSchema) -> Condition: if s.condition_type == "application": return ApplicationCondition( apps=s.apps or [], match_type=s.match_type or "running", ) raise ValueError(f"Unknown condition type: {s.condition_type}") def _condition_to_schema(c: Condition) -> ConditionSchema: d = c.to_dict() return ConditionSchema(**d) def _profile_to_response(profile, engine: ProfileEngine) -> ProfileResponse: state = engine.get_profile_state(profile.id) return ProfileResponse( id=profile.id, name=profile.name, enabled=profile.enabled, condition_logic=profile.condition_logic, conditions=[_condition_to_schema(c) for c in profile.conditions], target_ids=profile.target_ids, is_active=state["is_active"], active_target_ids=state["active_target_ids"], last_activated_at=state.get("last_activated_at"), last_deactivated_at=state.get("last_deactivated_at"), created_at=profile.created_at, updated_at=profile.updated_at, ) def _validate_condition_logic(logic: str) -> None: if logic not in ("or", "and"): raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.") def _validate_target_ids(target_ids: list, target_store: PictureTargetStore) -> None: for tid in target_ids: try: target_store.get_target(tid) except ValueError: raise HTTPException(status_code=400, detail=f"Target not found: {tid}") # ===== CRUD Endpoints ===== @router.post( "/api/v1/profiles", response_model=ProfileResponse, tags=["Profiles"], status_code=201, ) async def create_profile( data: ProfileCreate, _auth: AuthRequired, store: ProfileStore = Depends(get_profile_store), engine: ProfileEngine = Depends(get_profile_engine), target_store: PictureTargetStore = Depends(get_picture_target_store), ): """Create a new profile.""" _validate_condition_logic(data.condition_logic) _validate_target_ids(data.target_ids, target_store) try: conditions = [_condition_from_schema(c) for c in data.conditions] except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) profile = store.create_profile( name=data.name, enabled=data.enabled, condition_logic=data.condition_logic, conditions=conditions, target_ids=data.target_ids, ) if profile.enabled: await engine.trigger_evaluate() return _profile_to_response(profile, engine) @router.get( "/api/v1/profiles", response_model=ProfileListResponse, tags=["Profiles"], ) async def list_profiles( _auth: AuthRequired, store: ProfileStore = Depends(get_profile_store), engine: ProfileEngine = Depends(get_profile_engine), ): """List all profiles.""" profiles = store.get_all_profiles() return ProfileListResponse( profiles=[_profile_to_response(p, engine) for p in profiles], count=len(profiles), ) @router.get( "/api/v1/profiles/{profile_id}", response_model=ProfileResponse, tags=["Profiles"], ) async def get_profile( profile_id: str, _auth: AuthRequired, store: ProfileStore = Depends(get_profile_store), engine: ProfileEngine = Depends(get_profile_engine), ): """Get a single profile.""" try: profile = store.get_profile(profile_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) return _profile_to_response(profile, engine) @router.put( "/api/v1/profiles/{profile_id}", response_model=ProfileResponse, tags=["Profiles"], ) async def update_profile( profile_id: str, data: ProfileUpdate, _auth: AuthRequired, store: ProfileStore = Depends(get_profile_store), engine: ProfileEngine = Depends(get_profile_engine), target_store: PictureTargetStore = Depends(get_picture_target_store), ): """Update a profile.""" if data.condition_logic is not None: _validate_condition_logic(data.condition_logic) if data.target_ids is not None: _validate_target_ids(data.target_ids, target_store) conditions = None if data.conditions is not None: try: conditions = [_condition_from_schema(c) for c in data.conditions] except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) try: # If disabling, deactivate first if data.enabled is False: await engine.deactivate_if_active(profile_id) profile = store.update_profile( profile_id=profile_id, name=data.name, enabled=data.enabled, condition_logic=data.condition_logic, conditions=conditions, target_ids=data.target_ids, ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) # Re-evaluate immediately if profile is enabled (may have new conditions/targets) if profile.enabled: await engine.trigger_evaluate() return _profile_to_response(profile, engine) @router.delete( "/api/v1/profiles/{profile_id}", status_code=204, tags=["Profiles"], ) async def delete_profile( profile_id: str, _auth: AuthRequired, store: ProfileStore = Depends(get_profile_store), engine: ProfileEngine = Depends(get_profile_engine), ): """Delete a profile.""" # Deactivate first (stop owned targets) await engine.deactivate_if_active(profile_id) try: store.delete_profile(profile_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) # ===== Enable/Disable ===== @router.post( "/api/v1/profiles/{profile_id}/enable", response_model=ProfileResponse, tags=["Profiles"], ) async def enable_profile( profile_id: str, _auth: AuthRequired, store: ProfileStore = Depends(get_profile_store), engine: ProfileEngine = Depends(get_profile_engine), ): """Enable a profile.""" try: profile = store.update_profile(profile_id=profile_id, enabled=True) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) # Evaluate immediately so targets start without waiting for the next poll cycle await engine.trigger_evaluate() return _profile_to_response(profile, engine) @router.post( "/api/v1/profiles/{profile_id}/disable", response_model=ProfileResponse, tags=["Profiles"], ) async def disable_profile( profile_id: str, _auth: AuthRequired, store: ProfileStore = Depends(get_profile_store), engine: ProfileEngine = Depends(get_profile_engine), ): """Disable a profile and stop any targets it owns.""" await engine.deactivate_if_active(profile_id) try: profile = store.update_profile(profile_id=profile_id, enabled=False) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) return _profile_to_response(profile, engine)