Add categories and tags features

- Create Category and Tag models
- Create TodoTag association table
- Add category_id to Todo model
- Create Alembic migration for new tables
- Create schemas for Category and Tag
- Update Todo schemas to include Category and Tags
- Create CRUD operations for Categories and Tags
- Update Todo CRUD operations to handle categories and tags
- Create API endpoints for categories and tags
- Update Todo API endpoints with category and tag filtering
- Update documentation
This commit is contained in:
Automated Action 2025-06-17 02:52:26 +00:00
parent 5b2cda28a1
commit d60767d0ba
16 changed files with 626 additions and 8 deletions

View File

@ -11,6 +11,8 @@ A powerful, secure RESTful API for managing tasks and todos, built with FastAPI
- 📝 Todo CRUD operations
- Priority levels (High, Medium, Low)
- Due dates for better task management
- Categories for task organization
- Tags for flexible grouping and filtering
- Smart ordering by priority and due date
- 👤 User management
- 🔍 Advanced todo filtering and pagination
@ -93,6 +95,22 @@ The application can be configured using the following environment variables:
- `PUT /api/v1/todos/{id}` - Update a todo
- `DELETE /api/v1/todos/{id}` - Delete a todo
### Categories
- `GET /api/v1/categories/` - List all categories
- `POST /api/v1/categories/` - Create a new category
- `GET /api/v1/categories/{id}` - Get category by ID
- `PUT /api/v1/categories/{id}` - Update a category
- `DELETE /api/v1/categories/{id}` - Delete a category
### Tags
- `GET /api/v1/tags/` - List all tags
- `POST /api/v1/tags/` - Create a new tag
- `GET /api/v1/tags/{id}` - Get tag by ID
- `PUT /api/v1/tags/{id}` - Update a tag
- `DELETE /api/v1/tags/{id}` - Delete a tag
#### Todo Filtering
The `GET /api/v1/todos/` endpoint supports the following query parameters:
@ -104,6 +122,8 @@ The `GET /api/v1/todos/` endpoint supports the following query parameters:
- `priority`: Filter by priority level (low, medium, high)
- `due_date_before`: Filter for todos due before this date
- `due_date_after`: Filter for todos due after this date
- `category_id`: Filter by category ID
- `tag_id`: Filter by tag ID
## Database Schema
@ -126,9 +146,34 @@ description: Text (Optional)
is_completed: Boolean (Default: False)
priority: Enum(low, medium, high) (Default: medium)
due_date: DateTime (Optional)
category_id: Integer (Foreign Key to Category, Optional)
owner_id: Integer (Foreign Key to User)
```
### Category Model
```
id: Integer (Primary Key)
name: String (Unique, Indexed)
description: String (Optional)
owner_id: Integer (Foreign Key to User)
```
### Tag Model
```
id: Integer (Primary Key)
name: String (Unique, Indexed)
owner_id: Integer (Foreign Key to User)
```
### TodoTag Association Table
```
todo_id: Integer (Foreign Key to Todo, Primary Key)
tag_id: Integer (Foreign Key to Tag, Primary Key)
```
### RefreshToken Model
```

View File

@ -1,9 +1,11 @@
from fastapi import APIRouter
from app.api.v1.endpoints import admin, auth, todos, users
from app.api.v1.endpoints import admin, auth, categories, tags, todos, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(todos.router, prefix="/todos", tags=["todos"])
api_router.include_router(categories.router, prefix="/categories", tags=["categories"])
api_router.include_router(tags.router, prefix="/tags", tags=["tags"])
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])

View File

