from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ 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) -> 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. """ now = datetime.now() query = select(Todo).where( and_( 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: 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) query = select(Todo) 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: Settings = Depends(get_current_settings) ): """Create a new todo.""" new_todo = Todo(**todo.model_dump()) 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: Settings = Depends(get_current_settings) ): """Get a specific todo by ID.""" result = await db.execute(select(Todo).where(Todo.id == todo_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: Settings = Depends(get_current_settings) ): """Update a todo.""" result = await db.execute(select(Todo).where(Todo.id == todo_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_user.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: Settings = Depends(get_current_settings) ): """Delete a todo.""" result = await db.execute(select(Todo).where(Todo.id == todo_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: 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 = 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_user.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