This commit is contained in:
@@ -126,6 +126,24 @@ class RecurrenceException(Base):
|
||||
deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class Countdown(Base):
|
||||
__tablename__ = "countdowns"
|
||||
__table_args__ = (
|
||||
Index("ix_countdowns_user_archive_date", "user_id", "archived_at", "event_date"),
|
||||
)
|
||||
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))
|
||||
event_date: Mapped[date] = mapped_column(Date)
|
||||
kind: Mapped[str] = mapped_column(String(16), default="countdown")
|
||||
repeat_rule: Mapped[str] = mapped_column(String(16), default="none")
|
||||
icon: Mapped[str] = mapped_column(String(32), default="📅")
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class Habit(Base):
|
||||
__tablename__ = "habits"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
|
||||
+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}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-ciX24dmm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ZmXgd88u.css">
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-2NrQsU0j.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DQ5ZvkC6.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
Reference in New Issue
Block a user