services/projects.py:get_project_summary built task_counts dynamically
from a GROUP BY query, so a project with no done tasks would omit the
'done' key entirely. Frontend's TypeScript interface declares all three
lifecycle keys as required, and ProjectView.vue summed them to render
the Tasks tab counter — undefined + N = NaN.
Two fixes:
1. Backend: initialise task_counts with {todo: 0, in_progress: 0,
done: 0} so the service returns the contract its consumers expect.
Catches the same problem for HomeView's project widget and any
other consumer.
2. Frontend: defensive ?? 0 on the tab-counter sum, so the existing
deploy renders correctly even before the backend rolls.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""Project management service."""
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.note import Note
|
|
from fabledassistant.models.project import Project
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def create_project(
|
|
user_id: int,
|
|
title: str,
|
|
description: str = "",
|
|
goal: str = "",
|
|
color: str | None = None,
|
|
status: str = "active",
|
|
) -> Project:
|
|
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)
|
|
)
|
|
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()),
|
|
).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)
|
|
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:
|
|
import asyncio
|
|
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 None
|
|
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)
|
|
asyncio.create_task(generate_project_summary(user_id, project_id))
|
|
return project
|
|
|
|
|
|
async def generate_project_summary(user_id: int, project_id: int) -> None:
|
|
"""Generate an LLM summary for a project and persist it. Fire-and-forget safe."""
|
|
try:
|
|
async with async_session() as session:
|
|
project = (await session.execute(
|
|
select(Project).where(Project.id == project_id, Project.user_id == user_id)
|
|
)).scalars().first()
|
|
if project is None:
|
|
return
|
|
|
|
note_rows = (await session.execute(
|
|
select(Note.title, Note.body)
|
|
.where(Note.project_id == project_id, Note.user_id == user_id)
|
|
.order_by(Note.updated_at.desc())
|
|
.limit(10)
|
|
)).all()
|
|
|
|
title = project.title or ""
|
|
description = project.description or ""
|
|
goal = project.goal or ""
|
|
note_snippets = "\n".join(
|
|
f"- {r.title}: {(r.body or '')[:200]}" for r in note_rows
|
|
)
|
|
|
|
prompt = (
|
|
f"Summarize this project in 3-4 sentences covering its purpose, themes, and content.\n"
|
|
f"Title: {title}\nDescription: {description}\nGoal: {goal}\n"
|
|
f"Recent notes:\n{note_snippets}"
|
|
)
|
|
|
|
from fabledassistant.services.llm import generate_completion
|
|
from fabledassistant.config import Config
|
|
from fabledassistant.services.settings import get_setting
|
|
messages = [{"role": "user", "content": prompt}]
|
|
bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
|
|
summary = await generate_completion(messages, model=bg_model, max_tokens=400, num_ctx=2048)
|
|
if not summary:
|
|
return
|
|
|
|
async with async_session() as session:
|
|
project = (await session.execute(
|
|
select(Project).where(Project.id == project_id)
|
|
)).scalars().first()
|
|
if project:
|
|
project.auto_summary = summary.strip()
|
|
project.summary_updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
logger.debug("Generated summary for project %d", project_id)
|
|
except Exception:
|
|
logger.debug("Failed to generate summary for project %d", project_id, exc_info=True)
|
|
|
|
|
|
# Cutoff: summaries older than this were generated by qwen2.5:3b, which
|
|
# produced broken output (token repetition, hallucinated topics, misspellings).
|
|
# Anything stamped before the switch to gemma3:4b on 2026-04-12 gets
|
|
# regenerated at startup so the whole project list ends up on the better model.
|
|
_BACKGROUND_MODEL_CUTOVER = datetime(2026, 4, 12, tzinfo=timezone.utc)
|
|
|
|
|
|
async def backfill_project_summaries() -> None:
|
|
"""Generate summaries for projects missing or predating the gemma3:4b cutover.
|
|
|
|
Fire-and-forget: each summary runs as its own background task.
|
|
"""
|
|
import asyncio
|
|
from sqlalchemy import or_
|
|
try:
|
|
async with async_session() as session:
|
|
rows = (await session.execute(
|
|
select(Project.id, Project.user_id).where(
|
|
or_(
|
|
Project.auto_summary.is_(None),
|
|
Project.summary_updated_at.is_(None),
|
|
Project.summary_updated_at < _BACKGROUND_MODEL_CUTOVER,
|
|
)
|
|
)
|
|
)).all()
|
|
for row in rows:
|
|
asyncio.create_task(generate_project_summary(row.user_id, row.id))
|
|
except Exception:
|
|
logger.debug("backfill_project_summaries failed", exc_info=True)
|
|
|
|
|
|
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),
|
|
)
|
|
.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_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 fabledassistant.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 fabledassistant.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 fabledassistant.models.group import GroupMembership
|
|
from fabledassistant.models.share import ProjectShare
|
|
from fabledassistant.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
|