""" 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, AgentExecutionStatus, ) from app.schemas import ( ProjectCreate, ProjectUpdate, ProjectSummary, ProjectDetail, TaskCreate, TaskResponse, ) 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) # ─── 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, test_mode=body.test_mode, ) 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})", source="api", ) db.add(audit) await db.flush() # Refresh to load eager relationships (tasks, audit_logs, agent_executions) await db.refresh(project, ["tasks", "audit_logs", "agent_executions"]) log.info(f"Project created: {slug} (workflow: {body.workflow_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}") return project @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, test_mode=p.test_mode, task_count=total, tasks_done=done, 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 # ─── 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