CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Reading the 28 route modules against each other and against the MCP tools: - routes/notes.py carried a PUT and a PATCH handler that were the same function minus the supersedes contract on one of them — one handler now serves both verbs, so both carry it. - The two _attach_supersession copies (REST + MCP) become supersession_svc.attach_relations(uid, note_id, data, hint=) — the seam the two surfaces must agree through; only the agent surface adds the one-sentence reading hint. - Three local _uid() wrappers over g.user.id → scribe.auth.get_current_user_id like every other module; design_systems' private _not_found → routes.utils. not_found; the four "********" literals → settings_svc.SECRET_MASK with the read/write contract written once. - routes/plugin.py: the project_id/repo resolution block and the comma-separated id parse were copied into three endpoints — _project_scope() and _int_list() now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
251 lines
9.8 KiB
Python
251 lines
9.8 KiB
Python
"""Design system + token REST endpoints (milestone #254 step 4).
|
|
|
|
Wraps `services/design_systems.py`, which owns the ACL and the cycle guard.
|
|
Design systems are owner-scoped top-level records rather than project-scoped
|
|
ones, so these do NOT nest under `/api/projects/` — `routes/rulebooks.py` is the
|
|
closer shape.
|
|
|
|
Two failures this layer has to keep apart, which is why the service raises for
|
|
one and returns None for the other:
|
|
|
|
- `DesignSystemCycle` -> 400 with its message. "That parent already inherits
|
|
from this system" is a correctable mistake and the caller needs to be told
|
|
which one they made.
|
|
- None -> 404, covering both "no such system" and "not yours". Conflating
|
|
those two IS the intent: distinguishing them would confirm the existence of
|
|
records the caller may not see.
|
|
"""
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from scribe.auth import get_current_user_id, login_required
|
|
from scribe.routes.utils import not_found
|
|
from scribe.services import design_systems as ds_svc
|
|
from scribe.services.design_starter_roles import (
|
|
DEFAULT_TOKEN_PREFIX,
|
|
describe_groups,
|
|
)
|
|
from scribe.services.design_systems import DesignSystemCycle
|
|
|
|
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
|
|
|
|
|
|
|
|
|
|
# ── Design systems ──────────────────────────────────────────────────────
|
|
|
|
@design_systems_bp.get("/design-systems")
|
|
@login_required
|
|
async def list_design_systems():
|
|
"""The caller's design systems. An empty list is the ordinary state for an
|
|
install that has never made one, not an error."""
|
|
rows = await ds_svc.list_design_systems(get_current_user_id())
|
|
return jsonify({"design_systems": [s.to_dict() for s in rows]})
|
|
|
|
|
|
@design_systems_bp.post("/design-systems")
|
|
@login_required
|
|
async def create_design_system():
|
|
data = await request.get_json() or {}
|
|
title = (data.get("title") or "").strip()
|
|
if not title:
|
|
return jsonify({"error": "title is required"}), 400
|
|
system = await ds_svc.create_design_system(
|
|
user_id=get_current_user_id(),
|
|
title=title,
|
|
description=data.get("description") or None,
|
|
guidance=data.get("guidance") or None,
|
|
parent_id=data.get("parent_id"),
|
|
starter_role_groups=data.get("starter_role_groups"),
|
|
token_prefix=data.get("token_prefix") or DEFAULT_TOKEN_PREFIX,
|
|
)
|
|
if system is None:
|
|
return jsonify({"error": "parent design system not found"}), 404
|
|
return jsonify(system.to_dict()), 201
|
|
|
|
|
|
@design_systems_bp.get("/design-systems/starter-roles")
|
|
@login_required
|
|
async def list_starter_role_groups():
|
|
"""The starter role catalogue, for the creation form's checklist.
|
|
|
|
Roles and purposes only — no values, ever. See services/design_starter_roles.
|
|
"""
|
|
return jsonify({
|
|
"groups": describe_groups(),
|
|
"default_prefix": DEFAULT_TOKEN_PREFIX,
|
|
})
|
|
|
|
|
|
@design_systems_bp.get("/design-systems/<int:design_system_id>")
|
|
@login_required
|
|
async def get_design_system(design_system_id: int):
|
|
system = await ds_svc.get_design_system(get_current_user_id(), design_system_id)
|
|
if system is None:
|
|
return not_found("Design system")
|
|
return jsonify(system.to_dict())
|
|
|
|
|
|
@design_systems_bp.patch("/design-systems/<int:design_system_id>")
|
|
@login_required
|
|
async def update_design_system(design_system_id: int):
|
|
data = await request.get_json() or {}
|
|
fields = {
|
|
k: v for k, v in data.items() if k in ("title", "description", "guidance")
|
|
}
|
|
# Presence, not truthiness: `{"parent_id": null}` means "make this a root",
|
|
# which a `if data.get("parent_id")` filter would silently drop.
|
|
if "parent_id" in data:
|
|
fields["parent_id"] = data["parent_id"]
|
|
try:
|
|
system = await ds_svc.update_design_system(get_current_user_id(), design_system_id, **fields)
|
|
except DesignSystemCycle as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
if system is None:
|
|
return not_found("Design system")
|
|
return jsonify(system.to_dict())
|
|
|
|
|
|
@design_systems_bp.delete("/design-systems/<int:design_system_id>")
|
|
@login_required
|
|
async def delete_design_system(design_system_id: int):
|
|
if not await ds_svc.delete_design_system(get_current_user_id(), design_system_id):
|
|
return not_found("Design system")
|
|
return "", 204
|
|
|
|
|
|
@design_systems_bp.get("/design-systems/<int:design_system_id>/resolved")
|
|
@login_required
|
|
async def resolve_design_system(design_system_id: int):
|
|
"""The EFFECTIVE token set — everything inherited, with this system's on top.
|
|
|
|
Distinct from `/tokens` on purpose: that returns what this system CHANGES,
|
|
this returns what it ends up being. Both are real questions and answering
|
|
only one would make the other a client-side computation.
|
|
"""
|
|
resolved = await ds_svc.resolve_design_system(get_current_user_id(), design_system_id)
|
|
if resolved is None:
|
|
return not_found("Design system")
|
|
return jsonify({
|
|
"design_system_id": design_system_id,
|
|
"tokens": [t.to_dict() for t in resolved],
|
|
})
|
|
|
|
|
|
@design_systems_bp.get("/design-systems/<int:design_system_id>/stylesheet")
|
|
@login_required
|
|
async def get_design_system_stylesheet(design_system_id: int):
|
|
"""The master CSS sheet this design system generates.
|
|
|
|
JSON by default (the UI wants the reuse report alongside the CSS); add
|
|
`?format=css` for the raw stylesheet as `text/css`, which is what a build
|
|
step or a `curl` wants.
|
|
|
|
`?root=` overrides the base selector — a container-scoped preview cannot use
|
|
`:root`, so the generator takes it as a parameter.
|
|
"""
|
|
root = (request.args.get("root") or ":root").strip() or ":root"
|
|
result = await ds_svc.stylesheet_for_system(get_current_user_id(), design_system_id, root)
|
|
if result is None:
|
|
return not_found("Design system")
|
|
if request.args.get("format") == "css":
|
|
return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"}
|
|
return jsonify(result)
|
|
|
|
|
|
@design_systems_bp.get("/design-systems/<int:design_system_id>/snippet-check")
|
|
@login_required
|
|
async def check_snippets_against_system(design_system_id: int):
|
|
"""Which recorded snippets disagree with this system's sheet.
|
|
|
|
`?project_id=` narrows to one project; omit it to check every project, which
|
|
is usually right — a component recorded elsewhere still has to use the same
|
|
tags.
|
|
"""
|
|
project_id = request.args.get("project_id", type=int) or 0
|
|
result = await ds_svc.check_snippets_against_system(
|
|
get_current_user_id(), design_system_id, project_id
|
|
)
|
|
if result is None:
|
|
return not_found("Design system")
|
|
return jsonify(result)
|
|
|
|
|
|
# ── Tokens ──────────────────────────────────────────────────────────────
|
|
|
|
@design_systems_bp.get("/design-systems/<int:design_system_id>/tokens")
|
|
@login_required
|
|
async def list_design_tokens(design_system_id: int):
|
|
"""This system's OWN tokens — its override set, not its effective set."""
|
|
if await ds_svc.get_design_system(get_current_user_id(), design_system_id) is None:
|
|
return not_found("Design system")
|
|
rows = await ds_svc.list_tokens(get_current_user_id(), design_system_id)
|
|
return jsonify({"tokens": [t.to_dict() for t in rows]})
|
|
|
|
|
|
@design_systems_bp.post("/design-systems/<int:design_system_id>/tokens")
|
|
@login_required
|
|
async def create_design_token(design_system_id: int):
|
|
data = await request.get_json() or {}
|
|
name = (data.get("name") or "").strip()
|
|
if not name:
|
|
return jsonify({"error": "name is required"}), 400
|
|
token = await ds_svc.create_token(
|
|
user_id=get_current_user_id(),
|
|
design_system_id=design_system_id,
|
|
name=name,
|
|
value_by_mode=data.get("value_by_mode"),
|
|
group_name=data.get("group_name") or None,
|
|
purpose=data.get("purpose") or None,
|
|
rationale=data.get("rationale") or None,
|
|
supersedes=data.get("supersedes"),
|
|
order_index=data.get("order_index") or 0,
|
|
)
|
|
if token is None:
|
|
return not_found("Design system")
|
|
return jsonify(token.to_dict()), 201
|
|
|
|
|
|
@design_systems_bp.patch("/design-tokens/<int:token_id>")
|
|
@login_required
|
|
async def update_design_token(token_id: int):
|
|
data = await request.get_json() or {}
|
|
fields = {
|
|
k: v for k, v in data.items()
|
|
if k in (
|
|
"name", "value_by_mode", "group_name", "purpose", "rationale",
|
|
"supersedes", "order_index",
|
|
)
|
|
}
|
|
token = await ds_svc.update_token(get_current_user_id(), token_id, **fields)
|
|
if token is None:
|
|
return not_found("Design token")
|
|
return jsonify(token.to_dict())
|
|
|
|
|
|
@design_systems_bp.delete("/design-tokens/<int:token_id>")
|
|
@login_required
|
|
async def delete_design_token(token_id: int):
|
|
if not await ds_svc.delete_token(get_current_user_id(), token_id):
|
|
return not_found("Design token")
|
|
return "", 204
|
|
|
|
|
|
# ── The project pointer ─────────────────────────────────────────────────
|
|
|
|
@design_systems_bp.put("/projects/<int:project_id>/design-system")
|
|
@login_required
|
|
async def set_project_design_system(project_id: int):
|
|
"""Point a project at a design system. `{"design_system_id": null}` clears it.
|
|
|
|
PUT rather than PATCH: this sets one field to exactly what is sent, and
|
|
clearing it is a first-class outcome rather than an omission.
|
|
"""
|
|
data = await request.get_json() or {}
|
|
ok = await ds_svc.set_project_design_system(
|
|
get_current_user_id(), project_id, data.get("design_system_id")
|
|
)
|
|
if not ok:
|
|
return not_found("Project or design system")
|
|
return jsonify({"project_id": project_id,
|
|
"design_system_id": data.get("design_system_id")})
|