diff --git a/README.md b/README.md index 54d7412..948db20 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ # Simple Todo Application - FastAPI -A simple Todo API application built with FastAPI and SQLite. This RESTful API provides endpoints to create, read, update, and delete todo items. +A simple Todo API application built with FastAPI and SQLite. This RESTful API provides endpoints to create, read, update, and delete todo items with advanced organization features. ## Features - RESTful API for Todo management - CRUD operations for Todo items +- Todo categorization with categories +- Priority levels (low, medium, high) +- Due dates for task deadlines +- Advanced filtering capabilities: + - Filter by category + - Filter by priority + - Filter by completion status + - Filter by due date range +- Customizable sorting options +- Todo statistics and analytics - SQLAlchemy ORM with SQLite database - Alembic for database migrations - FastAPI's automatic OpenAPI documentation @@ -38,15 +48,35 @@ simpletodoapplication/ ## API Endpoints -- **GET /api/v1/todos/** - Get all todos +### Todo Operations +- **GET /api/v1/todos/** - Get all todos with filtering options - **POST /api/v1/todos/** - Create a new todo - **GET /api/v1/todos/{todo_id}** - Get a specific todo - **PUT /api/v1/todos/{todo_id}** - Update a todo - **DELETE /api/v1/todos/{todo_id}** - Delete a todo + +### Statistics +- **GET /api/v1/todos/stats** - Get todo statistics and analytics + +### System - **GET /health** - Health check endpoint - **GET /docs** - API documentation (Swagger UI) - **GET /redoc** - Alternative API documentation (ReDoc) +## Filtering and Sorting + +The GET /api/v1/todos/ endpoint supports the following query parameters: + +- **category** - Filter todos by category +- **priority** - Filter by priority level (low, medium, high) +- **completed** - Filter by completion status (true/false) +- **due_before** - Filter todos due before a specific date +- **due_after** - Filter todos due after a specific date +- **sort_by** - Sort by any todo field (default: created_at) +- **sort_desc** - Sort in descending order (default: true) +- **skip** - Number of records to skip (for pagination) +- **limit** - Maximum number of records to return (for pagination) + ## Setup and Running 1. Install dependencies: @@ -77,7 +107,34 @@ simpletodoapplication/ "title": "Sample Todo", "description": "This is a sample todo item", "completed": false, + "category": "work", + "priority": "high", + "due_date": "2025-05-20T12:00:00", "created_at": "2025-05-13T12:00:00", "updated_at": null } +``` + +## Statistics + +The /api/v1/todos/stats endpoint returns the following information: + +```json +{ + "total": 10, + "completed": 3, + "incomplete": 7, + "overdue": 2, + "by_category": { + "work": 4, + "personal": 3, + "shopping": 2, + "uncategorized": 1 + }, + "by_priority": { + "high": 3, + "medium": 5, + "low": 2 + } +} ``` \ No newline at end of file diff --git a/alembic/versions/5f42ebcf5b2e_add_category_priority_and_due_date.py b/alembic/versions/5f42ebcf5b2e_add_category_priority_and_due_date.py new file mode 100644 index 0000000..ab3f2db --- /dev/null +++ b/alembic/versions/5f42ebcf5b2e_add_category_priority_and_due_date.py @@ -0,0 +1,45 @@ +"""add category priority and due date + +Revision ID: 5f42ebcf5b2e +Revises: 4f42ebcf5b2d +Create Date: 2025-05-13 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5f42ebcf5b2e' +down_revision = '4f42ebcf5b2d' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create an enum type for priority + op.execute("CREATE TYPE priority_enum AS ENUM ('low', 'medium', 'high')") + + # Add new columns + op.add_column('todos', sa.Column('category', sa.String(), nullable=True)) + op.add_column('todos', sa.Column('priority', sa.Enum('low', 'medium', 'high', name='priority_enum'), nullable=True)) + op.add_column('todos', sa.Column('due_date', sa.DateTime(timezone=True), nullable=True)) + + # Create index on category for faster queries + op.create_index(op.f('ix_todos_category'), 'todos', ['category'], unique=False) + + # Set default values + op.execute("UPDATE todos SET priority = 'medium' WHERE priority IS NULL") + + +def downgrade(): + # Drop index + op.drop_index(op.f('ix_todos_category'), table_name='todos') + + # Drop columns + op.drop_column('todos', 'due_date') + op.drop_column('todos', 'priority') + op.drop_column('todos', 'category') + + # Drop enum type + op.execute("DROP TYPE priority_enum") \ No newline at end of file diff --git a/app/api/routes/todos.py b/app/api/routes/todos.py index 5bb4810..380e844 100644 --- a/app/api/routes/todos.py +++ b/app/api/routes/todos.py @@ -1,27 +1,68 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session -from typing import List +from typing import List, Optional, Dict, Any +from datetime import datetime from app import crud -from app.schemas.todo import Todo, TodoCreate, TodoUpdate +from app.models.todo import PriorityEnum as ModelPriorityEnum +from app.schemas.todo import Todo, TodoCreate, TodoUpdate, PriorityEnum from app.db.base import get_db router = APIRouter() @router.get("/", response_model=List[Todo]) -def read_todos(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): - todos = crud.get_todos(db, skip=skip, limit=limit) +def read_todos( + skip: int = 0, + limit: int = 100, + category: Optional[str] = None, + priority: Optional[PriorityEnum] = None, + completed: Optional[bool] = None, + due_before: Optional[datetime] = None, + due_after: Optional[datetime] = None, + sort_by: str = "created_at", + sort_desc: bool = True, + db: Session = Depends(get_db) +): + """ + Get all todos with filtering options: + - Filter by category + - Filter by priority level + - Filter by completion status + - Filter by due date (before/after) + - Sort by any field + - Pagination + """ + todos = crud.get_todos( + db=db, + skip=skip, + limit=limit, + category=category, + priority=priority.value if priority else None, + completed=completed, + due_before=due_before, + due_after=due_after, + sort_by=sort_by, + sort_desc=sort_desc + ) return todos @router.post("/", response_model=Todo, status_code=status.HTTP_201_CREATED) def create_todo(todo: TodoCreate, db: Session = Depends(get_db)): + """Create a new todo item with extended fields""" return crud.create_todo(db=db, todo=todo) +@router.get("/stats", response_model=Dict[str, Any]) +def get_todo_stats(db: Session = Depends(get_db)): + """Get statistics about todos""" + return crud.get_todo_stats(db=db) + + @router.get("/{todo_id}", response_model=Todo) def read_todo(todo_id: int, db: Session = Depends(get_db)): + """Get a specific todo by ID""" db_todo = crud.get_todo(db, todo_id=todo_id) if db_todo is None: raise HTTPException(status_code=404, detail="Todo not found") @@ -30,6 +71,7 @@ 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: TodoUpdate, db: Session = Depends(get_db)): + """Update a todo with new values including category, priority and due date""" db_todo = crud.update_todo(db=db, todo_id=todo_id, todo=todo) if db_todo is None: raise HTTPException(status_code=404, detail="Todo not found") @@ -38,6 +80,7 @@ def update_todo(todo_id: int, todo: TodoUpdate, db: Session = Depends(get_db)): @router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_todo(todo_id: int, db: Session = Depends(get_db)): + """Delete a todo by ID""" success = crud.delete_todo(db=db, todo_id=todo_id) if not success: raise HTTPException(status_code=404, detail="Todo not found") diff --git a/app/crud/todo.py b/app/crud/todo.py index f7a4e81..cb7f7dd 100644 --- a/app/crud/todo.py +++ b/app/crud/todo.py @@ -1,7 +1,9 @@ from sqlalchemy.orm import Session -from typing import List, Optional +from sqlalchemy import asc, desc +from typing import List, Optional, Dict, Any +from datetime import datetime -from app.models.todo import Todo +from app.models.todo import Todo, PriorityEnum from app.schemas.todo import TodoCreate, TodoUpdate @@ -9,16 +11,58 @@ def get_todo(db: Session, todo_id: int) -> Optional[Todo]: return db.query(Todo).filter(Todo.id == todo_id).first() -def get_todos(db: Session, skip: int = 0, limit: int = 100) -> List[Todo]: - return db.query(Todo).order_by(Todo.created_at.desc()).offset(skip).limit(limit).all() +def get_todos( + db: Session, + skip: int = 0, + limit: int = 100, + category: Optional[str] = None, + priority: Optional[str] = None, + completed: Optional[bool] = None, + due_before: Optional[datetime] = None, + due_after: Optional[datetime] = None, + sort_by: str = "created_at", + sort_desc: bool = True +) -> List[Todo]: + query = db.query(Todo) + + # Apply filters if provided + if category: + query = query.filter(Todo.category == category) + + if priority: + try: + priority_enum = PriorityEnum[priority.upper()] + query = query.filter(Todo.priority == priority_enum) + except (KeyError, AttributeError): + pass # Invalid priority value, ignore filter + + if completed is not None: + query = query.filter(Todo.completed == completed) + + if due_before: + query = query.filter(Todo.due_date <= due_before) + + if due_after: + query = query.filter(Todo.due_date >= due_after) + + # Apply sorting + if hasattr(Todo, sort_by): + sort_column = getattr(Todo, sort_by) + if sort_desc: + query = query.order_by(desc(sort_column)) + else: + query = query.order_by(asc(sort_column)) + else: + # Default sort by created_at if invalid column provided + query = query.order_by(desc(Todo.created_at)) + + # Apply pagination + return query.offset(skip).limit(limit).all() def create_todo(db: Session, todo: TodoCreate) -> Todo: - db_todo = Todo( - title=todo.title, - description=todo.description, - completed=todo.completed - ) + todo_data = todo.model_dump() + db_todo = Todo(**todo_data) db.add(db_todo) db.commit() db.refresh(db_todo) @@ -29,11 +73,11 @@ def update_todo(db: Session, todo_id: int, todo: TodoUpdate) -> Optional[Todo]: db_todo = get_todo(db, todo_id) if db_todo is None: return None - + update_data = todo.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(db_todo, field, value) - + db.commit() db.refresh(db_todo) return db_todo @@ -43,7 +87,46 @@ def delete_todo(db: Session, todo_id: int) -> bool: db_todo = get_todo(db, todo_id) if db_todo is None: return False - + db.delete(db_todo) db.commit() - return True \ No newline at end of file + return True + + +def get_todo_stats(db: Session) -> Dict[str, Any]: + """Get statistics about todos""" + total = db.query(Todo).count() + completed = db.query(Todo).filter(Todo.completed == True).count() + incomplete = total - completed + + # Group by category + categories = db.query( + Todo.category, + db.func.count(Todo.id) + ).group_by( + Todo.category + ).all() + + # Group by priority + priorities = db.query( + Todo.priority, + db.func.count(Todo.id) + ).group_by( + Todo.priority + ).all() + + # Overdue todos (due date in past and not completed) + now = datetime.now() + overdue = db.query(Todo).filter( + Todo.due_date < now, + Todo.completed == False + ).count() + + return { + "total": total, + "completed": completed, + "incomplete": incomplete, + "overdue": overdue, + "by_category": {cat or "uncategorized": count for cat, count in categories}, + "by_priority": {str(pri.name).lower() if pri else "none": count for pri, count in priorities} + } \ No newline at end of file diff --git a/app/models/todo.py b/app/models/todo.py index 325465e..23b67d5 100644 --- a/app/models/todo.py +++ b/app/models/todo.py @@ -1,8 +1,14 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum from sqlalchemy.sql import func +import enum from app.db.base import Base +class PriorityEnum(enum.Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + class Todo(Base): __tablename__ = "todos" @@ -10,5 +16,8 @@ class Todo(Base): title = Column(String, index=True) description = Column(String, nullable=True) completed = Column(Boolean, default=False) + category = Column(String, nullable=True, index=True) + priority = Column(Enum(PriorityEnum), default=PriorityEnum.MEDIUM, nullable=True) + due_date = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file diff --git a/app/schemas/todo.py b/app/schemas/todo.py index 8a2df56..b0b182b 100644 --- a/app/schemas/todo.py +++ b/app/schemas/todo.py @@ -1,11 +1,20 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field from datetime import datetime -from typing import Optional +from typing import Optional, Literal +from enum import Enum + +class PriorityEnum(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" class TodoBase(BaseModel): title: str description: Optional[str] = None completed: bool = False + category: Optional[str] = None + priority: Optional[PriorityEnum] = PriorityEnum.MEDIUM + due_date: Optional[datetime] = None class TodoCreate(TodoBase): pass @@ -14,6 +23,9 @@ class TodoUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None completed: Optional[bool] = None + category: Optional[str] = None + priority: Optional[PriorityEnum] = None + due_date: Optional[datetime] = None class TodoInDB(TodoBase): id: int