411 lines
13 KiB
Python
411 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,
|
|
)
|
|
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: dict, db: Session = Depends(get_db)):
|
|
u = db.get(Update, uid)
|
|
if not u:
|
|
raise HTTPException(404, "记录不存在")
|
|
for k in ("content", "summary", "progress", "status", "risk", "next_step", "owner", "task_id"):
|
|
if k in payload:
|
|
setattr(u, k, payload[k])
|
|
db.commit()
|
|
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}
|