feat: recurring tasks, habits, attachments, import/export, audit and security
This commit is contained in:
+450
@@ -0,0 +1,450 @@
|
||||
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]
|
||||
Reference in New Issue
Block a user