unis_manager/app/routers/projects.py

432 lines
13 KiB
Python

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}