v0.1.6 changes

This commit is contained in:
serversdwn
2025-11-25 23:22:44 +00:00
parent 8d5ad6a809
commit 1a6c8cf98c
12 changed files with 650 additions and 101 deletions

View File

@@ -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)
@@ -47,6 +52,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(
@@ -104,13 +114,13 @@ def check_and_update_parent_status(db: Session, parent_id: int):
return
# Check if all children are done
all_done = all(child.status == models.TaskStatus.DONE for child in children)
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 != models.TaskStatus.DONE:
parent.status = models.TaskStatus.DONE
if parent and parent.status != "done":
parent.status = "done"
db.commit()
# Recursively check grandparent
@@ -126,12 +136,16 @@ def update_task(
return None
update_data = task.model_dump(exclude_unset=True)
status_changed = False
# Check if status is being updated
# 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)
@@ -140,7 +154,7 @@ def update_task(
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 == models.TaskStatus.DONE and db_task.parent_task_id:
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
@@ -155,8 +169,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

View File

@@ -98,13 +98,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)
@@ -116,7 +119,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)
@@ -131,10 +137,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)
@@ -188,6 +197,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,
@@ -228,7 +258,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": [
{
@@ -246,15 +277,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

View File

@@ -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
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,7 @@ 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)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -32,7 +29,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)

View File

@@ -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,18 @@ 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
class Project(ProjectBase):
id: int
statuses: List[str]
created_at: datetime
updated_at: datetime
@@ -79,7 +81,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 +91,7 @@ class ImportSubtask(BaseModel):
class ImportProject(BaseModel):
name: str
description: Optional[str] = None
statuses: Optional[List[str]] = None
class ImportData(BaseModel):