refactor: rename package fabledassistant -> scribe (code-only)
Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""Milestone management service."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.milestone import Milestone
|
||||
from scribe.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,
|
||||
Milestone.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
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,
|
||||
Milestone.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
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,
|
||||
Milestone.deleted_at.is_(None),
|
||||
)
|
||||
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.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
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),
|
||||
Note.deleted_at.is_(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())
|
||||
cancelled = status_counts.get("cancelled", 0)
|
||||
completed = status_counts.get("done", 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 {
|
||||
"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),
|
||||
"cancelled": cancelled,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user