Some checks failed
- Backend: FastAPI + SQLite (WAL mode), 22 tables, ~40 API endpoints - Frontend: Vue 3 + Vite + Pinia + Vue Router, 8 views, 3 stores - Database: migrate from JSON file to SQLite with proper schema - Dockerfile: multi-stage build (node + python) - Deploy: K8s manifests (namespace, deployment, service, ingress, pvc, backup) - CI/CD: Gitea Actions (test, deploy, PR preview at pr-$id.planner.oci.euphon.net) - Tests: 20 Cypress E2E test files, 196 test cases, ~85% coverage - Doc: test-coverage.md with full feature coverage report Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1072 lines
29 KiB
Python
1072 lines
29 KiB
Python
"""Hera Planner - FastAPI Backend"""
|
|
import json
|
|
import os
|
|
import hashlib
|
|
import time
|
|
import shutil
|
|
import threading
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from contextlib import contextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from pydantic import BaseModel
|
|
from typing import Optional
|
|
|
|
from .database import get_db, init_db, DB_PATH
|
|
|
|
app = FastAPI(title="Hera's Planner")
|
|
|
|
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
|
BACKUP_DIR = os.path.join(DATA_DIR, "backups")
|
|
FRONTEND_DIR = os.environ.get("FRONTEND_DIR", "/app/frontend")
|
|
|
|
DEFAULT_HASH = "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"
|
|
|
|
|
|
# ============================================================
|
|
# Helpers
|
|
# ============================================================
|
|
|
|
def _ensure_dirs():
|
|
Path(DATA_DIR).mkdir(parents=True, exist_ok=True)
|
|
Path(BACKUP_DIR).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _get_config(key, default=None):
|
|
conn = get_db()
|
|
row = conn.execute("SELECT value FROM config WHERE key = ?", (key,)).fetchone()
|
|
conn.close()
|
|
return row["value"] if row else default
|
|
|
|
|
|
def _set_config(key, value):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
|
|
(key, value, value),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _do_backup():
|
|
"""Backup the SQLite database."""
|
|
if not os.path.exists(DB_PATH):
|
|
return
|
|
_ensure_dirs()
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
dest = os.path.join(BACKUP_DIR, f"planner_{ts}.db")
|
|
# Use SQLite backup API for consistency
|
|
import sqlite3
|
|
src_conn = sqlite3.connect(DB_PATH)
|
|
dst_conn = sqlite3.connect(dest)
|
|
src_conn.backup(dst_conn)
|
|
dst_conn.close()
|
|
src_conn.close()
|
|
# Keep only last 30 backups
|
|
backups = sorted(Path(BACKUP_DIR).glob("planner_*.db"))
|
|
for old in backups[:-30]:
|
|
old.unlink()
|
|
|
|
|
|
# WAL checkpoint thread
|
|
def _wal_checkpoint_loop():
|
|
while True:
|
|
time.sleep(300)
|
|
try:
|
|
conn = get_db()
|
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ============================================================
|
|
# Startup
|
|
# ============================================================
|
|
|
|
@app.on_event("startup")
|
|
def startup():
|
|
_ensure_dirs()
|
|
init_db()
|
|
# Start WAL checkpoint thread
|
|
t = threading.Thread(target=_wal_checkpoint_loop, daemon=True)
|
|
t.start()
|
|
# Mount frontend static files
|
|
dist_assets = os.path.join(FRONTEND_DIR, "assets")
|
|
if os.path.isdir(dist_assets):
|
|
app.mount("/assets", StaticFiles(directory=dist_assets), name="assets")
|
|
|
|
|
|
# ============================================================
|
|
# Auth
|
|
# ============================================================
|
|
|
|
class LoginRequest(BaseModel):
|
|
hash: str
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
oldHash: str
|
|
newHash: str
|
|
|
|
|
|
@app.post("/api/login")
|
|
def login(req: LoginRequest):
|
|
stored = _get_config("password_hash", DEFAULT_HASH)
|
|
if req.hash != stored:
|
|
raise HTTPException(401, "密码不正确")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/change-password")
|
|
def change_password(req: ChangePasswordRequest):
|
|
stored = _get_config("password_hash", DEFAULT_HASH)
|
|
if req.oldHash != stored:
|
|
raise HTTPException(401, "当前密码不正确")
|
|
_set_config("password_hash", req.newHash)
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Notes (随手记)
|
|
# ============================================================
|
|
|
|
@app.get("/api/notes")
|
|
def list_notes():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM notes ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class NoteIn(BaseModel):
|
|
id: str
|
|
text: str
|
|
tag: str = "灵感"
|
|
|
|
@app.post("/api/notes")
|
|
def create_note(note: NoteIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO notes (id, text, tag) VALUES (?, ?, ?) "
|
|
"ON CONFLICT(id) DO UPDATE SET text=?, tag=?, updated_at=datetime('now')",
|
|
(note.id, note.text, note.tag, note.text, note.tag),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/notes/{note_id}")
|
|
def delete_note(note_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM notes WHERE id = ?", (note_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Todos (待办四象限)
|
|
# ============================================================
|
|
|
|
@app.get("/api/todos")
|
|
def list_todos():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM todos ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class TodoIn(BaseModel):
|
|
id: str
|
|
text: str
|
|
quadrant: str = "q1"
|
|
done: int = 0
|
|
|
|
@app.post("/api/todos")
|
|
def upsert_todo(todo: TodoIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO todos (id, text, quadrant, done) VALUES (?, ?, ?, ?) "
|
|
"ON CONFLICT(id) DO UPDATE SET text=?, quadrant=?, done=?, updated_at=datetime('now')",
|
|
(todo.id, todo.text, todo.quadrant, todo.done, todo.text, todo.quadrant, todo.done),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/todos/{todo_id}")
|
|
def delete_todo(todo_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Inbox (收集箱)
|
|
# ============================================================
|
|
|
|
@app.get("/api/inbox")
|
|
def list_inbox():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM inbox ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class InboxIn(BaseModel):
|
|
id: str
|
|
text: str
|
|
|
|
@app.post("/api/inbox")
|
|
def add_inbox(item: InboxIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO inbox (id, text) VALUES (?, ?)",
|
|
(item.id, item.text),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/inbox/{item_id}")
|
|
def delete_inbox(item_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM inbox WHERE id = ?", (item_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/inbox")
|
|
def clear_inbox():
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM inbox")
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Reminders (提醒)
|
|
# ============================================================
|
|
|
|
@app.get("/api/reminders")
|
|
def list_reminders():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM reminders ORDER BY time").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class ReminderIn(BaseModel):
|
|
id: str
|
|
text: str
|
|
time: Optional[str] = None
|
|
repeat: str = "none"
|
|
enabled: int = 1
|
|
|
|
@app.post("/api/reminders")
|
|
def upsert_reminder(r: ReminderIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO reminders (id, text, time, repeat, enabled) VALUES (?, ?, ?, ?, ?) "
|
|
"ON CONFLICT(id) DO UPDATE SET text=?, time=?, repeat=?, enabled=?",
|
|
(r.id, r.text, r.time, r.repeat, r.enabled, r.text, r.time, r.repeat, r.enabled),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/reminders/{reminder_id}")
|
|
def delete_reminder(reminder_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Goals (目标)
|
|
# ============================================================
|
|
|
|
@app.get("/api/goals")
|
|
def list_goals():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM goals ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class GoalIn(BaseModel):
|
|
id: str
|
|
name: str
|
|
month: Optional[str] = None
|
|
checks: str = "{}"
|
|
|
|
@app.post("/api/goals")
|
|
def upsert_goal(g: GoalIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO goals (id, name, month, checks) VALUES (?, ?, ?, ?) "
|
|
"ON CONFLICT(id) DO UPDATE SET name=?, month=?, checks=?",
|
|
(g.id, g.name, g.month, g.checks, g.name, g.month, g.checks),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/goals/{goal_id}")
|
|
def delete_goal(goal_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM goals WHERE id = ?", (goal_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Checklists (清单)
|
|
# ============================================================
|
|
|
|
@app.get("/api/checklists")
|
|
def list_checklists():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM checklists ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class ChecklistIn(BaseModel):
|
|
id: str
|
|
title: str
|
|
items: str = "[]"
|
|
archived: int = 0
|
|
|
|
@app.post("/api/checklists")
|
|
def upsert_checklist(cl: ChecklistIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO checklists (id, title, items, archived) VALUES (?, ?, ?, ?) "
|
|
"ON CONFLICT(id) DO UPDATE SET title=?, items=?, archived=?",
|
|
(cl.id, cl.title, cl.items, cl.archived, cl.title, cl.items, cl.archived),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/checklists/{cl_id}")
|
|
def delete_checklist(cl_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM checklists WHERE id = ?", (cl_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Health / Music Check-in (打卡)
|
|
# ============================================================
|
|
|
|
@app.get("/api/health-items")
|
|
def list_health_items(type: str = "health"):
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM health_items WHERE type = ?", (type,)).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class HealthItemIn(BaseModel):
|
|
id: str
|
|
name: str
|
|
type: str = "health"
|
|
|
|
@app.post("/api/health-items")
|
|
def upsert_health_item(item: HealthItemIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO health_items (id, name, type) VALUES (?, ?, ?)",
|
|
(item.id, item.name, item.type),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/health-items/{item_id}")
|
|
def delete_health_item(item_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM health_items WHERE id = ?", (item_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/health-plans")
|
|
def get_health_plans(type: str = "health"):
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM health_plans WHERE type = ?", (type,)).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class HealthPlanIn(BaseModel):
|
|
month: str
|
|
type: str = "health"
|
|
item_ids: str = "[]"
|
|
|
|
@app.post("/api/health-plans")
|
|
def upsert_health_plan(plan: HealthPlanIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO health_plans (month, type, item_ids) VALUES (?, ?, ?) "
|
|
"ON CONFLICT(month, type) DO UPDATE SET item_ids=?",
|
|
(plan.month, plan.type, plan.item_ids, plan.item_ids),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/health-checks")
|
|
def list_health_checks(type: str = "health", month: Optional[str] = None):
|
|
conn = get_db()
|
|
if month:
|
|
rows = conn.execute(
|
|
"SELECT * FROM health_checks WHERE type = ? AND date LIKE ?",
|
|
(type, f"{month}%"),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute("SELECT * FROM health_checks WHERE type = ?", (type,)).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class HealthCheckIn(BaseModel):
|
|
date: str
|
|
type: str = "health"
|
|
item_id: str
|
|
checked: int = 1
|
|
|
|
@app.post("/api/health-checks")
|
|
def upsert_health_check(check: HealthCheckIn):
|
|
conn = get_db()
|
|
if check.checked:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO health_checks (date, type, item_id, checked) VALUES (?, ?, ?, ?)",
|
|
(check.date, check.type, check.item_id, check.checked),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"DELETE FROM health_checks WHERE date = ? AND type = ? AND item_id = ?",
|
|
(check.date, check.type, check.item_id),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Sleep Records (睡眠)
|
|
# ============================================================
|
|
|
|
@app.get("/api/sleep")
|
|
def list_sleep():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM sleep_records ORDER BY date DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class SleepIn(BaseModel):
|
|
date: str
|
|
time: str
|
|
minutes: Optional[float] = None
|
|
|
|
@app.post("/api/sleep")
|
|
def upsert_sleep(record: SleepIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO sleep_records (date, time, minutes) VALUES (?, ?, ?) "
|
|
"ON CONFLICT(date) DO UPDATE SET time=?, minutes=?",
|
|
(record.date, record.time, record.minutes, record.time, record.minutes),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/sleep/{date}")
|
|
def delete_sleep(date: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM sleep_records WHERE date = ?", (date,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Gym Records (健身)
|
|
# ============================================================
|
|
|
|
@app.get("/api/gym")
|
|
def list_gym():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM gym_records ORDER BY date DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class GymIn(BaseModel):
|
|
id: str
|
|
date: str
|
|
type: str = ""
|
|
duration: str = ""
|
|
note: str = ""
|
|
|
|
@app.post("/api/gym")
|
|
def upsert_gym(record: GymIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO gym_records (id, date, type, duration, note) VALUES (?, ?, ?, ?, ?) "
|
|
"ON CONFLICT(id) DO UPDATE SET date=?, type=?, duration=?, note=?",
|
|
(record.id, record.date, record.type, record.duration, record.note,
|
|
record.date, record.type, record.duration, record.note),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/gym/{record_id}")
|
|
def delete_gym(record_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM gym_records WHERE id = ?", (record_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Period Records (经期)
|
|
# ============================================================
|
|
|
|
@app.get("/api/period")
|
|
def list_period():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM period_records ORDER BY start_date DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class PeriodIn(BaseModel):
|
|
id: str
|
|
start_date: str
|
|
end_date: Optional[str] = None
|
|
note: str = ""
|
|
|
|
@app.post("/api/period")
|
|
def upsert_period(record: PeriodIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO period_records (id, start_date, end_date, note) VALUES (?, ?, ?, ?) "
|
|
"ON CONFLICT(id) DO UPDATE SET start_date=?, end_date=?, note=?",
|
|
(record.id, record.start_date, record.end_date, record.note,
|
|
record.start_date, record.end_date, record.note),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/period/{record_id}")
|
|
def delete_period(record_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM period_records WHERE id = ?", (record_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Docs (文档)
|
|
# ============================================================
|
|
|
|
@app.get("/api/docs")
|
|
def list_docs():
|
|
conn = get_db()
|
|
docs = conn.execute("SELECT * FROM docs ORDER BY created_at").fetchall()
|
|
result = []
|
|
for d in docs:
|
|
doc = dict(d)
|
|
entries = conn.execute(
|
|
"SELECT * FROM doc_entries WHERE doc_id = ? ORDER BY created_at DESC",
|
|
(d["id"],),
|
|
).fetchall()
|
|
doc["entries"] = [dict(e) for e in entries]
|
|
result.append(doc)
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
class DocIn(BaseModel):
|
|
id: str
|
|
name: str
|
|
icon: str = "📄"
|
|
keywords: str = ""
|
|
extract_rule: str = "none"
|
|
|
|
@app.post("/api/docs")
|
|
def upsert_doc(doc: DocIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO docs (id, name, icon, keywords, extract_rule) VALUES (?, ?, ?, ?, ?) "
|
|
"ON CONFLICT(id) DO UPDATE SET name=?, icon=?, keywords=?, extract_rule=?",
|
|
(doc.id, doc.name, doc.icon, doc.keywords, doc.extract_rule,
|
|
doc.name, doc.icon, doc.keywords, doc.extract_rule),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/docs/{doc_id}")
|
|
def delete_doc(doc_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM docs WHERE id = ?", (doc_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
class DocEntryIn(BaseModel):
|
|
id: str
|
|
doc_id: str
|
|
text: str
|
|
note_id: Optional[str] = None
|
|
|
|
@app.post("/api/doc-entries")
|
|
def add_doc_entry(entry: DocEntryIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO doc_entries (id, doc_id, text, note_id) VALUES (?, ?, ?, ?)",
|
|
(entry.id, entry.doc_id, entry.text, entry.note_id),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/doc-entries/{entry_id}")
|
|
def delete_doc_entry(entry_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM doc_entries WHERE id = ?", (entry_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Schedule (日程)
|
|
# ============================================================
|
|
|
|
@app.get("/api/schedule-modules")
|
|
def list_schedule_modules():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM schedule_modules ORDER BY sort_order").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class ScheduleModuleIn(BaseModel):
|
|
id: str
|
|
name: str
|
|
emoji: str = "📌"
|
|
color: str = "#667eea"
|
|
sort_order: int = 0
|
|
|
|
@app.post("/api/schedule-modules")
|
|
def upsert_schedule_module(m: ScheduleModuleIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO schedule_modules (id, name, emoji, color, sort_order) VALUES (?, ?, ?, ?, ?) "
|
|
"ON CONFLICT(id) DO UPDATE SET name=?, emoji=?, color=?, sort_order=?",
|
|
(m.id, m.name, m.emoji, m.color, m.sort_order, m.name, m.emoji, m.color, m.sort_order),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/schedule-modules/{module_id}")
|
|
def delete_schedule_module(module_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM schedule_modules WHERE id = ?", (module_id,))
|
|
conn.execute("DELETE FROM schedule_slots WHERE module_id = ?", (module_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/schedule-slots")
|
|
def list_schedule_slots(date: Optional[str] = None):
|
|
conn = get_db()
|
|
if date:
|
|
rows = conn.execute("SELECT * FROM schedule_slots WHERE date = ?", (date,)).fetchall()
|
|
else:
|
|
rows = conn.execute("SELECT * FROM schedule_slots").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class ScheduleSlotIn(BaseModel):
|
|
date: str
|
|
time_slot: str
|
|
module_id: str
|
|
|
|
@app.post("/api/schedule-slots")
|
|
def add_schedule_slot(slot: ScheduleSlotIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO schedule_slots (date, time_slot, module_id) VALUES (?, ?, ?)",
|
|
(slot.date, slot.time_slot, slot.module_id),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/schedule-slots")
|
|
def delete_schedule_slots(date: str, time_slot: Optional[str] = None, module_id: Optional[str] = None):
|
|
conn = get_db()
|
|
if time_slot and module_id:
|
|
conn.execute(
|
|
"DELETE FROM schedule_slots WHERE date = ? AND time_slot = ? AND module_id = ?",
|
|
(date, time_slot, module_id),
|
|
)
|
|
elif time_slot:
|
|
conn.execute("DELETE FROM schedule_slots WHERE date = ? AND time_slot = ?", (date, time_slot))
|
|
else:
|
|
conn.execute("DELETE FROM schedule_slots WHERE date = ?", (date,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Weekly Template (每周模板)
|
|
# ============================================================
|
|
|
|
@app.get("/api/weekly-template")
|
|
def get_weekly_template():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM weekly_template ORDER BY day").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class WeeklyTemplateIn(BaseModel):
|
|
day: int
|
|
data: str = "[]"
|
|
|
|
@app.post("/api/weekly-template")
|
|
def upsert_weekly_template(t: WeeklyTemplateIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO weekly_template (day, data) VALUES (?, ?) "
|
|
"ON CONFLICT(day) DO UPDATE SET data=?",
|
|
(t.day, t.data, t.data),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Reviews (周回顾)
|
|
# ============================================================
|
|
|
|
@app.get("/api/reviews")
|
|
def list_reviews():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM reviews ORDER BY week DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class ReviewIn(BaseModel):
|
|
week: str
|
|
data: str = "{}"
|
|
|
|
@app.post("/api/reviews")
|
|
def upsert_review(r: ReviewIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO reviews (week, data) VALUES (?, ?) "
|
|
"ON CONFLICT(week) DO UPDATE SET data=?",
|
|
(r.week, r.data, r.data),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Bugs
|
|
# ============================================================
|
|
|
|
@app.get("/api/bugs")
|
|
def list_bugs():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM bugs ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
class BugIn(BaseModel):
|
|
id: str
|
|
text: str
|
|
status: str = "open"
|
|
|
|
@app.post("/api/bugs")
|
|
def upsert_bug(bug: BugIn):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO bugs (id, text, status) VALUES (?, ?, ?) "
|
|
"ON CONFLICT(id) DO UPDATE SET text=?, status=?",
|
|
(bug.id, bug.text, bug.status, bug.text, bug.status),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/bugs/{bug_id}")
|
|
def delete_bug(bug_id: str):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM bugs WHERE id = ?", (bug_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Sleep Buddy (独立的社交睡眠打卡)
|
|
# ============================================================
|
|
|
|
class BuddyRegister(BaseModel):
|
|
username: str
|
|
hash: str
|
|
|
|
@app.post("/api/buddy-register")
|
|
def buddy_register(req: BuddyRegister):
|
|
username = req.username.strip()
|
|
if not username or not req.hash:
|
|
raise HTTPException(400, "请输入用户名和密码")
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT username FROM buddy_users WHERE username = ?", (username,)).fetchone()
|
|
if existing:
|
|
conn.close()
|
|
raise HTTPException(400, "用户名已存在")
|
|
conn.execute(
|
|
"INSERT INTO buddy_users (username, password_hash) VALUES (?, ?)",
|
|
(username, req.hash),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
class BuddyLogin(BaseModel):
|
|
username: str
|
|
hash: str
|
|
|
|
@app.post("/api/buddy-login")
|
|
def buddy_login(req: BuddyLogin):
|
|
username = req.username.strip()
|
|
conn = get_db()
|
|
user = conn.execute(
|
|
"SELECT * FROM buddy_users WHERE username = ? AND password_hash = ?",
|
|
(username, req.hash),
|
|
).fetchone()
|
|
conn.close()
|
|
if not user:
|
|
raise HTTPException(401, "用户名或密码不正确")
|
|
return {"ok": True, "username": username}
|
|
|
|
|
|
class BuddyDeleteUser(BaseModel):
|
|
adminHash: str
|
|
username: str
|
|
|
|
@app.post("/api/buddy-delete-user")
|
|
def buddy_delete_user(req: BuddyDeleteUser):
|
|
stored = _get_config("password_hash", DEFAULT_HASH)
|
|
if req.adminHash != stored:
|
|
raise HTTPException(401, "需要管理员密码")
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM buddy_users WHERE username = ?", (req.username,))
|
|
conn.execute("DELETE FROM buddy_records WHERE username = ?", (req.username,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/sleep-buddy")
|
|
def get_buddy_data():
|
|
conn = get_db()
|
|
users_rows = conn.execute("SELECT * FROM buddy_users").fetchall()
|
|
users = {}
|
|
for u in users_rows:
|
|
records = conn.execute(
|
|
"SELECT date, time FROM buddy_records WHERE username = ? ORDER BY date DESC LIMIT 60",
|
|
(u["username"],),
|
|
).fetchall()
|
|
users[u["username"]] = [dict(r) for r in records]
|
|
|
|
targets = {}
|
|
for u in users_rows:
|
|
targets[u["username"]] = u["target_time"]
|
|
|
|
notifs = conn.execute(
|
|
"SELECT * FROM buddy_notifications ORDER BY created_at DESC LIMIT 20"
|
|
).fetchall()
|
|
conn.close()
|
|
|
|
return {
|
|
"users": users,
|
|
"targets": targets,
|
|
"notifications": [dict(n) for n in notifs],
|
|
}
|
|
|
|
|
|
class BuddyAction(BaseModel):
|
|
user: str
|
|
action: str
|
|
record: Optional[dict] = None
|
|
date: Optional[str] = None
|
|
target: Optional[str] = None
|
|
|
|
@app.post("/api/sleep-buddy")
|
|
def buddy_action(req: BuddyAction):
|
|
conn = get_db()
|
|
|
|
if req.action == "record":
|
|
record = req.record or {}
|
|
conn.execute(
|
|
"INSERT INTO buddy_records (username, date, time) VALUES (?, ?, ?) "
|
|
"ON CONFLICT(username, date) DO UPDATE SET time=?",
|
|
(req.user, record.get("date", ""), record.get("time", ""), record.get("time", "")),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
elif req.action == "delete-record":
|
|
conn.execute(
|
|
"DELETE FROM buddy_records WHERE username = ? AND date = ?",
|
|
(req.user, req.date),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
elif req.action == "sleep-now":
|
|
now = datetime.now()
|
|
conn.execute(
|
|
"INSERT INTO buddy_notifications (from_user, message, time, date, created_at) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(
|
|
req.user,
|
|
f"{req.user} 去睡觉啦,你也早点休息!",
|
|
now.strftime("%H:%M"),
|
|
now.strftime("%Y-%m-%d"),
|
|
time.time(),
|
|
),
|
|
)
|
|
# Keep only last 20 notifications
|
|
conn.execute("""
|
|
DELETE FROM buddy_notifications WHERE id NOT IN (
|
|
SELECT id FROM buddy_notifications ORDER BY created_at DESC LIMIT 20
|
|
)
|
|
""")
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
elif req.action == "get-notifications":
|
|
cutoff = time.time() - 86400
|
|
rows = conn.execute(
|
|
"SELECT * FROM buddy_notifications WHERE from_user != ? AND created_at > ? ORDER BY created_at DESC",
|
|
(req.user, cutoff),
|
|
).fetchall()
|
|
conn.close()
|
|
return {"notifications": [dict(r) for r in rows]}
|
|
|
|
elif req.action == "set-target":
|
|
conn.execute(
|
|
"UPDATE buddy_users SET target_time = ? WHERE username = ?",
|
|
(req.target or "22:00", req.user),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
conn.close()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# Backups
|
|
# ============================================================
|
|
|
|
@app.get("/api/backups")
|
|
def list_backups():
|
|
_ensure_dirs()
|
|
backups = sorted(Path(BACKUP_DIR).glob("planner_*.db"), reverse=True)
|
|
items = []
|
|
for b in backups[:20]:
|
|
items.append({
|
|
"name": b.name,
|
|
"size": b.stat().st_size,
|
|
"time": b.stem.split("_", 1)[1],
|
|
})
|
|
return items
|
|
|
|
|
|
@app.post("/api/backup")
|
|
def trigger_backup():
|
|
_do_backup()
|
|
return {"ok": True}
|
|
|
|
|
|
# ============================================================
|
|
# SPA Fallback
|
|
# ============================================================
|
|
|
|
@app.get("/{path:path}")
|
|
async def spa_fallback(path: str):
|
|
# Try static file first
|
|
file_path = os.path.join(FRONTEND_DIR, path)
|
|
if path and os.path.isfile(file_path):
|
|
return FileResponse(file_path)
|
|
# SPA fallback
|
|
index = os.path.join(FRONTEND_DIR, "index.html")
|
|
if os.path.isfile(index):
|
|
return FileResponse(index, headers={
|
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
})
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|