feat: swipe complete, list archive, today habits and calendar subscriptions
ci / docker (push) Successful in 3m20s
ci / docker (push) Successful in 3m20s
This commit is contained in:
+35
-10
@@ -329,20 +329,28 @@ async def create_list(
|
||||
|
||||
|
||||
@app.get("/api/v1/lists", response_model=list[ListOut])
|
||||
async def list_lists(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
query = select(TaskList).where(TaskList.user_id == user.id, TaskList.deleted_at.is_(None))
|
||||
async def list_lists(
|
||||
archived: bool = False,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
deleted_condition = TaskList.deleted_at.is_not(None) if archived else TaskList.deleted_at.is_(None)
|
||||
query = select(TaskList).where(TaskList.user_id == user.id, deleted_condition)
|
||||
ordering = (TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at)
|
||||
return list((await db.scalars(query.order_by(*ordering))).all())
|
||||
|
||||
|
||||
async def _owned_list(db: AsyncSession, user_id: UUID, list_id: UUID) -> TaskList:
|
||||
item = await db.scalar(
|
||||
select(TaskList).where(
|
||||
TaskList.id == list_id,
|
||||
TaskList.user_id == user_id,
|
||||
TaskList.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
async def _owned_list(
|
||||
db: AsyncSession,
|
||||
user_id: UUID,
|
||||
list_id: UUID,
|
||||
*,
|
||||
include_archived: bool = False,
|
||||
) -> TaskList:
|
||||
conditions = [TaskList.id == list_id, TaskList.user_id == user_id]
|
||||
if not include_archived:
|
||||
conditions.append(TaskList.deleted_at.is_(None))
|
||||
item = await db.scalar(select(TaskList).where(*conditions))
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="清单不存在")
|
||||
return item
|
||||
@@ -394,6 +402,23 @@ async def delete_list(
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.post("/api/v1/lists/{list_id}/restore", response_model=ListOut)
|
||||
async def restore_list(
|
||||
list_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
item = await _owned_list(db, user.id, list_id, include_archived=True)
|
||||
if item.is_inbox or item.deleted_at is None:
|
||||
raise HTTPException(status_code=409, detail="清单未归档")
|
||||
item.deleted_at = None
|
||||
await db.flush()
|
||||
audit(db, user.id, "restore", "list", item.id)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/api/v1/tags", response_model=TagOut, status_code=201)
|
||||
async def create_tag(
|
||||
payload: TagCreate,
|
||||
|
||||
@@ -188,6 +188,17 @@ class Attachment(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class CalendarSubscription(Base):
|
||||
__tablename__ = "calendar_subscriptions"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
url: Mapped[str] = mapped_column(Text)
|
||||
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
|
||||
+179
@@ -1,6 +1,12 @@
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
@@ -17,6 +23,7 @@ from .db import get_db
|
||||
from .models import (
|
||||
Attachment,
|
||||
AuditLog,
|
||||
CalendarSubscription,
|
||||
Folder,
|
||||
Habit,
|
||||
HabitLog,
|
||||
@@ -251,6 +258,178 @@ async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", patte
|
||||
await db.commit(); return Response(status_code=204)
|
||||
|
||||
|
||||
class CalendarSubscriptionCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
url: str = Field(min_length=1, max_length=2000)
|
||||
color: str = Field(default="#f15a29", min_length=1, max_length=32)
|
||||
|
||||
|
||||
def calendar_subscription_dict(row: CalendarSubscription):
|
||||
return {"id": row.id, "name": row.name, "url": row.url, "color": row.color, "enabled": row.enabled}
|
||||
|
||||
|
||||
def parse_ics_datetime(value: str):
|
||||
raw = value.strip()
|
||||
if len(raw) == 8 and raw.isdigit():
|
||||
return datetime.strptime(raw, "%Y%m%d").replace(tzinfo=UTC), True
|
||||
cleaned = raw.removesuffix("Z")
|
||||
parsed = datetime.fromisoformat(cleaned)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC), False
|
||||
|
||||
|
||||
def parse_ics_events(content: str, source_name: str, color: str):
|
||||
events = []
|
||||
current = None
|
||||
for raw_line in content.splitlines():
|
||||
line = raw_line.strip()
|
||||
if line == "BEGIN:VEVENT":
|
||||
current = {}
|
||||
continue
|
||||
if line == "END:VEVENT":
|
||||
if current and current.get("title") and current.get("starts_at"):
|
||||
events.append({
|
||||
"id": current.get("uid") or f"{source_name}-{current['starts_at'].isoformat()}-{current['title']}",
|
||||
"title": current["title"],
|
||||
"starts_at": current["starts_at"],
|
||||
"ends_at": current.get("ends_at"),
|
||||
"all_day": current.get("all_day", False),
|
||||
"source_name": source_name,
|
||||
"color": color,
|
||||
})
|
||||
current = None
|
||||
continue
|
||||
if current is None or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
field = key.split(";", 1)[0].upper()
|
||||
if field == "SUMMARY":
|
||||
current["title"] = value.strip()
|
||||
elif field == "UID":
|
||||
current["uid"] = value.strip()
|
||||
elif field in {"DTSTART", "DTEND"}:
|
||||
try:
|
||||
dt, all_day = parse_ics_datetime(value)
|
||||
except ValueError:
|
||||
continue
|
||||
current["all_day"] = current.get("all_day", False) or all_day
|
||||
current["starts_at" if field == "DTSTART" else "ends_at"] = dt
|
||||
return events
|
||||
|
||||
|
||||
def _validate_public_calendar_url(url: str):
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise HTTPException(422, "日历订阅只支持 http/https 链接")
|
||||
try:
|
||||
addresses = socket.getaddrinfo(parsed.hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise HTTPException(422, "日历订阅域名无法解析") from exc
|
||||
for item in addresses:
|
||||
ip = ipaddress.ip_address(item[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved:
|
||||
raise HTTPException(422, "日历订阅不能指向内网地址")
|
||||
|
||||
|
||||
def fetch_calendar_events(url: str, source_name: str, color: str):
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
current_url = url
|
||||
opener = urllib.request.build_opener(NoRedirect)
|
||||
for _ in range(4):
|
||||
_validate_public_calendar_url(current_url)
|
||||
try:
|
||||
with opener.open(current_url, timeout=15) as response:
|
||||
content_type = response.headers.get_content_type()
|
||||
if content_type not in {"text/calendar", "text/plain", "application/octet-stream"}:
|
||||
raise HTTPException(422, "订阅链接没有返回 ICS 日历内容")
|
||||
body = response.read(2_000_001)
|
||||
if len(body) > 2_000_000:
|
||||
raise HTTPException(413, "日历订阅内容超过 2MB")
|
||||
return parse_ics_events(body.decode("utf-8", errors="ignore"), source_name, color)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code not in {301, 302, 303, 307, 308}:
|
||||
raise HTTPException(502, f"订阅拉取失败: HTTP {exc.code}") from exc
|
||||
location = exc.headers.get("Location")
|
||||
if not location:
|
||||
raise HTTPException(502, "订阅重定向缺少目标地址") from exc
|
||||
current_url = urllib.parse.urljoin(current_url, location)
|
||||
except urllib.error.URLError as exc:
|
||||
raise HTTPException(502, f"订阅拉取失败: {exc.reason}") from exc
|
||||
raise HTTPException(502, "日历订阅重定向次数过多")
|
||||
|
||||
|
||||
@router.get("/calendar-subscriptions")
|
||||
async def list_calendar_subscriptions(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.scalars(select(CalendarSubscription).where(CalendarSubscription.user_id == user.id).order_by(CalendarSubscription.created_at))).all()
|
||||
return [calendar_subscription_dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/calendar-subscriptions", status_code=201)
|
||||
async def create_calendar_subscription(payload: CalendarSubscriptionCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
_validate_public_calendar_url(payload.url)
|
||||
row = CalendarSubscription(user_id=user.id, **payload.model_dump())
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return calendar_subscription_dict(row)
|
||||
|
||||
|
||||
@router.get("/calendar-subscriptions/today")
|
||||
async def today_calendar_events(
|
||||
day: date,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
rows = list((await db.scalars(select(CalendarSubscription).where(CalendarSubscription.user_id == user.id, CalendarSubscription.enabled.is_(True)).order_by(CalendarSubscription.created_at))).all())
|
||||
if not rows:
|
||||
return []
|
||||
tomorrow = day + timedelta(days=1)
|
||||
day_start = datetime.combine(day, time.min, tzinfo=UTC)
|
||||
day_end = datetime.combine(tomorrow, time.min, tzinfo=UTC)
|
||||
|
||||
async def pull(row: CalendarSubscription):
|
||||
return await asyncio.to_thread(fetch_calendar_events, row.url, row.name, row.color)
|
||||
|
||||
results = await asyncio.gather(*(pull(row) for row in rows), return_exceptions=True)
|
||||
events = []
|
||||
for row, result in zip(rows, results, strict=True):
|
||||
if isinstance(result, Exception):
|
||||
continue
|
||||
for item in result:
|
||||
starts_at = item["starts_at"]
|
||||
ends_at = item.get("ends_at")
|
||||
if item["all_day"]:
|
||||
if not (day_start <= starts_at < day_end):
|
||||
continue
|
||||
elif not (day_start <= starts_at < day_end):
|
||||
continue
|
||||
events.append({
|
||||
"id": item["id"],
|
||||
"title": item["title"],
|
||||
"starts_at": starts_at.isoformat(),
|
||||
"ends_at": ends_at.isoformat() if ends_at else None,
|
||||
"all_day": item["all_day"],
|
||||
"source_name": row.name,
|
||||
"color": row.color,
|
||||
})
|
||||
events.sort(key=lambda item: (item["all_day"] is False, item["starts_at"], item["title"]))
|
||||
return events
|
||||
|
||||
|
||||
@router.delete("/calendar-subscriptions/{subscription_id}", status_code=204)
|
||||
async def delete_calendar_subscription(subscription_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await db.scalar(select(CalendarSubscription).where(CalendarSubscription.id == subscription_id, CalendarSubscription.user_id == user.id))
|
||||
if not row:
|
||||
raise HTTPException(404, "日历订阅不存在")
|
||||
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)$")
|
||||
|
||||
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
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-VdV4G6jw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CPHlHtC0.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-BizE1tvq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BR_rdQ3j.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
Reference in New Issue
Block a user