import csv import io import re from datetime import UTC, date, datetime, time, timedelta from pathlib import Path from uuid import UUID from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field, model_validator from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from .auth import current_user from .config import get_settings from .db import get_db from .models import ( Attachment, AuditLog, Folder, Habit, HabitLog, HabitPause, RecurrenceException, RecurrenceTemplate, Tag, Task, TaskList, User, new_id, utcnow, ) router = APIRouter(prefix="/api/v1") def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity_id=None, **details): db.add(AuditLog(user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details)) class RecurrenceCreate(BaseModel): task_id: UUID rrule: str = Field(min_length=5, max_length=1000) class RecurrenceChange(BaseModel): title: str | None = Field(None, min_length=1, max_length=500) due_at: datetime | None = None rrule: str | None = None class OccurrenceComplete(BaseModel): occurrence_at: datetime _RRULE_PART = re.compile(r"^[A-Z]+=[A-Z0-9,+-]+$") _WEEKDAYS = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6} def parse_rrule(value: str) -> dict[str, str]: parts = {} for part in value.upper().split(";"): if not _RRULE_PART.fullmatch(part): raise HTTPException(422, "无效的 RRULE") key, val = part.split("=", 1) parts[key] = val if parts.get("FREQ") not in {"DAILY", "WEEKLY", "MONTHLY"}: raise HTTPException(422, "仅支持 DAILY、WEEKLY、MONTHLY") try: if "INTERVAL" in parts and int(parts["INTERVAL"]) < 1: raise ValueError if "COUNT" in parts and int(parts["COUNT"]) < 1: raise ValueError except ValueError as exc: raise HTTPException(422, "无效的 RRULE 数字") from exc return parts def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cutoff=None): parts = parse_rrule(rule) interval = int(parts.get("INTERVAL", 1)) count = int(parts.get("COUNT", 100000)) if "UNTIL" in parts: until = datetime.fromisoformat(parts["UNTIL"]) until = until.replace(tzinfo=UTC) if until.tzinfo is None else until else: until = end if starts.tzinfo is None: starts = starts.replace(tzinfo=UTC) if until.tzinfo is None: until = until.replace(tzinfo=UTC) if start.tzinfo is None: start = start.replace(tzinfo=UTC) if end.tzinfo is None: end = end.replace(tzinfo=UTC) result = [] cursor = starts emitted = 0 while cursor <= until and emitted < count: include = False if parts["FREQ"] == "DAILY": include = (cursor.date() - starts.date()).days % interval == 0 elif parts["FREQ"] == "WEEKLY": days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")} include = cursor.weekday() in days and ((cursor.date() - starts.date()).days // 7) % interval == 0 else: month_delta = (cursor.year - starts.year) * 12 + cursor.month - starts.month month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")} include = month_delta % interval == 0 and cursor.day in month_days if include and cursor >= starts: emitted += 1 if start <= cursor <= end: result.append(cursor) cursor += timedelta(days=1) return result async def owned_task(db, user_id, task_id): task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user_id, Task.deleted_at.is_(None))) if not task: raise HTTPException(404, "任务不存在") return task async def owned_recurrence(db, user_id, recurrence_id): row = await db.scalar(select(RecurrenceTemplate).where(RecurrenceTemplate.id == recurrence_id, RecurrenceTemplate.user_id == user_id)) if not row: raise HTTPException(404, "重复规则不存在") return row @router.post("/recurrences", status_code=201) async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): task = await owned_task(db, user.id, payload.task_id) if not task.due_at: raise HTTPException(422, "重复任务需要截止时间") parse_rrule(payload.rrule) if await db.scalar(select(RecurrenceTemplate.id).where(RecurrenceTemplate.task_id == task.id)): raise HTTPException(409, "任务已有重复规则") row = RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule.upper(), starts_at=task.due_at) db.add(row) await db.commit(); await db.refresh(row) return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at} @router.get("/calendar") async def calendar(start: date, end: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): if end < start or (end - start).days > 366: raise HTTPException(422, "日期范围无效或超过一年") start_dt = datetime.combine(start, time.min, tzinfo=UTC) end_dt = datetime.combine(end, time.max, tzinfo=UTC) rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all() output = [] for template, task in rows: exception_rows = (await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id == template.id))).all() exceptions: dict[datetime, RecurrenceException] = {} for exc in exception_rows: key = exc.occurrence_at.replace(tzinfo=UTC) if exc.occurrence_at.tzinfo is None else exc.occurrence_at exceptions[key] = exc for at in occurrences(template.rrule, template.starts_at, start_dt, end_dt, template.ends_at): exception = exceptions.get(at) if exception and exception.deleted: continue output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": exception.due_at if exception and exception.due_at else at, "completed": bool(exception and exception.completed)}) return sorted(output, key=lambda item: item["occurrence_at"]) async def upsert_exception(db, template_id, at): row = await db.scalar(select(RecurrenceException).where(RecurrenceException.template_id == template_id, RecurrenceException.occurrence_at == at)) if not row: row = RecurrenceException(template_id=template_id, occurrence_at=at) db.add(row) await db.flush() return row @router.patch("/recurrences/{recurrence_id}") async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope: str = Query("all", pattern="^(this|this-and-future|all)$"), occurrence_at: datetime | None = None, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): template = await owned_recurrence(db, user.id, recurrence_id) task = await owned_task(db, user.id, template.task_id) if scope == "this": if not occurrence_at: raise HTTPException(422, "需要 occurrence_at") row = await upsert_exception(db, template.id, occurrence_at) if payload.title is not None: row.title = payload.title if payload.due_at is not None: row.due_at = payload.due_at elif scope == "this-and-future": if not occurrence_at: raise HTTPException(422, "需要 occurrence_at") template.ends_at = occurrence_at - timedelta(microseconds=1) if payload.rrule: parse_rrule(payload.rrule) db.add(RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule, starts_at=payload.due_at or occurrence_at)) elif payload.title: task.title = payload.title else: if payload.rrule: parse_rrule(payload.rrule); template.rrule = payload.rrule.upper() if payload.title is not None: task.title = payload.title if payload.due_at is not None: task.due_at = payload.due_at; template.starts_at = payload.due_at await db.commit() return {"id": template.id, "scope": scope} @router.post("/recurrences/{recurrence_id}/complete") async def complete_occurrence(recurrence_id: UUID, payload: OccurrenceComplete, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): template = await owned_recurrence(db, user.id, recurrence_id) row = await upsert_exception(db, template.id, payload.occurrence_at); row.completed = True audit(db, user.id, "complete", "task", template.task_id, occurrence_at=payload.occurrence_at.isoformat()) await db.commit(); return {"completed": True} @router.delete("/recurrences/{recurrence_id}", status_code=204) async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", pattern="^(this|this-and-future|all)$"), occurrence_at: datetime | None = None, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): template = await owned_recurrence(db, user.id, recurrence_id) if scope == "this": if not occurrence_at: raise HTTPException(422, "需要 occurrence_at") row = await upsert_exception(db, template.id, occurrence_at); row.deleted = True elif scope == "this-and-future": if not occurrence_at: raise HTTPException(422, "需要 occurrence_at") template.ends_at = occurrence_at - timedelta(microseconds=1) else: await db.delete(template) 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)$") target: float = Field(1, gt=0) max_value: float | None = Field(None, gt=0) schedule_type: str = Field("daily", pattern="^(daily|weekly|monthly|interval)$") weekdays: list[int] | None = None month_days: list[int] | None = None interval_days: int | None = Field(None, ge=1) start_date: date = Field(default_factory=date.today) @model_validator(mode="after") def schedule_valid(self): if self.schedule_type == "interval" and not self.interval_days: raise ValueError("interval_days required") if self.kind == "boolean": self.target = 1; self.max_value = 1 return self class HabitLogInput(BaseModel): day: date value: float = Field(gt=0) class HabitLogEdit(BaseModel): value: float = Field(ge=0) class PauseInput(BaseModel): start_date: date end_date: date @model_validator(mode="after") def ordered(self): if self.end_date < self.start_date: raise ValueError("invalid range") return self def habit_dict(h): return {"id": h.id, "name": h.name, "kind": h.kind, "target": h.target, "max_value": h.max_value, "schedule_type": h.schedule_type, "weekdays": [int(x) for x in h.weekdays.split(",")] if h.weekdays else None, "month_days": [int(x) for x in h.month_days.split(",")] if h.month_days else None, "interval_days": h.interval_days, "start_date": h.start_date, "archived_at": h.archived_at} async def owned_habit(db, user_id, habit_id): row = await db.scalar(select(Habit).where(Habit.id == habit_id, Habit.user_id == user_id)) if not row: raise HTTPException(404, "习惯不存在") return row @router.post("/habits", status_code=201) async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): row = Habit(user_id=user.id, **payload.model_dump(exclude={"weekdays", "month_days"}), weekdays=",".join(map(str, payload.weekdays)) if payload.weekdays else None, month_days=",".join(map(str, payload.month_days)) if payload.month_days else None) db.add(row); await db.commit(); await db.refresh(row); return habit_dict(row) @router.get("/habits") async def list_habits(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): condition = Habit.archived_at.is_not(None) if archived else Habit.archived_at.is_(None) return [habit_dict(h) for h in (await db.scalars(select(Habit).where(Habit.user_id == user.id, condition).order_by(Habit.created_at))).all()] @router.patch("/habits/{habit_id}") async def edit_habit(habit_id: UUID, payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): row = await owned_habit(db, user.id, habit_id) for key, value in payload.model_dump(exclude={"weekdays", "month_days"}).items(): setattr(row, key, value) row.weekdays = ",".join(map(str, payload.weekdays)) if payload.weekdays else None; row.month_days = ",".join(map(str, payload.month_days)) if payload.month_days else None await db.commit(); return habit_dict(row) @router.delete("/habits/{habit_id}", status_code=204) async def archive_habit(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): row = await owned_habit(db, user.id, habit_id); row.archived_at = utcnow(); await db.commit(); return Response(status_code=204) @router.post("/habits/{habit_id}/logs") async def add_habit_log(habit_id: UUID, payload: HabitLogInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): habit = await owned_habit(db, user.id, habit_id) row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == payload.day)) value = min((row.value if row else 0) + payload.value, habit.max_value or float("inf")) if habit.kind == "boolean": value = 1 if row: row.value = value; row.updated_at = utcnow() else: row = HabitLog(habit_id=habit.id, day=payload.day, value=value); db.add(row) await db.commit(); return {"day": row.day, "value": row.value} @router.put("/habits/{habit_id}/logs/{day}") async def edit_habit_log(habit_id: UUID, day: date, payload: HabitLogEdit, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): habit = await owned_habit(db, user.id, habit_id) value = min(payload.value, habit.max_value or float("inf")); value = float(bool(value)) if habit.kind == "boolean" else value row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day)) if row: row.value = value; row.updated_at = utcnow() else: row = HabitLog(habit_id=habit.id, day=day, value=value); db.add(row) await db.commit(); return {"day": row.day, "value": row.value} @router.get("/habits/{habit_id}/logs") async def habit_logs(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): habit = await owned_habit(db, user.id, habit_id) return [{"day": x.day, "value": x.value} for x in (await db.scalars(select(HabitLog).where(HabitLog.habit_id == habit.id).order_by(HabitLog.day.desc()))).all()] @router.post("/habits/{habit_id}/pauses", status_code=201) async def pause_habit(habit_id: UUID, payload: PauseInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): habit = await owned_habit(db, user.id, habit_id); row = HabitPause(habit_id=habit.id, **payload.model_dump()); db.add(row); await db.commit(); await db.refresh(row); return {"id": row.id, **payload.model_dump()} def scheduled(h, day): if day < h.start_date: return False if h.schedule_type == "daily": return True if h.schedule_type == "weekly": return day.weekday() in {int(x) for x in (h.weekdays or "").split(",") if x} if h.schedule_type == "monthly": return day.day in {int(x) for x in (h.month_days or "").split(",") if x} return (day - h.start_date).days % h.interval_days == 0 @router.get("/habits/grid") async def habits_grid(week: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): start = week - timedelta(days=week.weekday()); days = [start + timedelta(days=i) for i in range(7)] habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id, Habit.archived_at.is_(None)))).all()) output = [] for h in habits: logs = {x.day: x.value for x in (await db.scalars(select(HabitLog).where(HabitLog.habit_id == h.id, HabitLog.day.between(days[0], days[-1])))).all()} pauses = list((await db.scalars(select(HabitPause).where(HabitPause.habit_id == h.id))).all()) output.append({"id": h.id, "name": h.name, "cells": [{"day": d, "scheduled": scheduled(h, d), "paused": any(p.start_date <= d <= p.end_date for p in pauses), "value": logs.get(d, 0)} for d in days]}) return {"days": days, "habits": output} @router.get("/habits/{habit_id}/stats") async def habit_stats(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): habit = await owned_habit(db, user.id, habit_id); logs = list((await db.scalars(select(HabitLog).where(HabitLog.habit_id == habit.id).order_by(HabitLog.day))).all()) return {"total": sum(x.value for x in logs), "completed_days": sum(x.value >= habit.target for x in logs), "logged_days": len(logs)} _ALLOWED_MIME = {"text/plain", "text/csv", "application/pdf", "image/jpeg", "image/png", "image/gif", "application/json", "application/zip"} @router.post("/tasks/{task_id}/attachments", status_code=201) async def upload_attachment(task_id: UUID, file: UploadFile = File(...), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): await owned_task(db, user.id, task_id) name = Path(file.filename or "").name if not name or name != file.filename or file.content_type not in _ALLOWED_MIME: raise HTTPException(400, "文件名或类型不允许") limit = get_settings().attachment_max_mb * 1024 * 1024; content = await file.read(limit + 1) if len(content) > limit: raise HTTPException(413, "文件过大") root = Path(get_settings().attachment_dir).resolve(); root.mkdir(parents=True, exist_ok=True); storage = str(new_id()) (root / storage).write_bytes(content) row = Attachment(user_id=user.id, task_id=task_id, filename=name, storage_name=storage, mime_type=file.content_type, size=len(content)); db.add(row); await db.commit(); await db.refresh(row) return {"id": row.id, "task_id": row.task_id, "filename": row.filename, "mime_type": row.mime_type, "size": row.size} async def owned_attachment(db, user_id, attachment_id): row = await db.scalar(select(Attachment).where(Attachment.id == attachment_id, Attachment.user_id == user_id)) if not row: raise HTTPException(404, "附件不存在") return row @router.get("/attachments/{attachment_id}") async def download_attachment(attachment_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): row = await owned_attachment(db, user.id, attachment_id); path = Path(get_settings().attachment_dir).resolve() / row.storage_name if not path.is_file(): raise HTTPException(404, "附件文件不存在") return FileResponse(path, media_type=row.mime_type, filename=row.filename) @router.delete("/attachments/{attachment_id}", status_code=204) async def delete_attachment(attachment_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): row = await owned_attachment(db, user.id, attachment_id); (Path(get_settings().attachment_dir).resolve() / row.storage_name).unlink(missing_ok=True); await db.delete(row); await db.commit(); return Response(status_code=204) def read_ticktick(content: bytes): try: text = content.decode("utf-8-sig") except UnicodeDecodeError as exc: raise HTTPException(422, "CSV 必须为 UTF-8") from exc reader = csv.DictReader(io.StringIO(text)); required = {"Title", "ID"} if not reader.fieldnames or not required <= set(reader.fieldnames): raise HTTPException(422, "CSV 缺少 Title 或 ID") rows = []; errors = [] for index, row in enumerate(reader, 2): if not row.get("Title", "").strip() or not row.get("ID", "").strip(): errors.append({"row": index, "error": "Title/ID required"}) else: rows.append(row) return rows, errors @router.post("/import/ticktick/preview") async def preview_ticktick(file: UploadFile = File(...), user: User = Depends(current_user)): rows, errors = read_ticktick(await file.read()); return {"valid": len(rows), "invalid": len(errors), "errors": errors, "sample": rows[:10]} @router.post("/import/ticktick") async def import_ticktick(file: UploadFile = File(...), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): rows, errors = read_ticktick(await file.read()) if errors: raise HTTPException(422, errors) inbox = await db.scalar(select(TaskList).where(TaskList.user_id == user.id, TaskList.is_inbox.is_(True))) imported = skipped = 0 for raw in rows: external_id = raw["ID"].strip() if await db.scalar(select(Task.id).where(Task.user_id == user.id, Task.external_id == external_id)): skipped += 1; continue due = None if raw.get("Due Date"): try: due = datetime.combine(date.fromisoformat(raw["Due Date"][:10]), time.min, tzinfo=UTC) except ValueError: raise HTTPException(422, f"无效日期: {raw['Due Date']}") task = Task(user_id=user.id, list_id=inbox.id, title=raw["Title"].strip(), completed=raw.get("Status", "0").lower() in {"1", "completed", "true"}, due_at=due, external_id=external_id); db.add(task); imported += 1 audit(db, user.id, "import", "task", count=imported); await db.commit(); return {"imported": imported, "skipped": skipped} @router.get("/export") 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()); 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]} @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 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)) 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 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() restored = 0 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} @router.get("/audit-logs") async def audit_logs(limit: int = Query(100, ge=1, le=500), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): rows = (await db.scalars(select(AuditLog).where(AuditLog.user_id == user.id).order_by(AuditLog.created_at.desc()).limit(limit))).all() return [{"id": x.id, "action": x.action, "entity_type": x.entity_type, "entity_id": x.entity_id, "details": x.details, "created_at": x.created_at} for x in rows]