feat(systems): the area vocabulary becomes a global table so a rule can point at one (#3027, milestone 307 step 1)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped
The eight standard area names already existed — as STANDARD_SYSTEMS, a tuple in
services/systems.py that milestone 297 seeds at inception. A constant cannot be
a foreign key, so nothing outside a project could reference an area: systems.
project_id is NOT NULL, and a rule that spans projects would have to chain
itself to one project's row. And because the list only ever applied on the
inception-seed path, three spellings of one area reached this instance anyway
(CI & runners / CI and Release / CI & release).
- canonical_systems: global, no user_id — a shared project inherits the
vocabulary instead of re-earning it. Migration 0087 seeds the same eight.
- systems.canonical_id: nullable, SET NULL. Association only — no System is
renamed and record_systems is untouched, so no record's tags move.
- canonical_slug folds &/and, case and punctuation, so spelling variants map
mechanically and a real difference ("CI & runners") becomes a proposal a
human confirms. propose_mappings reports; set_system_canonical is the only
writer.
- seed_standard_systems now reads the catalog and maps as it mints, so a
project born standard never needs a reconciliation pass.
- Catalog writes are admin-only; reads are open — a global list anyone can
extend stops being shared.
- backup: carried by SLUG, not id (ids are per-install). Restore reuses the
target's own rows and only creates entries an admin added on the source; an
unknown slug restores unmapped rather than failing.
Rule 22: STANDARD_SYSTEMS is removed, not deprecated. Rule 115: nothing seeded
names an app, repo or house convention. Design in note 3026.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""Canonical-system routes — the GLOBAL area vocabulary, and the mapping of a
|
||||
project's Systems onto it (milestone 307, decision note 3026).
|
||||
|
||||
Two shapes live here because they are two halves of one idea:
|
||||
|
||||
- `/api/canonical-systems` — the catalog itself. Readable by any signed-in
|
||||
user (it is shared vocabulary, not user data); writable only by an admin,
|
||||
since a global list anyone can extend stops being a shared list.
|
||||
- the mapping endpoints — authorised by the PROJECT, because mapping writes a
|
||||
project's own System row. 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 canonical_systems as canonical_svc
|
||||
from scribe.services.projects import get_project_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
canonical_systems_bp = Blueprint("canonical_systems", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@canonical_systems_bp.route("/canonical-systems", methods=["GET"])
|
||||
@login_required
|
||||
async def list_canonical_systems_route():
|
||||
entries = await canonical_svc.list_canonical_systems()
|
||||
return jsonify({"canonical_systems": [e.to_dict() for e in entries]})
|
||||
|
||||
|
||||
@canonical_systems_bp.route("/canonical-systems", methods=["POST"])
|
||||
@admin_required
|
||||
async def create_canonical_system_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
|
||||
entry = await canonical_svc.create_canonical_system(
|
||||
uid, data["name"], description=data.get("description"),
|
||||
)
|
||||
if entry is None:
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
# The slug duplicate gate answers with the entry that already covers the
|
||||
# area rather than minting a second spelling of it — 409, not a silent
|
||||
# second row (the whole point of the table).
|
||||
if isinstance(entry, dict):
|
||||
return jsonify(entry), 409
|
||||
return jsonify(entry.to_dict()), 201
|
||||
|
||||
|
||||
@canonical_systems_bp.route("/canonical-systems/<int:canonical_id>", methods=["PATCH"])
|
||||
@admin_required
|
||||
async def update_canonical_system_route(canonical_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 ("name", "description", "order_index")}
|
||||
entry = await canonical_svc.update_canonical_system(uid, canonical_id, **fields)
|
||||
if entry is None:
|
||||
return not_found("Canonical system")
|
||||
return jsonify(entry.to_dict())
|
||||
|
||||
|
||||
@canonical_systems_bp.route(
|
||||
"/projects/<int:project_id>/canonical-proposals", methods=["GET"]
|
||||
)
|
||||
@login_required
|
||||
async def propose_canonical_mappings_route(project_id: int):
|
||||
"""Proposals only — this endpoint writes nothing. The PUT below applies one."""
|
||||
uid = get_current_user_id()
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
return jsonify({"proposals": await canonical_svc.propose_mappings(uid, project_id)})
|
||||
|
||||
|
||||
@canonical_systems_bp.route("/systems/<int:system_id>/canonical", methods=["PUT"])
|
||||
@login_required
|
||||
async def map_system_to_canonical_route(system_id: int):
|
||||
"""Map or unmap one System. Body: {"canonical_id": <id>|null}.
|
||||
|
||||
Sets that column and nothing else — no rename, no change to which records
|
||||
are tagged to the System.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
canonical_id = data.get("canonical_id")
|
||||
if canonical_id is not None and not isinstance(canonical_id, int):
|
||||
return jsonify({"error": "canonical_id must be an integer or null"}), 400
|
||||
system = await canonical_svc.set_system_canonical(uid, system_id, canonical_id)
|
||||
if system is None:
|
||||
return not_found("System or canonical system")
|
||||
return jsonify(system.to_dict())
|
||||
Reference in New Issue
Block a user