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:
parent
6432d4a805
commit
cf0d0e45b0
76
README.md
76
README.md
@ -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"
|
||||
```
|
||||
|
@ -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 ###
|
@ -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
|
||||
|
@ -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")
|
@ -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
|
||||
|
||||
|
@ -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)
|
@ -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 []
|
@ -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")
|
@ -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
|
||||
),
|
||||
)
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user