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,253 @@
|
||||
"""Project management service."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project, ProjectStatus
|
||||
|
||||
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(
|
||||
user_id: int,
|
||||
title: str,
|
||||
description: str = "",
|
||||
goal: str = "",
|
||||
color: str | None = None,
|
||||
status: str = "active",
|
||||
) -> Project:
|
||||
status = _validate_status(status)
|
||||
async with async_session() as session:
|
||||
project = Project(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
description=description,
|
||||
goal=goal,
|
||||
color=color,
|
||||
status=status,
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
async def get_project(user_id: int, project_id: int) -> Project | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id, Project.user_id == user_id,
|
||||
Project.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_project_by_title(user_id: int, title: str) -> Project | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project).where(
|
||||
Project.user_id == user_id,
|
||||
func.lower(Project.title) == func.lower(title.strip()),
|
||||
Project.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_or_create_project(user_id: int, title: str) -> Project:
|
||||
project = await get_project_by_title(user_id, title)
|
||||
if project:
|
||||
return project
|
||||
return await create_project(user_id, title=title)
|
||||
|
||||
|
||||
async def list_projects(user_id: int, status: str | None = None) -> list[Project]:
|
||||
async with async_session() as session:
|
||||
query = select(Project).where(
|
||||
Project.user_id == user_id, Project.deleted_at.is_(None)
|
||||
)
|
||||
if status:
|
||||
query = query.where(Project.status == status)
|
||||
query = query.order_by(Project.updated_at.desc())
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id, Project.user_id == user_id,
|
||||
Project.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
if project is 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():
|
||||
if hasattr(project, key):
|
||||
setattr(project, key, value)
|
||||
project.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
async def delete_project(user_id: int, project_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == user_id)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
if project is None:
|
||||
return False
|
||||
# Unlink notes (cascade handled by DB ON DELETE SET NULL, but we delete project here)
|
||||
await session.delete(project)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_project_summary(user_id: int, project_id: int) -> dict:
|
||||
"""Return task counts by status, note count, and last activity."""
|
||||
async with async_session() as session:
|
||||
# Task counts by status
|
||||
task_rows = await session.execute(
|
||||
select(Note.status, func.count(Note.id))
|
||||
.where(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id == project_id,
|
||||
Note.status.isnot(None),
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(Note.status)
|
||||
)
|
||||
# Initialise all three lifecycle keys to 0 so consumers can sum them
|
||||
# safely without `?? 0` guards. Frontend interface declares all three
|
||||
# as required; rendering `undefined + N` yields NaN.
|
||||
task_counts: dict[str, int] = {"todo": 0, "in_progress": 0, "done": 0}
|
||||
for status, count in task_rows.fetchall():
|
||||
task_counts[status] = count
|
||||
|
||||
# Note count (non-tasks)
|
||||
note_count_result = await session.scalar(
|
||||
select(func.count(Note.id)).where(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id == project_id,
|
||||
Note.status.is_(None),
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
note_count = note_count_result or 0
|
||||
|
||||
# Last activity
|
||||
last_activity_result = await session.scalar(
|
||||
select(func.max(Note.updated_at)).where(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id == project_id,
|
||||
)
|
||||
)
|
||||
|
||||
from scribe.services.milestones import get_project_milestone_summary
|
||||
milestone_summary = await get_project_milestone_summary(user_id, project_id)
|
||||
|
||||
return {
|
||||
"task_counts": task_counts,
|
||||
"note_count": note_count,
|
||||
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
|
||||
"milestone_summary": milestone_summary,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared-access variants (honour ProjectShare in addition to ownership)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_project_for_user(
|
||||
accessing_user_id: int, project_id: int
|
||||
) -> tuple[Project, str] | None:
|
||||
"""Returns (project, permission) if user has any access, else None."""
|
||||
from scribe.services.access import get_project_permission
|
||||
perm = await get_project_permission(accessing_user_id, project_id)
|
||||
if perm is None:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
project = await session.get(Project, project_id)
|
||||
return (project, perm) if project else None
|
||||
|
||||
|
||||
async def list_projects_for_user(user_id: int, status: str | None = None) -> list[dict]:
|
||||
"""Owned projects + shared projects, each dict has 'permission' field."""
|
||||
from scribe.models.group import GroupMembership
|
||||
from scribe.models.share import ProjectShare
|
||||
from scribe.services.access import PERMISSION_RANK
|
||||
|
||||
owned = await list_projects(user_id, status)
|
||||
owned_ids = {p.id for p in owned}
|
||||
|
||||
result = []
|
||||
for p in owned:
|
||||
d = p.to_dict() if hasattr(p, "to_dict") else {"id": p.id, "title": p.title}
|
||||
d["permission"] = "owner"
|
||||
result.append(d)
|
||||
|
||||
async with async_session() as session:
|
||||
user_group_ids = (await session.execute(
|
||||
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
|
||||
)).scalars().all()
|
||||
|
||||
shared_direct = (await session.execute(
|
||||
select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id)
|
||||
)).scalars().all()
|
||||
|
||||
shared_group: list[ProjectShare] = []
|
||||
if user_group_ids:
|
||||
shared_group = (await session.execute(
|
||||
select(ProjectShare).where(
|
||||
ProjectShare.shared_with_group_id.in_(user_group_ids)
|
||||
)
|
||||
)).scalars().all()
|
||||
|
||||
seen: dict[int, str] = {}
|
||||
for share in list(shared_direct) + list(shared_group):
|
||||
if share.project_id in owned_ids:
|
||||
continue
|
||||
prev = seen.get(share.project_id)
|
||||
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
|
||||
seen[share.project_id] = share.permission
|
||||
|
||||
for pid, perm in seen.items():
|
||||
if status:
|
||||
async with async_session() as session:
|
||||
proj = await session.get(Project, pid)
|
||||
if not proj or proj.status != status:
|
||||
continue
|
||||
else:
|
||||
async with async_session() as session:
|
||||
proj = await session.get(Project, pid)
|
||||
if proj:
|
||||
d = proj.to_dict() if hasattr(proj, "to_dict") else {"id": proj.id, "title": proj.title}
|
||||
d["permission"] = perm
|
||||
d["is_shared"] = True
|
||||
result.append(d)
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user