feat: sort tasks by due time
This commit is contained in:
+135
-23
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user