feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s

A project's platforms decide which family ideas reach it. This step makes membership answerable from every door:

- services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one.
- coverage: the archive scan now carries every path, and the refresh runs detection fail-open.
- inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected.
- MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms.
- REST: /api/platforms (admin writes) and /api/projects/<id>/platforms.
- UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:51:28 -04:00
co-authored by Claude Opus 5.5
parent e68ccc5884
commit 07d2542479
27 changed files with 1638 additions and 51 deletions
+96
View File
@@ -0,0 +1,96 @@
"""Platform routes — the GLOBAL platform catalog, and which platforms a
project is (milestone 463 step 2).
Two shapes, as with the canonical areas:
- `/api/platforms` — the catalog. Readable by any signed-in user; writable
only by an admin, since a vocabulary anyone extends stops being shared.
- `/api/projects/<id>/platforms` — a project's answers, authorised by the
PROJECT (read to see them, write to change them). The service enforces
both; these are thin wrappers.
"""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import admin_required, get_current_user_id, login_required
from scribe.routes.utils import not_found
from scribe.services import platforms as platforms_svc
logger = logging.getLogger(__name__)
platforms_bp = Blueprint("platforms", __name__, url_prefix="/api")
_EDITABLE = ("name", "description", "order_index", "markers")
@platforms_bp.route("/platforms", methods=["GET"])
@login_required
async def list_platforms_route():
entries = await platforms_svc.list_platforms()
return jsonify({"platforms": [e.to_dict() for e in entries]})
@platforms_bp.route("/platforms", methods=["POST"])
@admin_required
async def create_platform_route():
uid = get_current_user_id()
data = await request.get_json() or {}
if not (data.get("name") or "").strip():
return jsonify({"error": "name is required"}), 400
try:
entry = await platforms_svc.create_platform(
uid, data["name"], description=data.get("description"),
markers=data.get("markers"),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if entry is None:
return jsonify({"error": "Permission denied"}), 403
# Duplicate-gated on the slug: the entry that already is this platform
# comes back as a 409 rather than a second spelling of it.
if isinstance(entry, dict):
return jsonify(entry), 409
return jsonify(entry.to_dict()), 201
@platforms_bp.route("/platforms/<int:platform_id>", methods=["PATCH"])
@admin_required
async def update_platform_route(platform_id: int):
uid = get_current_user_id()
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in _EDITABLE}
try:
entry = await platforms_svc.update_platform(uid, platform_id, **fields)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if entry is None:
return not_found("Platform")
return jsonify(entry.to_dict())
@platforms_bp.route("/projects/<int:project_id>/platforms", methods=["GET"])
@login_required
async def project_platforms_route(project_id: int):
"""Every platform the project has an answer for — rejected included, so
a screen can show "no" as well as "yes"."""
rows = await platforms_svc.project_platforms(get_current_user_id(), project_id)
if rows is None:
return not_found("Project")
return jsonify({"project_platforms": rows})
@platforms_bp.route("/projects/<int:project_id>/platforms", methods=["PUT"])
@login_required
async def set_project_platforms_route(project_id: int):
"""Body: {"platforms": {<slug>: "declared" | "rejected" | null}}. Only
the slugs named change; null withdraws the answer. Applies whole or not
at all."""
data = await request.get_json() or {}
try:
rows = await platforms_svc.set_project_platforms(
get_current_user_id(), project_id, data.get("platforms"),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"project_platforms": rows})