fix: audit findings — list details, batch integrity, migrations, restore fidelity
ci / docker (push) Successful in 3m17s
ci / docker (push) Successful in 3m17s
This commit is contained in:
+96
-11
@@ -17,7 +17,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, Up
|
||||
from fastapi.responses import FileResponse
|
||||
from icalendar import Calendar
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import case, delete, func, select
|
||||
from sqlalchemy import case, delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .auth import current_user
|
||||
@@ -36,6 +36,7 @@ from .models import (
|
||||
Tag,
|
||||
Task,
|
||||
TaskList,
|
||||
TaskTag,
|
||||
User,
|
||||
new_id,
|
||||
utcnow,
|
||||
@@ -718,29 +719,113 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep
|
||||
def serialize(row, fields):
|
||||
return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields}
|
||||
folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tags = list((await db.scalars(select(Tag).where(Tag.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all())
|
||||
return {"version": 1, "exported_at": utcnow(), "folders": [serialize(x,["id","name","position","deleted_at"]) for x in folders], "lists": [serialize(x,["id","folder_id","name","is_inbox","position","deleted_at"]) for x in lists], "tags": [serialize(x,["id","name","color"]) for x in tags], "tasks": [serialize(x,["id","list_id","parent_id","title","description","priority","completed","due_at","external_id","deleted_at"]) for x in tasks], "habits": [serialize(x,["id","name","kind","target","max_value","schedule_type","weekdays","month_days","interval_days","start_date","archived_at"]) for x in habits]}
|
||||
return {
|
||||
"version": 1,
|
||||
"exported_at": utcnow(),
|
||||
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
|
||||
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
|
||||
"tags": [serialize(x, ["id", "name", "color"]) for x in tags],
|
||||
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "external_id", "deleted_at"]) for x in tasks],
|
||||
"task_tags": [{"task_id": str(x.task_id), "tag_id": str(x.tag_id)} for x in (await db.scalars(select(TaskTag))).all() if x.task_id in {task.id for task in tasks}],
|
||||
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at"]) for x in habits],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
if payload.get("version") != 1: raise HTTPException(422, "不支持的备份版本")
|
||||
if payload.get("version") != 1:
|
||||
raise HTTPException(422, "不支持的备份版本")
|
||||
if mode == "replace":
|
||||
await db.execute(delete(Task).where(Task.user_id == user.id)); await db.execute(delete(TaskList).where(TaskList.user_id == user.id)); await db.execute(delete(Folder).where(Folder.user_id == user.id))
|
||||
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(select(Task.id).where(Task.user_id == user.id))))
|
||||
await db.execute(delete(Task).where(Task.user_id == user.id))
|
||||
await db.execute(delete(Tag).where(Tag.user_id == user.id))
|
||||
await db.execute(delete(Habit).where(Habit.user_id == user.id))
|
||||
await db.execute(delete(TaskList).where(TaskList.user_id == user.id))
|
||||
await db.execute(delete(Folder).where(Folder.user_id == user.id))
|
||||
id_map = {}
|
||||
task_id_map = {}
|
||||
tag_id_map = {}
|
||||
for raw in payload.get("folders", []):
|
||||
old = raw["id"]; row = Folder(user_id=user.id, name=raw["name"], position=raw.get("position",0)); db.add(row); await db.flush(); id_map[old] = row.id
|
||||
old = raw["id"]
|
||||
row = Folder(user_id=user.id, name=raw["name"], position=raw.get("position", 0))
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
id_map[old] = row.id
|
||||
inbox = None
|
||||
for raw in payload.get("lists", []):
|
||||
row = TaskList(user_id=user.id, folder_id=id_map.get(raw.get("folder_id")), name=raw["name"], is_inbox=raw.get("is_inbox",False), position=raw.get("position",0)); db.add(row); await db.flush(); id_map[raw["id"]] = row.id
|
||||
if row.is_inbox: inbox = row
|
||||
if not inbox: inbox = TaskList(user_id=user.id, name="收集箱", is_inbox=True); db.add(inbox); await db.flush()
|
||||
row = TaskList(
|
||||
user_id=user.id,
|
||||
folder_id=id_map.get(raw.get("folder_id")),
|
||||
name=raw["name"],
|
||||
is_inbox=raw.get("is_inbox", False),
|
||||
position=raw.get("position", 0),
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
id_map[raw["id"]] = row.id
|
||||
if row.is_inbox:
|
||||
inbox = row
|
||||
if not inbox:
|
||||
inbox = TaskList(user_id=user.id, name="收集箱", is_inbox=True)
|
||||
db.add(inbox)
|
||||
await db.flush()
|
||||
for raw in payload.get("tags", []):
|
||||
row = Tag(user_id=user.id, name=raw["name"], color=raw.get("color", "#f15a29"))
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
tag_id_map[raw["id"]] = row.id
|
||||
restored = 0
|
||||
pending_tasks = []
|
||||
for raw in payload.get("tasks", []):
|
||||
ext = raw.get("external_id")
|
||||
existing = await db.scalar(select(Task).where(Task.user_id == user.id, Task.external_id == ext)) if ext else None
|
||||
if existing and mode == "merge": continue
|
||||
row = Task(user_id=user.id, list_id=id_map.get(raw.get("list_id"), inbox.id), title=raw["title"], description=raw.get("description", ""), priority=raw.get("priority",0), completed=raw.get("completed",False), due_at=datetime.fromisoformat(raw["due_at"]) if raw.get("due_at") else None, external_id=ext); db.add(row); restored += 1
|
||||
audit(db, user.id, "restore", "backup", count=restored, mode=mode); await db.commit(); return {"restored": restored, "mode": mode}
|
||||
if existing and mode == "merge":
|
||||
task_id_map[raw["id"]] = existing.id
|
||||
continue
|
||||
row = Task(
|
||||
user_id=user.id,
|
||||
list_id=id_map.get(raw.get("list_id"), inbox.id),
|
||||
title=raw["title"],
|
||||
description=raw.get("description", ""),
|
||||
priority=raw.get("priority", 0),
|
||||
completed=raw.get("completed", False),
|
||||
due_at=datetime.fromisoformat(raw["due_at"]) if raw.get("due_at") else None,
|
||||
external_id=ext,
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
task_id_map[raw["id"]] = row.id
|
||||
pending_tasks.append((row.id, raw.get("parent_id")))
|
||||
restored += 1
|
||||
for task_id, old_parent_id in pending_tasks:
|
||||
if old_parent_id and old_parent_id in task_id_map:
|
||||
await db.execute(update(Task).where(Task.id == task_id, Task.user_id == user.id).values(parent_id=task_id_map[old_parent_id]))
|
||||
task_tag_rows = []
|
||||
for raw in payload.get("task_tags", []):
|
||||
task_id = task_id_map.get(raw.get("task_id"))
|
||||
tag_id = tag_id_map.get(raw.get("tag_id"))
|
||||
if task_id and tag_id:
|
||||
task_tag_rows.append(TaskTag(task_id=task_id, tag_id=tag_id))
|
||||
if task_tag_rows:
|
||||
db.add_all(task_tag_rows)
|
||||
for raw in payload.get("habits", []):
|
||||
row = Habit(
|
||||
user_id=user.id,
|
||||
name=raw["name"],
|
||||
kind=raw.get("kind", "boolean"),
|
||||
target=raw.get("target", 1),
|
||||
max_value=raw.get("max_value"),
|
||||
schedule_type=raw.get("schedule_type", "daily"),
|
||||
weekdays=raw.get("weekdays"),
|
||||
month_days=raw.get("month_days"),
|
||||
interval_days=raw.get("interval_days"),
|
||||
start_date=date.fromisoformat(raw["start_date"]),
|
||||
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None,
|
||||
)
|
||||
db.add(row)
|
||||
audit(db, user.id, "restore", "backup", count=restored, mode=mode)
|
||||
await db.commit()
|
||||
return {"restored": restored, "mode": mode}
|
||||
|
||||
|
||||
@router.get("/audit-logs")
|
||||
|
||||
Reference in New Issue
Block a user