"""System management + record<->system associations. A System is a per-project, reusable, self-describing subsystem/area. Access is governed by the project's permission via services/access.py (multi-user ACL rule) — never a bare owner filter. Records (notes/tasks/issues) link to systems many-to-many through record_systems, mutable over time. """ import logging from datetime import datetime, timezone from sqlalchemy import delete, func, select from scribe.models import async_session from scribe.models.note import Note from scribe.models.system import RecordSystem, System from scribe.services import access logger = logging.getLogger(__name__) async def create_system( user_id: int, project_id: int, name: str, description: str | None = None, color: str | None = None, order_index: int = 0, ) -> System | None: """Create a System. None if the user can't write the project.""" if not await access.can_write_project(user_id, project_id): return None async with async_session() as session: system = System( user_id=user_id, project_id=project_id, name=name.strip(), description=description, color=color, order_index=order_index, ) session.add(system) await session.commit() await session.refresh(system) return system async def get_system(user_id: int, system_id: int) -> System | None: """Fetch a System if the user can read its project.""" async with async_session() as session: system = await session.get(System, system_id) if system is None or system.deleted_at is not None: return None if not await access.can_read_project(user_id, system.project_id): return None return system async def list_systems( user_id: int, project_id: int, include_archived: bool = False ) -> list[System]: """A project's systems (active by default). [] if no read access.""" if not await access.can_read_project(user_id, project_id): return [] async with async_session() as session: query = select(System).where( System.project_id == project_id, System.deleted_at.is_(None), ) if not include_archived: query = query.where(System.status == "active") query = query.order_by(System.order_index.asc(), System.created_at.asc()) result = await session.execute(query) return list(result.scalars().all()) async def update_system(user_id: int, system_id: int, **fields: object) -> System | None: """Update a System if the user can write its project.""" allowed = {"name", "description", "color", "status", "order_index"} async with async_session() as session: system = await session.get(System, system_id) if system is None or system.deleted_at is not None: return None if not await access.can_write_project(user_id, system.project_id): return None for key, value in fields.items(): if key in allowed and value is not None: setattr(system, key, value) system.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(system) return system async def archive_system(user_id: int, system_id: int) -> System | None: return await update_system(user_id, system_id, status="archived") async def delete_system(user_id: int, system_id: int) -> bool: """Soft-delete a System (recoverable). Requires project write access.""" async with async_session() as session: system = await session.get(System, system_id) if system is None or system.deleted_at is not None: return False if not await access.can_write_project(user_id, system.project_id): return False system.deleted_at = datetime.now(timezone.utc) await session.commit() return True # --- record <-> system associations (many-to-many, mutable) --- async def set_record_systems( user_id: int, note_id: int, system_ids: list[int] ) -> list[int] | None: """Replace a record's system associations with `system_ids` (set semantics). Returns the resulting associated system ids, or None if the user can't write the record. Silently drops ids that don't exist or the user can't read — associations only link accessible systems. """ if not await access.can_write_note(user_id, note_id): return None async with async_session() as session: wanted: list[int] = [] for sid in dict.fromkeys(system_ids): # de-dup, preserve order system = await session.get(System, sid) if system is None or system.deleted_at is not None: continue if not await access.can_read_project(user_id, system.project_id): continue wanted.append(sid) existing = set( ( await session.execute( select(RecordSystem.system_id).where(RecordSystem.note_id == note_id) ) ).scalars().all() ) wanted_set = set(wanted) to_remove = existing - wanted_set if to_remove: await session.execute( delete(RecordSystem).where( RecordSystem.note_id == note_id, RecordSystem.system_id.in_(to_remove), ) ) for sid in wanted: if sid not in existing: session.add(RecordSystem(note_id=note_id, system_id=sid)) await session.commit() return wanted async def list_record_systems(user_id: int, note_id: int) -> list[System]: """Systems associated with a record (if the user can read it).""" if not await access.can_read_note(user_id, note_id): return [] async with async_session() as session: result = await session.execute( select(System) .join(RecordSystem, RecordSystem.system_id == System.id) .where(RecordSystem.note_id == note_id, System.deleted_at.is_(None)) .order_by(System.order_index.asc(), System.name.asc()) ) return list(result.scalars().all()) async def list_records_for_system( user_id: int, system_id: int, kind: str | None = None, open_only: bool = False ) -> list[Note]: """Records associated with a System. `kind` filters task_kind (e.g. 'issue'); `open_only` limits to tasks not done/cancelled. [] if no read access.""" system = await get_system(user_id, system_id) if system is None: return [] async with async_session() as session: query = ( select(Note) .join(RecordSystem, RecordSystem.note_id == Note.id) .where(RecordSystem.system_id == system_id, Note.deleted_at.is_(None)) ) if kind: query = query.where(Note.task_kind == kind) if open_only: query = query.where(Note.status.not_in(["done", "cancelled"])) query = query.order_by(Note.updated_at.desc()) result = await session.execute(query) return list(result.scalars().all()) async def count_open_issues(user_id: int, project_id: int) -> int: """Count open (not done/cancelled) issues in a project. 0 if no read access.""" if not await access.can_read_project(user_id, project_id): return 0 async with async_session() as session: result = await session.execute( select(func.count(Note.id)).where( Note.project_id == project_id, Note.task_kind == "issue", Note.status.isnot(None), Note.status.not_in(["done", "cancelled"]), Note.deleted_at.is_(None), ) ) return int(result.scalar() or 0) async def open_issue_counts_by_system(user_id: int, project_id: int) -> dict[int, int]: """{system_id: open-issue count} for all systems in a project, in one query. {} if no read access.""" if not await access.can_read_project(user_id, project_id): return {} async with async_session() as session: rows = await session.execute( select(RecordSystem.system_id, func.count(Note.id)) .join(Note, Note.id == RecordSystem.note_id) .join(System, System.id == RecordSystem.system_id) .where( System.project_id == project_id, Note.task_kind == "issue", Note.status.isnot(None), Note.status.not_in(["done", "cancelled"]), Note.deleted_at.is_(None), ) .group_by(RecordSystem.system_id) ) return {sid: cnt for sid, cnt in rows.fetchall()} async def list_issues(user_id: int, project_id: int, open_only: bool = True) -> list[Note]: """Issues in a project (open by default), newest first. [] if no read access.""" if not await access.can_read_project(user_id, project_id): return [] async with async_session() as session: query = select(Note).where( Note.project_id == project_id, Note.task_kind == "issue", Note.status.isnot(None), Note.deleted_at.is_(None), ) if open_only: query = query.where(Note.status.not_in(["done", "cancelled"])) query = query.order_by(Note.updated_at.desc()) result = await session.execute(query) return list(result.scalars().all())