"""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) async def backfill_project_summaries() -> None: """Generate summaries for all projects missing auto_summary. Fire-and-forget.""" import asyncio try: async with async_session() as session: rows = (await session.execute( select(Project.id, Project.user_id).where(Project.auto_summary.is_(None)) )).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) ) task_counts: dict[str, int] = {} 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