
Added comprehensive due date functionality to the FastAPI todo application: API Enhancements: - Updated main /todos endpoint with due date query parameters (overdue, due_soon, due_date_from, due_date_to) - Added sort_by parameter supporting "due_date" and "created_at" options - Enhanced existing endpoints to handle due_date in responses New Specialized Endpoints: - GET /api/v1/todos/overdue - Get all overdue todos with pagination - GET /api/v1/todos/due-soon - Get todos due within next N days (default 7) - GET /api/v1/todos/due-today - Get todos due today with date range filtering Technical Improvements: - Added comprehensive OpenAPI documentation with parameter descriptions and examples - Implemented proper date validation and timezone handling - Added date utility functions for consistent date operations - Enhanced error handling for invalid date parameters - Added proper FastAPI Query parameter validation with regex patterns Migration & Schema Updates: - Created migration 007 to add due_date column with timezone support - Added database index on due_date for optimal query performance - Updated Todo model with computed properties (is_overdue, days_until_due, is_due_soon) - Enhanced schemas with due_date field validation and Field descriptions All endpoints follow existing API patterns with pagination, filtering, and comprehensive documentation.
376 lines
12 KiB
Python
376 lines
12 KiB
Python
from typing import Optional, List
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
import math
|
|
|
|
from app.crud import todo as todo_crud
|
|
from app.db.session import get_db
|
|
from app.models.todo import Priority
|
|
from app.schemas.todo import (
|
|
Todo,
|
|
TodoCreate,
|
|
TodoUpdate,
|
|
TodoListResponse,
|
|
SubtaskCreate,
|
|
)
|
|
from app.utils.date_utils import (
|
|
get_date_range_today,
|
|
)
|
|
|
|
router = APIRouter(prefix="/todos", tags=["todos"])
|
|
|
|
|
|
@router.get("/", response_model=TodoListResponse)
|
|
def read_todos(
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
per_page: int = Query(10, ge=1, le=100, description="Items per page"),
|
|
completed: Optional[bool] = Query(None, description="Filter by completion status"),
|
|
priority: Optional[Priority] = Query(None, description="Filter by priority"),
|
|
search: Optional[str] = Query(None, description="Search in title and description"),
|
|
category_id: Optional[int] = Query(None, description="Filter by category ID"),
|
|
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
|
overdue: Optional[bool] = Query(None, description="Filter by overdue status"),
|
|
due_soon: Optional[bool] = Query(
|
|
None, description="Filter todos due within next 7 days"
|
|
),
|
|
due_date_from: Optional[datetime] = Query(
|
|
None, description="Filter todos due after this date"
|
|
),
|
|
due_date_to: Optional[datetime] = Query(
|
|
None, description="Filter todos due before this date"
|
|
),
|
|
sort_by: Optional[str] = Query(
|
|
None,
|
|
description="Sort by field (due_date, created_at)",
|
|
regex="^(due_date|created_at)$",
|
|
),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Retrieve todos with comprehensive filtering and sorting options.
|
|
|
|
- **page**: Page number (starts from 1)
|
|
- **per_page**: Number of items per page (1-100)
|
|
- **completed**: Filter by completion status (true/false)
|
|
- **priority**: Filter by priority level (low/medium/high)
|
|
- **search**: Search text in title and description
|
|
- **category_id**: Filter by category ID
|
|
- **project_id**: Filter by project ID
|
|
- **overdue**: Filter overdue todos (true/false)
|
|
- **due_soon**: Filter todos due within next 7 days (true/false)
|
|
- **due_date_from**: Show todos due after this date (ISO format)
|
|
- **due_date_to**: Show todos due before this date (ISO format)
|
|
- **sort_by**: Sort results by 'due_date' or 'created_at'
|
|
"""
|
|
skip = (page - 1) * per_page
|
|
|
|
# Determine sorting preference
|
|
sort_by_due_date = sort_by == "due_date"
|
|
|
|
# Handle due_soon filter by converting to overdue parameter
|
|
if due_soon is not None:
|
|
# For due_soon, we'll use the specialized function
|
|
if due_soon:
|
|
todos, total = todo_crud.get_todos_due_soon(
|
|
db,
|
|
days=7,
|
|
skip=skip,
|
|
limit=per_page,
|
|
completed=completed,
|
|
priority=priority,
|
|
category_id=category_id,
|
|
project_id=project_id,
|
|
)
|
|
else:
|
|
# Get all todos that are NOT due soon
|
|
todos, total = todo_crud.get_todos(
|
|
db,
|
|
skip=skip,
|
|
limit=per_page,
|
|
completed=completed,
|
|
priority=priority,
|
|
search=search,
|
|
category_id=category_id,
|
|
project_id=project_id,
|
|
overdue=False,
|
|
sort_by_due_date=sort_by_due_date,
|
|
due_date_start=due_date_from,
|
|
due_date_end=due_date_to,
|
|
)
|
|
elif overdue is not None and overdue:
|
|
# Use specialized overdue function
|
|
todos, total = todo_crud.get_overdue_todos(
|
|
db,
|
|
skip=skip,
|
|
limit=per_page,
|
|
completed=completed,
|
|
priority=priority,
|
|
category_id=category_id,
|
|
project_id=project_id,
|
|
)
|
|
else:
|
|
# Standard query with all filters
|
|
todos, total = todo_crud.get_todos(
|
|
db,
|
|
skip=skip,
|
|
limit=per_page,
|
|
completed=completed,
|
|
priority=priority,
|
|
search=search,
|
|
category_id=category_id,
|
|
project_id=project_id,
|
|
overdue=overdue,
|
|
sort_by_due_date=sort_by_due_date,
|
|
due_date_start=due_date_from,
|
|
due_date_end=due_date_to,
|
|
)
|
|
|
|
total_pages = math.ceil(total / per_page)
|
|
|
|
return TodoListResponse(
|
|
items=todos,
|
|
total=total,
|
|
page=page,
|
|
per_page=per_page,
|
|
has_next=page < total_pages,
|
|
has_prev=page > 1,
|
|
)
|
|
|
|
|
|
@router.get("/overdue", response_model=TodoListResponse)
|
|
def get_overdue_todos(
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
per_page: int = Query(10, ge=1, le=100, description="Items per page"),
|
|
completed: Optional[bool] = Query(None, description="Filter by completion status"),
|
|
priority: Optional[Priority] = Query(None, description="Filter by priority"),
|
|
category_id: Optional[int] = Query(None, description="Filter by category ID"),
|
|
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get all overdue todos.
|
|
|
|
Returns todos that have a due date in the past and are not completed.
|
|
Results are sorted by due date (earliest first).
|
|
"""
|
|
skip = (page - 1) * per_page
|
|
todos, total = todo_crud.get_overdue_todos(
|
|
db,
|
|
skip=skip,
|
|
limit=per_page,
|
|
completed=completed,
|
|
priority=priority,
|
|
category_id=category_id,
|
|
project_id=project_id,
|
|
)
|
|
|
|
total_pages = math.ceil(total / per_page)
|
|
|
|
return TodoListResponse(
|
|
items=todos,
|
|
total=total,
|
|
page=page,
|
|
per_page=per_page,
|
|
has_next=page < total_pages,
|
|
has_prev=page > 1,
|
|
)
|
|
|
|
|
|
@router.get("/due-soon", response_model=TodoListResponse)
|
|
def get_todos_due_soon(
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
per_page: int = Query(10, ge=1, le=100, description="Items per page"),
|
|
days: int = Query(7, ge=1, le=30, description="Number of days to look ahead"),
|
|
completed: Optional[bool] = Query(None, description="Filter by completion status"),
|
|
priority: Optional[Priority] = Query(None, description="Filter by priority"),
|
|
category_id: Optional[int] = Query(None, description="Filter by category ID"),
|
|
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get todos due within the next N days (default 7 days).
|
|
|
|
Returns todos that have a due date within the specified number of days from now.
|
|
Results are sorted by due date (earliest first).
|
|
|
|
- **days**: Number of days to look ahead (1-30, default 7)
|
|
"""
|
|
skip = (page - 1) * per_page
|
|
todos, total = todo_crud.get_todos_due_soon(
|
|
db,
|
|
days=days,
|
|
skip=skip,
|
|
limit=per_page,
|
|
completed=completed,
|
|
priority=priority,
|
|
category_id=category_id,
|
|
project_id=project_id,
|
|
)
|
|
|
|
total_pages = math.ceil(total / per_page)
|
|
|
|
return TodoListResponse(
|
|
items=todos,
|
|
total=total,
|
|
page=page,
|
|
per_page=per_page,
|
|
has_next=page < total_pages,
|
|
has_prev=page > 1,
|
|
)
|
|
|
|
|
|
@router.get("/due-today", response_model=TodoListResponse)
|
|
def get_todos_due_today(
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
per_page: int = Query(10, ge=1, le=100, description="Items per page"),
|
|
completed: Optional[bool] = Query(None, description="Filter by completion status"),
|
|
priority: Optional[Priority] = Query(None, description="Filter by priority"),
|
|
category_id: Optional[int] = Query(None, description="Filter by category ID"),
|
|
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get todos due today.
|
|
|
|
Returns todos that have a due date within today's date range.
|
|
Results are sorted by due date (earliest first).
|
|
"""
|
|
skip = (page - 1) * per_page
|
|
start_date, end_date = get_date_range_today()
|
|
|
|
todos, total = todo_crud.get_todos_by_date_range(
|
|
db,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
skip=skip,
|
|
limit=per_page,
|
|
completed=completed,
|
|
priority=priority,
|
|
category_id=category_id,
|
|
project_id=project_id,
|
|
)
|
|
|
|
total_pages = math.ceil(total / per_page)
|
|
|
|
return TodoListResponse(
|
|
items=todos,
|
|
total=total,
|
|
page=page,
|
|
per_page=per_page,
|
|
has_next=page < total_pages,
|
|
has_prev=page > 1,
|
|
)
|
|
|
|
|
|
@router.post("/", response_model=Todo)
|
|
def create_todo(todo: TodoCreate, db: Session = Depends(get_db)):
|
|
"""
|
|
Create a new todo.
|
|
|
|
- **title**: Todo title (required, 1-200 characters)
|
|
- **description**: Optional description (max 500 characters)
|
|
- **completed**: Completion status (default: false)
|
|
- **priority**: Priority level (low/medium/high, default: medium)
|
|
- **category_id**: Optional category ID
|
|
- **project_id**: Optional project ID
|
|
- **due_date**: Optional due date with timezone
|
|
- **tag_ids**: Optional list of tag IDs
|
|
"""
|
|
return todo_crud.create_todo(db=db, todo=todo)
|
|
|
|
|
|
@router.get("/{todo_id}", response_model=Todo)
|
|
def read_todo(todo_id: int, db: Session = Depends(get_db)):
|
|
"""
|
|
Get a specific todo by ID.
|
|
|
|
Returns the todo with all its details including computed fields like
|
|
is_overdue and days_until_due.
|
|
"""
|
|
db_todo = todo_crud.get_todo(db, todo_id=todo_id)
|
|
if db_todo is None:
|
|
raise HTTPException(status_code=404, detail="Todo not found")
|
|
return db_todo
|
|
|
|
|
|
@router.put("/{todo_id}", response_model=Todo)
|
|
def update_todo(todo_id: int, todo_update: TodoUpdate, db: Session = Depends(get_db)):
|
|
"""
|
|
Update a specific todo.
|
|
|
|
All fields are optional. Only provided fields will be updated.
|
|
Use null value for due_date to clear it.
|
|
"""
|
|
db_todo = todo_crud.update_todo(db, todo_id=todo_id, todo_update=todo_update)
|
|
if db_todo is None:
|
|
raise HTTPException(status_code=404, detail="Todo not found")
|
|
return db_todo
|
|
|
|
|
|
@router.delete("/{todo_id}")
|
|
def delete_todo(todo_id: int, db: Session = Depends(get_db)):
|
|
"""
|
|
Delete a specific todo.
|
|
|
|
This will also delete all subtasks associated with this todo.
|
|
"""
|
|
success = todo_crud.delete_todo(db, todo_id=todo_id)
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Todo not found")
|
|
return {"message": "Todo deleted successfully"}
|
|
|
|
|
|
@router.post("/{todo_id}/subtasks", response_model=Todo)
|
|
def create_subtask(todo_id: int, subtask: SubtaskCreate, db: Session = Depends(get_db)):
|
|
"""
|
|
Create a subtask for a specific todo.
|
|
|
|
- **title**: Subtask title (required, 1-200 characters)
|
|
- **description**: Optional description (max 500 characters)
|
|
- **priority**: Priority level (low/medium/high, default: medium)
|
|
- **due_date**: Optional due date with timezone
|
|
"""
|
|
db_subtask = todo_crud.create_subtask(db, parent_id=todo_id, subtask=subtask)
|
|
if db_subtask is None:
|
|
raise HTTPException(status_code=404, detail="Parent todo not found")
|
|
return db_subtask
|
|
|
|
|
|
@router.get("/{todo_id}/subtasks", response_model=List[Todo])
|
|
def get_subtasks(todo_id: int, db: Session = Depends(get_db)):
|
|
"""
|
|
Get all subtasks for a specific todo.
|
|
|
|
Returns a list of todos that are subtasks of the specified parent todo.
|
|
"""
|
|
# First check if parent todo exists
|
|
parent_todo = todo_crud.get_todo(db, todo_id=todo_id)
|
|
if parent_todo is None:
|
|
raise HTTPException(status_code=404, detail="Todo not found")
|
|
|
|
subtasks = todo_crud.get_subtasks(db, parent_id=todo_id)
|
|
return subtasks
|
|
|
|
|
|
@router.put("/subtasks/{subtask_id}/move", response_model=Todo)
|
|
def move_subtask(
|
|
subtask_id: int, new_parent_id: Optional[int] = None, db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Move a subtask to a different parent or make it a main todo.
|
|
|
|
- **new_parent_id**: New parent todo ID (null to make it a main todo)
|
|
|
|
Prevents cycles and validates that the new parent exists.
|
|
"""
|
|
moved_subtask = todo_crud.move_subtask(
|
|
db, subtask_id=subtask_id, new_parent_id=new_parent_id
|
|
)
|
|
if moved_subtask is None:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot move subtask: subtask not found, invalid parent, or would create a cycle",
|
|
)
|
|
return moved_subtask
|