feat: countdown anniversaries and birthdays like ticktick
ci / docker (push) Successful in 6m18s

This commit is contained in:
2026-09-06 15:36:42 +08:00
parent 3e20943d1e
commit afc78dba6c
17 changed files with 561 additions and 13 deletions
+208 -1
View File
@@ -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}