from __future__ import annotations from collections import defaultdict from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import or_ from sqlalchemy.orm import Session from ..db import get_db from ..models import ( Category, Period, Project, Setting, Tag, Task, Update, people_like, set_people, ) from ..schemas import ( CategoryIn, ProjectIn, ProjectPatch, TagIn, UpdateIn, UpdatePatch, ) from .board import resolve_period_ids, worst_status router = APIRouter() def _sync_tags(db: Session, project: Project, names: list[str] | None): if names is None: return tags = [] for n in names: n = (n or "").strip() if not n: continue t = db.query(Tag).filter(Tag.name == n).one_or_none() if not t: t = Tag(name=n) db.add(t) db.flush() tags.append(t) project.tags = tags def _scope_index(db: Session, period_id: int | None, year: int | None, month: int | None): """返回 (范围内周期 id, 基准周期 sort_key, {各周 sort_key: 序号}),序号差即「隔了几周没更新」。""" ids = resolve_period_ids(db, period_id, year, month) weeks = db.query(Period).order_by(Period.sort_key).all() index = {w.sort_key: i for i, w in enumerate(weeks)} if ids: ref = max(w.sort_key for w in weeks if w.id in set(ids)) else: ref = weeks[-1].sort_key if weeks else 0 return ids, ref, index def _idle_weeks(last_sort: int | None, ref_sort: int, index: dict[int, int]) -> int | None: """距基准周期几周没有执行记录;从没更新过返回 None。""" if not last_sort: return None return max(0, index.get(ref_sort, 0) - index.get(last_sort, index.get(ref_sort, 0))) # ---------------------------------------------------------------- 项目 @router.get("/projects") def list_projects( category_id: int | None = None, is_key: bool | None = None, archived: str = Query("false", pattern="^(true|false|all)$"), keyword: str | None = None, owner: str | None = None, period_id: int | None = None, year: int | None = None, month: int | None = None, db: Session = Depends(get_db), ): """项目清单。附带「本周期是否更新 / 已连续几周没更新」,周更管理一眼可见。""" ids, ref_sort, week_index = _scope_index(db, period_id, year, month) q = db.query(Project) if archived == "false": q = q.filter(Project.archived.is_(False)) elif archived == "true": q = q.filter(Project.archived.is_(True)) if category_id: q = q.filter(Project.category_id == category_id) if is_key is not None: q = q.filter(Project.is_key.is_(is_key)) if keyword: q = q.filter(Project.name.contains(keyword)) if owner: # 负责人筛选连其他负责人一起命中(主负责人在 owner 列,其余在 other_owners 列) q = q.filter(or_(Project.owner == owner, Project.other_owners.like(people_like(owner)))) projects = q.order_by(Project.is_key.desc(), Project.sort_order, Project.id).all() # 最近一次更新 ups = ( db.query(Update, Period) .join(Period, Period.id == Update.period_id) .order_by(Period.sort_key.desc(), Update.id.desc()) .all() ) last: dict[int, dict] = {} last_sort: dict[int, int] = {} counts: dict[int, int] = defaultdict(int) for u, p in ups: # 按周期倒序遍历,第一条即该项目最近一次记录 counts[u.project_id] += 1 if u.project_id not in last: last_sort[u.project_id] = p.sort_key last[u.project_id] = { "period_label": p.label, "period_full": p.to_dict()["full_label"], "status": u.status, "summary": u.summary, "progress": u.progress, } # 范围内有执行记录的项目(月粒度时不能只看基准周) in_scope = { row[0] for row in db.query(Update.project_id) .filter(Update.period_id.in_(ids)) .distinct() .all() } if ids else set() result = [] for p in projects: d = p.to_dict() d["last_update"] = last.get(p.id) d["update_count"] = counts.get(p.id, 0) d["task_count"] = len(p.tasks) d["idle_weeks"] = _idle_weeks(last_sort.get(p.id), ref_sort, week_index) d["updated_this_period"] = p.id in in_scope result.append(d) return result @router.post("/projects") def create_project(payload: ProjectIn, db: Session = Depends(get_db)): if db.query(Project).filter(Project.name == payload.name).one_or_none(): raise HTTPException(400, "同名项目已存在") data = payload.model_dump(exclude={"tags", "owners", "members"}) p = Project(**data) set_people(p, payload.owners, payload.members) db.add(p) db.flush() _sync_tags(db, p, payload.tags) db.commit() db.refresh(p) return p.to_dict() @router.get("/projects/{pid}") def get_project(pid: int, db: Session = Depends(get_db)): p = db.get(Project, pid) if not p: raise HTTPException(404, "项目不存在") ups = ( db.query(Update, Period) .join(Period, Period.id == Update.period_id) .filter(Update.project_id == pid) .order_by(Period.sort_key, Update.id) .all() ) timeline: dict[int, dict] = {} for u, period in ups: t = timeline.setdefault( period.id, { "period": period.to_dict(), "updates": [], "progresses": [], }, ) t["updates"].append(u.to_dict()) if u.progress is not None: t["progresses"].append(u.progress) tl = [] for t in timeline.values(): pr = t["progresses"] tl.append( { "period": t["period"], "updates": t["updates"], "progress": round(max(pr), 1) if pr else None, "status": worst_status([u["status"] for u in t["updates"]]), } ) tl.sort(key=lambda x: x["period"]["sort_key"]) return { "project": p.to_dict(brief=False), "tasks": [t.to_dict() for t in p.tasks], "timeline": tl, "updates": [u.to_dict() for u, _ in ups][::-1], } @router.patch("/projects/{pid}") def patch_project(pid: int, payload: ProjectPatch, db: Session = Depends(get_db)): p = db.get(Project, pid) if not p: raise HTTPException(404, "项目不存在") data = payload.model_dump(exclude_unset=True, exclude={"tags", "owners", "members"}) if "name" in data: new_name = (data.get("name") or "").strip() if not new_name: raise HTTPException(400, "项目名称不能为空") if new_name != p.name: dup = ( db.query(Project) .filter(Project.name == new_name, Project.id != pid) .one_or_none() ) if dup: raise HTTPException(400, f"已存在同名项目「{new_name}」,请换一个名称") data["name"] = new_name for k, v in data.items(): setattr(p, k, v) set_people(p, payload.owners, payload.members) _sync_tags(db, p, payload.tags) db.commit() db.refresh(p) return p.to_dict() @router.delete("/projects/{pid}") def delete_project(pid: int, db: Session = Depends(get_db)): """删除项目,级联删除其子任务与执行记录。""" p = db.get(Project, pid) if not p: raise HTTPException(404, "项目不存在") name = p.name n_task = len(p.tasks) n_upd = len(p.updates) db.delete(p) db.commit() return {"ok": True, "name": name, "deleted_tasks": n_task, "deleted_updates": n_upd} # ---------------------------------------------------------------- 分类 / 标记 @router.get("/categories") def list_categories(db: Session = Depends(get_db)): return [c.to_dict(with_count=True) for c in db.query(Category).order_by(Category.sort_order, Category.id).all()] @router.post("/categories") def create_category(payload: CategoryIn, db: Session = Depends(get_db)): if db.query(Category).filter(Category.name == payload.name).one_or_none(): raise HTTPException(400, "同名分类已存在") c = Category(name=payload.name, color=payload.color) db.add(c) db.commit() db.refresh(c) return c.to_dict() @router.patch("/categories/{cid}") def patch_category(cid: int, payload: CategoryIn, db: Session = Depends(get_db)): c = db.get(Category, cid) if not c: raise HTTPException(404, "分类不存在") c.name = payload.name c.color = payload.color db.commit() return c.to_dict() @router.delete("/categories/{cid}") def delete_category(cid: int, db: Session = Depends(get_db)): c = db.get(Category, cid) if not c: raise HTTPException(404, "分类不存在") db.query(Project).filter(Project.category_id == cid).update({"category_id": None}) db.delete(c) db.commit() return {"ok": True} @router.get("/tags") def list_tags(db: Session = Depends(get_db)): return [t.to_dict() for t in db.query(Tag).order_by(Tag.id).all()] @router.post("/tags") def create_tag(payload: TagIn, db: Session = Depends(get_db)): t = db.query(Tag).filter(Tag.name == payload.name).one_or_none() if t: return t.to_dict() t = Tag(name=payload.name, color=payload.color) db.add(t) db.commit() db.refresh(t) return t.to_dict() @router.delete("/tags/{tid}") def delete_tag(tid: int, db: Session = Depends(get_db)): t = db.get(Tag, tid) if not t: raise HTTPException(404, "标记不存在") db.delete(t) db.commit() return {"ok": True} # ---------------------------------------------------------------- 执行记录 @router.get("/updates") def list_updates( project_id: int | None = None, period_id: int | None = None, limit: int = Query(50, le=500), db: Session = Depends(get_db), ): q = db.query(Update) if project_id: q = q.filter(Update.project_id == project_id) if period_id: q = q.filter(Update.period_id == period_id) rows = q.order_by(Update.id.desc()).limit(limit).all() return [u.to_dict(with_project=True) for u in rows] @router.post("/updates") def create_update(payload: UpdateIn, db: Session = Depends(get_db)): data = payload.model_dump(exclude={"task_name"}) task_id = data.get("task_id") if not task_id and payload.task_name: t = ( db.query(Task) .filter(Task.project_id == payload.project_id, Task.name == payload.task_name) .one_or_none() ) if not t: t = Task(project_id=payload.project_id, name=payload.task_name) db.add(t) db.flush() data["task_id"] = t.id u = Update(**data) db.add(u) p = db.get(Project, payload.project_id) if p and data.get("progress") is not None: p.progress = float(data["progress"]) db.commit() db.refresh(u) return u.to_dict() @router.patch("/updates/{uid}") def patch_update(uid: int, payload: UpdatePatch, db: Session = Depends(get_db)): """修改单条执行记录;改了进度就同步项目档案,与新建时的口径一致。""" u = db.get(Update, uid) if not u: raise HTTPException(404, "记录不存在") data = payload.model_dump(exclude_unset=True) task_name = (data.pop("task_name", None) or "").strip() if task_name: t = ( db.query(Task) .filter(Task.project_id == u.project_id, Task.name == task_name) .one_or_none() ) if not t: t = Task(project_id=u.project_id, name=task_name) db.add(t) db.flush() data["task_id"] = t.id if "period_id" in data and not data["period_id"]: raise HTTPException(400, "周期不能为空") for k, v in data.items(): setattr(u, k, v) if data.get("progress") is not None: p = db.get(Project, u.project_id) if p: p.progress = float(data["progress"]) db.commit() db.refresh(u) return u.to_dict() @router.delete("/updates/{uid}") def delete_update(uid: int, db: Session = Depends(get_db)): u = db.get(Update, uid) if not u: raise HTTPException(404, "记录不存在") db.delete(u) db.commit() return {"ok": True} @router.post("/tasks") def create_task(payload: dict, db: Session = Depends(get_db)): t = Task(project_id=payload["project_id"], name=payload["name"]) db.add(t) db.commit() db.refresh(t) return t.to_dict() # ---------------------------------------------------------------- 设置 @router.get("/settings") def get_settings(db: Session = Depends(get_db)): from ..ai_parser import get_ai_settings from ..config import AI_SETTING_KEYS s = get_ai_settings(db) return {k: s.get(k, "") for k in AI_SETTING_KEYS} @router.put("/settings") def put_settings(payload: dict, db: Session = Depends(get_db)): from ..config import AI_SETTING_KEYS for k, v in (payload.get("values") or {}).items(): if k not in AI_SETTING_KEYS: continue row = db.get(Setting, k) if not row: row = Setting(key=k) db.add(row) row.value = "" if v is None else str(v) db.commit() return {"ok": True}