fix(status-enum): add paused to ProjectStatus, validate, fix progress
Drift-audit Group 6 + Group 5 #6 (enum-extension / status drift): - ProjectStatus gains 'paused' — routes and frontend already treated it as first-class, but the enum (the source of truth) omitted it and the error strings lied. A future CHECK derived from the enum would have rejected existing paused rows. - create_project/update_project now validate status via ProjectStatus at the service layer (canonical gate; notes.status has no DB CHECK), so the MCP create/update_project path can't persist a typo'd status. MCP docstrings realigned to the 4-value domain; route error strings corrected. - get_milestone_progress: cancelled tasks are excluded from the percent denominator (and now reported in status_counts), so a milestone whose only open task was cancelled reaches 100% instead of stalling below it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ from fabledassistant.services import trash as trash_svc
|
|||||||
async def list_projects() -> dict:
|
async def list_projects() -> dict:
|
||||||
"""List all Scribe projects for the current user.
|
"""List all Scribe projects for the current user.
|
||||||
|
|
||||||
Returns id, title, description, goal, status (active/archived), color,
|
Returns id, title, description, goal, status (active/paused/completed/archived), color,
|
||||||
and a short auto-generated summary for each project.
|
and a short auto-generated summary for each project.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
@@ -142,7 +142,7 @@ async def create_project(
|
|||||||
title: Project name (required).
|
title: Project name (required).
|
||||||
description: Short summary of what the project is.
|
description: Short summary of what the project is.
|
||||||
goal: The desired outcome or definition of done for the project.
|
goal: The desired outcome or definition of done for the project.
|
||||||
status: active (default) or archived.
|
status: one of active (default), paused, completed, archived.
|
||||||
color: Optional hex colour for the project card (e.g. "#6366f1").
|
color: Optional hex colour for the project card (e.g. "#6366f1").
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
@@ -172,7 +172,7 @@ async def update_project(
|
|||||||
title: New title, or omit to leave unchanged.
|
title: New title, or omit to leave unchanged.
|
||||||
description: New description, or omit to leave unchanged.
|
description: New description, or omit to leave unchanged.
|
||||||
goal: New goal/definition-of-done, or omit to leave unchanged.
|
goal: New goal/definition-of-done, or omit to leave unchanged.
|
||||||
status: New status — active or archived.
|
status: New status — one of active, paused, completed, archived.
|
||||||
color: New hex colour, or omit to leave unchanged.
|
color: New hex colour, or omit to leave unchanged.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
|
|||||||
|
|
||||||
class ProjectStatus(str, enum.Enum):
|
class ProjectStatus(str, enum.Enum):
|
||||||
active = "active"
|
active = "active"
|
||||||
|
paused = "paused"
|
||||||
completed = "completed"
|
completed = "completed"
|
||||||
archived = "archived"
|
archived = "archived"
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async def create_project_route():
|
|||||||
return jsonify({"error": "title is required"}), 400
|
return jsonify({"error": "title is required"}), 400
|
||||||
status = data.get("status", "active")
|
status = data.get("status", "active")
|
||||||
if status not in ("active", "paused", "completed", "archived"):
|
if status not in ("active", "paused", "completed", "archived"):
|
||||||
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
|
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
|
||||||
project = await create_project(
|
project = await create_project(
|
||||||
uid,
|
uid,
|
||||||
title=data["title"],
|
title=data["title"],
|
||||||
@@ -90,7 +90,7 @@ async def update_project_route(project_id: int):
|
|||||||
allowed = {"title", "description", "goal", "status", "color"}
|
allowed = {"title", "description", "goal", "status", "color"}
|
||||||
fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
|
fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
|
||||||
if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
|
if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
|
||||||
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
|
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
|
||||||
project = await update_project(uid, project_id, **fields)
|
project = await update_project(uid, project_id, **fields)
|
||||||
if project is None:
|
if project is None:
|
||||||
return not_found("Project")
|
return not_found("Project")
|
||||||
|
|||||||
@@ -155,8 +155,13 @@ async def get_milestone_progress(milestone_id: int) -> dict:
|
|||||||
status_counts[status] = count
|
status_counts[status] = count
|
||||||
|
|
||||||
total = sum(status_counts.values())
|
total = sum(status_counts.values())
|
||||||
|
cancelled = status_counts.get("cancelled", 0)
|
||||||
completed = status_counts.get("done", 0)
|
completed = status_counts.get("done", 0)
|
||||||
pct = round(completed / total * 100, 1) if total > 0 else 0.0
|
# Cancelled tasks are resolved work, not pending — exclude them from the
|
||||||
|
# percent-complete denominator so a milestone whose only open task was
|
||||||
|
# cancelled still reaches 100% (and auto-collapses) instead of stalling.
|
||||||
|
active_total = total - cancelled
|
||||||
|
pct = round(completed / active_total * 100, 1) if active_total > 0 else 0.0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -166,6 +171,7 @@ async def get_milestone_progress(milestone_id: int) -> dict:
|
|||||||
"todo": status_counts.get("todo", 0),
|
"todo": status_counts.get("todo", 0),
|
||||||
"in_progress": status_counts.get("in_progress", 0),
|
"in_progress": status_counts.get("in_progress", 0),
|
||||||
"done": status_counts.get("done", 0),
|
"done": status_counts.get("done", 0),
|
||||||
|
"cancelled": cancelled,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,25 @@ from sqlalchemy import func, select
|
|||||||
|
|
||||||
from fabledassistant.models import async_session
|
from fabledassistant.models import async_session
|
||||||
from fabledassistant.models.note import Note
|
from fabledassistant.models.note import Note
|
||||||
from fabledassistant.models.project import Project
|
from fabledassistant.models.project import Project, ProjectStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_status(status: str) -> str:
|
||||||
|
"""Coerce/validate a project status against ProjectStatus.
|
||||||
|
|
||||||
|
Canonical gate so the MCP create/update_project path (which passes status
|
||||||
|
straight through) can't persist an out-of-enum value — there's no DB CHECK.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return ProjectStatus(status).value
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid status: {status!r}. Must be one of: {[s.value for s in ProjectStatus]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def create_project(
|
async def create_project(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -19,6 +33,7 @@ async def create_project(
|
|||||||
color: str | None = None,
|
color: str | None = None,
|
||||||
status: str = "active",
|
status: str = "active",
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
status = _validate_status(status)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
project = Project(
|
project = Project(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -87,6 +102,8 @@ async def update_project(user_id: int, project_id: int, **fields: object) -> Pro
|
|||||||
project = result.scalars().first()
|
project = result.scalars().first()
|
||||||
if project is None:
|
if project is None:
|
||||||
return None
|
return None
|
||||||
|
if "status" in fields and fields["status"] is not None:
|
||||||
|
fields["status"] = _validate_status(fields["status"])
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
if hasattr(project, key):
|
if hasattr(project, key):
|
||||||
setattr(project, key, value)
|
setattr(project, key, value)
|
||||||
|
|||||||
Reference in New Issue
Block a user