Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 1370019@bnt-lap001 Assistant-Session: d462dd63-16de-4189-ae70-b9cd6566bf87
284 lines
9.7 KiB
Python
284 lines
9.7 KiB
Python
"""Space queries and PageOps-backed mutations (ADR-0003 / ADR-0004)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import IntegrityError, transaction
|
|
from django.db.models import Count, 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]:
|
|
"""Only active tenant spaces with an explicit membership or creator link."""
|
|
if member is None:
|
|
return Space.objects.none()
|
|
|
|
ambiguous = Space.objects.values("slug").annotate(n=Count("pk")).filter(n__gt=1)
|
|
qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True).exclude(
|
|
slug__in=ambiguous.values("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()
|
|
|
|
|
|
def can_write_space(member: Member | None, space: Space) -> bool:
|
|
"""Require an exact tenant/space grant, not a same-slug visibility match."""
|
|
if member is None or member.tenant_id != space.tenant_id or not space.is_active:
|
|
return False
|
|
if Space.objects.filter(slug=space.slug).exclude(pk=space.pk).exists():
|
|
return False
|
|
membership = SpaceMembership.objects.filter(space=space, member=member).first()
|
|
if membership is not None:
|
|
return membership.role in {SpaceMembership.Role.OWNER, SpaceMembership.Role.MEMBER}
|
|
return space.created_by_id == member.pk
|
|
|
|
|
|
def can_manage_space(member: Member | None, space: Space) -> bool:
|
|
if not can_write_space(member, space):
|
|
return False
|
|
grant = SpaceMembership.objects.filter(space=space, member=member).first()
|
|
return grant.role == SpaceMembership.Role.OWNER if grant else space.created_by_id == member.pk
|
|
|
|
|
|
@transaction.atomic
|
|
def set_space_access(member: Member, space: Space, target_id, role: str) -> None:
|
|
"""Owners grant access to an existing, exact local member; never invite by email."""
|
|
space = Space.objects.select_for_update().get(pk=space.pk)
|
|
if not can_manage_space(member, space):
|
|
raise pageops.PageOpsError("Only a space owner can manage sharing")
|
|
try:
|
|
target = Member.objects.select_for_update().get(pk=target_id, tenant_id=space.tenant_id)
|
|
except (Member.DoesNotExist, ValidationError, ValueError, TypeError):
|
|
raise pageops.PageOpsError("Choose an existing member in this tenant") from None
|
|
if role not in {*SpaceMembership.Role.values, "remove"}:
|
|
raise pageops.PageOpsError("Choose a valid access role")
|
|
# A creator has an implicit grant. Do not claim to revoke that grant by
|
|
# deleting a membership that would simply re-enable the fallback.
|
|
if target.pk == space.created_by_id and role != SpaceMembership.Role.OWNER:
|
|
raise pageops.PageOpsError("The creator must retain owner access")
|
|
current = SpaceMembership.objects.filter(space=space, member=target).first()
|
|
if current and current.role == "owner" and role != "owner":
|
|
others = SpaceMembership.objects.filter(
|
|
space=space, role="owner", member__tenant_id=space.tenant_id,
|
|
).exclude(member=target)
|
|
if not others.exists() and not (
|
|
space.created_by_id and space.created_by_id != target.pk
|
|
and space.created_by.tenant_id == space.tenant_id
|
|
):
|
|
raise pageops.PageOpsError("Keep at least one owner")
|
|
if role == "remove":
|
|
SpaceMembership.objects.filter(space=space, member=target).delete()
|
|
else:
|
|
SpaceMembership.objects.update_or_create(
|
|
space=space, member=target, defaults={"role": role},
|
|
)
|
|
|
|
|
|
def require_space_write(member: Member | None, space: Space) -> None:
|
|
if not can_write_space(member, space):
|
|
raise pageops.PageOpsError("You have read-only access to this space")
|
|
|
|
|
|
@pageops.content_writer
|
|
@transaction.atomic
|
|
def create_space(
|
|
member: Member,
|
|
*,
|
|
title: str,
|
|
abstractor: str = "",
|
|
slug: str | None = None,
|
|
visual: str = "",
|
|
) -> Space:
|
|
if member is None:
|
|
raise pageops.PageOpsError("Sign in with a local member profile before creating a 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(slug=space_slug).exists():
|
|
raise pageops.PageOpsError("A space with this slug already exists in this content instance")
|
|
|
|
try:
|
|
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",
|
|
)
|
|
except IntegrityError as exc:
|
|
raise pageops.PageOpsError(
|
|
"A space with this slug already exists in this content instance"
|
|
) from exc
|
|
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
|
|
|
|
|
|
@pageops.content_writer
|
|
@transaction.atomic
|
|
def update_space(
|
|
member: Member,
|
|
space: Space,
|
|
*,
|
|
title: str | None = None,
|
|
abstractor: str | None = None,
|
|
visual: str | None = None,
|
|
expected_revision: str | None = None,
|
|
) -> Space:
|
|
require_space_write(member, space)
|
|
index = pageops.read_page(space.slug, "index")
|
|
if expected_revision is not None and index.revision != expected_revision:
|
|
raise pageops.PageOpsError(
|
|
"This space changed. Reopen it and merge your edits before saving"
|
|
)
|
|
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
|
|
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={**{k: v for k, v in index.meta.items()
|
|
if k not in {"title", "abstractor", "visual", "space"}},
|
|
"bubble_type": "space"},
|
|
expected_revision=expected_revision,
|
|
)
|
|
return space
|
|
|
|
|
|
@pageops.content_writer
|
|
@transaction.atomic
|
|
def archive_space(member: Member, space: Space) -> Space:
|
|
require_space_write(member, space)
|
|
space.is_active = False
|
|
space.save(update_fields=["is_active", "updated_at"])
|
|
pageops.archive_space_tree(space.slug)
|
|
return space
|
|
|
|
|
|
def list_space_pages(space: Space) -> list[pageops.PageDoc]:
|
|
return pageops.list_pages(space.slug)
|
|
|
|
|
|
@pageops.content_writer
|
|
def put_space_page(
|
|
member: Member,
|
|
space: Space,
|
|
page_slug: str,
|
|
*,
|
|
title: str,
|
|
abstractor: str = "",
|
|
visual: str = "",
|
|
body: str = "",
|
|
extra_meta: dict | None = None,
|
|
expected_revision: str | None = None,
|
|
) -> pageops.PageDoc:
|
|
require_space_write(member, space)
|
|
metadata = (pageops.read_page(space.slug, page_slug).meta
|
|
if extra_meta is None else extra_meta)
|
|
return pageops.write_page(
|
|
space.slug,
|
|
page_slug,
|
|
title=title,
|
|
abstractor=abstractor,
|
|
visual=visual,
|
|
body=body,
|
|
extra_meta={k: v for k, v in metadata.items()
|
|
if k not in {"title", "abstractor", "visual", "space"}},
|
|
expected_revision=expected_revision,
|
|
)
|
|
|
|
|
|
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:
|
|
require_space_write(member, space)
|
|
target = dest_space or space
|
|
require_space_write(member, target)
|
|
return pageops.copy_page(
|
|
space.slug,
|
|
page_slug,
|
|
target.slug,
|
|
dest_slug,
|
|
title=title,
|
|
)
|
|
|
|
|
|
@pageops.content_writer
|
|
def save_space_page(member, space, source_slug, target_slug, *, expected_revision, **fields):
|
|
require_space_write(member, space)
|
|
source = pageops.read_page(space.slug, source_slug)
|
|
if expected_revision is None or source.revision != expected_revision:
|
|
raise pageops.PageOpsError(
|
|
"This page changed. Reopen it and merge your edits before saving"
|
|
)
|
|
if source_slug == "index" and target_slug != "index":
|
|
raise pageops.PageOpsError("The space index file is fixed")
|
|
if target_slug != source_slug and pageops.page_file(space.slug, target_slug).exists():
|
|
raise pageops.PageOpsError("A page with that slug already exists")
|
|
doc = put_space_page(member, space, target_slug, extra_meta=source.meta,
|
|
expected_revision=expected_revision if source_slug == target_slug else "",
|
|
**fields)
|
|
if source_slug != target_slug:
|
|
pageops.remember_page_address(space.slug, source_slug, space.slug, target_slug)
|
|
pageops.delete_page(space.slug, source_slug)
|
|
return doc
|
|
|
|
|
|
def transfer_space_page(
|
|
member: Member,
|
|
space: Space,
|
|
page_slug: str,
|
|
dest_space: Space,
|
|
*,
|
|
dest_slug: str | None = None,
|
|
) -> pageops.PageDoc:
|
|
require_space_write(member, space)
|
|
require_space_write(member, dest_space)
|
|
return pageops.transfer_page(
|
|
space.slug,
|
|
page_slug,
|
|
dest_space.slug,
|
|
dest_slug,
|
|
)
|