Kyle Pope cbf4663e8d Fix TS build errors and apply remaining QA fixes
Remove unused imports (UserCheck, Loader2, ShieldOff) and replace
non-existent SmartphoneOff icon with Smartphone in admin components.
Includes backend query fixes, performance indexes migration, and
admin page shared utilities extraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 04:42:23 +08:00

311 lines
9.7 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func
from typing import Optional, List
from datetime import datetime, date, timedelta
import calendar
from app.database import get_db
from app.models.todo import Todo
from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
from app.routers.auth import get_current_user, get_current_settings
from app.models.user import User
from app.models.settings import Settings
router = APIRouter()
def _calculate_recurrence(
recurrence_rule: str,
current_due_date: date | None,
first_day_of_week: int = 0,
) -> tuple[datetime | None, date | None]:
"""Calculate reset_at and next_due_date for a recurring todo.
Args:
recurrence_rule: "daily", "weekly", or "monthly"
current_due_date: The todo's current due date (may be None)
first_day_of_week: 0=Sunday, 1=Monday
Returns:
(reset_at, next_due_date) or (None, None) if rule is invalid
"""
today = date.today()
if recurrence_rule == "daily":
reset_date = today + timedelta(days=1)
next_due = reset_date
elif recurrence_rule == "weekly":
# Find the start of the next week based on first_day_of_week setting.
# Python weekday(): Monday=0 ... Sunday=6
# Setting: 0=Sunday, 1=Monday
target_weekday = 6 if first_day_of_week == 0 else 0 # Python weekday for start
days_ahead = (target_weekday - today.weekday()) % 7
if days_ahead == 0:
days_ahead = 7 # Always push to *next* week, even if completed on the first day
reset_date = today + timedelta(days=days_ahead)
if current_due_date:
# Preserve the day-of-week: place it in the reset week
dow_offset = (current_due_date.weekday() - target_weekday) % 7
next_due = reset_date + timedelta(days=dow_offset)
else:
next_due = reset_date
elif recurrence_rule == "monthly":
# First day of next month
if today.month == 12:
reset_date = date(today.year + 1, 1, 1)
else:
reset_date = date(today.year, today.month + 1, 1)
if current_due_date:
# Same day-of-month, clamped to month length
max_day = calendar.monthrange(reset_date.year, reset_date.month)[1]
day = min(current_due_date.day, max_day)
next_due = date(reset_date.year, reset_date.month, day)
else:
next_due = reset_date
else:
return None, None
reset_at = datetime(reset_date.year, reset_date.month, reset_date.day, 0, 0, 0)
return reset_at, next_due
async def _reactivate_recurring_todos(db: AsyncSession, user_id: int) -> None:
"""Auto-reactivate recurring todos whose reset_at has passed.
Uses flush (not commit) so changes are visible to the subsequent query
within the same transaction. The caller's commit handles persistence.
Scoped to a single user to avoid cross-user reactivation.
"""
now = datetime.now()
# Fast-path: skip the FOR UPDATE lock when nothing needs reactivation (common case)
count = await db.scalar(
select(func.count()).select_from(Todo).where(
Todo.user_id == user_id,
Todo.completed == True, # noqa: E712
Todo.recurrence_rule.isnot(None),
Todo.reset_at.isnot(None),
Todo.reset_at <= now,
)
)
if count == 0:
return
query = select(Todo).where(
and_(
Todo.user_id == user_id,
Todo.completed == True,
Todo.recurrence_rule.isnot(None),
Todo.reset_at.isnot(None),
Todo.reset_at <= now,
)
).with_for_update()
result = await db.execute(query)
todos = result.scalars().all()
for todo in todos:
todo.completed = False
todo.completed_at = None
if todo.next_due_date:
todo.due_date = todo.next_due_date
todo.reset_at = None
todo.next_due_date = None
if todos:
await db.flush()
@router.get("/", response_model=List[TodoResponse])
async def get_todos(
completed: Optional[bool] = Query(None),
priority: Optional[str] = Query(None),
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
"""Get all todos with optional filters."""
# Reactivate any recurring todos whose reset time has passed
await _reactivate_recurring_todos(db, current_user.id)
query = select(Todo).where(Todo.user_id == current_user.id)
if completed is not None:
query = query.where(Todo.completed == completed)
if priority:
query = query.where(Todo.priority == priority)
if category:
query = query.where(Todo.category == category)
if search:
query = query.where(Todo.title.ilike(f"%{search}%"))
query = query.order_by(Todo.created_at.desc())
result = await db.execute(query)
todos = result.scalars().all()
await db.commit()
return todos
@router.post("/", response_model=TodoResponse, status_code=201)
async def create_todo(
todo: TodoCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new todo."""
new_todo = Todo(**todo.model_dump(), user_id=current_user.id)
db.add(new_todo)
await db.commit()
await db.refresh(new_todo)
return new_todo
@router.get("/{todo_id}", response_model=TodoResponse)
async def get_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a specific todo by ID."""
result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
return todo
@router.put("/{todo_id}", response_model=TodoResponse)
async def update_todo(
todo_id: int,
todo_update: TodoUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
"""Update a todo."""
result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
update_data = todo_update.model_dump(exclude_unset=True)
# Handle completion timestamp
if "completed" in update_data:
if update_data["completed"] and not todo.completed:
update_data["completed_at"] = datetime.now()
elif not update_data["completed"]:
update_data["completed_at"] = None
update_data["reset_at"] = None
update_data["next_due_date"] = None
# Clear due_time if due_date is being removed
if "due_date" in update_data and update_data["due_date"] is None:
update_data["due_time"] = None
for key, value in update_data.items():
setattr(todo, key, value)
# Recalculate recurrence schedule if the todo is completed and now
# has a recurrence rule (e.g. user edited a completed todo to add
# recurrence, or changed the rule/due_date while completed).
if todo.completed and todo.recurrence_rule:
reset_at, next_due = _calculate_recurrence(
todo.recurrence_rule,
todo.due_date,
current_settings.first_day_of_week,
)
todo.reset_at = reset_at
todo.next_due_date = next_due
elif todo.completed and not todo.recurrence_rule:
# Recurrence removed while completed — clear schedule
todo.reset_at = None
todo.next_due_date = None
await db.commit()
await db.refresh(todo)
return todo
@router.delete("/{todo_id}", status_code=204)
async def delete_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a todo."""
result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
await db.delete(todo)
await db.commit()
return None
@router.patch("/{todo_id}/toggle", response_model=TodoResponse)
async def toggle_todo(
todo_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
"""Toggle todo completion status. For recurring todos, calculates reset schedule."""
result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
todo.completed = not todo.completed
if todo.completed:
todo.completed_at = datetime.now()
# If recurring, schedule the reset
if todo.recurrence_rule:
reset_at, next_due = _calculate_recurrence(
todo.recurrence_rule,
todo.due_date,
current_settings.first_day_of_week,
)
todo.reset_at = reset_at
todo.next_due_date = next_due
else:
# Manual uncomplete — clear recurrence scheduling
todo.completed_at = None
todo.reset_at = None
todo.next_due_date = None
await db.commit()
await db.refresh(todo)
return todo