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:
|
def downgrade() -> None:
|
||||||
op.drop_index(op.f("ix_todos_due_date"), table_name="todos")
|
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 typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
import math
|
import math
|
||||||
@ -13,6 +14,9 @@ from app.schemas.todo import (
|
|||||||
TodoListResponse,
|
TodoListResponse,
|
||||||
SubtaskCreate,
|
SubtaskCreate,
|
||||||
)
|
)
|
||||||
|
from app.utils.date_utils import (
|
||||||
|
get_date_range_today,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/todos", tags=["todos"])
|
router = APIRouter(prefix="/todos", tags=["todos"])
|
||||||
|
|
||||||
@ -25,17 +29,226 @@ def read_todos(
|
|||||||
priority: Optional[Priority] = Query(None, description="Filter by priority"),
|
priority: Optional[Priority] = Query(None, description="Filter by priority"),
|
||||||
search: Optional[str] = Query(None, description="Search in title and description"),
|
search: Optional[str] = Query(None, description="Search in title and description"),
|
||||||
category_id: Optional[int] = Query(None, description="Filter by category ID"),
|
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),
|
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
|
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,
|
db,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
limit=per_page,
|
limit=per_page,
|
||||||
completed=completed,
|
completed=completed,
|
||||||
priority=priority,
|
priority=priority,
|
||||||
search=search,
|
|
||||||
category_id=category_id,
|
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)
|
total_pages = math.ceil(total / per_page)
|
||||||
@ -52,11 +265,29 @@ def read_todos(
|
|||||||
|
|
||||||
@router.post("/", response_model=Todo)
|
@router.post("/", response_model=Todo)
|
||||||
def create_todo(todo: TodoCreate, db: Session = Depends(get_db)):
|
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)
|
return todo_crud.create_todo(db=db, todo=todo)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{todo_id}", response_model=Todo)
|
@router.get("/{todo_id}", response_model=Todo)
|
||||||
def read_todo(todo_id: int, db: Session = Depends(get_db)):
|
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)
|
db_todo = todo_crud.get_todo(db, todo_id=todo_id)
|
||||||
if db_todo is None:
|
if db_todo is None:
|
||||||
raise HTTPException(status_code=404, detail="Todo not found")
|
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)
|
@router.put("/{todo_id}", response_model=Todo)
|
||||||
def update_todo(todo_id: int, todo_update: TodoUpdate, db: Session = Depends(get_db)):
|
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)
|
db_todo = todo_crud.update_todo(db, todo_id=todo_id, todo_update=todo_update)
|
||||||
if db_todo is None:
|
if db_todo is None:
|
||||||
raise HTTPException(status_code=404, detail="Todo not found")
|
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}")
|
@router.delete("/{todo_id}")
|
||||||
def delete_todo(todo_id: int, db: Session = Depends(get_db)):
|
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)
|
success = todo_crud.delete_todo(db, todo_id=todo_id)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=404, detail="Todo not found")
|
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)
|
@router.post("/{todo_id}/subtasks", response_model=Todo)
|
||||||
def create_subtask(todo_id: int, subtask: SubtaskCreate, db: Session = Depends(get_db)):
|
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)
|
db_subtask = todo_crud.create_subtask(db, parent_id=todo_id, subtask=subtask)
|
||||||
if db_subtask is None:
|
if db_subtask is None:
|
||||||
raise HTTPException(status_code=404, detail="Parent todo not found")
|
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])
|
@router.get("/{todo_id}/subtasks", response_model=List[Todo])
|
||||||
def get_subtasks(todo_id: int, db: Session = Depends(get_db)):
|
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
|
# First check if parent todo exists
|
||||||
parent_todo = todo_crud.get_todo(db, todo_id=todo_id)
|
parent_todo = todo_crud.get_todo(db, todo_id=todo_id)
|
||||||
if parent_todo is None:
|
if parent_todo is None:
|
||||||
@ -104,7 +357,13 @@ def get_subtasks(todo_id: int, db: Session = Depends(get_db)):
|
|||||||
def move_subtask(
|
def move_subtask(
|
||||||
subtask_id: int, new_parent_id: Optional[int] = None, db: Session = Depends(get_db)
|
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(
|
moved_subtask = todo_crud.move_subtask(
|
||||||
db, subtask_id=subtask_id, new_parent_id=new_parent_id
|
db, subtask_id=subtask_id, new_parent_id=new_parent_id
|
||||||
)
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user