""" Memos REST API (universal). Blueprint mounted at /api/memos. Visibility/permission enforcement lives in the service layer; this layer handles auth (logged-in user), the feature gate (annotation_ui.memos), request parsing, and error mapping. Privilege tier ("always read / may delete others"): adjudicators, via the adjudication manager. Admin-dashboard memo moderation is a later follow-up. """ from __future__ import annotations import logging from functools import wraps from flask import Blueprint, jsonify, request, session from . import ( MemoError, MemoNotFound, MemoPermissionError, create_memo, delete_memo, list_visible, update_memo, ) logger = logging.getLogger(__name__) memos_bp = Blueprint("memos", __name__, url_prefix="/api/memos") def _config() -> dict: from potato.server_utils.config_module import config return config def memos_enabled(config: dict) -> bool: """Default: off in standard mode; on when qda_mode or solo_mode is on. Explicit annotation_ui.memos always wins.""" ui = config.get("annotation_ui") or {} if isinstance(ui, dict) and "memos" in ui: return bool(ui["memos"]) return bool( (config.get("qda_mode") or {}).get("enabled") or (config.get("solo_mode") or {}).get("enabled") ) def _default_visibility(config: dict) -> str: ui = config.get("annotation_ui") or {} v = ui.get("visibility") if isinstance(ui, dict) else None return v if v in ("private", "shared") else "private" def _is_privileged(username: str) -> bool: try: from potato.adjudication import get_adjudication_manager adj = get_adjudication_manager() return bool(adj and adj.is_adjudicator(username)) except Exception: return False def _ctx(): """(task_dir, project, username, is_privileged) or None if not usable.""" config = _config() if not memos_enabled(config): return None, None, None, None, ("memos_disabled",) username = session.get("username") if not username: return None, None, None, None, ("unauthenticated",) task_dir = config.get("task_dir", ".") project = config.get("annotation_task_name") or "default" return task_dir, project, username, _is_privileged(username), None def memos_required(view): @wraps(view) def wrapper(*args, **kwargs): task_dir, project, username, priv, err = _ctx() if err == ("memos_disabled",): return jsonify({ "error": "Memos are not enabled in this deployment.", "hint": "Set annotation_ui.memos: true (on by default in " "qda_mode/solo_mode).", }), 503 if err == ("unauthenticated",): return jsonify({"error": "Not authenticated"}), 401 return view(task_dir, project, username, priv, *args, **kwargs) return wrapper def _handle(fn): """Map service exceptions to HTTP codes.""" try: return fn() except MemoNotFound as e: return jsonify({"error": str(e)}), 404 except MemoPermissionError as e: return jsonify({"error": str(e)}), 403 except MemoError as e: return jsonify({"error": str(e)}), 400 @memos_bp.route("", methods=["GET"]) @memos_required def list_memos(task_dir, project, username, priv): instance_id = request.args.get("instance_id") if not instance_id: return jsonify({"error": "instance_id is required"}), 400 memos = list_visible( task_dir, project=project, instance_id=instance_id, requester=username, is_privileged=priv, ) return jsonify({"memos": memos}) @memos_bp.route("", methods=["POST"]) @memos_required def post_memo(task_dir, project, username, priv): data = request.get_json(silent=True) or {} instance_id = data.get("instance_id") if not instance_id: return jsonify({"error": "instance_id is required"}), 400 visibility = data.get("visibility") or _default_visibility(_config()) return _handle(lambda: jsonify({"memo": create_memo( task_dir, project=project, instance_id=instance_id, body=data.get("body", ""), created_by=username, anchor=data.get("anchor"), visibility=visibility, )})) @memos_bp.route("/", methods=["PATCH"]) @memos_required def patch_memo(task_dir, project, username, priv, memo_id): data = request.get_json(silent=True) or {} return _handle(lambda: jsonify({"memo": update_memo( task_dir, memo_id, requester=username, is_privileged=priv, body=data.get("body"), visibility=data.get("visibility"), )})) @memos_bp.route("/", methods=["DELETE"]) @memos_required def remove_memo(task_dir, project, username, priv, memo_id): def _do(): delete_memo(task_dir, memo_id, requester=username, is_privileged=priv) return jsonify({"ok": True}) return _handle(_do)