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
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:
@@ -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})
|
||||
Reference in New Issue
Block a user