Files
FabledScribe/src/fabledassistant/services/milestones.py
T
bvandeusen c5191837fb fix(projects): audit pass — 8 correctness and consistency fixes
- Project.to_dict() now includes user_id and auto_summary
- Status validation unified to (active/completed/archived) on both
  create and update project routes; update route previously had none
- Milestone routes: replace get_project (ownership-only) with
  get_project_for_user so shared viewers/editors can access milestones
- Add get_milestone_in_project() to milestones service for project-
  scoped lookup without user_id filter; all milestone routes use it
- Milestone PATCH now validates status as 'active'|'done'; fix tool
  enum which was wrongly ['active','completed','cancelled']
- Write mutation routes (POST/PATCH/DELETE milestones) now check
  can_write_project() and return 403 for read-only shared users
- update_project tool now exposes title and color fields so projects
  can be renamed or recolored via chat
- create_project tool now exposes color field
- GET /api/projects?include_summary=true embeds summaries in one
  backend pass; ProjectListView switches to this, eliminating N+1
  per-project fetches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:14:42 -04:00

172 lines
6.0 KiB
Python

"""Milestone management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.milestone import Milestone
from fabledassistant.models.note import Note
logger = logging.getLogger(__name__)
async def create_milestone(
user_id: int,
project_id: int,
title: str,
description: str | None = None,
order_index: int = 0,
status: str = "active",
) -> Milestone:
async with async_session() as session:
milestone = Milestone(
user_id=user_id,
project_id=project_id,
title=title,
description=description,
status=status,
order_index=order_index,
)
session.add(milestone)
await session.commit()
await session.refresh(milestone)
return milestone
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
return result.scalars().first()
async def get_milestone_in_project(project_id: int, milestone_id: int) -> Milestone | None:
"""Fetch a milestone by id within a project, without a user_id ownership check.
Callers must verify project access separately before using this."""
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.id == milestone_id,
Milestone.project_id == project_id,
)
)
return result.scalars().first()
async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def find_milestone_by_title(user_id: int, title: str) -> Milestone | None:
"""Find a milestone by title across ALL projects for this user (case-insensitive)."""
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).order_by(Milestone.id).limit(1)
)
return result.scalars().first()
async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone:
milestone = await get_milestone_by_title(user_id, project_id, title)
if milestone:
return milestone
return await create_milestone(user_id, project_id, title=title)
async def list_milestones(
user_id: int, project_id: int, status: str | None = None
) -> list[Milestone]:
async with async_session() as session:
query = select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
)
if status:
query = query.where(Milestone.status == status)
query = query.order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return None
for key, value in fields.items():
if hasattr(milestone, key):
setattr(milestone, key, value)
milestone.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(milestone)
return milestone
async def delete_milestone(user_id: int, milestone_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return False
await session.delete(milestone)
await session.commit()
return True
async def get_milestone_progress(milestone_id: int) -> dict:
"""Return task completion stats for a milestone."""
async with async_session() as session:
rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(Note.milestone_id == milestone_id, Note.status.isnot(None))
.group_by(Note.status)
)
status_counts: dict[str, int] = {}
for status, count in rows.fetchall():
status_counts[status] = count
total = sum(status_counts.values())
completed = status_counts.get("done", 0)
pct = round(completed / total * 100, 1) if total > 0 else 0.0
return {
"total": total,
"completed": completed,
"pct": pct,
"status_counts": {
"todo": status_counts.get("todo", 0),
"in_progress": status_counts.get("in_progress", 0),
"done": status_counts.get("done", 0),
},
}
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
"""Return ordered list of milestones with their progress stats."""
milestones = await list_milestones(user_id, project_id)
result = []
for m in milestones:
progress = await get_milestone_progress(m.id)
entry = m.to_dict()
entry.update(progress)
result.append(entry)
return result