feat: add standalone memos
This commit is contained in:
@@ -239,6 +239,21 @@ class Attachment(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class Memo(Base):
|
||||
__tablename__ = "memos"
|
||||
__table_args__ = (
|
||||
Index("ix_memos_user_deleted_updated", "user_id", "deleted_at", "updated_at", "id"),
|
||||
)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
|
||||
+203
-3
@@ -32,6 +32,7 @@ from .models import (
|
||||
Habit,
|
||||
HabitLog,
|
||||
HabitPause,
|
||||
Memo,
|
||||
RecurrenceException,
|
||||
RecurrenceTemplate,
|
||||
Task,
|
||||
@@ -48,6 +49,172 @@ def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity
|
||||
db.add(AuditLog(user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details))
|
||||
|
||||
|
||||
class MemoCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
content: str = ""
|
||||
|
||||
@field_validator("title", mode="before")
|
||||
@classmethod
|
||||
def clean_title(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class MemoUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
content: str | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
@field_validator("title", mode="before")
|
||||
@classmethod
|
||||
def clean_title(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
return value.strip()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_nulls(self):
|
||||
for field in ("title", "content"):
|
||||
if field in self.model_fields_set and getattr(self, field) is None:
|
||||
raise ValueError(f"{field} cannot be null")
|
||||
return self
|
||||
|
||||
|
||||
class MemoOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
id: UUID
|
||||
title: str
|
||||
content: str
|
||||
version: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None
|
||||
|
||||
|
||||
class MemoListItem(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
excerpt: str
|
||||
version: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None
|
||||
|
||||
|
||||
def _memo_excerpt(content: str) -> str:
|
||||
collapsed = " ".join(content.split())
|
||||
return collapsed if len(collapsed) <= 120 else collapsed[:119] + "…"
|
||||
|
||||
|
||||
@router.get("/memos")
|
||||
async def list_memos(
|
||||
scope: str = Query("active", pattern="^(active|trash)$"),
|
||||
q: str = "",
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
filters = [Memo.user_id == user.id]
|
||||
filters.append(Memo.deleted_at.is_(None) if scope == "active" else Memo.deleted_at.is_not(None))
|
||||
query = q.strip()
|
||||
if query:
|
||||
pattern = f"%{query}%"
|
||||
filters.append((Memo.title.ilike(pattern)) | (Memo.content.ilike(pattern)))
|
||||
total = await db.scalar(select(func.count()).select_from(Memo).where(*filters))
|
||||
rows = (await db.scalars(
|
||||
select(Memo).where(*filters).order_by(Memo.updated_at.desc(), Memo.id.desc())
|
||||
.offset((page - 1) * page_size).limit(page_size)
|
||||
)).all()
|
||||
items = [MemoListItem(
|
||||
id=row.id, title=row.title, excerpt=_memo_excerpt(row.content), version=row.version,
|
||||
created_at=row.created_at, updated_at=row.updated_at, deleted_at=row.deleted_at,
|
||||
) for row in rows]
|
||||
return {"items": items, "total": total or 0, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
@router.post("/memos", response_model=MemoOut, status_code=201)
|
||||
async def create_memo(payload: MemoCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = Memo(user_id=user.id, title=payload.title, content=payload.content)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
audit(db, user.id, "create", "memo", row.id, fields=["title"])
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.patch("/memos/{memo_id}", response_model=MemoOut)
|
||||
async def update_memo(
|
||||
memo_id: UUID, payload: MemoUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
existing = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
|
||||
if not existing:
|
||||
raise HTTPException(404, "备忘录不存在")
|
||||
if existing.deleted_at is not None:
|
||||
raise HTTPException(409, "已删除的备忘录不能编辑")
|
||||
values = payload.model_dump(exclude={"version"}, exclude_unset=True)
|
||||
now = utcnow()
|
||||
result = await db.execute(
|
||||
update(Memo).where(
|
||||
Memo.id == memo_id, Memo.user_id == user.id, Memo.deleted_at.is_(None), Memo.version == payload.version
|
||||
).values(**values, version=Memo.version + 1, updated_at=now)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise HTTPException(409, "备忘录版本冲突")
|
||||
audit(db, user.id, "update", "memo", memo_id, fields=sorted(values))
|
||||
await db.commit()
|
||||
return await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
|
||||
|
||||
|
||||
@router.post("/memos/{memo_id}/restore", response_model=MemoOut)
|
||||
async def restore_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
|
||||
if not row:
|
||||
raise HTTPException(404, "备忘录不存在")
|
||||
if row.deleted_at is not None:
|
||||
row.deleted_at = None
|
||||
row.updated_at = utcnow()
|
||||
audit(db, user.id, "restore", "memo", row.id)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.delete("/memos/{memo_id}/purge", status_code=204)
|
||||
async def purge_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
|
||||
if not row:
|
||||
raise HTTPException(404, "备忘录不存在")
|
||||
if row.deleted_at is None:
|
||||
raise HTTPException(409, "只能永久删除回收站中的备忘录")
|
||||
audit(db, user.id, "purge", "memo", row.id)
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.delete("/memos/{memo_id}", status_code=204)
|
||||
async def delete_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
|
||||
if not row:
|
||||
raise HTTPException(404, "备忘录不存在")
|
||||
if row.deleted_at is None:
|
||||
now = utcnow()
|
||||
row.deleted_at = now
|
||||
row.updated_at = now
|
||||
audit(db, user.id, "delete", "memo", row.id)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/memos/{memo_id}", response_model=MemoOut)
|
||||
async def get_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
|
||||
if not row:
|
||||
raise HTTPException(404, "备忘录不存在")
|
||||
return row
|
||||
|
||||
|
||||
class RecurrenceCreate(BaseModel):
|
||||
task_id: UUID
|
||||
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||
@@ -1101,7 +1268,7 @@ def _serialize_export_value(value):
|
||||
return value
|
||||
|
||||
|
||||
def _export_payload(folders, lists, tasks, recurrences, habits, countdowns):
|
||||
def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memos):
|
||||
def serialize(row, fields):
|
||||
return {field: _serialize_export_value(getattr(row, field)) for field in fields}
|
||||
|
||||
@@ -1114,6 +1281,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns):
|
||||
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
|
||||
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
|
||||
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
||||
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
|
||||
}
|
||||
|
||||
|
||||
@@ -1124,7 +1292,8 @@ async def _load_export_rows(user, db):
|
||||
habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all())
|
||||
countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all())
|
||||
recurrences = list((await db.scalars(select(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id))).all())
|
||||
return folders, lists, tasks, recurrences, habits, countdowns
|
||||
memos = list((await db.scalars(select(Memo).where(Memo.user_id == user.id))).all())
|
||||
return folders, lists, tasks, recurrences, habits, countdowns, memos
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
@@ -1138,7 +1307,7 @@ async def export_csv(user: User = Depends(current_user), db: AsyncSession = Depe
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, lineterminator="\n")
|
||||
writer.writerow(["entity", "data"])
|
||||
for entity in ("folders", "lists", "tasks", "recurrences", "habits", "countdowns"):
|
||||
for entity in ("folders", "lists", "tasks", "recurrences", "habits", "countdowns", "memos"):
|
||||
for row in payload[entity]:
|
||||
writer.writerow([entity, json.dumps(row, ensure_ascii=False, separators=(",", ":"))])
|
||||
content = "\ufeff" + output.getvalue()
|
||||
@@ -1165,6 +1334,7 @@ async def restore_csv(
|
||||
"recurrences": [],
|
||||
"habits": [],
|
||||
"countdowns": [],
|
||||
"memos": [],
|
||||
}
|
||||
try:
|
||||
for row in csv.DictReader(io.StringIO(text)):
|
||||
@@ -1246,6 +1416,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
raise HTTPException(422, "不支持的备份版本")
|
||||
parsed_countdowns = _validate_countdown_backups(payload)
|
||||
if mode == "replace":
|
||||
await db.execute(delete(Memo).where(Memo.user_id == user.id))
|
||||
await db.execute(delete(Countdown).where(Countdown.user_id == user.id))
|
||||
await db.execute(delete(Task).where(Task.user_id == user.id))
|
||||
await db.execute(delete(Habit).where(Habit.user_id == user.id))
|
||||
@@ -1372,6 +1543,35 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
existing_countdown_ids.add(row_id)
|
||||
occupied_countdown_ids[row_id] = user.id
|
||||
restored += 1
|
||||
existing_memo_ids = set((await db.scalars(select(Memo.id).where(Memo.user_id == user.id))).all())
|
||||
for raw in payload.get("memos", []):
|
||||
try:
|
||||
source_id = UUID(raw["id"])
|
||||
title = raw["title"].strip()
|
||||
content = raw.get("content", "")
|
||||
version = raw.get("version", 1)
|
||||
if not title or len(title) > 200 or not isinstance(content, str):
|
||||
raise ValueError
|
||||
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
||||
raise ValueError
|
||||
created_at = datetime.fromisoformat(raw["created_at"])
|
||||
updated_at = datetime.fromisoformat(raw["updated_at"])
|
||||
deleted_at = datetime.fromisoformat(raw["deleted_at"]) if raw.get("deleted_at") else None
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(422, "无效的备忘录备份数据") from exc
|
||||
occupied_user = await db.scalar(select(Memo.user_id).where(Memo.id == source_id))
|
||||
row_id = source_id
|
||||
while occupied_user not in (None, user.id):
|
||||
row_id = uuid5(user.id, str(row_id))
|
||||
occupied_user = await db.scalar(select(Memo.user_id).where(Memo.id == row_id))
|
||||
if mode == "merge" and row_id in existing_memo_ids:
|
||||
continue
|
||||
db.add(Memo(
|
||||
id=row_id, user_id=user.id, title=title, content=content, version=version,
|
||||
created_at=created_at, updated_at=updated_at, deleted_at=deleted_at,
|
||||
))
|
||||
existing_memo_ids.add(row_id)
|
||||
restored += 1
|
||||
audit(db, user.id, "restore", "backup", count=restored, mode=mode)
|
||||
await db.commit()
|
||||
return {"restored": restored, "mode": mode}
|
||||
|
||||
Reference in New Issue
Block a user