Spaces:
Running
Running
File size: 4,196 Bytes
d646dd7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | """Projects API (原 tasks) — 按项目聚合 episode,列出状态分布。
任务概念已移除:episode 归属项目(Session.project_id → Project.name),
这里按项目名分组统计,供导航树和项目页面使用。
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from app.models import Episode, Session as SessionModel, Project
from app.routes.ingestion import _ep_to_out
router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
@router.get("/summary")
async def project_summary(
status: str | None = Query(default=None, description="Filter by episode status"),
search: str | None = Query(default=None, description="Search project name"),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_session),
):
"""按项目聚合 episode 状态分布(原 /api/v1/tasks)。
返回字段保留 ``task_name``(= 项目名)以兼容旧导航树调用。
"""
# 聚合:project → status → counts
cols = [
Project.name.label("project_name"),
Episode.status,
func.count(Episode.id).label("cnt"),
func.avg(Episode.frame_count).label("avg_frames"),
func.max(Episode.received_at).label("last_upload"),
]
base = (
select(*cols)
.select_from(Episode)
.join(SessionModel, Episode.session_id == SessionModel.id)
.join(Project, SessionModel.project_id == Project.id)
.where(Episode.deleted_at.is_(None))
.group_by(Project.name, Episode.status)
)
if status:
base = base.where(Episode.status == status)
rows = (await db.execute(base)).all()
# 无项目归属的 episode → Uncategorized
orphan_base = (
select(
func.count(Episode.id).label("cnt"),
Episode.status,
)
.select_from(Episode)
.outerjoin(SessionModel, Episode.session_id == SessionModel.id)
.where(Episode.deleted_at.is_(None), SessionModel.id.is_(None))
.group_by(Episode.status)
)
if status:
orphan_base = orphan_base.where(Episode.status == status)
orphan_rows = (await db.execute(orphan_base)).all()
projects_map: dict[str, dict] = {}
stats: dict[str, dict] = {}
for pname, ep_status, cnt, avg_fr, last_up in rows:
pname = pname or "unknown"
if pname not in projects_map:
projects_map[pname] = {"task_name": pname, "project_name": pname,
"total_episodes": 0, "status_counts": {}}
stats[pname] = {"avg_frames": 0, "last_upload_at": None, "failed_count": 0}
projects_map[pname]["total_episodes"] += cnt
projects_map[pname]["status_counts"][ep_status] = cnt
if avg_fr:
stats[pname]["avg_frames"] = round(float(avg_fr), 1)
if last_up:
stats[pname]["last_upload_at"] = last_up.isoformat()
if ep_status == "failed":
stats[pname]["failed_count"] += cnt
for _cnt, ep_status in orphan_rows:
pname = "Uncategorized"
if pname not in projects_map:
projects_map[pname] = {"task_name": pname, "project_name": pname,
"total_episodes": 0, "status_counts": {}}
stats[pname] = {"avg_frames": 0, "last_upload_at": None, "failed_count": 0}
projects_map[pname]["total_episodes"] += _cnt
projects_map[pname]["status_counts"][ep_status] = _cnt
projects = list(projects_map.values())
if search:
low = search.lower()
projects = [p for p in projects if low in p["task_name"].lower()]
projects.sort(key=lambda p: p["total_episodes"], reverse=True)
for p in projects:
st = stats.get(p["task_name"], {})
p["avg_frames"] = st.get("avg_frames", 0)
p["last_upload_at"] = st.get("last_upload_at")
p["cleaning_failed"] = st.get("failed_count", 0)
total = len(projects)
return {"tasks": projects[offset:offset + limit], "total": total,
"limit": limit, "offset": offset}
|