coulomb-social/coulomb_social/apps/spaces/services.py
tegwick affed64839 Land CSOC-WP-0006 PageOps: member space/page CRUD, copy, transfer
Thin CONTENT_ROOT content plane (ADR-0003/0004) with space visual field,
product UI for Title/Abstract/Visual, page list sidebar, and tests. Update
capability model and smoke checklist; mark WP-0006 finished.
2026-08-13 12:46:39 +02:00

197 lines
5.4 KiB
Python

"""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,
)