diff --git a/README.md b/README.md index 0d51db2..37f10f4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ -# Todo API Application +# Enhanced Todo API Application -This is a simple Todo API application built with FastAPI and SQLite. +This is an improved Todo API application built with FastAPI and SQLite, featuring advanced task management capabilities. ## Features - Create, Read, Update, and Delete todos +- Todo prioritization (low, medium, high) +- Due dates for deadline tracking +- Tag/category support for organizing todos +- Advanced search and filtering capabilities +- Upcoming tasks view for planning - Health endpoint for monitoring application status - SQLite database for data persistence - Alembic for database migrations @@ -20,7 +25,9 @@ simpletodoapplication/ │ └── schemas.py ├── migrations/ │ ├── versions/ -│ │ └── 001_create_todos_table.py +│ │ ├── 001_create_todos_table.py +│ │ ├── 002_add_priority_and_due_date.py +│ │ └── 003_add_tags_table_and_associations.py │ ├── env.py │ ├── README │ └── script.py.mako @@ -31,13 +38,35 @@ simpletodoapplication/ ## API Endpoints +### Health - `GET /health` - Check API health -- `GET /todos` - Get all todos + +### Todo Management +- `GET /todos` - Get all todos with optional filtering - `POST /todos` - Create a new todo +- `POST /todos/with-tags` - Create a new todo with tags (creates tags if they don't exist) - `GET /todos/{todo_id}` - Get a specific todo - `PUT /todos/{todo_id}` - Update a todo - `DELETE /todos/{todo_id}` - Delete a todo +### Search & Filtering +- `GET /todos/search` - Search todos by title, description, or tag +- `GET /todos/upcoming` - Get todos due in the next N days (defaults to 7) +- `GET /todos` - With query parameters for advanced filtering: + - `title` - Filter by title (partial match) + - `description` - Filter by description (partial match) + - `completed` - Filter by completion status + - `priority` - Filter by priority level + - `tag` - Filter by tag name + - `due_before` - Filter todos due before a specific date + - `due_after` - Filter todos due after a specific date + - `overdue` - Filter overdue todos + +### Tag Management +- `GET /tags` - Get all tags +- `POST /tags` - Create a new tag +- `GET /tags/{tag_id}` - Get a specific tag + ## Requirements - Python 3.8+ diff --git a/app/models.py b/app/models.py index 4fd4fab..160756f 100644 --- a/app/models.py +++ b/app/models.py @@ -1,8 +1,33 @@ -from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime +from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Enum, Table, ForeignKey +from sqlalchemy.orm import relationship from sqlalchemy.sql import func +import enum from .database import Base +# Association table for the many-to-many relationship between Todo and Tag +todo_tag = Table( + "todo_tag", + Base.metadata, + Column("todo_id", Integer, ForeignKey("todos.id"), primary_key=True), + Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True) +) + +class PriorityLevel(str, enum.Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + +class Tag(Base): + __tablename__ = "tags" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(50), nullable=False, unique=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationship to todos + todos = relationship("Todo", secondary=todo_tag, back_populates="tags") + class Todo(Base): __tablename__ = "todos" @@ -10,5 +35,10 @@ class Todo(Base): title = Column(String(100), nullable=False) description = Column(Text, nullable=True) completed = Column(Boolean, default=False) + priority = Column(Enum(PriorityLevel), default=PriorityLevel.MEDIUM) + 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 + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationship to tags + tags = relationship("Tag", secondary=todo_tag, back_populates="todos") \ No newline at end of file diff --git a/app/schemas.py b/app/schemas.py index a65e531..2709d1d 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,19 +1,52 @@ from pydantic import BaseModel, Field -from typing import Optional +from typing import Optional, List from datetime import datetime +from enum import Enum + +class PriorityLevel(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + +class TagBase(BaseModel): + name: str = Field(..., min_length=1, max_length=50) + +class TagCreate(TagBase): + pass + +class TagResponse(TagBase): + id: int + created_at: datetime + + class Config: + from_attributes = True class TodoBase(BaseModel): title: str = Field(..., min_length=1, max_length=100) description: Optional[str] = None completed: bool = False + priority: PriorityLevel = PriorityLevel.MEDIUM + due_date: Optional[datetime] = None class TodoCreate(TodoBase): - pass + tag_ids: Optional[List[int]] = [] + +class TodoCreateWithTags(TodoBase): + tags: Optional[List[str]] = [] + +class TodoUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = None + completed: Optional[bool] = None + priority: Optional[PriorityLevel] = None + due_date: Optional[datetime] = None + tag_ids: Optional[List[int]] = None class TodoResponse(TodoBase): id: int created_at: datetime updated_at: Optional[datetime] = None + tags: List[TagResponse] = [] class Config: from_attributes = True diff --git a/main.py b/main.py index db69d94..75c30b9 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,25 @@ -from fastapi import FastAPI, Depends, HTTPException +from fastapi import FastAPI, Depends, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware import uvicorn from typing import List, Optional from sqlalchemy.orm import Session +from sqlalchemy import or_, and_, func +from datetime import datetime, timedelta -from app.database import get_db -from app.models import Todo -from app.schemas import TodoCreate, TodoResponse, HealthResponse +from app.database import get_db, engine +from app.models import Todo, Tag, Base +from app.schemas import ( + TodoCreate, TodoCreateWithTags, TodoUpdate, TodoResponse, + TagCreate, TagResponse, HealthResponse +) + +# Create tables if they don't exist +Base.metadata.create_all(bind=engine) app = FastAPI( - title="Todo API", - description="A simple API for managing todos", - version="0.1.0", + title="Enhanced Todo API", + description="An improved API for managing todos with tags, priority levels, due dates, and search functionality", + version="0.2.0", ) app.add_middleware( @@ -26,18 +34,136 @@ app.add_middleware( def health(): return {"status": "healthy"} +# Tag endpoints +@app.post("/tags/", response_model=TagResponse) +def create_tag(tag: TagCreate, db: Session = Depends(get_db)): + # Check if tag already exists + existing_tag = db.query(Tag).filter(Tag.name == tag.name).first() + if existing_tag: + return existing_tag + + db_tag = Tag(**tag.model_dump()) + db.add(db_tag) + db.commit() + db.refresh(db_tag) + return db_tag + +@app.get("/tags/", response_model=List[TagResponse]) +def get_tags(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + return db.query(Tag).offset(skip).limit(limit).all() + +@app.get("/tags/{tag_id}", response_model=TagResponse) +def get_tag(tag_id: int, db: Session = Depends(get_db)): + db_tag = db.query(Tag).filter(Tag.id == tag_id).first() + if db_tag is None: + raise HTTPException(status_code=404, detail="Tag not found") + return db_tag + +# Todo endpoints @app.post("/todos/", response_model=TodoResponse) def create_todo(todo: TodoCreate, db: Session = Depends(get_db)): - db_todo = Todo(**todo.model_dump()) + todo_data = todo.model_dump(exclude={"tag_ids"}) + db_todo = Todo(**todo_data) + + # Add tags if provided + if todo.tag_ids: + for tag_id in todo.tag_ids: + tag = db.query(Tag).filter(Tag.id == tag_id).first() + if tag is None: + raise HTTPException(status_code=404, detail=f"Tag with id {tag_id} not found") + db_todo.tags.append(tag) + + db.add(db_todo) + db.commit() + db.refresh(db_todo) + return db_todo + +@app.post("/todos/with-tags/", response_model=TodoResponse) +def create_todo_with_tags(todo: TodoCreateWithTags, db: Session = Depends(get_db)): + # Extract and create todo + todo_data = todo.model_dump(exclude={"tags"}) + db_todo = Todo(**todo_data) + + # Process tags + if todo.tags: + for tag_name in todo.tags: + # Find existing tag or create new one + tag = db.query(Tag).filter(Tag.name == tag_name).first() + if not tag: + tag = Tag(name=tag_name) + db.add(tag) + db.flush() + db_todo.tags.append(tag) + db.add(db_todo) db.commit() db.refresh(db_todo) return db_todo @app.get("/todos/", response_model=List[TodoResponse]) -def get_todos(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): - todos = db.query(Todo).offset(skip).limit(limit).all() - return todos +def get_todos( + skip: int = 0, + limit: int = 100, + title: Optional[str] = None, + description: Optional[str] = None, + completed: Optional[bool] = None, + priority: Optional[str] = None, + tag: Optional[str] = None, + due_before: Optional[datetime] = None, + due_after: Optional[datetime] = None, + overdue: Optional[bool] = None, + db: Session = Depends(get_db) +): + query = db.query(Todo) + + # Apply filters if provided + if title: + query = query.filter(Todo.title.ilike(f"%{title}%")) + if description: + query = query.filter(Todo.description.ilike(f"%{description}%")) + if completed is not None: + query = query.filter(Todo.completed == completed) + if priority: + query = query.filter(Todo.priority == priority) + if tag: + query = query.join(Todo.tags).filter(Tag.name == tag) + if due_before: + query = query.filter(Todo.due_date <= due_before) + if due_after: + query = query.filter(Todo.due_date >= due_after) + if overdue is not None and overdue: + query = query.filter(and_( + Todo.due_date < datetime.now(), + Todo.completed == False + )) + + return query.offset(skip).limit(limit).all() + +@app.get("/todos/search/", response_model=List[TodoResponse]) +def search_todos( + q: str = Query(..., min_length=1, description="Search query"), + db: Session = Depends(get_db) +): + """Search todos by title, description or tag name""" + return db.query(Todo).filter( + or_( + Todo.title.ilike(f"%{q}%"), + Todo.description.ilike(f"%{q}%"), + Todo.tags.any(Tag.name.ilike(f"%{q}%")) + ) + ).all() + +@app.get("/todos/upcoming/", response_model=List[TodoResponse]) +def get_upcoming_todos(days: int = 7, db: Session = Depends(get_db)): + """Get todos with due dates in the next N days""" + future_date = datetime.now() + timedelta(days=days) + return db.query(Todo).filter( + and_( + Todo.due_date <= future_date, + Todo.due_date >= datetime.now(), + Todo.completed == False + ) + ).order_by(Todo.due_date).all() @app.get("/todos/{todo_id}", response_model=TodoResponse) def get_todo(todo_id: int, db: Session = Depends(get_db)): @@ -47,14 +173,27 @@ def get_todo(todo_id: int, db: Session = Depends(get_db)): return db_todo @app.put("/todos/{todo_id}", response_model=TodoResponse) -def update_todo(todo_id: int, todo: TodoCreate, db: Session = Depends(get_db)): +def update_todo(todo_id: int, todo: TodoUpdate, db: Session = Depends(get_db)): db_todo = db.query(Todo).filter(Todo.id == todo_id).first() if db_todo is None: raise HTTPException(status_code=404, detail="Todo not found") - - for key, value in todo.model_dump().items(): + + # Update todo attributes + todo_data = todo.model_dump(exclude={"tag_ids"}, exclude_unset=True) + for key, value in todo_data.items(): setattr(db_todo, key, value) - + + # Update tags if provided + if todo.tag_ids is not None: + # Clear existing tags + db_todo.tags = [] + # Add new tags + for tag_id in todo.tag_ids: + tag = db.query(Tag).filter(Tag.id == tag_id).first() + if tag is None: + raise HTTPException(status_code=404, detail=f"Tag with id {tag_id} not found") + db_todo.tags.append(tag) + db.commit() db.refresh(db_todo) return db_todo @@ -64,7 +203,7 @@ def delete_todo(todo_id: int, db: Session = Depends(get_db)): db_todo = db.query(Todo).filter(Todo.id == todo_id).first() if db_todo is None: raise HTTPException(status_code=404, detail="Todo not found") - + db.delete(db_todo) db.commit() return db_todo diff --git a/migrations/versions/002_add_priority_and_due_date.py b/migrations/versions/002_add_priority_and_due_date.py new file mode 100644 index 0000000..54888e3 --- /dev/null +++ b/migrations/versions/002_add_priority_and_due_date.py @@ -0,0 +1,30 @@ +"""add priority and due date to todos + +Revision ID: 002 +Revises: 001 +Create Date: 2023-11-21 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision = '002' +down_revision = '001' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add priority column to todos table + with op.batch_alter_table('todos') as batch_op: + batch_op.add_column(sa.Column('priority', sa.String(length=10), nullable=False, server_default='medium')) + batch_op.add_column(sa.Column('due_date', sa.DateTime(timezone=True), nullable=True)) + + +def downgrade(): + # Remove columns + with op.batch_alter_table('todos') as batch_op: + batch_op.drop_column('due_date') + batch_op.drop_column('priority') \ No newline at end of file diff --git a/migrations/versions/003_add_tags_table_and_associations.py b/migrations/versions/003_add_tags_table_and_associations.py new file mode 100644 index 0000000..b15bab0 --- /dev/null +++ b/migrations/versions/003_add_tags_table_and_associations.py @@ -0,0 +1,45 @@ +"""add tags table and associations + +Revision ID: 003 +Revises: 002 +Create Date: 2023-11-22 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '003' +down_revision = '002' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create tags table + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tags_id'), 'tags', ['id'], unique=False) + op.create_index(op.f('ix_tags_name'), 'tags', ['name'], unique=True) + + # Create association table + op.create_table('todo_tag', + sa.Column('todo_id', sa.Integer(), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.ForeignKeyConstraint(['todo_id'], ['todos.id'], ), + sa.PrimaryKeyConstraint('todo_id', 'tag_id') + ) + + +def downgrade(): + # Drop tables + op.drop_table('todo_tag') + op.drop_index(op.f('ix_tags_name'), table_name='tags') + op.drop_index(op.f('ix_tags_id'), table_name='tags') + op.drop_table('tags') \ No newline at end of file