feat: sort tasks by due time
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m35s

This commit is contained in:
2026-09-10 18:16:13 +08:00
parent ab8c0a5138
commit addd49363d
8 changed files with 576 additions and 58 deletions
+135 -23
View File
@@ -2,11 +2,12 @@ import base64
import json
import logging
import os
import re
import shutil
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from pathlib import Path, PureWindowsPath
from uuid import UUID, uuid4
@@ -14,7 +15,7 @@ from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import delete, exists, func, or_, select, update
from sqlalchemy import and_, case, delete, exists, func, or_, select, tuple_, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -817,16 +818,123 @@ async def purge_list(
def _encode_cursor(position: int, created_at: datetime, task_id: UUID) -> str:
raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode()
def _task_ordering():
"""Portable total ordering: open, dated, due time, then manual/stable keys."""
return (
case((Task.completed.is_(False), 0), else_=1),
case((Task.due_at.is_(None), 1), else_=0),
Task.due_at.asc(),
Task.position.asc(),
Task.created_at.asc(),
Task.id.asc(),
)
def _task_cursor_values(task: Task) -> list[object]:
return [
0 if not task.completed else 1,
1 if task.due_at is None else 0,
task.due_at.isoformat() if task.due_at else None,
task.position,
task.created_at.isoformat(),
str(task.id),
]
def _encode_task_cursor(task: Task) -> str:
raw = json.dumps({"v": 2, "keys": _task_cursor_values(task)}).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _decode_cursor(cursor: str) -> tuple[int, datetime, UUID]:
_CURSOR_MAX_POSITION = 2**63 - 1
_CURSOR_DATETIME = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$"
)
def _cursor_rank(value: object) -> int:
if type(value) is not int or value not in (0, 1):
raise ValueError
return value
def _cursor_position(value: object) -> int:
if type(value) is not int or not 0 <= value <= _CURSOR_MAX_POSITION:
raise ValueError
return value
def _cursor_datetime(value: object) -> datetime:
if not isinstance(value, str) or not _CURSOR_DATETIME.fullmatch(value):
raise ValueError
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise ValueError
return parsed.astimezone(UTC)
def _decode_task_cursor(cursor: str) -> tuple[int, int, datetime | None, int, datetime, UUID]:
try:
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
position, timestamp, task_id = json.loads(raw)
return int(position), datetime.fromisoformat(timestamp), UUID(task_id)
payload = json.loads(raw)
if not isinstance(payload, dict) or type(payload.get("v")) is not int or payload["v"] != 2:
raise ValueError
keys = payload["keys"]
if not isinstance(keys, list) or len(keys) != 6:
raise ValueError
completed_rank, due_rank, due_at, position, created_at, task_id = keys
completed_rank = _cursor_rank(completed_rank)
due_rank = _cursor_rank(due_rank)
if (due_rank == 0) != (due_at is not None):
raise ValueError
if not isinstance(task_id, str):
raise TypeError
return (
completed_rank,
due_rank,
_cursor_datetime(due_at) if due_at is not None else None,
_cursor_position(position),
_cursor_datetime(created_at),
UUID(task_id),
)
except (ValueError, TypeError, KeyError, UnicodeError, json.JSONDecodeError, OverflowError) as exc:
raise HTTPException(status_code=422, detail="无效的游标") from exc
def _task_after_cursor(values: tuple[int, int, datetime | None, int, datetime, UUID]):
completed_rank, due_rank, due_at, position, created_at, task_id = values
completed_expr = case((Task.completed.is_(False), 0), else_=1)
due_expr = case((Task.due_at.is_(None), 1), else_=0)
prefix = [completed_expr == completed_rank, due_expr == due_rank]
alternatives = [completed_expr > completed_rank, and_(prefix[0], due_expr > due_rank)]
if due_rank == 0 and due_at is not None:
alternatives.append(and_(*prefix, Task.due_at > due_at))
prefix.append(Task.due_at == due_at)
alternatives.extend(
[
and_(*prefix, Task.position > position),
and_(*prefix, Task.position == position, Task.created_at > created_at),
and_(
*prefix,
Task.position == position,
Task.created_at == created_at,
Task.id > task_id,
),
]
)
return or_(*alternatives)
def _encode_trash_cursor(created_at: datetime, task_id: UUID) -> str:
raw = json.dumps([created_at.isoformat(), str(task_id)]).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _decode_trash_cursor(cursor: str) -> tuple[datetime, UUID]:
try:
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
timestamp, task_id = json.loads(raw)
return datetime.fromisoformat(timestamp), UUID(task_id)
except (ValueError, TypeError, json.JSONDecodeError) as exc:
raise HTTPException(status_code=422, detail="无效的游标") from exc
@@ -922,43 +1030,40 @@ async def list_tasks(
or_(Task.title.ilike(pattern), Task.description.ilike(pattern), list_match)
)
total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
ordering = (Task.position, Task.created_at, Task.id)
ordering = _task_ordering()
if page is not None:
size = page_size or limit
items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all())
return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
if cursor:
position, created_at, task_id = _decode_cursor(cursor)
query = query.where(
or_(
Task.position > position,
(Task.position == position) & (Task.created_at > created_at),
(Task.position == position) & (Task.created_at == created_at) & (Task.id > task_id),
)
)
query = query.where(_task_after_cursor(_decode_task_cursor(cursor)))
rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all())
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = _encode_cursor(items[-1].position, items[-1].created_at, items[-1].id) if has_more else None
next_cursor = _encode_task_cursor(items[-1]) if has_more else None
return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)
async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailOut]:
if not tasks:
return []
task_ids = [task.id for task in tasks]
allowed_scopes = {(task.id, task.user_id, task.list_id) for task in tasks}
subtasks = list(
(
await db.scalars(
select(Task)
.where(Task.parent_id.in_(task_ids), Task.deleted_at.is_(None))
.order_by(Task.position, Task.created_at, Task.id)
.where(
tuple_(Task.parent_id, Task.user_id, Task.list_id).in_(allowed_scopes),
Task.deleted_at.is_(None),
)
.order_by(*_task_ordering())
)
).all()
)
subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list)
for subtask in subtasks:
subtasks_by_task[subtask.parent_id].append(subtask)
if (subtask.parent_id, subtask.user_id, subtask.list_id) in allowed_scopes:
subtasks_by_task[subtask.parent_id].append(subtask)
return [
TaskDetailOut(
**TaskOut.model_validate(task).model_dump(),
@@ -985,9 +1090,16 @@ async def reorder_tasks(
parent_scopes = {row.parent_id for row in rows}
if len(parent_scopes) != 1:
raise HTTPException(status_code=400, detail="只能调整同一层级任务的顺序")
list_scopes = {row.list_id for row in rows}
if len(list_scopes) != 1:
raise HTTPException(status_code=400, detail="只能调整同一清单内任务的顺序")
sort_tiers = {(row.completed, row.due_at) for row in rows}
if len(sort_tiers) != 1:
raise HTTPException(status_code=400, detail="只能调整相同完成状态和截止时间档的任务顺序")
parent_id = next(iter(parent_scopes))
scope_query = select(Task).where(
Task.user_id == user.id,
Task.list_id == next(iter(list_scopes)),
Task.deleted_at.is_(None),
Task.parent_id.is_(None) if parent_id is None else Task.parent_id == parent_id,
).order_by(Task.position, Task.created_at, Task.id)
@@ -1150,14 +1262,14 @@ async def list_trash(
items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all())
return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
if cursor:
created_at, task_id = _decode_cursor(cursor)
created_at, task_id = _decode_trash_cursor(cursor)
query = query.where(
or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id))
)
rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all())
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None
next_cursor = _encode_trash_cursor(items[-1].created_at, items[-1].id) if has_more else None
return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)