"""项目管理 API。""" from __future__ import annotations import json from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy import and_, delete, func, select, update from sqlalchemy.exc import IntegrityError 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 ( AIOrchestrationRun, AIWorkerExecution, Budget, ConversationSession, CostTracking, Experiment, OperationExecutionLedger, OperationLog, Project, ProjectSignalLink, Signal, SignalSourceRegistry, Task, User, UserProject, WorkItem, ) from ..services.ai_pmo_kernel_service import AIPMOKernelService from ..services.context_cache import context_cache_service from ..services.operation_log_service import log_operation from ..services.project_member_service import ( ProjectMemberAssignmentError, notify_member_assignment_failure, replace_project_members, resolve_project_member_ids, ) from ..utils.sequence import ensure_project_id_sequence from ..utils.title import compact_title router = APIRouter(prefix="/projects", tags=["projects"]) DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"} def _as_dict(value: object) -> dict[str, Any]: if isinstance(value, dict): return value if isinstance(value, str) and value.strip(): try: parsed = json.loads(value) return parsed if isinstance(parsed, dict) else {"text": value} except (TypeError, ValueError): return {"text": value} return {} class ProjectCreateRequest(BaseModel): title: str description: Optional[str] = None status: str = "active" domain_id: Optional[int] = None assigned_role: Optional[str] = None member_user_ids: list[int] = Field(default_factory=list) 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 class ProjectMembersAssignRequest(BaseModel): member_user_ids: list[int] = Field(default_factory=list) 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 _fetch_project_member_user_ids( db: AsyncSession, *, tenant_id: int, project_id: int, ) -> list[int]: rows = await db.execute( select(UserProject.user_id).where( and_( UserProject.tenant_id == int(tenant_id), UserProject.project_id == int(project_id), ) ) ) return sorted(int(user_id) for user_id in rows.scalars().all() if user_id is not None) 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 async def _cleanup_project_dependencies( db: AsyncSession, *, tenant_id: int, project_id: int, ) -> None: task_ids_subquery = ( select(Task.id) .where( and_( Task.tenant_id == tenant_id, Task.project_id == project_id, ) ) .scalar_subquery() ) # 先断开任务子依赖,避免删除 tasks 时触发约束错误。 await db.execute( update(CostTracking) .where( and_( CostTracking.tenant_id == tenant_id, CostTracking.task_id.in_(task_ids_subquery), ) ) .values(task_id=None) ) # 非空外键:必须先删依赖记录。 await db.execute( delete(AIWorkerExecution).where( and_( AIWorkerExecution.tenant_id == tenant_id, AIWorkerExecution.project_id == project_id, ) ) ) await db.execute( delete(AIOrchestrationRun).where( and_( AIOrchestrationRun.tenant_id == tenant_id, AIOrchestrationRun.project_id == project_id, ) ) ) await db.execute( delete(SignalSourceRegistry).where( and_( SignalSourceRegistry.tenant_id == tenant_id, SignalSourceRegistry.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(UserProject).where( and_( UserProject.tenant_id == tenant_id, UserProject.project_id == project_id, ) ) ) # 可空外键:保留记录,将 project 解绑为 null。 await db.execute( update(WorkItem) .where( and_( WorkItem.tenant_id == tenant_id, WorkItem.project_id == project_id, ) ) .values(project_id=None) ) await db.execute( update(OperationLog) .where( and_( OperationLog.tenant_id == tenant_id, OperationLog.project_id == project_id, ) ) .values(project_id=None) ) await db.execute( update(OperationExecutionLedger) .where( and_( OperationExecutionLedger.tenant_id == tenant_id, OperationExecutionLedger.project_id == project_id, ) ) .values(project_id=None) ) await db.execute( update(Experiment) .where( and_( Experiment.tenant_id == tenant_id, Experiment.project_id == project_id, ) ) .values(project_id=None) ) await db.execute( update(Budget) .where( and_( Budget.tenant_id == tenant_id, Budget.project_id == project_id, ) ) .values(project_id=None) ) await db.execute( update(ConversationSession) .where( and_( ConversationSession.tenant_id == tenant_id, ConversationSession.current_project_id == project_id, ) ) .values(current_project_id=None) ) @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") try: member_user_ids = await resolve_project_member_ids( db, tenant_id=tenant_id, owner_id=int(current_user.id), assigned_role=body.assigned_role, requested_member_user_ids=body.member_user_ids, require_requested_members=False, include_owner=True, ) except ProjectMemberAssignmentError as exc: await notify_member_assignment_failure( db, tenant_id=tenant_id, actor_user_id=int(current_user.id), project_title=title, reason=str(exc), interface_path="/projects/", commit=True, ) raise HTTPException(status_code=400, detail=str(exc)) from exc await ensure_project_id_sequence(db) 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) try: await db.flush() await replace_project_members( db, tenant_id=tenant_id, project_id=int(project.id), member_user_ids=member_user_ids, ) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=int(project.id), entity_type="project", entity_id=str(project.id), action="project.create", before_data=None, after_data={ "title": project.title, "status": project.status, "member_user_ids": member_user_ids, }, interface_path="/projects/", caller="user", ) await db.commit() except Exception: await db.rollback() raise await db.refresh(project) await context_cache_service.invalidate_system_context(tenant_id=tenant_id) await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id)) return _serialize_project_row(project) @router.put("/{project_id}/members") async def assign_project_members( project_id: int, body: ProjectMembersAssignRequest, 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) before_member_user_ids = await _fetch_project_member_user_ids( db, tenant_id=tenant_id, project_id=int(project.id), ) try: member_user_ids = await resolve_project_member_ids( db, tenant_id=tenant_id, owner_id=None, assigned_role=project.assigned_role, requested_member_user_ids=body.member_user_ids, require_requested_members=True, include_owner=False, ) await replace_project_members( db, tenant_id=tenant_id, project_id=int(project.id), member_user_ids=member_user_ids, ) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=int(project.id), entity_type="project", entity_id=str(project.id), action="project.members.assign", before_data={"member_user_ids": before_member_user_ids}, after_data={"member_user_ids": member_user_ids}, interface_path=f"/projects/{project_id}/members", caller="user", ) await db.commit() except ProjectMemberAssignmentError as exc: await db.rollback() await notify_member_assignment_failure( db, tenant_id=tenant_id, actor_user_id=int(current_user.id), project_id=int(project.id), project_title=project.title or project.name, reason=str(exc), interface_path=f"/projects/{project_id}/members", commit=True, ) raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception: await db.rollback() raise await context_cache_service.invalidate_system_context(tenant_id=tenant_id) await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id)) return { "project_id": str(project.id), "member_user_ids": [str(user_id) for user_id in member_user_ids], } @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) await context_cache_service.invalidate_system_context(tenant_id=tenant_id) await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id)) 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 try: await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) await _cleanup_project_dependencies( db, tenant_id=tenant_id, project_id=project_id, ) await db.execute( delete(Project).where( and_( Project.tenant_id == tenant_id, Project.id == project_id, ) ) ) await db.commit() except IntegrityError as exc: await db.rollback() raise HTTPException(status_code=409, detail="Project has related records and cannot be deleted") from exc except Exception: await db.rollback() raise await context_cache_service.invalidate_system_context(tenant_id=tenant_id) await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id) return {"message": "Project deleted"} @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) await context_cache_service.invalidate_system_context(tenant_id=tenant_id) 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() await context_cache_service.invalidate_system_context(tenant_id=tenant_id) return {"message": "Signal unlinked"}