diff --git a/README.md b/README.md index a232aea..5730f53 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/app/api/v1/api.py b/app/api/v1/api.py index d8ab29c..a12327c 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -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"]) \ No newline at end of file diff --git a/app/api/v1/endpoints/categories.py b/app/api/v1/endpoints/categories.py new file mode 100644 index 0000000..fbc0767 --- /dev/null +++ b/app/api/v1/endpoints/categories.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/tags.py b/app/api/v1/endpoints/tags.py new file mode 100644 index 0000000..5120640 --- /dev/null +++ b/app/api/v1/endpoints/tags.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/todos.py b/app/api/v1/endpoints/todos.py index 59f8d68..09fd0d6 100644 --- a/app/api/v1/endpoints/todos.py +++ b/app/api/v1/endpoints/todos.py @@ -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 diff --git a/app/crud/crud_category.py b/app/crud/crud_category.py new file mode 100644 index 0000000..e01a158 --- /dev/null +++ b/app/crud/crud_category.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_tag.py b/app/crud/crud_tag.py new file mode 100644 index 0000000..d37d55c --- /dev/null +++ b/app/crud/crud_tag.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_todo.py b/app/crud/crud_todo.py index 9c7887e..6a32792 100644 --- a/app/crud/crud_todo.py +++ b/app/crud/crud_todo.py @@ -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 diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..5b46938 --- /dev/null +++ b/app/models/category.py @@ -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") \ No newline at end of file diff --git a/app/models/tag.py b/app/models/tag.py new file mode 100644 index 0000000..400cb1e --- /dev/null +++ b/app/models/tag.py @@ -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") \ No newline at end of file diff --git a/app/models/todo.py b/app/models/todo.py index e106ae7..decd692 100644 --- a/app/models/todo.py +++ b/app/models/todo.py @@ -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") \ No newline at end of file + owner = relationship("User", back_populates="todos") + category = relationship("Category", back_populates="todos") + tags = relationship("Tag", secondary=todo_tag, back_populates="todos") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index 5b7f67a..7f82d89 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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") \ No newline at end of file + todos = relationship("Todo", back_populates="owner") + categories = relationship("Category", back_populates="owner") + tags = relationship("Tag", back_populates="owner") \ No newline at end of file diff --git a/app/schemas/category.py b/app/schemas/category.py new file mode 100644 index 0000000..c7a68cb --- /dev/null +++ b/app/schemas/category.py @@ -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 \ No newline at end of file diff --git a/app/schemas/tag.py b/app/schemas/tag.py new file mode 100644 index 0000000..8389115 --- /dev/null +++ b/app/schemas/tag.py @@ -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 \ No newline at end of file diff --git a/app/schemas/todo.py b/app/schemas/todo.py index b82f8f9..a6dc226 100644 --- a/app/schemas/todo.py +++ b/app/schemas/todo.py @@ -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 diff --git a/migrations/versions/005_add_categories_and_tags.py b/migrations/versions/005_add_categories_and_tags.py new file mode 100644 index 0000000..a224835 --- /dev/null +++ b/migrations/versions/005_add_categories_and_tags.py @@ -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') \ No newline at end of file