344 lines
12 KiB
Python
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
|