"""Space queries and PageOps-backed mutations (ADR-0003 / ADR-0004).""" from __future__ import annotations from django.db import transaction from django.db.models import Q, QuerySet from coulomb_social.apps.members.models import Member from . import pageops from .models import Space, SpaceMembership def spaces_for_member(member: Member | None) -> QuerySet[Space]: """Spaces visible to this member (same tenant, active). Explicit SpaceMembership grants access; if none exist for the tenant yet, all active tenant spaces are listed (operator-seeded MVP). Once any memberships exist for the tenant, only membership + created_by apply. """ if member is None: return Space.objects.none() qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True) tenant_has_memberships = SpaceMembership.objects.filter( space__tenant_id=member.tenant_id ).exists() if not tenant_has_memberships: return qs.order_by("title", "slug") return ( qs.filter(Q(memberships__member=member) | Q(created_by=member)) .distinct() .order_by("title", "slug") ) def get_space_for_member(member: Member | None, slug: str) -> Space | None: if member is None: return None return spaces_for_member(member).filter(slug=slug).first() @transaction.atomic def create_space( member: Member, *, title: str, abstractor: str = "", slug: str | None = None, visual: str = "", ) -> Space: title = (title or "").strip() if not title: raise pageops.PageOpsError("Title is required") space_slug = pageops.normalize_slug(slug or Space.suggest_slug(title)) if Space.objects.filter(tenant_id=member.tenant_id, slug=space_slug).exists(): raise pageops.PageOpsError("A space with this slug already exists") space = Space.objects.create( tenant_id=member.tenant_id, slug=space_slug, title=title, description=abstractor or "", visual=visual or "", created_by=member, content_root="pages", default_branch="main", ) SpaceMembership.objects.get_or_create( space=space, member=member, defaults={"role": SpaceMembership.Role.OWNER}, ) pageops.init_space_content( space_slug, title=title, abstractor=abstractor or "", visual=visual or "", ) return space @transaction.atomic def update_space( member: Member, space: Space, *, title: str | None = None, abstractor: str | None = None, visual: str | None = None, ) -> Space: if get_space_for_member(member, space.slug) is None: raise pageops.PageOpsError("Space not found") if title is not None: space.title = title.strip() or space.title if abstractor is not None: space.description = abstractor if visual is not None: space.visual = visual space.save() # Keep index.md in sync index = pageops.read_page(space.slug, "index") body = index.body if index.exists else f"# {space.title}\n" pageops.write_page( space.slug, "index", title=space.title, abstractor=space.description, visual=space.visual, body=body, extra_meta={"bubble_type": "space"}, ) return space @transaction.atomic def archive_space(member: Member, space: Space) -> Space: if get_space_for_member(member, space.slug) is None: raise pageops.PageOpsError("Space not found") space.is_active = False space.save(update_fields=["is_active", "updated_at"]) try: pageops.archive_space_tree(space.slug) except Exception: # Tree archive is best-effort pass return space def list_space_pages(space: Space) -> list[pageops.PageDoc]: return pageops.list_pages(space.slug) def put_space_page( member: Member, space: Space, page_slug: str, *, title: str, abstractor: str = "", visual: str = "", body: str = "", ) -> pageops.PageDoc: if get_space_for_member(member, space.slug) is None: raise pageops.PageOpsError("Space not found") return pageops.write_page( space.slug, page_slug, title=title, abstractor=abstractor, visual=visual, body=body, ) def copy_space_page( member: Member, space: Space, page_slug: str, *, dest_slug: str, dest_space: Space | None = None, title: str | None = None, ) -> pageops.PageDoc: if get_space_for_member(member, space.slug) is None: raise pageops.PageOpsError("Source space not found") target = dest_space or space if get_space_for_member(member, target.slug) is None: raise pageops.PageOpsError("Destination space not found") return pageops.copy_page( space.slug, page_slug, target.slug, dest_slug, title=title, ) def transfer_space_page( member: Member, space: Space, page_slug: str, dest_space: Space, *, dest_slug: str | None = None, ) -> pageops.PageDoc: if get_space_for_member(member, space.slug) is None: raise pageops.PageOpsError("Source space not found") if get_space_for_member(member, dest_space.slug) is None: raise pageops.PageOpsError("Destination space not found") return pageops.transfer_page( space.slug, page_slug, dest_space.slug, dest_slug, )