Merge remote branch and resolve conflicts with BIT rename
Kept remote's pydantic-settings, env_file, SearchBar, and new components. Applied BIT/Break It Down naming throughout conflicted files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
14
backend/.env.example
Normal file
14
backend/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL=sqlite:///./bit.db
|
||||
|
||||
# API Configuration
|
||||
API_TITLE=Break It Down (BIT) - Nested Todo Tree API
|
||||
API_DESCRIPTION=API for managing deeply nested todo trees
|
||||
API_VERSION=1.0.0
|
||||
|
||||
# CORS Configuration (comma-separated list of allowed origins)
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Server Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
@@ -5,7 +5,12 @@ from . import models, schemas
|
||||
|
||||
# Project CRUD
|
||||
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
|
||||
db_project = models.Project(**project.model_dump())
|
||||
project_data = project.model_dump()
|
||||
# Ensure statuses has a default value if not provided
|
||||
if project_data.get("statuses") is None:
|
||||
project_data["statuses"] = models.DEFAULT_STATUSES
|
||||
|
||||
db_project = models.Project(**project_data)
|
||||
db.add(db_project)
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
@@ -16,8 +21,14 @@ def get_project(db: Session, project_id: int) -> Optional[models.Project]:
|
||||
return db.query(models.Project).filter(models.Project.id == project_id).first()
|
||||
|
||||
|
||||
def get_projects(db: Session, skip: int = 0, limit: int = 100) -> List[models.Project]:
|
||||
return db.query(models.Project).offset(skip).limit(limit).all()
|
||||
def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
|
||||
query = db.query(models.Project)
|
||||
|
||||
# Filter by archive status if specified
|
||||
if archived is not None:
|
||||
query = query.filter(models.Project.is_archived == archived)
|
||||
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def update_project(
|
||||
@@ -47,6 +58,11 @@ def delete_project(db: Session, project_id: int) -> bool:
|
||||
|
||||
# Task CRUD
|
||||
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
|
||||
# Validate status against project's statuses
|
||||
project = get_project(db, task.project_id)
|
||||
if project and task.status not in project.statuses:
|
||||
raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}")
|
||||
|
||||
# Get max sort_order for siblings
|
||||
if task.parent_task_id:
|
||||
max_order = db.query(models.Task).filter(
|
||||
@@ -92,6 +108,32 @@ def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
|
||||
).filter(models.Task.id == task_id).first()
|
||||
|
||||
|
||||
def check_and_update_parent_status(db: Session, parent_id: int):
|
||||
"""Check if all children of a parent are done, and mark parent as done if so"""
|
||||
# Get all children of this parent
|
||||
children = db.query(models.Task).filter(
|
||||
models.Task.parent_task_id == parent_id
|
||||
).all()
|
||||
|
||||
# If no children, nothing to do
|
||||
if not children:
|
||||
return
|
||||
|
||||
# Check if all children are done
|
||||
all_done = all(child.status == "done" for child in children)
|
||||
|
||||
if all_done:
|
||||
# Mark parent as done
|
||||
parent = get_task(db, parent_id)
|
||||
if parent and parent.status != "done":
|
||||
parent.status = "done"
|
||||
db.commit()
|
||||
|
||||
# Recursively check grandparent
|
||||
if parent.parent_task_id:
|
||||
check_and_update_parent_status(db, parent.parent_task_id)
|
||||
|
||||
|
||||
def update_task(
|
||||
db: Session, task_id: int, task: schemas.TaskUpdate
|
||||
) -> Optional[models.Task]:
|
||||
@@ -100,11 +142,27 @@ def update_task(
|
||||
return None
|
||||
|
||||
update_data = task.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate status against project's statuses if status is being updated
|
||||
if "status" in update_data:
|
||||
project = get_project(db, db_task.project_id)
|
||||
if project and update_data["status"] not in project.statuses:
|
||||
raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}")
|
||||
status_changed = True
|
||||
old_status = db_task.status
|
||||
else:
|
||||
status_changed = False
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(db_task, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_task)
|
||||
|
||||
# If status changed to 'done' and this task has a parent, check if parent should auto-complete
|
||||
if status_changed and db_task.status == "done" and db_task.parent_task_id:
|
||||
check_and_update_parent_status(db, db_task.parent_task_id)
|
||||
|
||||
return db_task
|
||||
|
||||
|
||||
@@ -117,8 +175,13 @@ def delete_task(db: Session, task_id: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def get_tasks_by_status(db: Session, project_id: int, status: models.TaskStatus) -> List[models.Task]:
|
||||
def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[models.Task]:
|
||||
"""Get all tasks for a project with a specific status"""
|
||||
# Validate status against project's statuses
|
||||
project = get_project(db, project_id)
|
||||
if project and status not in project.statuses:
|
||||
raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}")
|
||||
|
||||
return db.query(models.Task).filter(
|
||||
models.Task.project_id == project_id,
|
||||
models.Task.status == status
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from .settings import settings
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./bit.db"
|
||||
SQLALCHEMY_DATABASE_URL = settings.database_url
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
|
||||
@@ -6,20 +6,21 @@ import json
|
||||
|
||||
from . import models, schemas, crud
|
||||
from .database import engine, get_db
|
||||
from .settings import settings
|
||||
|
||||
# Create database tables
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="BIT - Break It Down API",
|
||||
description="API for managing deeply nested todo trees",
|
||||
version="1.0.0"
|
||||
title=settings.api_title,
|
||||
description=settings.api_description,
|
||||
version=settings.api_version
|
||||
)
|
||||
|
||||
# CORS middleware for frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000"], # Vite default port
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -29,9 +30,14 @@ app.add_middleware(
|
||||
# ========== PROJECT ENDPOINTS ==========
|
||||
|
||||
@app.get("/api/projects", response_model=List[schemas.Project])
|
||||
def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
"""List all projects"""
|
||||
return crud.get_projects(db, skip=skip, limit=limit)
|
||||
def list_projects(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
archived: Optional[bool] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List all projects with optional archive filter"""
|
||||
return crud.get_projects(db, skip=skip, limit=limit, archived=archived)
|
||||
|
||||
|
||||
@app.post("/api/projects", response_model=schemas.Project, status_code=201)
|
||||
@@ -97,13 +103,16 @@ def get_project_task_tree(project_id: int, db: Session = Depends(get_db)):
|
||||
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
|
||||
def get_tasks_by_status(
|
||||
project_id: int,
|
||||
status: models.TaskStatus,
|
||||
status: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all tasks for a project filtered by status (for Kanban view)"""
|
||||
if not crud.get_project(db, project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return crud.get_tasks_by_status(db, project_id, status)
|
||||
try:
|
||||
return crud.get_tasks_by_status(db, project_id, status)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/tasks", response_model=schemas.Task, status_code=201)
|
||||
@@ -115,7 +124,10 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
|
||||
if task.parent_task_id and not crud.get_task(db, task.parent_task_id):
|
||||
raise HTTPException(status_code=404, detail="Parent task not found")
|
||||
|
||||
return crud.create_task(db, task)
|
||||
try:
|
||||
return crud.create_task(db, task)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}", response_model=schemas.Task)
|
||||
@@ -130,10 +142,13 @@ def get_task(task_id: int, db: Session = Depends(get_db)):
|
||||
@app.put("/api/tasks/{task_id}", response_model=schemas.Task)
|
||||
def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
|
||||
"""Update a task"""
|
||||
db_task = crud.update_task(db, task_id, task)
|
||||
if not db_task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return db_task
|
||||
try:
|
||||
db_task = crud.update_task(db, task_id, task)
|
||||
if not db_task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return db_task
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.delete("/api/tasks/{task_id}", status_code=204)
|
||||
@@ -187,6 +202,27 @@ def search_tasks(
|
||||
|
||||
# ========== JSON IMPORT ENDPOINT ==========
|
||||
|
||||
def _validate_task_statuses_recursive(
|
||||
tasks: List[schemas.ImportSubtask],
|
||||
valid_statuses: List[str],
|
||||
path: str = ""
|
||||
) -> None:
|
||||
"""Recursively validate all task statuses against the project's valid statuses"""
|
||||
for idx, task_data in enumerate(tasks):
|
||||
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
|
||||
if task_data.status not in valid_statuses:
|
||||
raise ValueError(
|
||||
f"Invalid status '{task_data.status}' at {task_path}. "
|
||||
f"Must be one of: {', '.join(valid_statuses)}"
|
||||
)
|
||||
if task_data.subtasks:
|
||||
_validate_task_statuses_recursive(
|
||||
task_data.subtasks,
|
||||
valid_statuses,
|
||||
f"{task_path}.subtasks"
|
||||
)
|
||||
|
||||
|
||||
def _import_tasks_recursive(
|
||||
db: Session,
|
||||
project_id: int,
|
||||
@@ -227,7 +263,8 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
|
||||
{
|
||||
"project": {
|
||||
"name": "Project Name",
|
||||
"description": "Optional description"
|
||||
"description": "Optional description",
|
||||
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
@@ -245,15 +282,26 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
|
||||
]
|
||||
}
|
||||
"""
|
||||
# Create the project
|
||||
# Create the project with optional statuses
|
||||
project = crud.create_project(
|
||||
db,
|
||||
schemas.ProjectCreate(
|
||||
name=import_data.project.name,
|
||||
description=import_data.project.description
|
||||
description=import_data.project.description,
|
||||
statuses=import_data.project.statuses
|
||||
)
|
||||
)
|
||||
|
||||
# Validate all task statuses before importing
|
||||
if import_data.tasks:
|
||||
try:
|
||||
_validate_task_statuses_recursive(import_data.tasks, project.statuses)
|
||||
except ValueError as e:
|
||||
# Rollback the project creation if validation fails
|
||||
db.delete(project)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Recursively import tasks
|
||||
tasks_created = _import_tasks_recursive(
|
||||
db, project.id, import_data.tasks
|
||||
@@ -271,6 +319,6 @@ def root():
|
||||
"""API health check"""
|
||||
return {
|
||||
"status": "online",
|
||||
"message": "BIT API - Break It Down",
|
||||
"message": "Break It Down (BIT) API",
|
||||
"docs": "/docs"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, JSON
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from .database import Base
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
BACKLOG = "backlog"
|
||||
IN_PROGRESS = "in_progress"
|
||||
BLOCKED = "blocked"
|
||||
DONE = "done"
|
||||
# Default statuses for new projects
|
||||
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
||||
|
||||
|
||||
class Project(Base):
|
||||
@@ -18,6 +14,8 @@ class Project(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
|
||||
is_archived = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -32,7 +30,7 @@ class Task(Base):
|
||||
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(Enum(TaskStatus), default=TaskStatus.BACKLOG, nullable=False)
|
||||
status = Column(String(50), default="backlog", nullable=False)
|
||||
sort_order = Column(Integer, default=0)
|
||||
estimated_minutes = Column(Integer, nullable=True)
|
||||
tags = Column(JSON, nullable=True)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from .models import TaskStatus
|
||||
from .models import DEFAULT_STATUSES
|
||||
|
||||
|
||||
# Task Schemas
|
||||
class TaskBase(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: TaskStatus = TaskStatus.BACKLOG
|
||||
status: str = "backlog"
|
||||
parent_task_id: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
estimated_minutes: Optional[int] = None
|
||||
@@ -23,7 +23,7 @@ class TaskCreate(TaskBase):
|
||||
class TaskUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[TaskStatus] = None
|
||||
status: Optional[str] = None
|
||||
parent_task_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
estimated_minutes: Optional[int] = None
|
||||
@@ -53,16 +53,20 @@ class ProjectBase(BaseModel):
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
pass
|
||||
statuses: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
statuses: Optional[List[str]] = None
|
||||
is_archived: Optional[bool] = None
|
||||
|
||||
|
||||
class Project(ProjectBase):
|
||||
id: int
|
||||
statuses: List[str]
|
||||
is_archived: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -79,7 +83,7 @@ class ProjectWithTasks(Project):
|
||||
class ImportSubtask(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: TaskStatus = TaskStatus.BACKLOG
|
||||
status: str = "backlog"
|
||||
estimated_minutes: Optional[int] = None
|
||||
tags: Optional[List[str]] = None
|
||||
flag_color: Optional[str] = None
|
||||
@@ -89,6 +93,7 @@ class ImportSubtask(BaseModel):
|
||||
class ImportProject(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
statuses: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ImportData(BaseModel):
|
||||
|
||||
37
backend/app/settings.py
Normal file
37
backend/app/settings.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import List
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables"""
|
||||
|
||||
# Database Configuration
|
||||
database_url: str = "sqlite:///./bit.db"
|
||||
|
||||
# API Configuration
|
||||
api_title: str = "Break It Down (BIT) - Nested Todo Tree API"
|
||||
api_description: str = "API for managing deeply nested todo trees"
|
||||
api_version: str = "1.0.0"
|
||||
|
||||
# CORS Configuration
|
||||
cors_origins: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
# Server Configuration
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> List[str]:
|
||||
"""Parse comma-separated CORS origins into a list"""
|
||||
return [origin.strip() for origin in self.cors_origins.split(",")]
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
37
backend/migrate_add_is_archived.py
Normal file
37
backend/migrate_add_is_archived.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Migration script to add is_archived column to existing projects.
|
||||
Run this once if you have an existing database.
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# Get the database path
|
||||
db_path = os.path.join(os.path.dirname(__file__), 'bit.db')
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database not found at {db_path}")
|
||||
print("No migration needed - new database will be created with the correct schema.")
|
||||
exit(0)
|
||||
|
||||
# Connect to the database
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if the column already exists
|
||||
cursor.execute("PRAGMA table_info(projects)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'is_archived' in columns:
|
||||
print("Column 'is_archived' already exists. Migration not needed.")
|
||||
else:
|
||||
# Add the is_archived column
|
||||
cursor.execute("ALTER TABLE projects ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT 0")
|
||||
conn.commit()
|
||||
print("Successfully added 'is_archived' column to projects table.")
|
||||
print("All existing projects have been set to is_archived=False (0).")
|
||||
except Exception as e:
|
||||
print(f"Error during migration: {e}")
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
62
backend/migrate_add_statuses.py
Normal file
62
backend/migrate_add_statuses.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Migration script to add statuses column to projects table
|
||||
Run this script once to update existing database
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
# Default statuses for existing projects
|
||||
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
||||
|
||||
def migrate():
|
||||
# Connect to the database
|
||||
conn = sqlite3.connect('bit.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if statuses column already exists
|
||||
cursor.execute("PRAGMA table_info(projects)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'statuses' in columns:
|
||||
print("✓ Column 'statuses' already exists in projects table")
|
||||
return
|
||||
|
||||
# Add statuses column with default value
|
||||
print("Adding 'statuses' column to projects table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN statuses TEXT NOT NULL DEFAULT ?
|
||||
""", (json.dumps(DEFAULT_STATUSES),))
|
||||
|
||||
# Update all existing projects with default statuses
|
||||
cursor.execute("""
|
||||
UPDATE projects
|
||||
SET statuses = ?
|
||||
WHERE statuses IS NULL OR statuses = ''
|
||||
""", (json.dumps(DEFAULT_STATUSES),))
|
||||
|
||||
conn.commit()
|
||||
print("✓ Successfully added 'statuses' column to projects table")
|
||||
print(f"✓ Set default statuses for all existing projects: {DEFAULT_STATUSES}")
|
||||
|
||||
# Show count of updated projects
|
||||
cursor.execute("SELECT COUNT(*) FROM projects")
|
||||
count = cursor.fetchone()[0]
|
||||
print(f"✓ Updated {count} project(s)")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"✗ Error during migration: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("Database Migration: Add statuses column to projects")
|
||||
print("=" * 60)
|
||||
migrate()
|
||||
print("=" * 60)
|
||||
print("Migration completed!")
|
||||
print("=" * 60)
|
||||
Reference in New Issue
Block a user