"""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 from scribe.services import canonical_systems as canonical_systems_svc logger = logging.getLogger(__name__) def local_name_key(name: str) -> str: """The within-project uniqueness key: case and spacing, nothing else. Deliberately weaker than `canonical_slug`. This one answers "is this the same System I already have here", where the operator's own spelling is the thing being compared; the canonical slug answers "is this the same AREA as some other project's System", where spelling is exactly what must be ignored. """ return " ".join(name.split()).lower() async def assess_system_name(user_id: int, project_id: int, name: str) -> dict: """What BOTH doors must know before minting a System name (milestone 307). Lived in the MCP tool alone until now, which is how the web UI shipped without a gate the agent surface enforced (#2482). One service function, so the two doors cannot answer the same question differently (rule 33). Returns `{"duplicate": …|None, "canonical": …|None}`: - `duplicate` — this project already has a System by that name. A hard stop for the caller: a second one splits the area's records across two piles. - `canonical` — the global catalog covers this area, with a `basis`. `exact` is mechanical and safe to apply on the spot; `overlap` is a judgment call and must be OFFERED, never applied. Neither ever blocks: an unmatched name is a project-specific area, which is legitimate. Fails open on both arms — a naming aid must never break a create. """ out: dict = {"duplicate": None, "canonical": None} key = local_name_key(name) if not key: return out try: for existing in await list_systems(user_id, project_id, include_archived=True): if local_name_key(existing.name) == key: out["duplicate"] = {"id": existing.id, "name": existing.name} return out except Exception: logger.debug("system name assessment: local scan failed", exc_info=True) return out try: exact = await canonical_systems_svc.find_by_name(name) if exact is not None: out["canonical"] = { "id": exact.id, "name": exact.name, "basis": "exact", } return out # No exact hit: fall back to the same overlap scoring the review # surface uses, so a create-time offer and a later proposal never # disagree about which area a name resembles. near = await canonical_systems_svc.best_overlap(name) if near is not None: out["canonical"] = near except Exception: logger.debug("system name assessment: catalog lookup failed", exc_info=True) return out async def standard_systems() -> list[tuple[str, str]]: """The standard cross-project vocabulary (#2798) as (name, charter) pairs. Reads the GLOBAL canonical catalog (milestone 307). This was a tuple constant in this module until the catalog became a table: a constant cannot be a foreign key, so nothing outside a project could reference an area, and the list only ever applied on the inception-seed path — which is how three spellings of "CI & Release" reached one instance anyway. """ return [(entry.name, entry.description or "") for entry in await canonical_systems_svc.list_canonical_systems()] async def seed_standard_systems(user_id: int, project_id: int) -> list[System]: """Mint the standard starter set for a project that has NO Systems yet (milestone 297). Idempotent: a project with any System — the vocabulary already started, standard or not — gets nothing; the duplicate gate and the project's own judgment take it from there. [] without write access. Seeded Systems are mapped to their catalog entry as they are created, so a project born this way needs no reconciliation pass later.""" if await list_systems(user_id, project_id, include_archived=True): return [] out: list[System] = [] for index, entry in enumerate(await canonical_systems_svc.list_canonical_systems()): system = await create_system( user_id, project_id, entry.name, description=entry.description, order_index=index, canonical_id=entry.id, ) if system is None: break out.append(system) return out async def create_system( user_id: int, project_id: int, name: str, description: str | None = None, color: str | None = None, order_index: int = 0, canonical_id: int | None = None, ) -> System | None: """Create a System. None if the user can't write the project. `canonical_id` maps the new System onto the global catalog; leaving it None is fine — an unmapped System is fully usable, and the mapping can be proposed later (services/canonical_systems.propose_mappings). """ 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, canonical_id=canonical_id, ) 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.""" # canonical_id is deliberately NOT settable here: canonical_systems. # set_system_canonical is its single writer, because it also validates the # catalog entry is live. Two entry points onto one column is the drift this # table exists to end. 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())