"""项目管理 API。""" from __future__ import annotations from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import and_, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from ..database import get_db from ..dependencies import get_current_user, get_tenant_id, require_roles from ..models import Project, ProjectSignalLink, Signal, Task, User from ..utils.title import compact_title router = APIRouter(prefix="/projects", tags=["projects"]) DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"} class ProjectCreateRequest(BaseModel): title: str description: Optional[str] = None status: str = "active" domain_id: Optional[int] = None assigned_role: Optional[str] = None class ProjectUpdateRequest(BaseModel): title: Optional[str] = None description: Optional[str] = None status: Optional[str] = None domain_id: Optional[int] = None assigned_role: Optional[str] = None class LinkSignalRequest(BaseModel): link_type: Optional[str] = "related" relevance_score: Optional[float] = 1.0 def _serialize_project_row(project: Project) -> dict: return { "id": str(project.id), "title": project.title or project.name, "description": project.description, "status": project.status, "domain_id": str(project.domain_id) if project.domain_id is not None else None, "assigned_role": project.assigned_role, "signal_count": int(project.signal_count or 0), "created_at": project.created_at.isoformat() if project.created_at else None, "updated_at": project.updated_at.isoformat() if project.updated_at else None, } async def _build_project_summary( db: AsyncSession, *, tenant_id: int, project: Project, ) -> dict: base = _serialize_project_row(project) signal_count_result = await db.execute( select(func.count(ProjectSignalLink.id)).where( and_( ProjectSignalLink.tenant_id == tenant_id, ProjectSignalLink.project_id == int(project.id), ) ) ) signal_count = int(signal_count_result.scalar() or 0) base["signal_count"] = signal_count task_rows = ( await db.execute( select(Task).where( and_( Task.tenant_id == tenant_id, Task.project_id == int(project.id), ) ) ) ).scalars().all() total_tasks = len(task_rows) completed_tasks = 0 in_progress_tasks = 0 pending_tasks = 0 task_items: list[dict] = [] for task in task_rows: status_value = (task.status or "").lower() if status_value in DONE_STATUSES: completed_tasks += 1 elif status_value in {"in_progress", "review", "blocked"}: in_progress_tasks += 1 else: pending_tasks += 1 task_items.append( { "task_name": task.title, "type": "task", "status": task.status, "signal_id": str(task.truth_node_id) if task.truth_node_id is not None else "", } ) base["progress"] = round((completed_tasks / total_tasks) * 100, 2) if total_tasks else 0.0 base["total_tasks"] = total_tasks base["completed_tasks"] = completed_tasks base["in_progress_tasks"] = in_progress_tasks base["pending_tasks"] = pending_tasks base["tasks"] = task_items return base async def _get_project_or_404( db: AsyncSession, *, tenant_id: int, project_id: int, ) -> Project: project = ( await db.execute( select(Project).where( and_( Project.tenant_id == tenant_id, Project.id == project_id, ) ) ) ).scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="Project not found") return project @router.get("/") async def list_projects( summary: Optional[int] = Query(default=0), status: Optional[str] = Query(default=None), assigned_role: Optional[str] = Query(default=None), tenant_id: int = Depends(get_tenant_id), db: AsyncSession = Depends(get_db), ): query = select(Project).where(Project.tenant_id == tenant_id) if status: query = query.where(Project.status == status) if assigned_role: query = query.where(Project.assigned_role == assigned_role) query = query.order_by(Project.updated_at.desc(), Project.id.desc()) projects = (await db.execute(query)).scalars().all() if int(summary or 0) == 1: rows = [ await _build_project_summary( db, tenant_id=tenant_id, project=project, ) for project in projects ] return {"projects": rows} return [_serialize_project_row(project) for project in projects] @router.get("/{project_id}") async def get_project( project_id: int, tenant_id: int = Depends(get_tenant_id), db: AsyncSession = Depends(get_db), ): project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) return _serialize_project_row(project) @router.post("/") async def create_project( body: ProjectCreateRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_roles("BOSS", "ADMIN")), ): tenant_id = int(current_user.tenant_id) title = compact_title(body.title, max_len=24) if not title: raise HTTPException(status_code=400, detail="title required") project = Project( tenant_id=tenant_id, name=title, title=title, description=body.description, status=body.status or "active", domain_id=body.domain_id, assigned_role=body.assigned_role, owner_id=int(current_user.id), signal_count=0, ) db.add(project) await db.commit() await db.refresh(project) return _serialize_project_row(project) @router.patch("/{project_id}") async def update_project( project_id: int, body: ProjectUpdateRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_roles("BOSS", "ADMIN")), ): tenant_id = int(current_user.tenant_id) project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) if body.title is not None: title = compact_title(body.title, max_len=24) if not title: raise HTTPException(status_code=400, detail="title cannot be empty") project.title = title project.name = title if body.description is not None: project.description = body.description if body.status is not None: project.status = body.status if body.domain_id is not None: project.domain_id = body.domain_id if body.assigned_role is not None: project.assigned_role = body.assigned_role await db.commit() await db.refresh(project) return _serialize_project_row(project) @router.delete("/{project_id}") async def delete_project( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_roles("BOSS", "ADMIN")), ): tenant_id = int(current_user.tenant_id) _ = current_user await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) await db.execute( delete(ProjectSignalLink).where( and_( ProjectSignalLink.tenant_id == tenant_id, ProjectSignalLink.project_id == project_id, ) ) ) await db.execute( delete(Task).where( and_( Task.tenant_id == tenant_id, Task.project_id == project_id, ) ) ) await db.execute( delete(Project).where( and_( Project.tenant_id == tenant_id, Project.id == project_id, ) ) ) await db.commit() return {"message": "Project deleted"} @router.get("/{project_id}/signals") async def list_project_signals( project_id: int, tenant_id: int = Depends(get_tenant_id), db: AsyncSession = Depends(get_db), ): await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) rows = ( await db.execute( select(ProjectSignalLink, Signal) .join(Signal, Signal.id == ProjectSignalLink.signal_id) .where( and_( ProjectSignalLink.tenant_id == tenant_id, ProjectSignalLink.project_id == project_id, Signal.tenant_id == tenant_id, ) ) ) ).all() return [ { "id": str(signal.id), "source": signal.source, "content": signal.content, "impact_score": signal.impact_score, "priority": signal.priority, "processed": bool(signal.processed), "created_at": signal.created_at.isoformat() if signal.created_at else None, "link_type": link.link_type, "relevance_score": float(link.relevance_score or 0.0), } for link, signal in rows ] @router.post("/{project_id}/signals/{signal_id}") async def link_signal_to_project( project_id: int, signal_id: int, body: LinkSignalRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_roles("BOSS", "ADMIN")), ): tenant_id = int(current_user.tenant_id) await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) signal = ( await db.execute( select(Signal).where( and_( Signal.tenant_id == tenant_id, Signal.id == signal_id, ) ) ) ).scalar_one_or_none() if not signal: raise HTTPException(status_code=404, detail="Signal not found") existing = ( await db.execute( select(ProjectSignalLink).where( and_( ProjectSignalLink.tenant_id == tenant_id, ProjectSignalLink.project_id == project_id, ProjectSignalLink.signal_id == signal_id, ) ) ) ).scalar_one_or_none() if existing: return { "id": str(existing.id), "project_id": str(existing.project_id), "signal_id": str(existing.signal_id), "link_type": existing.link_type, "relevance_score": float(existing.relevance_score or 0.0), "created_at": existing.created_at.isoformat() if existing.created_at else None, } link = ProjectSignalLink( tenant_id=tenant_id, project_id=project_id, signal_id=signal_id, link_type=body.link_type or "related", relevance_score=float(body.relevance_score or 1.0), ) db.add(link) await db.commit() await db.refresh(link) return { "id": str(link.id), "project_id": str(link.project_id), "signal_id": str(link.signal_id), "link_type": link.link_type, "relevance_score": float(link.relevance_score or 0.0), "created_at": link.created_at.isoformat() if link.created_at else None, } @router.delete("/{project_id}/signals/{signal_id}") async def unlink_signal_from_project( project_id: int, signal_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_roles("BOSS", "ADMIN")), ): tenant_id = int(current_user.tenant_id) _ = current_user await db.execute( delete(ProjectSignalLink).where( and_( ProjectSignalLink.tenant_id == tenant_id, ProjectSignalLink.project_id == project_id, ProjectSignalLink.signal_id == signal_id, ) ) ) await db.commit() return {"message": "Signal unlinked"}