Update README with Categories system documentation

- Document category management features
- Update API endpoints to include categories
- Revise project structure to reflect current implementation
- Add usage examples for categories and filtering
- Remove references to unimplemented features
- Update models documentation for Todo and Category
This commit is contained in:
Automated Action 2025-06-19 00:09:01 +00:00
parent 6432d4a805
commit cf0d0e45b0
10 changed files with 74 additions and 390 deletions

View File

@ -5,6 +5,9 @@ A simple todo application built with FastAPI and SQLite.
## Features
- Create, read, update, and delete todos
- Category management system for organizing todos
- Priority levels for todos (low, medium, high)
- Search and filtering by category, priority, and completion status
- SQLite database with SQLAlchemy ORM
- Database migrations with Alembic
- FastAPI with automatic OpenAPI documentation
@ -13,16 +16,26 @@ A simple todo application built with FastAPI and SQLite.
## API Endpoints
### General
- `GET /` - Root endpoint with basic information
- `GET /health` - Health check endpoint
- `GET /docs` - Interactive API documentation (Swagger UI)
- `GET /redoc` - Alternative API documentation
- `GET /api/v1/todos` - Get all todos
### Todos
- `GET /api/v1/todos` - Get all todos (with filtering by category, priority, completion status, and search)
- `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 specific todo
- `DELETE /api/v1/todos/{todo_id}` - Delete a specific todo
### Categories
- `GET /api/v1/categories` - Get all categories
- `POST /api/v1/categories` - Create a new category
- `GET /api/v1/categories/{category_id}` - Get a specific category
- `PUT /api/v1/categories/{category_id}` - Update a specific category
- `DELETE /api/v1/categories/{category_id}` - Delete a specific category
## Installation
1. Install the dependencies:
@ -49,24 +62,30 @@ The API will be available at `http://localhost:8000`
│ ├── api/
│ │ └── v1/
│ │ ├── __init__.py
│ │ └── todos.py
│ │ ├── todos.py
│ │ └── categories.py
│ ├── crud/
│ │ ├── __init__.py
│ │ └── todo.py
│ │ ├── todo.py
│ │ └── category.py
│ ├── db/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ └── session.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── todo.py
│ │ ├── todo.py
│ │ └── category.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ └── todo.py
│ │ ├── todo.py
│ │ └── category.py
│ └── __init__.py
├── alembic/
│ ├── versions/
│ │ └── 001_initial_todo_table.py
│ │ ├── 001_initial_todo_table.py
│ │ ├── 002_add_priority_field.py
│ │ └── 003_add_categories.py
│ ├── env.py
│ └── script.py.mako
├── alembic.ini
@ -79,12 +98,55 @@ The API will be available at `http://localhost:8000`
The application uses SQLite database stored at `/app/storage/db/db.sqlite`. The database schema is managed using Alembic migrations.
## Todo Model
## Models
### Todo Model
Each todo has the following fields:
- `id`: Unique identifier (auto-generated)
- `title`: Todo title (required, max 200 characters)
- `description`: Todo description (optional, max 500 characters)
- `completed`: Completion status (boolean, default: false)
- `priority`: Priority level (low, medium, high, default: medium)
- `category_id`: Reference to category (optional)
- `created_at`: Creation timestamp
- `updated_at`: Last update timestamp
### Category Model
Each category has the following fields:
- `id`: Unique identifier (auto-generated)
- `name`: Category name (required, max 100 characters, unique)
- `description`: Category description (optional, max 500 characters)
- `color`: Color code in hex format (optional, e.g., #FF0000)
- `created_at`: Creation timestamp
- `updated_at`: Last update timestamp
## Usage Examples
### Creating a Category
```bash
curl -X POST "http://localhost:8000/api/v1/categories" \
-H "Content-Type: application/json" \
-d '{"name": "Work", "description": "Work related tasks", "color": "#FF6B6B"}'
```
### Creating a Todo with a Category
```bash
curl -X POST "http://localhost:8000/api/v1/todos" \
-H "Content-Type: application/json" \
-d '{"title": "Review presentation", "description": "Review the quarterly presentation", "category_id": 1, "priority": "high"}'
```
### Filtering Todos by Category
```bash
curl "http://localhost:8000/api/v1/todos?category_id=1"
```
### Filtering Todos by Priority and Completion Status
```bash
curl "http://localhost:8000/api/v1/todos?priority=high&completed=false"
```
### Search Todos
```bash
curl "http://localhost:8000/api/v1/todos?search=presentation"
```

View File

@ -1,43 +0,0 @@
"""Add subtasks support with parent_id field
Revision ID: 003
Revises: 002
Create Date: 2024-01-01 00:00:00.000000
"""
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() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"todos",
sa.Column("parent_id", sa.Integer(), nullable=True),
)
# Add foreign key constraint
op.create_foreign_key(
"fk_todos_parent_id",
"todos",
"todos",
["parent_id"],
["id"],
ondelete="CASCADE",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Drop foreign key constraint first
op.drop_constraint("fk_todos_parent_id", "todos", type_="foreignkey")
# Drop the column
op.drop_column("todos", "parent_id")
# ### end Alembic commands ###

View File

@ -1,7 +1,7 @@
"""Add projects table and project_id to todos
Revision ID: 005_add_projects
Revises: 004_add_tags
Revision ID: 004_add_projects
Revises: 003_add_categories
Create Date: 2025-06-18 15:00:00.000000
"""
@ -13,8 +13,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "005_add_projects"
down_revision: Union[str, None] = "004_add_tags"
revision: str = "004_add_projects"
down_revision: Union[str, None] = "003_add_categories"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

View File

@ -1,57 +0,0 @@
"""Add tags and todo_tags tables
Revision ID: 004_add_tags
Revises: 003_add_project_category
Create Date: 2025-06-18 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "004_add_tags"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 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("color", sa.String(length=7), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_index(op.f("ix_tags_id"), "tags", ["id"], unique=False)
# Create todo_tags association table
op.create_table(
"todo_tags",
sa.Column("todo_id", sa.Integer(), nullable=False),
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["todo_id"], ["todos.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("todo_id", "tag_id"),
)
def downgrade() -> None:
# Drop todo_tags association table
op.drop_table("todo_tags")
# Drop tags table
op.drop_index(op.f("ix_tags_id"), table_name="tags")
op.drop_table("tags")

View File

@ -1,4 +1,4 @@
from typing import Optional
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session

View File

@ -1,118 +0,0 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.crud import tag as tag_crud
from app.schemas.tag import Tag, TagCreate, TagUpdate, TagListResponse
from app.schemas.todo import Todo
router = APIRouter()
@router.get("/", response_model=TagListResponse)
def read_tags(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
"""
Retrieve tags with pagination.
"""
tags = tag_crud.get_tags(db, skip=skip, limit=limit)
total = tag_crud.get_tags_count(db)
return TagListResponse(items=tags, total=total)
@router.post("/", response_model=Tag, status_code=status.HTTP_201_CREATED)
def create_tag(tag: TagCreate, db: Session = Depends(get_db)):
"""
Create a new tag.
"""
# Check if tag with this name already exists
db_tag = tag_crud.get_tag_by_name(db, name=tag.name)
if db_tag:
raise HTTPException(status_code=400, detail="Tag with this name already exists")
return tag_crud.create_tag(db=db, tag=tag)
@router.get("/{tag_id}", response_model=Tag)
def read_tag(tag_id: int, db: Session = Depends(get_db)):
"""
Get a specific tag by ID.
"""
db_tag = tag_crud.get_tag(db, tag_id=tag_id)
if db_tag is None:
raise HTTPException(status_code=404, detail="Tag not found")
return db_tag
@router.put("/{tag_id}", response_model=Tag)
def update_tag(tag_id: int, tag: TagUpdate, db: Session = Depends(get_db)):
"""
Update a tag.
"""
# Check if tag exists
db_tag = tag_crud.get_tag(db, tag_id=tag_id)
if db_tag is None:
raise HTTPException(status_code=404, detail="Tag not found")
# Check if new name conflicts with existing tag
if tag.name and tag.name != db_tag.name:
existing_tag = tag_crud.get_tag_by_name(db, name=tag.name)
if existing_tag:
raise HTTPException(
status_code=400, detail="Tag with this name already exists"
)
return tag_crud.update_tag(db=db, tag_id=tag_id, tag=tag)
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_tag(tag_id: int, db: Session = Depends(get_db)):
"""
Delete a tag.
"""
success = tag_crud.delete_tag(db, tag_id=tag_id)
if not success:
raise HTTPException(status_code=404, detail="Tag not found")
@router.post("/{tag_id}/todos/{todo_id}", status_code=status.HTTP_201_CREATED)
def add_tag_to_todo(tag_id: int, todo_id: int, db: Session = Depends(get_db)):
"""
Add a tag to a todo.
"""
success = tag_crud.add_tag_to_todo(db, todo_id=todo_id, tag_id=tag_id)
if not success:
raise HTTPException(
status_code=400,
detail="Failed to add tag to todo. Tag or todo not found, or tag already assigned.",
)
return {"message": "Tag successfully added to todo"}
@router.delete("/{tag_id}/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_tag_from_todo(tag_id: int, todo_id: int, db: Session = Depends(get_db)):
"""
Remove a tag from a todo.
"""
success = tag_crud.remove_tag_from_todo(db, todo_id=todo_id, tag_id=tag_id)
if not success:
raise HTTPException(
status_code=400,
detail="Failed to remove tag from todo. Tag or todo not found, or tag not assigned.",
)
@router.get("/{tag_id}/todos", response_model=List[Todo])
def get_todos_by_tag(
tag_id: int, skip: int = 0, limit: int = 100, db: Session = Depends(get_db)
):
"""
Get all todos that have a specific tag.
"""
# Check if tag exists
db_tag = tag_crud.get_tag(db, tag_id=tag_id)
if db_tag is None:
raise HTTPException(status_code=404, detail="Tag not found")
return tag_crud.get_todos_by_tag(db, tag_id=tag_id, skip=skip, limit=limit)

View File

@ -1,92 +0,0 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models.tag import Tag
from app.models.todo import Todo
from app.schemas.tag import TagCreate, TagUpdate
def get_tag(db: Session, tag_id: int) -> Optional[Tag]:
"""Get a single tag by ID."""
return db.query(Tag).filter(Tag.id == tag_id).first()
def get_tag_by_name(db: Session, name: str) -> Optional[Tag]:
"""Get a tag by name."""
return db.query(Tag).filter(Tag.name == name).first()
def get_tags(db: Session, skip: int = 0, limit: int = 100) -> List[Tag]:
"""Get multiple tags with pagination."""
return db.query(Tag).offset(skip).limit(limit).all()
def get_tags_count(db: Session) -> int:
"""Get total count of tags."""
return db.query(func.count(Tag.id)).scalar()
def create_tag(db: Session, tag: TagCreate) -> Tag:
"""Create a new tag."""
db_tag = Tag(name=tag.name, color=tag.color)
db.add(db_tag)
db.commit()
db.refresh(db_tag)
return db_tag
def update_tag(db: Session, tag_id: int, tag: TagUpdate) -> Optional[Tag]:
"""Update an existing tag."""
db_tag = db.query(Tag).filter(Tag.id == tag_id).first()
if db_tag:
update_data = tag.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_tag, field, value)
db.commit()
db.refresh(db_tag)
return db_tag
def delete_tag(db: Session, tag_id: int) -> bool:
"""Delete a tag."""
db_tag = db.query(Tag).filter(Tag.id == tag_id).first()
if db_tag:
db.delete(db_tag)
db.commit()
return True
return False
def add_tag_to_todo(db: Session, todo_id: int, tag_id: int) -> bool:
"""Add a tag to a todo."""
todo = db.query(Todo).filter(Todo.id == todo_id).first()
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if todo and tag and tag not in todo.tags:
todo.tags.append(tag)
db.commit()
return True
return False
def remove_tag_from_todo(db: Session, todo_id: int, tag_id: int) -> bool:
"""Remove a tag from a todo."""
todo = db.query(Todo).filter(Todo.id == todo_id).first()
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if todo and tag and tag in todo.tags:
todo.tags.remove(tag)
db.commit()
return True
return False
def get_todos_by_tag(
db: Session, tag_id: int, skip: int = 0, limit: int = 100
) -> List[Todo]:
"""Get all todos that have a specific tag."""
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if tag:
return tag.todos[skip : skip + limit]
return []

View File

@ -1,17 +0,0 @@
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.db.base import Base
class Tag(Base):
__tablename__ = "tags"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False, unique=True)
color = Column(String(7), nullable=False, default="#3B82F6") # Hex color code
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Many-to-many relationship with todos
todos = relationship("Todo", secondary="todo_tags", back_populates="tags")

View File

@ -1,15 +0,0 @@
from sqlalchemy import Column, Integer, ForeignKey, Table
from app.db.base import Base
# Association table for many-to-many relationship between todos and tags
todo_tags = Table(
"todo_tags",
Base.metadata,
Column(
"todo_id", Integer, ForeignKey("todos.id", ondelete="CASCADE"), primary_key=True
),
Column(
"tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True
),
)

View File

@ -1,36 +0,0 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class TagBase(BaseModel):
name: str = Field(..., min_length=1, max_length=50, description="Tag name")
color: str = Field(
default="#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code"
)
class TagCreate(TagBase):
pass
class TagUpdate(BaseModel):
name: Optional[str] = Field(
None, min_length=1, max_length=50, description="Tag name"
)
color: Optional[str] = Field(
None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code"
)
class Tag(TagBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class TagListResponse(BaseModel):
items: list[Tag]
total: int