d58e38d58f
PR #14 从旧分支复制文件导致回退了 PR #10 的 lint 修复。 修复内容: - autoflake 移除未使用导入/变量 - autopep8 修复缩进/空格 - 手动修复 F821(pathlib→Path), F541(f-string), F841(未使用变量) - 所有修复均通过 flake8 --max-line-length=120 --extend-ignore=E501 检查 (0 errors)
205 lines
7.6 KiB
Python
205 lines
7.6 KiB
Python
"""API 路由 — 项目管理"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
|
|
from src.blackboard.registry import ProjectRegistry
|
|
from src.utils import get_data_root
|
|
|
|
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
|
|
|
|
|
def _registry() -> ProjectRegistry:
|
|
return ProjectRegistry(get_data_root())
|
|
|
|
|
|
@router.get("")
|
|
async def list_projects():
|
|
reg = _registry()
|
|
projects = reg.list_projects()
|
|
from pathlib import Path
|
|
import sqlite3
|
|
# 实时统计每个项目的任务数
|
|
for pid, info in projects.items():
|
|
if info.get("status") in ("archived", "deleted"):
|
|
continue
|
|
db_path = Path(reg.root) / pid / "blackboard.db"
|
|
if db_path.exists():
|
|
try:
|
|
conn = sqlite3.connect(str(db_path), timeout=5)
|
|
total = conn.execute(
|
|
"SELECT COUNT(*) FROM tasks WHERE status != 'cancelled'").fetchone()[0]
|
|
active = conn.execute(
|
|
"SELECT COUNT(*) FROM tasks WHERE COALESCE(archived,0)=0").fetchone()[0]
|
|
archived = total - active
|
|
conn.close()
|
|
info['task_count'] = active
|
|
info['task_count_total'] = total
|
|
info['task_count_archived'] = archived
|
|
except Exception:
|
|
pass
|
|
# 虚拟项目 _general:如果 blackboard.db 存在则插入
|
|
general_db = Path(reg.root) / "_general" / "blackboard.db"
|
|
if general_db.exists() and "_general" not in projects:
|
|
try:
|
|
conn = sqlite3.connect(str(general_db), timeout=5)
|
|
total = conn.execute(
|
|
"SELECT COUNT(*) FROM tasks WHERE status != 'cancelled'").fetchone()[0]
|
|
active = conn.execute(
|
|
"SELECT COUNT(*) FROM tasks WHERE COALESCE(archived,0)=0").fetchone()[0]
|
|
conn.close()
|
|
projects["_general"] = {
|
|
"id": "_general", "name": "一般任务", "description": "无项目归属的通用任务",
|
|
"status": "active", "source": "virtual",
|
|
"task_count": active, "task_count_total": total, "task_count_archived": total - active,
|
|
}
|
|
except Exception:
|
|
pass
|
|
elif "_general" in projects:
|
|
general_db_check = Path(reg.root) / "_general" / "blackboard.db"
|
|
if general_db_check.exists():
|
|
try:
|
|
conn = sqlite3.connect(str(general_db_check), timeout=5)
|
|
total = conn.execute(
|
|
"SELECT COUNT(*) FROM tasks WHERE status != 'cancelled'").fetchone()[0]
|
|
active = conn.execute(
|
|
"SELECT COUNT(*) FROM tasks WHERE COALESCE(archived,0)=0").fetchone()[0]
|
|
conn.close()
|
|
projects["_general"]["task_count"] = active
|
|
projects["_general"]["task_count_total"] = total
|
|
projects["_general"]["task_count_archived"] = total - active
|
|
except Exception:
|
|
pass
|
|
# 不过滤 archived 项目,前端按任务级 archived 字段自行筛选
|
|
return {"projects": projects}
|
|
|
|
|
|
@router.post("")
|
|
async def create_project(body: Dict[str, Any]):
|
|
reg = _registry()
|
|
try:
|
|
reg.create_project(
|
|
body["id"], body["name"],
|
|
agents=body.get("agents", []),
|
|
description=body.get("description", ""),
|
|
)
|
|
return {"ok": True, "project_id": body["id"]}
|
|
except ValueError as e:
|
|
raise HTTPException(409, str(e))
|
|
|
|
|
|
@router.get("/{project_id}")
|
|
async def get_project(project_id: str):
|
|
reg = _registry()
|
|
info = reg.get_project(project_id)
|
|
if not info:
|
|
raise HTTPException(404, f"Project not found: {project_id}")
|
|
return info
|
|
|
|
|
|
@router.post("/{project_id}/archive")
|
|
async def archive_project(project_id: str):
|
|
reg = _registry()
|
|
if not reg.archive_project(project_id):
|
|
raise HTTPException(404, f"Project not found: {project_id}")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/{project_id}")
|
|
async def delete_project(project_id: str, physical: bool = Query(False)):
|
|
"""删除项目(默认逻辑删除,physical=true 物理删除)"""
|
|
reg = _registry()
|
|
info = reg.get_project(project_id)
|
|
if not info:
|
|
raise HTTPException(404, f"Project not found: {project_id}")
|
|
|
|
if physical:
|
|
result = reg.physical_delete_project(project_id)
|
|
if not result:
|
|
raise HTTPException(500, "Physical delete failed")
|
|
return {"ok": True, "deleted": result}
|
|
else:
|
|
if not reg.delete_project(project_id):
|
|
raise HTTPException(500, "Delete failed")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.patch("/{project_id}")
|
|
async def update_project(project_id: str, body: Dict[str, Any]):
|
|
"""更新项目元数据(name/description 等)"""
|
|
reg = _registry()
|
|
allowed = {"name", "description", "status"}
|
|
updates = {k: v for k, v in body.items() if k in allowed}
|
|
if not updates:
|
|
return {"ok": True}
|
|
if not reg.update_project(project_id, **updates):
|
|
raise HTTPException(404, f"Project not found: {project_id}")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/{project_id}/tasks/{task_id}/move")
|
|
async def move_task(project_id: str, task_id: str, body: Dict[str, Any]):
|
|
"""移动任务到另一个项目(含子任务一起移动)"""
|
|
target_project = body.get("target_project_id")
|
|
if not target_project:
|
|
raise HTTPException(422, "Missing target_project_id")
|
|
|
|
reg = _registry()
|
|
# 验证目标项目存在
|
|
target = reg.get_project(target_project)
|
|
if not target or target.get("status") not in ("active",):
|
|
raise HTTPException(404, f"Target project not found: {target_project}")
|
|
|
|
# 从源项目读任务
|
|
from src.blackboard.operations import Blackboard
|
|
from src.blackboard.models import Task as TaskModel
|
|
src_bb = Blackboard(Path(reg.root) / project_id / "blackboard.db")
|
|
task = src_bb.get_task(task_id)
|
|
if not task:
|
|
raise HTTPException(404, f"Task not found: {task_id}")
|
|
|
|
# 查找子任务
|
|
children = src_bb.list_tasks(parent_task=task_id)
|
|
|
|
tgt_bb = Blackboard(Path(reg.root) / target_project / "blackboard.db")
|
|
moved_ids = []
|
|
|
|
# 先移动子任务
|
|
for child in children:
|
|
moved_child = TaskModel(
|
|
id=child.id, title=child.title, description=child.description,
|
|
status="pending", assignee=child.assignee, assigned_by=child.assigned_by,
|
|
priority=child.priority, task_type=child.task_type,
|
|
risk_level=child.risk_level, parent_task=child.parent_task,
|
|
stage=child.stage, stages_json=child.stages_json,
|
|
depends_on=child.depends_on, must_haves=child.must_haves,
|
|
)
|
|
tgt_bb.create_task(moved_child)
|
|
src_bb.update_task_status(
|
|
child.id,
|
|
"cancelled",
|
|
detail=f"Moved to {target_project}")
|
|
moved_ids.append(child.id)
|
|
|
|
# 移动主任务
|
|
moved_task = TaskModel(
|
|
id=task.id, title=task.title, description=task.description,
|
|
status="pending", assignee=task.assignee, assigned_by=task.assigned_by,
|
|
priority=task.priority, task_type=task.task_type,
|
|
risk_level=task.risk_level, parent_task=task.parent_task,
|
|
stage=task.stage, stages_json=task.stages_json,
|
|
depends_on=task.depends_on, must_haves=task.must_haves,
|
|
)
|
|
tgt_bb.create_task(moved_task)
|
|
src_bb.update_task_status(
|
|
task_id,
|
|
"cancelled",
|
|
detail=f"Moved to {target_project}")
|
|
moved_ids.insert(0, task_id)
|
|
|
|
return {"ok": True, "moved_to": target_project, "moved_ids": moved_ids}
|