344 lines
12 KiB
Python

"""
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