"""POST/PATCH /v1/admin/daemons — admin daemon CRUD (REQ-053).""" from __future__ import annotations import logging from fastapi import APIRouter, Depends, HTTPException, Response, status from app.core.auth import AuthContext, require_admin from app.core.config import Settings, get_settings from app.repositories.factory import get_portfolio_repository_from_settings from app.schemas.v1 import DaemonAdminResponse, DaemonCreateRequest, DaemonUpdateRequest logger = logging.getLogger(__name__) router = APIRouter(prefix="/v1/admin", tags=["v1-admin"]) def _get_repo(settings=None): from app.core.config import get_settings as _gs return get_portfolio_repository_from_settings(settings or _gs()) def _to_response(daemon) -> DaemonAdminResponse: return DaemonAdminResponse( id=daemon.id, daemon_slug=daemon.daemon_slug, display_name=daemon.display_name, owner_user_id=daemon.owner_user_id, is_active=daemon.is_active, ) def _daemon_error_to_http(exc: ValueError) -> HTTPException: detail = str(exc) if "찾을 수 없습니다" in detail: return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) if "이미 존재하는 daemon slug" in detail: return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) if "사용자를 찾을 수 없습니다" in detail: return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail) return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) # --------------------------------------------------------------------------- # POST /v1/admin/daemons — 데몬 생성 (REQ-053 AC-1/2) # --------------------------------------------------------------------------- @router.post("/daemons", response_model=DaemonAdminResponse, status_code=status.HTTP_201_CREATED) def create_daemon( body: DaemonCreateRequest, _auth: AuthContext = Depends(require_admin), settings: Settings = Depends(get_settings), ) -> DaemonAdminResponse: """Create a new daemon and assign owner (admin only). REQ-053 AC-1/2.""" repo = _get_repo(settings) try: daemon = repo.create_daemon( body.owner_user_id, daemon_slug=body.daemon_slug, display_name=body.display_name, ) return _to_response(daemon) except ValueError as exc: raise _daemon_error_to_http(exc) from exc finally: if callable(getattr(type(repo), "close", None)): repo.close() # --------------------------------------------------------------------------- # PATCH /v1/admin/daemons/{daemon_id} — display_name/owner 변경 (REQ-053 AC-3/4) # --------------------------------------------------------------------------- @router.patch("/daemons/{daemon_id}", response_model=DaemonAdminResponse) def update_daemon( daemon_id: str, body: DaemonUpdateRequest, _auth: AuthContext = Depends(require_admin), settings: Settings = Depends(get_settings), ) -> DaemonAdminResponse: """Update daemon display_name and/or owner. Owner change auto-invalidates all broker credentials for this daemon (REQ-053 AC-4). """ repo = _get_repo(settings) try: existing = repo.get_daemon_by_id(daemon_id) if existing is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다") if body.owner_user_id is not None and body.owner_user_id != existing.owner_user_id: # Owner change: update owner + invalidate credentials try: daemon = repo.update_daemon_owner(daemon_id, owner_user_id=body.owner_user_id) except ValueError as exc: raise _daemon_error_to_http(exc) from exc # Apply display_name change if also requested if body.display_name is not None: try: daemon = repo.update_daemon(daemon_id, display_name=body.display_name) except ValueError as exc: raise _daemon_error_to_http(exc) from exc else: # No owner change — update display_name only if provided if body.display_name is not None: try: daemon = repo.update_daemon(daemon_id, display_name=body.display_name) except ValueError as exc: raise _daemon_error_to_http(exc) from exc else: daemon = existing return _to_response(daemon) finally: if callable(getattr(type(repo), "close", None)): repo.close() # --------------------------------------------------------------------------- # POST /v1/admin/daemons/{daemon_id}/deactivate (REQ-053 AC-6) # --------------------------------------------------------------------------- @router.post("/daemons/{daemon_id}/deactivate", response_model=DaemonAdminResponse) def deactivate_daemon( daemon_id: str, _auth: AuthContext = Depends(require_admin), settings: Settings = Depends(get_settings), ) -> DaemonAdminResponse: """Deactivate daemon and auto-revoke bound service tokens (REQ-053 AC-6, REQ-022).""" repo = _get_repo(settings) try: existing = repo.get_daemon_by_id(daemon_id) if existing is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다") daemon = repo.update_daemon(daemon_id, is_active=False) # REQ-022: auto-revoke service tokens bound to this daemon revoked = repo.revoke_tokens_by_daemon_id(daemon_id) logger.info( "daemon %s deactivated; revoked %d service token(s)", daemon_id, revoked, ) return _to_response(daemon) except ValueError as exc: raise _daemon_error_to_http(exc) from exc finally: if callable(getattr(type(repo), "close", None)): repo.close() # --------------------------------------------------------------------------- # POST /v1/admin/daemons/{daemon_id}/activate (REQ-053 AC-7) # --------------------------------------------------------------------------- @router.post("/daemons/{daemon_id}/activate", response_model=DaemonAdminResponse) def activate_daemon( daemon_id: str, _auth: AuthContext = Depends(require_admin), settings: Settings = Depends(get_settings), ) -> DaemonAdminResponse: """Re-activate a deactivated daemon. REQ-053 AC-7.""" repo = _get_repo(settings) try: existing = repo.get_daemon_by_id(daemon_id) if existing is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다") daemon = repo.update_daemon(daemon_id, is_active=True) return _to_response(daemon) except ValueError as exc: raise _daemon_error_to_http(exc) from exc finally: if callable(getattr(type(repo), "close", None)): repo.close() # --------------------------------------------------------------------------- # DELETE /v1/admin/daemons/{daemon_id} — 하드 삭제 미제공 → 405 (REQ-053 AC-8) # --------------------------------------------------------------------------- @router.delete("/daemons/{daemon_id}", status_code=status.HTTP_405_METHOD_NOT_ALLOWED) def delete_daemon_not_allowed( daemon_id: str, _auth: AuthContext = Depends(require_admin), ) -> Response: """Hard delete is not supported — deactivate instead. REQ-053 AC-8.""" raise HTTPException( status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Hard delete not supported. Use POST /v1/admin/daemons/{daemon_id}/deactivate instead.", )