Implement complete nested todo tree web app MVP
This commit implements a fully functional self-hosted task decomposition engine with: Backend (FastAPI + SQLite): - RESTful API with full CRUD operations for projects and tasks - Arbitrary-depth hierarchical task structure using self-referencing parent_task_id - JSON import endpoint for seeding projects from LLM-generated breakdowns - SQLAlchemy models with proper relationships and cascade deletes - Status tracking (backlog, in_progress, blocked, done) - Auto-generated OpenAPI documentation Frontend (React + Vite + Tailwind): - Dark cyberpunk theme with orange accents - Project list page with create/import/delete functionality - Dual view modes: * Tree View: Collapsible hierarchical display with inline editing * Kanban Board: Drag-and-drop status management - Real-time CRUD operations for tasks and subtasks - JSON import modal with validation - Responsive design optimized for desktop Infrastructure: - Docker setup with multi-stage builds - docker-compose for orchestration - Nginx reverse proxy for production frontend - Named volume for SQLite persistence - CORS configuration for local development Documentation: - Comprehensive README with setup instructions - Example JSON import file demonstrating nested structure - API endpoint documentation - Data model diagrams
This commit is contained in:
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY ./app ./app
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
125
backend/app/crud.py
Normal file
125
backend/app/crud.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import List, Optional
|
||||
from . import models, schemas
|
||||
|
||||
|
||||
# Project CRUD
|
||||
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
|
||||
db_project = models.Project(**project.model_dump())
|
||||
db.add(db_project)
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
return db_project
|
||||
|
||||
|
||||
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 update_project(
|
||||
db: Session, project_id: int, project: schemas.ProjectUpdate
|
||||
) -> Optional[models.Project]:
|
||||
db_project = get_project(db, project_id)
|
||||
if not db_project:
|
||||
return None
|
||||
|
||||
update_data = project.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_project, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
return db_project
|
||||
|
||||
|
||||
def delete_project(db: Session, project_id: int) -> bool:
|
||||
db_project = get_project(db, project_id)
|
||||
if not db_project:
|
||||
return False
|
||||
db.delete(db_project)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
# Task CRUD
|
||||
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
|
||||
# Get max sort_order for siblings
|
||||
if task.parent_task_id:
|
||||
max_order = db.query(models.Task).filter(
|
||||
models.Task.parent_task_id == task.parent_task_id
|
||||
).count()
|
||||
else:
|
||||
max_order = db.query(models.Task).filter(
|
||||
models.Task.project_id == task.project_id,
|
||||
models.Task.parent_task_id.is_(None)
|
||||
).count()
|
||||
|
||||
task_data = task.model_dump()
|
||||
if "sort_order" not in task_data or task_data["sort_order"] == 0:
|
||||
task_data["sort_order"] = max_order
|
||||
|
||||
db_task = models.Task(**task_data)
|
||||
db.add(db_task)
|
||||
db.commit()
|
||||
db.refresh(db_task)
|
||||
return db_task
|
||||
|
||||
|
||||
def get_task(db: Session, task_id: int) -> Optional[models.Task]:
|
||||
return db.query(models.Task).filter(models.Task.id == task_id).first()
|
||||
|
||||
|
||||
def get_tasks_by_project(db: Session, project_id: int) -> List[models.Task]:
|
||||
return db.query(models.Task).filter(models.Task.project_id == project_id).all()
|
||||
|
||||
|
||||
def get_root_tasks(db: Session, project_id: int) -> List[models.Task]:
|
||||
"""Get all root-level tasks (no parent) for a project"""
|
||||
return db.query(models.Task).filter(
|
||||
models.Task.project_id == project_id,
|
||||
models.Task.parent_task_id.is_(None)
|
||||
).order_by(models.Task.sort_order).all()
|
||||
|
||||
|
||||
def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
|
||||
"""Recursively load a task with all its subtasks"""
|
||||
return db.query(models.Task).options(
|
||||
joinedload(models.Task.subtasks)
|
||||
).filter(models.Task.id == task_id).first()
|
||||
|
||||
|
||||
def update_task(
|
||||
db: Session, task_id: int, task: schemas.TaskUpdate
|
||||
) -> Optional[models.Task]:
|
||||
db_task = get_task(db, task_id)
|
||||
if not db_task:
|
||||
return None
|
||||
|
||||
update_data = task.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_task, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_task)
|
||||
return db_task
|
||||
|
||||
|
||||
def delete_task(db: Session, task_id: int) -> bool:
|
||||
db_task = get_task(db, task_id)
|
||||
if not db_task:
|
||||
return False
|
||||
db.delete(db_task)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def get_tasks_by_status(db: Session, project_id: int, status: models.TaskStatus) -> List[models.Task]:
|
||||
"""Get all tasks for a project with a specific status"""
|
||||
return db.query(models.Task).filter(
|
||||
models.Task.project_id == project_id,
|
||||
models.Task.status == status
|
||||
).all()
|
||||
20
backend/app/database.py
Normal file
20
backend/app/database.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./tesseract.db"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
235
backend/app/main.py
Normal file
235
backend/app/main.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
import json
|
||||
|
||||
from . import models, schemas, crud
|
||||
from .database import engine, get_db
|
||||
|
||||
# Create database tables
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="Tesseract - Nested Todo Tree API",
|
||||
description="API for managing deeply nested todo trees",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS middleware for frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000"], # Vite default port
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ========== 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)
|
||||
|
||||
|
||||
@app.post("/api/projects", response_model=schemas.Project, status_code=201)
|
||||
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
|
||||
"""Create a new project"""
|
||||
return crud.create_project(db, project)
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}", response_model=schemas.Project)
|
||||
def get_project(project_id: int, db: Session = Depends(get_db)):
|
||||
"""Get a specific project"""
|
||||
db_project = crud.get_project(db, project_id)
|
||||
if not db_project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return db_project
|
||||
|
||||
|
||||
@app.put("/api/projects/{project_id}", response_model=schemas.Project)
|
||||
def update_project(
|
||||
project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a project"""
|
||||
db_project = crud.update_project(db, project_id, project)
|
||||
if not db_project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return db_project
|
||||
|
||||
|
||||
@app.delete("/api/projects/{project_id}", status_code=204)
|
||||
def delete_project(project_id: int, db: Session = Depends(get_db)):
|
||||
"""Delete a project and all its tasks"""
|
||||
if not crud.delete_project(db, project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return None
|
||||
|
||||
|
||||
# ========== TASK ENDPOINTS ==========
|
||||
|
||||
@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task])
|
||||
def list_project_tasks(project_id: int, db: Session = Depends(get_db)):
|
||||
"""List all tasks for a project"""
|
||||
if not crud.get_project(db, project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return crud.get_tasks_by_project(db, project_id)
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks])
|
||||
def get_project_task_tree(project_id: int, db: Session = Depends(get_db)):
|
||||
"""Get the task tree (root tasks with nested subtasks) for a project"""
|
||||
if not crud.get_project(db, project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
root_tasks = crud.get_root_tasks(db, project_id)
|
||||
|
||||
def build_tree(task):
|
||||
task_dict = schemas.TaskWithSubtasks.model_validate(task)
|
||||
task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks]
|
||||
return task_dict
|
||||
|
||||
return [build_tree(task) for task in root_tasks]
|
||||
|
||||
|
||||
@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,
|
||||
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)
|
||||
|
||||
|
||||
@app.post("/api/tasks", response_model=schemas.Task, status_code=201)
|
||||
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
|
||||
"""Create a new task"""
|
||||
if not crud.get_project(db, task.project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}", response_model=schemas.Task)
|
||||
def get_task(task_id: int, db: Session = Depends(get_db)):
|
||||
"""Get a specific task"""
|
||||
db_task = crud.get_task(db, task_id)
|
||||
if not db_task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return db_task
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@app.delete("/api/tasks/{task_id}", status_code=204)
|
||||
def delete_task(task_id: int, db: Session = Depends(get_db)):
|
||||
"""Delete a task and all its subtasks"""
|
||||
if not crud.delete_task(db, task_id):
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return None
|
||||
|
||||
|
||||
# ========== JSON IMPORT ENDPOINT ==========
|
||||
|
||||
def _import_tasks_recursive(
|
||||
db: Session,
|
||||
project_id: int,
|
||||
tasks: List[schemas.ImportSubtask],
|
||||
parent_id: Optional[int] = None,
|
||||
count: int = 0
|
||||
) -> int:
|
||||
"""Recursively import tasks and their subtasks"""
|
||||
for idx, task_data in enumerate(tasks):
|
||||
task = schemas.TaskCreate(
|
||||
project_id=project_id,
|
||||
parent_task_id=parent_id,
|
||||
title=task_data.title,
|
||||
description=task_data.description,
|
||||
status=task_data.status,
|
||||
sort_order=idx
|
||||
)
|
||||
db_task = crud.create_task(db, task)
|
||||
count += 1
|
||||
|
||||
if task_data.subtasks:
|
||||
count = _import_tasks_recursive(
|
||||
db, project_id, task_data.subtasks, db_task.id, count
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
@app.post("/api/import-json", response_model=schemas.ImportResult)
|
||||
def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Import a project with nested tasks from JSON.
|
||||
|
||||
Expected format:
|
||||
{
|
||||
"project": {
|
||||
"name": "Project Name",
|
||||
"description": "Optional description"
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"title": "Task 1",
|
||||
"description": "Optional",
|
||||
"status": "backlog",
|
||||
"subtasks": [
|
||||
{
|
||||
"title": "Subtask 1.1",
|
||||
"status": "backlog",
|
||||
"subtasks": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
# Create the project
|
||||
project = crud.create_project(
|
||||
db,
|
||||
schemas.ProjectCreate(
|
||||
name=import_data.project.name,
|
||||
description=import_data.project.description
|
||||
)
|
||||
)
|
||||
|
||||
# Recursively import tasks
|
||||
tasks_created = _import_tasks_recursive(
|
||||
db, project.id, import_data.tasks
|
||||
)
|
||||
|
||||
return schemas.ImportResult(
|
||||
project_id=project.id,
|
||||
project_name=project.name,
|
||||
tasks_created=tasks_created
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
"""API health check"""
|
||||
return {
|
||||
"status": "online",
|
||||
"message": "Tesseract API - Nested Todo Tree Manager",
|
||||
"docs": "/docs"
|
||||
}
|
||||
|
||||
|
||||
from typing import Optional
|
||||
41
backend/app/models.py
Normal file
41
backend/app/models.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum
|
||||
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"
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||
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)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
project = relationship("Project", back_populates="tasks")
|
||||
parent = relationship("Task", remote_side=[id], backref="subtasks")
|
||||
93
backend/app/schemas.py
Normal file
93
backend/app/schemas.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from .models import TaskStatus
|
||||
|
||||
|
||||
# Task Schemas
|
||||
class TaskBase(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: TaskStatus = TaskStatus.BACKLOG
|
||||
parent_task_id: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class TaskCreate(TaskBase):
|
||||
project_id: int
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[TaskStatus] = None
|
||||
parent_task_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class Task(TaskBase):
|
||||
id: int
|
||||
project_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TaskWithSubtasks(Task):
|
||||
subtasks: List['TaskWithSubtasks'] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# Project Schemas
|
||||
class ProjectBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class Project(ProjectBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProjectWithTasks(Project):
|
||||
tasks: List[Task] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# JSON Import Schemas
|
||||
class ImportSubtask(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: TaskStatus = TaskStatus.BACKLOG
|
||||
subtasks: List['ImportSubtask'] = []
|
||||
|
||||
|
||||
class ImportProject(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ImportData(BaseModel):
|
||||
project: ImportProject
|
||||
tasks: List[ImportSubtask] = []
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
project_id: int
|
||||
project_name: str
|
||||
tasks_created: int
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
sqlalchemy==2.0.25
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-multipart==0.0.6
|
||||
Reference in New Issue
Block a user