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 ## Features
- Create, read, update, and delete todos - 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 - SQLite database with SQLAlchemy ORM
- Database migrations with Alembic - Database migrations with Alembic
- FastAPI with automatic OpenAPI documentation - FastAPI with automatic OpenAPI documentation
@ -13,16 +16,26 @@ A simple todo application built with FastAPI and SQLite.
## API Endpoints ## API Endpoints
### General
- `GET /` - Root endpoint with basic information - `GET /` - Root endpoint with basic information
- `GET /health` - Health check endpoint - `GET /health` - Health check endpoint
- `GET /docs` - Interactive API documentation (Swagger UI) - `GET /docs` - Interactive API documentation (Swagger UI)
- `GET /redoc` - Alternative API documentation - `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 - `POST /api/v1/todos` - Create a new todo
- `GET /api/v1/todos/{todo_id}` - Get a specific todo - `GET /api/v1/todos/{todo_id}` - Get a specific todo
- `PUT /api/v1/todos/{todo_id}` - Update a specific todo - `PUT /api/v1/todos/{todo_id}` - Update a specific todo
- `DELETE /api/v1/todos/{todo_id}` - Delete 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 ## Installation
1. Install the dependencies: 1. Install the dependencies:
@ -49,24 +62,30 @@ The API will be available at `http://localhost:8000`
│ ├── api/ │ ├── api/
│ │ └── v1/ │ │ └── v1/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ └── todos.py │ │ ├── todos.py
│ │ └── categories.py
│ ├── crud/ │ ├── crud/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ └── todo.py │ │ ├── todo.py
│ │ └── category.py
│ ├── db/ │ ├── db/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── base.py │ │ ├── base.py
│ │ └── session.py │ │ └── session.py
│ ├── models/ │ ├── models/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ └── todo.py │ │ ├── todo.py
│ │ └── category.py
│ ├── schemas/ │ ├── schemas/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ └── todo.py │ │ ├── todo.py
│ │ └── category.py
│ └── __init__.py │ └── __init__.py
├── alembic/ ├── alembic/
│ ├── versions/ │ ├── versions/
│ │ └── 001_initial_todo_table.py │ │ ├── 001_initial_todo_table.py
│ │ ├── 002_add_priority_field.py
│ │ └── 003_add_categories.py
│ ├── env.py │ ├── env.py
│ └── script.py.mako │ └── script.py.mako
├── alembic.ini ├── 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. 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: Each todo has the following fields:
- `id`: Unique identifier (auto-generated) - `id`: Unique identifier (auto-generated)
- `title`: Todo title (required, max 200 characters) - `title`: Todo title (required, max 200 characters)
- `description`: Todo description (optional, max 500 characters) - `description`: Todo description (optional, max 500 characters)
- `completed`: Completion status (boolean, default: false) - `completed`: Completion status (boolean, default: false)
- `priority`: Priority level (low, medium, high, default: medium)
- `category_id`: Reference to category (optional)
- `created_at`: Creation timestamp - `created_at`: Creation timestamp
- `updated_at`: Last update 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 """Add projects table and project_id to todos
Revision ID: 005_add_projects Revision ID: 004_add_projects
Revises: 004_add_tags Revises: 003_add_categories
Create Date: 2025-06-18 15:00:00.000000 Create Date: 2025-06-18 15:00:00.000000
""" """
@ -13,8 +13,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "005_add_projects" revision: str = "004_add_projects"
down_revision: Union[str, None] = "004_add_tags" down_revision: Union[str, None] = "003_add_categories"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: 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 fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session 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