@ -0,0 +1,111 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_active_user, get_db
from app.crud.crud_category import (
create_category,
delete_category,
get_categories,
get_category,
get_category_by_name,
update_category,
)
from app.models.user import User
from app.schemas.category import Category, CategoryCreate, CategoryUpdate
router = APIRouter()
@router.get("/", response_model=List[Category])
def read_categories(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve categories.
"""
categories = get_categories(db=db, owner_id=current_user.id, skip=skip, limit=limit)
return categories
@router.post("/", response_model=Category)
def create_category_item(
*,
db: Session = Depends(get_db),
category_in: CategoryCreate,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Create new category.
"""
category = get_category_by_name(
db=db, name=category_in.name, owner_id=current_user.id
)
if category:
raise HTTPException(
status_code=400,
detail="The category with this name already exists",
)
category = create_category(db=db, category_in=category_in, owner_id=current_user.id)
return category
@router.get("/{id}", response_model=Category)
def read_category(
*,
db: Session = Depends(get_db),
id: int,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get category by ID.
"""
category = get_category(db=db, category_id=id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
if category.owner_id != current_user.id:
raise HTTPException(status_code=400, detail="Not enough permissions")
return category
@router.put("/{id}", response_model=Category)
def update_category_item(
*,
db: Session = Depends(get_db),
id: int,
category_in: CategoryUpdate,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Update a category.
"""
category = get_category(db=db, category_id=id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
if category.owner_id != current_user.id:
raise HTTPException(status_code=400, detail="Not enough permissions")
category = update_category(db=db, db_obj=category, obj_in=category_in)
return category
@router.delete("/{id}", response_model=None, status_code=status.HTTP_204_NO_CONTENT)
def delete_category_item(
*,
db: Session = Depends(get_db),
id: int,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Delete a category.
"""
category = get_category(db=db, category_id=id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
if category.owner_id != current_user.id:
raise HTTPException(status_code=400, detail="Not enough permissions")
delete_category(db=db, category_id=id)
return None

View File

@ -0,0 +1,109 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_active_user, get_db
from app.crud.crud_tag import (
create_tag,
delete_tag,
get_tag,
get_tag_by_name,
get_tags,
update_tag,
)
from app.models.user import User
from app.schemas.tag import Tag, TagCreate, TagUpdate
router = APIRouter()
@router.get("/", response_model=List[Tag])
def read_tags(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve tags.
"""
tags = get_tags(db=db, owner_id=current_user.id, skip=skip, limit=limit)
return tags
@router.post("/", response_model=Tag)
def create_tag_item(
*,
db: Session = Depends(get_db),
tag_in: TagCreate,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Create new tag.
"""
tag = get_tag_by_name(db=db, name=tag_in.name, owner_id=current_user.id)
if tag:
raise HTTPException(
status_code=400,
detail="The tag with this name already exists",
)
tag = create_tag(db=db, tag_in=tag_in, owner_id=current_user.id)
return tag
@router.get("/{id}", response_model=Tag)
def read_tag(
*,
db: Session = Depends(get_db),
id: int,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get tag by ID.
"""
tag = get_tag(db=db, tag_id=id)
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
if tag.owner_id != current_user.id:
raise HTTPException(status_code=400, detail="Not enough permissions")
return tag
@router.put("/{id}", response_model=Tag)
def update_tag_item(
*,
db: Session = Depends(get_db),
id: int,
tag_in: TagUpdate,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Update a tag.
"""
tag = get_tag(db=db, tag_id=id)
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
if tag.owner_id != current_user.id:
raise HTTPException(status_code=400, detail="Not enough permissions")
tag = update_tag(db=db, db_obj=tag, obj_in=tag_in)
return tag
@router.delete("/{id}", response_model=None, status_code=status.HTTP_204_NO_CONTENT)
def delete_tag_item(
*,
db: Session = Depends(get_db),
id: int,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Delete a tag.
"""
tag = get_tag(db=db, tag_id=id)
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
if tag.owner_id != current_user.id:
raise HTTPException(status_code=400, detail="Not enough permissions")
delete_tag(db=db, tag_id=id)
return None

View File

@ -29,6 +29,8 @@ def read_todos(
priority: Optional[PriorityLevel] = None,
due_date_before: Optional[datetime] = None,
due_date_after: Optional[datetime] = None,
category_id: Optional[int] = None,
tag_id: Optional[int] = None,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
@ -41,6 +43,8 @@ def read_todos(
- **priority**: Filter by priority level (low, medium, high)
- **due_date_before**: Filter for todos due before this date
- **due_date_after**: Filter for todos due after this date
- **category_id**: Filter by category ID
- **tag_id**: Filter by tag ID
"""
todos = get_todos(
db=db,
@ -51,7 +55,9 @@ def read_todos(
is_completed=is_completed,
priority=priority,
due_date_before=due_date_before,
due_date_after=due_date_after
due_date_after=due_date_after,
category_id=category_id,
tag_id=tag_id
)
return todos

51
app/crud/crud_category.py Normal file
View File

@ -0,0 +1,51 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.category import Category
from app.schemas.category import CategoryCreate, CategoryUpdate
def get_category(db: Session, category_id: int) -> Optional[Category]:
return db.query(Category).filter(Category.id == category_id).first()
def get_category_by_name(db: Session, name: str, owner_id: int) -> Optional[Category]:
return db.query(Category).filter(Category.name == name, Category.owner_id == owner_id).first()
def get_categories(
db: Session, owner_id: int, skip: int = 0, limit: int = 100
) -> List[Category]:
return db.query(Category).filter(Category.owner_id == owner_id).offset(skip).limit(limit).all()
def create_category(db: Session, category_in: CategoryCreate, owner_id: int) -> Category:
db_category = Category(
name=category_in.name,
description=category_in.description,
owner_id=owner_id
)
db.add(db_category)
db.commit()
db.refresh(db_category)
return db_category
def update_category(
db: Session, db_obj: Category, obj_in: CategoryUpdate
) -> Category:
update_data = obj_in.model_dump(exclude_unset=True)
for field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete_category(db: Session, category_id: int) -> Category:
category = db.query(Category).filter(Category.id == category_id).first()
db.delete(category)
db.commit()
return category

54
app/crud/crud_tag.py Normal file
View File

@ -0,0 +1,54 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.tag import Tag
from app.schemas.tag import TagCreate, TagUpdate
def get_tag(db: Session, tag_id: int) -> Optional[Tag]:
return db.query(Tag).filter(Tag.id == tag_id).first()
def get_tag_by_name(db: Session, name: str, owner_id: int) -> Optional[Tag]:
return db.query(Tag).filter(Tag.name == name, Tag.owner_id == owner_id).first()
def get_tags(
db: Session, owner_id: int, skip: int = 0, limit: int = 100
) -> List[Tag]:
return db.query(Tag).filter(Tag.owner_id == owner_id).offset(skip).limit(limit).all()
def get_tags_by_ids(db: Session, tag_ids: List[int], owner_id: int) -> List[Tag]:
return db.query(Tag).filter(Tag.id.in_(tag_ids), Tag.owner_id == owner_id).all()
def create_tag(db: Session, tag_in: TagCreate, owner_id: int) -> Tag:
db_tag = Tag(
name=tag_in.name,
owner_id=owner_id
)
db.add(db_tag)
db.commit()
db.refresh(db_tag)
return db_tag
def update_tag(
db: Session, db_obj: Tag, obj_in: TagUpdate
) -> Tag:
update_data = obj_in.model_dump(exclude_unset=True)
for field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete_tag(db: Session, tag_id: int) -> Tag:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
db.delete(tag)
db.commit()
return tag

View File

@ -3,6 +3,7 @@ from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.crud_tag import get_tags_by_ids
from app.models.todo import PriorityLevel, Todo
from app.schemas.todo import TodoCreate, TodoUpdate
@ -20,7 +21,9 @@ def get_todos(
is_completed: Optional[bool] = None,
priority: Optional[PriorityLevel] = None,
due_date_before: Optional[datetime] = None,
due_date_after: Optional[datetime] = None
due_date_after: Optional[datetime] = None,
category_id: Optional[int] = None,
tag_id: Optional[int] = None
) -> List[Todo]:
query = db.query(Todo).filter(Todo.owner_id == owner_id)
@ -39,6 +42,12 @@ def get_todos(
if due_date_after is not None:
query = query.filter(Todo.due_date >= due_date_after)
if category_id is not None:
query = query.filter(Todo.category_id == category_id)
if tag_id is not None:
query = query.join(Todo.tags).filter(Todo.tags.any(id=tag_id))
# Order by priority (highest first) and due date (earliest first)
query = query.order_by(Todo.priority.desc(), Todo.due_date.asc())
@ -52,11 +61,21 @@ def create_todo(db: Session, todo_in: TodoCreate, owner_id: int) -> Todo:
is_completed=todo_in.is_completed,
priority=todo_in.priority,
due_date=todo_in.due_date,
category_id=todo_in.category_id,
owner_id=owner_id
)
db.add(db_todo)
db.commit()
db.refresh(db_todo)
# Add tags if provided
if todo_in.tags:
tags = get_tags_by_ids(db, todo_in.tags, owner_id)
db_todo.tags = tags
db.add(db_todo)
db.commit()
db.refresh(db_todo)
return db_todo
@ -64,11 +83,26 @@ def update_todo(
db: Session, db_obj: Todo, obj_in: TodoUpdate
) -> Todo:
update_data = obj_in.model_dump(exclude_unset=True)
# Handle tags separately
tags_data = update_data.pop("tags", None)
# Update other fields
for field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Update tags if provided
if tags_data is not None:
tags = get_tags_by_ids(db, tags_data, db_obj.owner_id)
db_obj.tags = tags
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj

16
app/models/category.py Normal file
View File

@ -0,0 +1,16 @@
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.base import Base
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, unique=True)
description = Column(String, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"))
owner = relationship("User", back_populates="categories")
todos = relationship("Todo", back_populates="category")

24
app/models/tag.py Normal file
View File

@ -0,0 +1,24 @@
from sqlalchemy import Column, ForeignKey, Integer, String, Table
from sqlalchemy.orm import relationship
from app.db.base import Base
# Association table for 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 Tag(Base):
__tablename__ = "tags"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, unique=True)
owner_id = Column(Integer, ForeignKey("users.id"))
owner = relationship("User", back_populates="tags")
todos = relationship("Todo", secondary=todo_tag, back_populates="tags")

View File

@ -3,6 +3,7 @@ from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, Str
from sqlalchemy.orm import relationship
from app.db.base import Base
from app.models.tag import todo_tag
class PriorityLevel(str, PyEnum):
@ -21,5 +22,8 @@ class Todo(Base):
priority = Column(Enum(PriorityLevel), default=PriorityLevel.MEDIUM)
due_date = Column(DateTime, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"))
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
owner = relationship("User", back_populates="todos")
owner = relationship("User", back_populates="todos")
category = relationship("Category", back_populates="todos")
tags = relationship("Tag", secondary=todo_tag, back_populates="todos")

View File

@ -19,4 +19,6 @@ class User(Base):
is_active = Column(Boolean, default=True)
role = Column(Enum(UserRole), default=UserRole.USER)
todos = relationship("Todo", back_populates="owner")
todos = relationship("Todo", back_populates="owner")
categories = relationship("Category", back_populates="owner")
tags = relationship("Tag", back_populates="owner")

39
app/schemas/category.py Normal file
View File

@ -0,0 +1,39 @@
from typing import Optional
from pydantic import BaseModel
# Shared properties
class CategoryBase(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
# Properties to receive on category creation
class CategoryCreate(CategoryBase):
name: str
# Properties to receive on category update
class CategoryUpdate(CategoryBase):
pass
# Properties shared by models stored in DB
class CategoryInDBBase(CategoryBase):
id: int
name: str
owner_id: int
class Config:
from_attributes = True
# Properties to return to client
class Category(CategoryInDBBase):
pass
# Properties properties stored in DB
class CategoryInDB(CategoryInDBBase):
pass

38
app/schemas/tag.py Normal file
View File

@ -0,0 +1,38 @@
from typing import Optional
from pydantic import BaseModel
# Shared properties
class TagBase(BaseModel):
name: Optional[str] = None
# Properties to receive on tag creation
class TagCreate(TagBase):
name: str
# Properties to receive on tag update
class TagUpdate(TagBase):
pass
# Properties shared by models stored in DB
class TagInDBBase(TagBase):
id: int
name: str
owner_id: int
class Config:
from_attributes = True
# Properties to return to client
class Tag(TagInDBBase):
pass
# Properties properties stored in DB
class TagInDB(TagInDBBase):
pass

View File

@ -1,9 +1,11 @@
from datetime import datetime
from typing import Optional
from typing import List, Optional
from pydantic import BaseModel
from app.models.todo import PriorityLevel
from app.schemas.category import Category
from app.schemas.tag import Tag
# Shared properties
@ -13,16 +15,18 @@ class TodoBase(BaseModel):
is_completed: Optional[bool] = False
priority: Optional[PriorityLevel] = PriorityLevel.MEDIUM
due_date: Optional[datetime] = None
category_id: Optional[int] = None
# Properties to receive on item creation
class TodoCreate(TodoBase):
title: str
tags: Optional[List[int]] = None # List of tag IDs
# Properties to receive on item update
class TodoUpdate(TodoBase):
pass
tags: Optional[List[int]] = None # List of tag IDs
# Properties shared by models stored in DB
@ -37,7 +41,8 @@ class TodoInDBBase(TodoBase):
# Properties to return to client
class Todo(TodoInDBBase):
pass
category: Optional[Category] = None
tags: List[Tag] = []
# Properties properties stored in DB

View File

@ -0,0 +1,78 @@
"""add categories and tags
Revision ID: 005
Revises: 004
Create Date: 2023-11-18
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '005'
down_revision = '004'
branch_labels = None
depends_on = None
def upgrade():
# Create categories table
op.create_table(
'categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('description', sa.String(), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_categories_id'), 'categories', ['id'], unique=False)
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True)
# Create tags table
op.create_table(
'tags',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
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 todo_tag 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')
)
# Add category_id to todos table
with op.batch_alter_table('todos', schema=None) as batch_op:
batch_op.add_column(sa.Column('category_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_todos_category_id', 'categories', ['category_id'], ['id'])
def downgrade():
# Remove category_id from todos table
with op.batch_alter_table('todos', schema=None) as batch_op:
batch_op.drop_constraint('fk_todos_category_id', type_='foreignkey')
batch_op.drop_column('category_id')
# Drop todo_tag association table
op.drop_table('todo_tag')
# Drop tags table
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')
# Drop categories table
op.drop_index(op.f('ix_categories_name'), table_name='categories')
op.drop_index(op.f('ix_categories_id'), table_name='categories')
op.drop_table('categories')