""" Project management API endpoints. """ import re import logging from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import ( Project, Task, AuditLog, AgentExecution, ProjectStatus, WorkflowType, ProjectType, AgentExecutionStatus, PROJECT_TYPE_INFO, ) from app.schemas import ( ProjectCreate, ProjectUpdate, ProjectSummary, ProjectDetail, TaskCreate, TaskResponse, ProjectTypeInfo, ) from app.workflows import ( get_workflow_steps, get_current_step, get_workflow_progress, ) from app.notifications import notify_project_event from app.routers.ws import manager log = logging.getLogger("foxy.api.projects") router = APIRouter(prefix="/api/projects", tags=["projects"]) def _slugify(name: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") return f"{slug}-{ts}" if slug else f"proj-{ts}" def _utcnow() -> datetime: return datetime.now(timezone.utc) # ─── Project Types ───────────────────────────────────────────────────────────── @router.get("/types", response_model=list[ProjectTypeInfo]) async def list_project_types(): """Return all available project types with emoji, label and description.""" return [ ProjectTypeInfo( value=pt.value, emoji=info[0], label=info[1], description=info[2], ) for pt, info in PROJECT_TYPE_INFO.items() ] # ─── CRUD ────────────────────────────────────────────────────────────────────── @router.post("", response_model=ProjectDetail, status_code=201) async def create_project(body: ProjectCreate, db: AsyncSession = Depends(get_db)): """Create a new project and initialize its workflow.""" slug = _slugify(body.name) project = Project( name=body.name, slug=slug, description=body.description, status=ProjectStatus.AWAITING_CONDUCTOR, workflow_type=body.workflow_type, project_type=body.project_type, test_mode=body.test_mode, install_path=body.install_path, deploy_server_id=body.deploy_server_id, git_server_id=body.git_server_id, ) db.add(project) await db.flush() audit = AuditLog( project_id=project.id, agent="system", action="PROJECT_CREATED", target=slug, message=f"Projet créé: {body.name} (workflow: {body.workflow_type.value}, type: {body.project_type.value})", source="api", ) db.add(audit) await db.flush() # Refresh to load eager relationships (tasks, audit_logs, agent_executions, servers) await db.refresh(project, ["tasks", "audit_logs", "agent_executions", "deploy_server", "git_server"]) log.info(f"Project created: {slug} (workflow: {body.workflow_type.value}, type: {body.project_type.value})") await manager.broadcast_project_update(project.id, project.status.value, project.name) await notify_project_event(project.name, "Projet créé", f"Workflow: {body.workflow_type.value}, Type: {body.project_type.value}") # Build response with server names return _project_detail(project) def _project_detail(p: Project) -> dict: """Build a ProjectDetail-compatible dict from a Project ORM instance.""" return { "id": p.id, "name": p.name, "slug": p.slug, "description": p.description, "status": p.status, "workflow_type": p.workflow_type, "project_type": p.project_type, "test_mode": p.test_mode, "install_path": p.install_path, "gitea_repo": p.gitea_repo, "deployment_target": p.deployment_target, "deploy_server_id": p.deploy_server_id, "git_server_id": p.git_server_id, "deploy_server_name": p.deploy_server.name if p.deploy_server else None, "git_server_name": p.git_server.name if p.git_server else None, "created_at": p.created_at, "updated_at": p.updated_at, "tasks": p.tasks, "audit_logs": p.audit_logs, "agent_executions": p.agent_executions, } @router.get("", response_model=list[ProjectSummary]) async def list_projects( status: Optional[str] = Query(None), workflow_type: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), ): """List all projects with optional filters.""" query = select(Project).order_by(Project.updated_at.desc()) if status: try: query = query.where(Project.status == ProjectStatus(status)) except ValueError: pass if workflow_type: try: query = query.where(Project.workflow_type == WorkflowType(workflow_type)) except ValueError: pass result = await db.execute(query) projects = result.scalars().all() summaries = [] for p in projects: total = len(p.tasks) done = sum(1 for t in p.tasks if t.status.value in ("DONE", "READY_FOR_DEPLOY")) summaries.append(ProjectSummary( id=p.id, name=p.name, slug=p.slug, status=p.status, workflow_type=p.workflow_type, project_type=p.project_type, test_mode=p.test_mode, install_path=p.install_path, task_count=total, tasks_done=done, deploy_server_name=p.deploy_server.name if p.deploy_server else None, git_server_name=p.git_server.name if p.git_server else None, created_at=p.created_at, updated_at=p.updated_at, )) return summaries @router.get("/{project_id}", response_model=ProjectDetail) async def get_project(project_id: int, db: AsyncSession = Depends(get_db)): """Get project detail with tasks, audit logs, and agent executions.""" result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="Project not found") return _project_detail(project) # ─── Workflow Control ────────────────────────────────────────────────────────── @router.post("/{project_id}/start") async def start_project(project_id: int, db: AsyncSession = Depends(get_db)): """Start or resume a project's workflow.""" result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="Project not found") if project.status in (ProjectStatus.COMPLETED, ProjectStatus.FAILED): raise HTTPException(status_code=400, detail=f"Cannot start a {project.status.value} project") # If paused, resume to previous awaiting status if project.status == ProjectStatus.PAUSED: steps = get_workflow_steps(project.workflow_type) project.status = ProjectStatus(steps[0].awaiting_status) elif project.status == ProjectStatus.PENDING: project.status = ProjectStatus.AWAITING_CONDUCTOR project.updated_at = _utcnow() audit = AuditLog( project_id=project.id, agent="system", action="WORKFLOW_STARTED", target=project.slug, message=f"Workflow démarré → {project.status.value}", source="api", ) db.add(audit) await db.flush() await manager.broadcast_project_update(project.id, project.status.value, project.name) await notify_project_event(project.name, f"Workflow démarré → {project.status.value}") return {"status": project.status.value, "message": "Workflow started"} @router.post("/{project_id}/pause") async def pause_project(project_id: int, db: AsyncSession = Depends(get_db)): """Pause a project's workflow.""" result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="Project not found") old_status = project.status.value project.status = ProjectStatus.PAUSED project.updated_at = _utcnow() audit = AuditLog( project_id=project.id, agent="system", action="WORKFLOW_PAUSED", target=project.slug, message=f"Workflow mis en pause (était: {old_status})", source="api", ) db.add(audit) await db.flush() await manager.broadcast_project_update(project.id, project.status.value, project.name) return {"status": "PAUSED", "message": f"Project paused (was {old_status})"} @router.post("/{project_id}/stop") async def stop_project(project_id: int, db: AsyncSession = Depends(get_db)): """Stop a project — marks it as FAILED.""" result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="Project not found") old_status = project.status.value project.status = ProjectStatus.FAILED project.updated_at = _utcnow() audit = AuditLog( project_id=project.id, agent="system", action="WORKFLOW_STOPPED", target=project.slug, message=f"Workflow arrêté (était: {old_status})", source="api", ) db.add(audit) await db.flush() await manager.broadcast_project_update(project.id, project.status.value, project.name) await notify_project_event(project.name, "Projet arrêté", f"Ancien statut: {old_status}") return {"status": "FAILED", "message": "Project stopped"} @router.post("/{project_id}/reset") async def reset_project(project_id: int, db: AsyncSession = Depends(get_db)): """Reset a project back to AWAITING_CONDUCTOR.""" result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="Project not found") old_status = project.status.value project.status = ProjectStatus.AWAITING_CONDUCTOR project.updated_at = _utcnow() audit = AuditLog( project_id=project.id, agent="system", action="WORKFLOW_RESET", target=project.slug, message=f"Workflow reset (était: {old_status}) → AWAITING_CONDUCTOR", source="api", ) db.add(audit) await db.flush() await manager.broadcast_project_update(project.id, project.status.value, project.name) await notify_project_event(project.name, "Reset", f"{old_status} → AWAITING_CONDUCTOR") return {"status": "AWAITING_CONDUCTOR", "message": "Project reset"} @router.delete("/{project_id}") async def delete_project(project_id: int, db: AsyncSession = Depends(get_db)): """Delete a project and all associated data.""" result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="Project not found") name = project.name await db.delete(project) await db.flush() log.info(f"Project deleted: {name}") return {"message": f"Project '{name}' deleted"} # ─── Progress & Workflow Info ────────────────────────────────────────────────── @router.get("/{project_id}/progress") async def get_project_progress(project_id: int, db: AsyncSession = Depends(get_db)): """Get workflow progress for a project.""" result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="Project not found") progress = get_workflow_progress(project.workflow_type, project.status.value) return progress