547 lines
16 KiB
Python
547 lines
16 KiB
Python
"""项目管理 API。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
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 ..utils.project_members import ensure_project_default_members
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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")
|
|
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)
|
|
await db.flush()
|
|
await ensure_project_default_members(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
project_id=int(project.id),
|
|
owner_id=int(current_user.id),
|
|
assigned_role=project.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.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"}
|