This commit is contained in:
+208
-1
@@ -1,9 +1,11 @@
|
||||
import calendar
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -17,6 +19,7 @@ from .db import get_db
|
||||
from .models import (
|
||||
Attachment,
|
||||
AuditLog,
|
||||
Countdown,
|
||||
Folder,
|
||||
Habit,
|
||||
HabitLog,
|
||||
@@ -251,6 +254,175 @@ async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", patte
|
||||
|
||||
|
||||
|
||||
def _clamped_date(year: int, month: int, day: int) -> date:
|
||||
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
|
||||
|
||||
|
||||
def countdown_occurrence(event_date: date, repeat_rule: str, today: date) -> date:
|
||||
"""Return the first date-only occurrence on or after today."""
|
||||
if repeat_rule == "none" or event_date >= today:
|
||||
return event_date
|
||||
if repeat_rule == "weekly":
|
||||
days = (event_date.weekday() - today.weekday()) % 7
|
||||
return today + timedelta(days=days)
|
||||
if repeat_rule == "monthly":
|
||||
candidate = _clamped_date(today.year, today.month, event_date.day)
|
||||
if candidate < today:
|
||||
year = today.year + (today.month == 12)
|
||||
month = 1 if today.month == 12 else today.month + 1
|
||||
candidate = _clamped_date(year, month, event_date.day)
|
||||
return candidate
|
||||
candidate = _clamped_date(today.year, event_date.month, event_date.day)
|
||||
if candidate < today:
|
||||
candidate = _clamped_date(today.year + 1, event_date.month, event_date.day)
|
||||
return candidate
|
||||
|
||||
|
||||
def countdown_status(display_date: date, today: date) -> tuple[int, str]:
|
||||
days = (display_date - today).days
|
||||
if days > 0:
|
||||
return days, f"还有 {days} 天"
|
||||
if days == 0:
|
||||
return 0, "就是今天"
|
||||
return days, f"已经 {-days} 天"
|
||||
|
||||
|
||||
class CountdownInput(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
event_date: date
|
||||
kind: str = Field("countdown", pattern="^(countdown|anniversary|birthday)$")
|
||||
repeat_rule: str = Field("none", pattern="^(none|weekly|monthly|yearly)$")
|
||||
icon: str = Field("📅", min_length=1, max_length=32)
|
||||
pinned: bool = False
|
||||
|
||||
|
||||
class CountdownUpdate(BaseModel):
|
||||
title: str | None = Field(None, min_length=1, max_length=200)
|
||||
event_date: date | None = None
|
||||
kind: str | None = Field(None, pattern="^(countdown|anniversary|birthday)$")
|
||||
repeat_rule: str | None = Field(None, pattern="^(none|weekly|monthly|yearly)$")
|
||||
icon: str | None = Field(None, min_length=1, max_length=32)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_explicit_nulls(self):
|
||||
for field in self.model_fields_set:
|
||||
if getattr(self, field) is None:
|
||||
raise ValueError(f"{field} cannot be null")
|
||||
return self
|
||||
|
||||
|
||||
def countdown_dict(row: Countdown, today: date | None = None):
|
||||
today = today or datetime.now(ZoneInfo("Asia/Shanghai")).date()
|
||||
display_date = countdown_occurrence(row.event_date, row.repeat_rule, today)
|
||||
days, day_text = countdown_status(display_date, today)
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"event_date": row.event_date,
|
||||
"display_date": display_date,
|
||||
"kind": row.kind,
|
||||
"repeat_rule": row.repeat_rule,
|
||||
"icon": row.icon,
|
||||
"pinned": row.pinned,
|
||||
"archived_at": row.archived_at,
|
||||
"days": days,
|
||||
"day_text": day_text,
|
||||
"created_at": row.created_at,
|
||||
"updated_at": row.updated_at,
|
||||
}
|
||||
|
||||
|
||||
async def owned_countdown(db, user_id, countdown_id):
|
||||
row = await db.scalar(select(Countdown).where(Countdown.id == countdown_id, Countdown.user_id == user_id))
|
||||
if not row:
|
||||
raise HTTPException(404, "倒数日不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/countdowns", status_code=201)
|
||||
async def create_countdown(payload: CountdownInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
if payload.pinned:
|
||||
await db.execute(update(Countdown).where(Countdown.user_id == user.id).values(pinned=False))
|
||||
row = Countdown(user_id=user.id, **payload.model_dump())
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
audit(db, user.id, "create", "countdown", row.id)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return countdown_dict(row)
|
||||
|
||||
|
||||
@router.get("/countdowns")
|
||||
async def list_countdowns(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
condition = Countdown.archived_at.is_not(None) if archived else Countdown.archived_at.is_(None)
|
||||
rows = (await db.scalars(
|
||||
select(Countdown)
|
||||
.where(Countdown.user_id == user.id, condition)
|
||||
.order_by(Countdown.pinned.desc(), Countdown.event_date, Countdown.created_at)
|
||||
)).all()
|
||||
result = [countdown_dict(row) for row in rows]
|
||||
return sorted(result, key=lambda item: (not item["pinned"], item["display_date"], item["created_at"]))
|
||||
|
||||
|
||||
@router.patch("/countdowns/{countdown_id}")
|
||||
async def edit_countdown(countdown_id: UUID, payload: CountdownUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_countdown(db, user.id, countdown_id)
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = utcnow()
|
||||
audit(db, user.id, "update", "countdown", row.id)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return countdown_dict(row)
|
||||
|
||||
|
||||
@router.post("/countdowns/{countdown_id}/pin")
|
||||
async def pin_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_countdown(db, user.id, countdown_id)
|
||||
if row.archived_at is not None:
|
||||
raise HTTPException(409, "已归档倒数日不能置顶")
|
||||
await db.execute(update(Countdown).where(Countdown.user_id == user.id, Countdown.id != row.id).values(pinned=False))
|
||||
row.pinned = True
|
||||
row.updated_at = utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return countdown_dict(row)
|
||||
|
||||
|
||||
@router.delete("/countdowns/{countdown_id}", status_code=204)
|
||||
async def archive_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_countdown(db, user.id, countdown_id)
|
||||
if row.archived_at is None:
|
||||
row.archived_at = utcnow()
|
||||
row.pinned = False
|
||||
audit(db, user.id, "archive", "countdown", row.id)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/countdowns/{countdown_id}/restore")
|
||||
async def restore_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_countdown(db, user.id, countdown_id)
|
||||
if row.archived_at is None:
|
||||
raise HTTPException(409, "倒数日未归档")
|
||||
row.archived_at = None
|
||||
row.updated_at = utcnow()
|
||||
audit(db, user.id, "restore", "countdown", row.id)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return countdown_dict(row)
|
||||
|
||||
|
||||
@router.delete("/countdowns/{countdown_id}/purge", status_code=204)
|
||||
async def purge_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_countdown(db, user.id, countdown_id)
|
||||
if row.archived_at is None:
|
||||
raise HTTPException(409, "请先归档再永久删除")
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
class HabitCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
kind: str = Field("boolean", pattern="^(boolean|numeric)$")
|
||||
@@ -492,7 +664,7 @@ async def import_ticktick(file: UploadFile = File(...), user: User = Depends(cur
|
||||
async def export_json(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
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()); 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())
|
||||
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()); 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()); countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all())
|
||||
return {
|
||||
"version": 1,
|
||||
"exported_at": utcnow(),
|
||||
@@ -500,6 +672,7 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep
|
||||
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
|
||||
"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],
|
||||
"countdowns": [serialize(x, ["id", "title", "event_date", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
||||
}
|
||||
|
||||
|
||||
@@ -508,6 +681,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
if payload.get("version") != 1:
|
||||
raise HTTPException(422, "不支持的备份版本")
|
||||
if mode == "replace":
|
||||
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))
|
||||
await db.execute(delete(TaskList).where(TaskList.user_id == user.id))
|
||||
@@ -579,6 +753,39 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None,
|
||||
)
|
||||
db.add(row)
|
||||
existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all())
|
||||
has_pinned_countdown = bool(await db.scalar(select(Countdown.id).where(Countdown.user_id == user.id, Countdown.pinned.is_(True))))
|
||||
for raw in payload.get("countdowns", []):
|
||||
try:
|
||||
source_id = UUID(raw["id"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(422, "无效的倒数日备份 ID") from exc
|
||||
if mode == "merge" and source_id in existing_countdown_ids:
|
||||
continue
|
||||
try:
|
||||
item = CountdownInput(
|
||||
title=raw["title"],
|
||||
event_date=date.fromisoformat(raw["event_date"]),
|
||||
kind=raw.get("kind", "countdown"),
|
||||
repeat_rule=raw.get("repeat_rule", "none"),
|
||||
icon=raw.get("icon", "📅"),
|
||||
pinned=bool(raw.get("pinned", False)) and not has_pinned_countdown,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(422, "无效的倒数日备份数据") from exc
|
||||
row = Countdown(
|
||||
id=source_id,
|
||||
user_id=user.id,
|
||||
**item.model_dump(),
|
||||
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None,
|
||||
created_at=datetime.fromisoformat(raw["created_at"]) if raw.get("created_at") else utcnow(),
|
||||
updated_at=datetime.fromisoformat(raw["updated_at"]) if raw.get("updated_at") else utcnow(),
|
||||
)
|
||||
if row.archived_at is not None:
|
||||
row.pinned = False
|
||||
has_pinned_countdown = has_pinned_countdown or row.pinned
|
||||
db.add(row)
|
||||
existing_countdown_ids.add(source_id)
|
||||
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