diff --git a/alembic/versions/007_add_due_date.py b/alembic/versions/007_add_due_date.py index b7382e4..44a737c 100644 --- a/alembic/versions/007_add_due_date.py +++ b/alembic/versions/007_add_due_date.py @@ -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") \ No newline at end of file + op.drop_column("todos", "due_date") diff --git a/app/api/v1/todos.py b/app/api/v1/todos.py index 7cce61b..8dad9db 100644 --- a/app/api/v1/todos.py +++ b/app/api/v1/todos.py @@ -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 )