"""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/", 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//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//canonical", methods=["PUT"]) @login_required async def map_system_to_canonical_route(system_id: int): """Map or unmap one System. Body: {"canonical_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())