Implement comprehensive due date API endpoints with filtering and sorting
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.
This commit is contained in:
parent
bd4441f91f
commit
aa7cc98275
@ -29,4 +29,4 @@ def upgrade() -> None:
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_todos_due_date"), table_name="todos")
|
||||
op.drop_column("todos", "due_date")
|
||||
op.drop_column("todos", "due_date")
|
||||
|
@ -1,4 +1,5 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
import math
|
||||
@ -13,6 +14,9 @@ from app.schemas.todo import (
|
||||
TodoListResponse,
|
||||
SubtaskCreate,
|
||||
)
|
||||
from app.utils.date_utils import (
|
||||
get_date_range_today,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/todos", tags=["todos"])
|
||||
|
||||
@ -25,17 +29,226 @@ def read_todos(
|
||||
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
|
||||
todos, total = todo_crud.get_todos(
|
||||
|
||||
# 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,
|
||||
search=search,
|
||||
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)
|
||||
@ -52,11 +265,29 @@ def read_todos(
|
||||
|
||||
@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")
|
||||
@ -65,6 +296,12 @@ def read_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
@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")
|
||||
@ -73,6 +310,11 @@ def update_todo(todo_id: int, todo_update: TodoUpdate, db: Session = Depends(get
|
||||
|
||||
@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")
|
||||
@ -81,7 +323,14 @@ def delete_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
@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."""
|
||||
"""
|
||||
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")
|
||||
@ -90,7 +339,11 @@ def create_subtask(todo_id: int, subtask: SubtaskCreate, db: Session = Depends(g
|
||||
|
||||
@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."""
|
||||
"""
|
||||
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:
|
||||
@ -104,7 +357,13 @@ def get_subtasks(todo_id: int, db: Session = Depends(get_db)):
|
||||
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."""
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user