v0.1.6 changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user