feat: 新增日程面板、复盘页面与 Agent 读写接口
- 日程:单次/每日/每周/每月重复,JSON 存储,月历按优先级着色圆点 + 悬停提示 - 复盘页:可自定义折线图卡片(SVG) + 每日心得总览(按日期/任务展开) - Agent 接口:GET /api/agent/overview 读日程+进度,POST /api/agent/schedules 建日程(PPM_API_TOKEN Bearer 鉴权) - 导出/导入/统计纳入日程与图表;47 项测试全绿
This commit is contained in:
115
app/main.py
115
app/main.py
@@ -8,13 +8,13 @@ from datetime import date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI, Query, Request
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .auth import SessionManager
|
||||
from .layouts import load_layout
|
||||
from .models import DailyRecordInput, LoginInput, PurgeInput, ReorderInput, TaskInput, TaskUpdate
|
||||
from .models import ChartInput, ChartUpdate, DailyRecordInput, LoginInput, PurgeInput, ReorderInput, ScheduleCompleteInput, ScheduleInput, ScheduleUpdate, TaskInput, TaskUpdate
|
||||
from .repository import ConflictError, JsonRepository, NotFoundError, SHANGHAI, ValidationError
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
|
||||
|
||||
def require_agent_token(request: Request):
|
||||
token = os.getenv("PPM_API_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=503, detail="agent API 未启用")
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not secrets.compare_digest(auth, f"Bearer {token}"):
|
||||
raise HTTPException(status_code=401, detail="token 无效")
|
||||
|
||||
|
||||
def _cookie_secure() -> bool:
|
||||
return os.getenv("PPM_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
|
||||
|
||||
@@ -97,11 +106,11 @@ def create_app(
|
||||
@app.middleware("http")
|
||||
async def security_middleware(request: Request, call_next):
|
||||
path = request.url.path
|
||||
public = path in {"/api/health", "/api/auth/status", "/api/auth/login"}
|
||||
public = path in {"/api/health", "/api/auth/status", "/api/auth/login"} or path.startswith("/api/agent/")
|
||||
if path.startswith("/api/") and not public:
|
||||
if session_manager.enabled and not session_manager.get(request.cookies.get("ppm_session")):
|
||||
return JSONResponse({"detail": "需要登录"}, status_code=401)
|
||||
if path.startswith("/api/") and request.method not in SAFE_METHODS and path != "/api/auth/login":
|
||||
if path.startswith("/api/") and request.method not in SAFE_METHODS and path != "/api/auth/login" and not path.startswith("/api/agent/"):
|
||||
submitted = request.headers.get("X-CSRF-Token", "")
|
||||
if session_manager.enabled:
|
||||
session = session_manager.get(request.cookies.get("ppm_session"))
|
||||
@@ -155,7 +164,7 @@ def create_app(
|
||||
|
||||
@app.get("/api/layouts/{page}")
|
||||
async def get_layout(page: str) -> dict:
|
||||
if page not in {"home", "task-detail"}:
|
||||
if page not in {"home", "task-detail", "review"}:
|
||||
raise NotFoundError("布局不存在")
|
||||
return load_layout(layouts, page)
|
||||
|
||||
@@ -189,6 +198,67 @@ def create_app(
|
||||
repository.purge_task(task_id, payload.confirmation)
|
||||
return {"purged": True, "taskId": task_id}
|
||||
|
||||
@app.get("/api/schedules")
|
||||
async def list_schedules(includeDeleted: bool = False) -> dict:
|
||||
return repository.list_schedules(includeDeleted)
|
||||
|
||||
@app.post("/api/schedules", status_code=201)
|
||||
async def create_schedule(payload: ScheduleInput) -> dict:
|
||||
return repository.create_schedule(payload)
|
||||
|
||||
@app.get("/api/schedules/calendar")
|
||||
async def schedule_calendar(month: Annotated[str | None, Query()] = None) -> dict:
|
||||
value = month or datetime.now(SHANGHAI).strftime("%Y-%m")
|
||||
try:
|
||||
year, month_number = (int(part) for part in value.split("-"))
|
||||
if len(value) != 7 or value[4] != "-" or not 1 <= month_number <= 12:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValidationError("月份必须是 YYYY-MM 格式")
|
||||
return repository.calendar_month(year, month_number)
|
||||
|
||||
@app.get("/api/schedules/today")
|
||||
async def schedules_today(date_: Annotated[date | None, Query(alias="date")] = None) -> dict:
|
||||
day = date_ or datetime.now(SHANGHAI).date()
|
||||
return {"date": day.isoformat(), "items": repository.schedules_for_date(day)}
|
||||
|
||||
@app.get("/api/schedules/{schedule_id}")
|
||||
async def get_schedule(schedule_id: str, includeDeleted: bool = False) -> dict:
|
||||
return repository.get_schedule(schedule_id, includeDeleted)
|
||||
|
||||
@app.put("/api/schedules/{schedule_id}")
|
||||
async def update_schedule(schedule_id: str, payload: ScheduleUpdate) -> dict:
|
||||
schedule_input = ScheduleInput.model_validate(payload.model_dump(exclude={"expectedVersion"}))
|
||||
return repository.update_schedule(schedule_id, schedule_input, payload.expectedVersion)
|
||||
|
||||
@app.delete("/api/schedules/{schedule_id}")
|
||||
async def delete_schedule(schedule_id: str, expectedVersion: int = Query(ge=1)) -> dict:
|
||||
return repository.soft_delete_schedule(schedule_id, expectedVersion)
|
||||
|
||||
@app.post("/api/schedules/{schedule_id}/restore")
|
||||
async def restore_schedule(schedule_id: str, expectedVersion: int = Query(ge=1)) -> dict:
|
||||
return repository.restore_schedule(schedule_id, expectedVersion)
|
||||
|
||||
@app.post("/api/schedules/{schedule_id}/purge")
|
||||
async def purge_schedule(schedule_id: str, payload: PurgeInput) -> dict:
|
||||
repository.purge_schedule(schedule_id, payload.confirmation)
|
||||
return {"purged": True, "scheduleId": schedule_id}
|
||||
|
||||
@app.post("/api/schedules/{schedule_id}/complete")
|
||||
async def complete_schedule(schedule_id: str, payload: ScheduleCompleteInput) -> dict:
|
||||
return repository.complete_schedule(schedule_id, payload.date, payload.completed, payload.expectedVersion)
|
||||
|
||||
@app.get("/api/agent/overview")
|
||||
async def agent_overview(
|
||||
date_: Annotated[date | None, Query(alias="date")] = None,
|
||||
_: None = Depends(require_agent_token),
|
||||
) -> dict:
|
||||
return repository.agent_overview(date_ or datetime.now(SHANGHAI).date())
|
||||
|
||||
@app.post("/api/agent/schedules", status_code=201)
|
||||
async def agent_create_schedule(payload: ScheduleInput, _: None = Depends(require_agent_token)) -> dict:
|
||||
return repository.create_schedule(payload)
|
||||
|
||||
@app.put("/api/tasks/reorder/all")
|
||||
async def reorder_tasks(payload: ReorderInput) -> dict:
|
||||
return repository.reorder(payload.taskIds, payload.expectedVersion)
|
||||
@@ -209,6 +279,41 @@ def create_app(
|
||||
async def task_summary(task_id: str, date_: Annotated[date | None, Query(alias="date")] = None) -> dict:
|
||||
return repository.summarize(date_, task_id)
|
||||
|
||||
@app.get("/api/charts")
|
||||
async def list_charts() -> dict:
|
||||
return repository.list_charts()
|
||||
|
||||
@app.post("/api/charts", status_code=201)
|
||||
async def create_chart(payload: ChartInput) -> dict:
|
||||
return repository.create_chart(payload)
|
||||
|
||||
@app.get("/api/charts/{chart_id}/data")
|
||||
async def chart_data(chart_id: str, ref: Annotated[date | None, Query()] = None) -> dict:
|
||||
return repository.chart_data(chart_id, ref)
|
||||
|
||||
@app.get("/api/charts/{chart_id}")
|
||||
async def get_chart(chart_id: str) -> dict:
|
||||
return repository.get_chart(chart_id)
|
||||
|
||||
@app.put("/api/charts/{chart_id}")
|
||||
async def update_chart(chart_id: str, payload: ChartUpdate) -> dict:
|
||||
chart_input = ChartInput.model_validate(payload.model_dump(exclude={"expectedVersion"}))
|
||||
return repository.update_chart(chart_id, chart_input, payload.expectedVersion)
|
||||
|
||||
@app.delete("/api/charts/{chart_id}")
|
||||
async def delete_chart(chart_id: str) -> dict:
|
||||
return repository.delete_chart(chart_id)
|
||||
|
||||
@app.get("/api/review/notes")
|
||||
async def review_notes(
|
||||
start: Annotated[date | None, Query()] = None,
|
||||
end: Annotated[date | None, Query()] = None,
|
||||
) -> dict:
|
||||
logical_today = datetime.now(SHANGHAI).date()
|
||||
review_end = end or logical_today
|
||||
review_start = start or review_end - timedelta(days=29)
|
||||
return repository.review_notes(review_start, review_end)
|
||||
|
||||
@app.get("/api/tasks/{task_id}/history")
|
||||
async def history(
|
||||
task_id: str,
|
||||
|
||||
Reference in New Issue
Block a user