From 691becf69b35876ca8e9423db363856dab72bee5 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Mon, 12 May 2025 23:58:50 +0000 Subject: [PATCH] Add Kanban-style board functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added TodoBoard model and BoardStatus enum - Created migration for todo boards - Added TodoBoard API endpoints - Added board-related features to the README 🤖 Generated with BackendIM... (backend.im) --- README.md | 21 +++- app/models.py | 25 +++++ app/schemas.py | 35 ++++++ main.py | 123 ++++++++++++++++++++- migrations/versions/005_add_todo_boards.py | 49 ++++++++ 5 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 migrations/versions/005_add_todo_boards.py diff --git a/README.md b/README.md index ad248fc..0cefeb6 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ This is an improved Todo API application built with FastAPI and SQLite, featurin - Health endpoint for monitoring application status - SQLite database for data persistence - Alembic for database migrations -- **NEW** Subtasks support for breaking down complex todos -- **NEW** Task reminder functionality +- Subtasks support for breaking down complex todos +- Task reminder functionality +- **NEW** Kanban-style board functionality +- **NEW** Task status tracking (todo, in progress, review, done) ## Project Structure @@ -30,7 +32,8 @@ simpletodoapplication/ │ │ ├── 001_create_todos_table.py │ │ ├── 002_add_priority_and_due_date.py │ │ ├── 003_add_tags_table_and_associations.py -│ │ └── 004_add_subtasks_and_reminders.py +│ │ ├── 004_add_subtasks_and_reminders.py +│ │ └── 005_add_todo_boards.py │ ├── env.py │ ├── README │ └── script.py.mako @@ -82,6 +85,18 @@ simpletodoapplication/ ### Reminder Management - `PUT /todos/{todo_id}/set-reminder` - Set or update a reminder for a todo +### Board Management +- `GET /boards` - Get all boards +- `POST /boards` - Create a new board +- `GET /boards/{board_id}` - Get a specific board with all its todos +- `PUT /boards/{board_id}` - Update a board +- `DELETE /boards/{board_id}` - Delete a board + +### Todo Board Operations +- `POST /boards/{board_id}/todos` - Create a new todo in a specific board +- `GET /boards/{board_id}/todos` - Get all todos in a board with optional status filter +- `PUT /todos/{todo_id}/move` - Move a todo to a different board and/or change its status + ## Requirements - Python 3.8+ diff --git a/app/models.py b/app/models.py index faa0f3e..817e6da 100644 --- a/app/models.py +++ b/app/models.py @@ -18,6 +18,12 @@ class PriorityLevel(str, enum.Enum): MEDIUM = "medium" HIGH = "high" +class BoardStatus(str, enum.Enum): + TODO = "todo" + IN_PROGRESS = "in_progress" + REVIEW = "review" + DONE = "done" + class Tag(Base): __tablename__ = "tags" @@ -43,6 +49,18 @@ class Subtask(Base): # Relationship to parent todo todo = relationship("Todo", back_populates="subtasks") +class TodoBoard(Base): + __tablename__ = "todo_boards" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationship to todos + todos = relationship("Todo", back_populates="board", cascade="all, delete-orphan") + class Todo(Base): __tablename__ = "todos" @@ -53,9 +71,16 @@ class Todo(Base): priority = Column(Enum(PriorityLevel), default=PriorityLevel.MEDIUM) due_date = Column(DateTime(timezone=True), nullable=True) remind_at = Column(DateTime(timezone=True), nullable=True) + status = Column(Enum(BoardStatus), default=BoardStatus.TODO) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + # Foreign key relationship to board + board_id = Column(Integer, ForeignKey("todo_boards.id"), nullable=True) + + # Relationship to board + board = relationship("TodoBoard", back_populates="todos") + # Relationship to tags tags = relationship("Tag", secondary=todo_tag, back_populates="todos") diff --git a/app/schemas.py b/app/schemas.py index b8c7e4d..afee51f 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -8,6 +8,12 @@ class PriorityLevel(str, Enum): MEDIUM = "medium" HIGH = "high" +class BoardStatus(str, Enum): + TODO = "todo" + IN_PROGRESS = "in_progress" + REVIEW = "review" + DONE = "done" + class TagBase(BaseModel): name: str = Field(..., min_length=1, max_length=50) @@ -46,8 +52,10 @@ class TodoBase(BaseModel): description: Optional[str] = None completed: bool = False priority: PriorityLevel = PriorityLevel.MEDIUM + status: BoardStatus = BoardStatus.TODO due_date: Optional[datetime] = None remind_at: Optional[datetime] = None + board_id: Optional[int] = None class TodoCreate(TodoBase): tag_ids: Optional[List[int]] = [] @@ -60,8 +68,10 @@ class TodoUpdate(BaseModel): description: Optional[str] = None completed: Optional[bool] = None priority: Optional[PriorityLevel] = None + status: Optional[BoardStatus] = None due_date: Optional[datetime] = None remind_at: Optional[datetime] = None + board_id: Optional[int] = None tag_ids: Optional[List[int]] = None class TodoResponse(TodoBase): @@ -74,5 +84,30 @@ class TodoResponse(TodoBase): class Config: from_attributes = True +class TodoBoardBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + +class TodoBoardCreate(TodoBoardBase): + pass + +class TodoBoardUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = None + +class TodoBoardResponse(TodoBoardBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + +class TodoBoardDetailResponse(TodoBoardResponse): + todos: List[TodoResponse] = [] + + class Config: + from_attributes = True + class HealthResponse(BaseModel): status: str \ No newline at end of file diff --git a/main.py b/main.py index 0b0df42..5a4762e 100644 --- a/main.py +++ b/main.py @@ -7,11 +7,12 @@ from sqlalchemy import or_, and_, func from datetime import datetime, timedelta from app.database import get_db, engine -from app.models import Todo, Tag, Subtask, Base +from app.models import Todo, Tag, Subtask, TodoBoard, Base, BoardStatus from app.schemas import ( TodoCreate, TodoCreateWithTags, TodoUpdate, TodoResponse, TagCreate, TagResponse, HealthResponse, - SubtaskCreate, SubtaskUpdate, SubtaskResponse + SubtaskCreate, SubtaskUpdate, SubtaskResponse, + TodoBoardCreate, TodoBoardUpdate, TodoBoardResponse, TodoBoardDetailResponse ) # Create tables if they don't exist @@ -304,5 +305,123 @@ def complete_all_subtasks(todo_id: int, db: Session = Depends(get_db)): db.refresh(db_todo) return db_todo +# TodoBoard endpoints +@app.post("/boards/", response_model=TodoBoardResponse) +def create_board(board: TodoBoardCreate, db: Session = Depends(get_db)): + """Create a new todo board""" + db_board = TodoBoard(**board.model_dump()) + db.add(db_board) + db.commit() + db.refresh(db_board) + return db_board + +@app.get("/boards/", response_model=List[TodoBoardResponse]) +def get_boards(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """Get all todo boards""" + return db.query(TodoBoard).offset(skip).limit(limit).all() + +@app.get("/boards/{board_id}", response_model=TodoBoardDetailResponse) +def get_board(board_id: int, db: Session = Depends(get_db)): + """Get a specific todo board with all its todos""" + db_board = db.query(TodoBoard).filter(TodoBoard.id == board_id).first() + if db_board is None: + raise HTTPException(status_code=404, detail="Board not found") + return db_board + +@app.put("/boards/{board_id}", response_model=TodoBoardResponse) +def update_board(board_id: int, board: TodoBoardUpdate, db: Session = Depends(get_db)): + """Update a specific todo board""" + db_board = db.query(TodoBoard).filter(TodoBoard.id == board_id).first() + if db_board is None: + raise HTTPException(status_code=404, detail="Board not found") + + board_data = board.model_dump(exclude_unset=True) + for key, value in board_data.items(): + setattr(db_board, key, value) + + db.commit() + db.refresh(db_board) + return db_board + +@app.delete("/boards/{board_id}", response_model=TodoBoardResponse) +def delete_board(board_id: int, db: Session = Depends(get_db)): + """Delete a specific todo board""" + db_board = db.query(TodoBoard).filter(TodoBoard.id == board_id).first() + if db_board is None: + raise HTTPException(status_code=404, detail="Board not found") + + db.delete(db_board) + db.commit() + return db_board + +@app.post("/boards/{board_id}/todos/", response_model=TodoResponse) +def create_todo_in_board(board_id: int, todo: TodoCreate, db: Session = Depends(get_db)): + """Create a new todo in a specific board""" + db_board = db.query(TodoBoard).filter(TodoBoard.id == board_id).first() + if db_board is None: + raise HTTPException(status_code=404, detail="Board not found") + + todo_data = todo.model_dump(exclude={"tag_ids", "board_id"}) + db_todo = Todo(**todo_data, board_id=board_id) + + # Add tags if provided + if todo.tag_ids: + for tag_id in todo.tag_ids: + tag = db.query(Tag).filter(Tag.id == tag_id).first() + if tag is None: + raise HTTPException(status_code=404, detail=f"Tag with id {tag_id} not found") + db_todo.tags.append(tag) + + db.add(db_todo) + db.commit() + db.refresh(db_todo) + return db_todo + +@app.get("/boards/{board_id}/todos/", response_model=List[TodoResponse]) +def get_todos_by_board( + board_id: int, + status: Optional[BoardStatus] = None, + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db) +): + """Get all todos in a specific board with optional status filter""" + db_board = db.query(TodoBoard).filter(TodoBoard.id == board_id).first() + if db_board is None: + raise HTTPException(status_code=404, detail="Board not found") + + query = db.query(Todo).filter(Todo.board_id == board_id) + + if status: + query = query.filter(Todo.status == status) + + return query.offset(skip).limit(limit).all() + +@app.put("/todos/{todo_id}/move", response_model=TodoResponse) +def move_todo_to_board( + todo_id: int, + board_id: Optional[int] = None, + status: Optional[BoardStatus] = None, + db: Session = Depends(get_db) +): + """Move a todo to a different board and/or change its status""" + db_todo = db.query(Todo).filter(Todo.id == todo_id).first() + if db_todo is None: + raise HTTPException(status_code=404, detail="Todo not found") + + if board_id is not None: + # Verify board exists + db_board = db.query(TodoBoard).filter(TodoBoard.id == board_id).first() + if db_board is None: + raise HTTPException(status_code=404, detail="Board not found") + db_todo.board_id = board_id + + if status is not None: + db_todo.status = status + + db.commit() + db.refresh(db_todo) + return db_todo + if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/migrations/versions/005_add_todo_boards.py b/migrations/versions/005_add_todo_boards.py new file mode 100644 index 0000000..77903a8 --- /dev/null +++ b/migrations/versions/005_add_todo_boards.py @@ -0,0 +1,49 @@ +"""add todo boards + +Revision ID: 005 +Revises: 004_add_subtasks_and_reminders +Create Date: 2025-05-12 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision = '005' +down_revision = '004_add_subtasks_and_reminders' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create todo_boards table + op.create_table( + 'todo_boards', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_todo_boards_id'), 'todo_boards', ['id'], unique=False) + + # Add the status and board_id columns to the todos table + op.add_column('todos', sa.Column('status', sa.Enum('todo', 'in_progress', 'review', 'done', name='boardstatus'), + nullable=False, server_default='todo')) + op.add_column('todos', sa.Column('board_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'todos', 'todo_boards', ['board_id'], ['id'], ondelete='SET NULL') + + +def downgrade(): + # Drop foreign key constraint in sqlite + with op.batch_alter_table('todos') as batch_op: + batch_op.drop_constraint('fk_todos_board_id_todo_boards', type_='foreignkey') + batch_op.drop_column('board_id') + batch_op.drop_column('status') + + # Drop the todo_boards table + op.drop_index(op.f('ix_todo_boards_id'), table_name='todo_boards') + op.drop_table('todo_boards') \ No newline at end of file