"""Design system + token persistence, and the guard on the parent chain. Access goes through `services/access.py` (rule #78), where reaching a system via a project you can see grants READ but never write — see `get_design_system_permission` for why that asymmetry exists. The cascade itself lives in `services/design_cascade.py` as pure functions. This module is the part that needs a database: loading the shape of the hierarchy, refusing writes that would break it, and storing tokens. Nothing here seeds or implies a default system. An install with no design systems is an ordinary install, and every caller must handle an empty list as the normal case rather than a missing prerequisite. """ import logging from datetime import datetime, timezone from sqlalchemy import select from scribe.models import async_session from scribe.models.design_system import DesignSystem, DesignToken from scribe.models.project import Project from scribe.services import access from scribe.services.design_cascade import ( ResolvedToken, ancestry, resolve_tokens, would_cycle, ) logger = logging.getLogger(__name__) class DesignSystemCycle(ValueError): """A parent assignment that would close an inheritance loop. Raised rather than returned as None because the two outcomes need different answers: None already means "not found, or not yours", and a caller that conflated them would show "no such design system" for what is really "that parent is one of its own descendants". Routes map this to a 400. """ async def _parent_map(session, owner_user_id: int) -> dict[int, int | None]: """`{id: parent_id}` for one owner's live systems — the hierarchy's shape. Scoped to the OWNER of the systems, not the caller, and the distinction is load-bearing on the read path: a caller reading through a shared project does not own any link in the chain, and a caller-scoped map would hand them an empty forest and a cascade truncated to one system. They would get a page that renders with plausible wrong values and no error anywhere. Owner-scoping is also the constraint on parenting: a chain may only be built from systems its owner controls, or someone else's delete or re-parent would silently restyle your app. """ rows = ( await session.execute( select(DesignSystem.id, DesignSystem.parent_id).where( DesignSystem.owner_user_id == owner_user_id, DesignSystem.deleted_at.is_(None), ) ) ).all() return dict(rows) # --- design systems --------------------------------------------------------- async def create_design_system( user_id: int, title: str, description: str | None = None, parent_id: int | None = None, ) -> DesignSystem | None: """Create a system, with or without a parent. Returns None when `parent_id` names a system the caller may not write — which, per the ACL, means one they do not own. """ if parent_id is not None and not await access.can_write_design_system( user_id, parent_id ): return None async with async_session() as session: system = DesignSystem( owner_user_id=user_id, title=title.strip(), description=description, parent_id=parent_id, ) session.add(system) await session.commit() await session.refresh(system) return system async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem | None: async with async_session() as session: system = await session.get(DesignSystem, design_system_id) if system is None or system.deleted_at is not None: return None if not await access.can_read_design_system(user_id, design_system_id): return None return system async def list_design_systems(user_id: int) -> list[DesignSystem]: """The caller's own systems, ordered by title. Empty is normal.""" async with async_session() as session: rows = await session.execute( select(DesignSystem) .where( DesignSystem.owner_user_id == user_id, DesignSystem.deleted_at.is_(None), ) .order_by(DesignSystem.title) ) return list(rows.scalars().all()) async def update_design_system( user_id: int, design_system_id: int, **fields: object ) -> DesignSystem | None: """Partial update. Raises DesignSystemCycle if `parent_id` would loop. `parent_id` is handled apart from the other fields because None is a meaningful value for it — "make this a root" — where for every other field None means "leave alone". Callers signal it by passing the key at all. """ if not await access.can_write_design_system(user_id, design_system_id): return None async with async_session() as session: system = await session.get(DesignSystem, design_system_id) if system is None or system.deleted_at is not None: return None if "parent_id" in fields: parent_id = fields.pop("parent_id") if parent_id is not None: parent_id = int(parent_id) if not await access.can_write_design_system(user_id, parent_id): return None if would_cycle( design_system_id, parent_id, await _parent_map(session, system.owner_user_id), ): raise DesignSystemCycle( f"Design system {design_system_id} cannot inherit from " f"{parent_id}: that system already inherits from it." ) system.parent_id = parent_id for key, value in fields.items(): if key in ("title", "description") 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 delete_design_system(user_id: int, design_system_id: int) -> bool: """Soft-delete a system. Children survive as roots (the FK is SET NULL).""" if not await access.can_write_design_system(user_id, design_system_id): return False async with async_session() as session: system = await session.get(DesignSystem, design_system_id) if system is None or system.deleted_at is not None: return False system.deleted_at = datetime.now(timezone.utc) await session.commit() return True # --- tokens ----------------------------------------------------------------- async def create_token( user_id: int, design_system_id: int, name: str, value_by_mode: dict | None = None, group_name: str | None = None, purpose: str | None = None, order_index: int = 0, ) -> DesignToken | None: if not await access.can_write_design_system(user_id, design_system_id): return None async with async_session() as session: token = DesignToken( design_system_id=design_system_id, name=name.strip(), # `or {}` and not the argument as given: the column is NOT NULL so # that absence has exactly one spelling. Passing None here would # otherwise store JSON null and reintroduce the second empty state. value_by_mode=value_by_mode or {}, group_name=group_name, purpose=purpose, order_index=order_index, ) session.add(token) await session.commit() await session.refresh(token) return token async def list_tokens(user_id: int, design_system_id: int) -> list[DesignToken]: """One system's OWN tokens — its override set, not its effective set. Resolving the chain is step 2's job; this deliberately answers the narrower question ("what does this system change?") that the model exists to make free. """ if not await access.can_read_design_system(user_id, design_system_id): return [] async with async_session() as session: rows = await session.execute( select(DesignToken) .where( DesignToken.design_system_id == design_system_id, DesignToken.deleted_at.is_(None), ) .order_by(DesignToken.order_index.asc(), DesignToken.name.asc()) ) return list(rows.scalars().all()) async def resolve_design_system( user_id: int, design_system_id: int ) -> list[ResolvedToken] | None: """A system's EFFECTIVE token set — everything it inherits, with its own on top. None when the caller may not read the system; an empty list when the chain genuinely holds no tokens, which is an ordinary state for a system that has just been created. Two queries regardless of how deep the chain runs: one for the hierarchy's shape, one for every token in it. The flattening itself is `design_cascade.resolve_tokens` — pure, so the cascade rule is tested without a database and this function is only the loading. """ if not await access.can_read_design_system(user_id, design_system_id): return None async with async_session() as session: system = await session.get(DesignSystem, design_system_id) if system is None or system.deleted_at is not None: return None parents = await _parent_map(session, system.owner_user_id) chain = ancestry(design_system_id, parents) rows = ( await session.execute( select(DesignToken) .where( DesignToken.design_system_id.in_(chain), DesignToken.deleted_at.is_(None), ) .order_by(DesignToken.order_index.asc(), DesignToken.name.asc()) ) ).scalars().all() tokens_by_system: dict[int, list[DesignToken]] = {} for token in rows: tokens_by_system.setdefault(token.design_system_id, []).append(token) return resolve_tokens(design_system_id, parents, tokens_by_system) async def update_token( user_id: int, token_id: int, **fields: object ) -> DesignToken | None: allowed = {"name", "value_by_mode", "group_name", "purpose", "order_index"} async with async_session() as session: token = await session.get(DesignToken, token_id) if token is None or token.deleted_at is not None: return None if not await access.can_write_design_system(user_id, token.design_system_id): return None for key, value in fields.items(): if key in allowed and value is not None: setattr(token, key, value) token.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(token) return token async def delete_token(user_id: int, token_id: int) -> bool: async with async_session() as session: token = await session.get(DesignToken, token_id) if token is None or token.deleted_at is not None: return False if not await access.can_write_design_system(user_id, token.design_system_id): return False token.deleted_at = datetime.now(timezone.utc) await session.commit() return True # --- the project pointer ---------------------------------------------------- async def set_project_design_system( user_id: int, project_id: int, design_system_id: int | None ) -> bool: """Point a project at a design system, or at nothing (None clears it). Needs write on the project and READ on the system: pointing at a system is consuming it, not changing it, so a system shared with you through another project is a legitimate choice here. """ if not await access.can_write_project(user_id, project_id): return False if design_system_id is not None and not await access.can_read_design_system( user_id, design_system_id ): return False async with async_session() as session: project = await session.get(Project, project_id) if project is None or project.deleted_at is not None: return False project.design_system_id = design_system_id project.updated_at = datetime.now(timezone.utc) await session.commit() return True