"""Admin endpoints for groups + their permission bindings.""" from __future__ import annotations import logging import uuid from flask import Blueprint, jsonify, request from pydantic import BaseModel, Field, ValidationError from app.core.auth_decorators import require_auth, require_perm from app.services import groups as groups_svc bp = Blueprint("groups", __name__, url_prefix="/groups") log = logging.getLogger("metamorph.api.groups") def _serialize(g: groups_svc.GroupView) -> dict: return { "id": str(g.id), "name": g.name, "description": g.description, "is_system": g.is_system, "members_count": g.members_count, "permissions": g.permissions, "created_at": g.created_at.isoformat(), "updated_at": g.updated_at.isoformat(), } class CreateGroupPayload(BaseModel): name: str = Field(min_length=1, max_length=80) description: str | None = Field(default=None, max_length=2000) model_config = {"extra": "forbid"} class UpdateGroupPayload(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=80) description: str | None = Field(default=None, max_length=2000) model_config = {"extra": "forbid"} class SetPermissionsPayload(BaseModel): codes: list[str] = Field(default_factory=list) model_config = {"extra": "forbid"} def _parse_uuid_or_400(raw: str): try: return uuid.UUID(raw) except ValueError: return None @bp.get("") @require_auth @require_perm("group.read") def list_groups(): rows = groups_svc.list_groups() return jsonify({"items": [_serialize(g) for g in rows], "total": len(rows)}) @bp.get("/") @require_auth @require_perm("group.read") def get_group(group_id: str): gid = _parse_uuid_or_400(group_id) if gid is None: return jsonify({"error": "invalid_id"}), 400 try: g = groups_svc.get_group(gid) except groups_svc.GroupNotFound: return jsonify({"error": "not_found"}), 404 return jsonify(_serialize(g)) @bp.post("") @require_auth @require_perm("group.create") def create_group(): try: payload = CreateGroupPayload.model_validate(request.get_json(silent=True) or {}) except ValidationError as e: return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400 try: g = groups_svc.create_group(name=payload.name, description=payload.description) except groups_svc.GroupNameConflict as e: return jsonify({"error": "name_conflict", "message": str(e)}), 409 except ValueError as e: return jsonify({"error": "invalid_request", "message": str(e)}), 400 log.info("metamorph.group.created", extra={"group_id": str(g.id), "group_name": g.name}) return jsonify(_serialize(g)), 201 @bp.patch("/") @require_auth @require_perm("group.update") def update_group(group_id: str): gid = _parse_uuid_or_400(group_id) if gid is None: return jsonify({"error": "invalid_id"}), 400 raw = request.get_json(silent=True) or {} try: payload = UpdateGroupPayload.model_validate(raw) except ValidationError as e: return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400 description_unset = "description" not in raw try: g = groups_svc.update_group( gid, name=payload.name, description=... if description_unset else payload.description, ) except groups_svc.GroupNotFound: return jsonify({"error": "not_found"}), 404 except groups_svc.SystemGroupProtected as e: return jsonify({"error": "system_group_protected", "message": str(e)}), 409 except groups_svc.GroupNameConflict as e: return jsonify({"error": "name_conflict", "message": str(e)}), 409 except ValueError as e: return jsonify({"error": "invalid_request", "message": str(e)}), 400 log.info("metamorph.group.updated", extra={"group_id": str(gid), "fields": sorted(raw.keys())}) return jsonify(_serialize(g)) @bp.delete("/") @require_auth @require_perm("group.delete") def soft_delete(group_id: str): gid = _parse_uuid_or_400(group_id) if gid is None: return jsonify({"error": "invalid_id"}), 400 try: groups_svc.soft_delete_group(gid) except groups_svc.GroupNotFound: return jsonify({"error": "not_found"}), 404 except groups_svc.SystemGroupProtected as e: return jsonify({"error": "system_group_protected", "message": str(e)}), 409 log.info("metamorph.group.soft_deleted", extra={"group_id": str(gid)}) return jsonify({"ok": True}) @bp.put("//permissions") @require_auth @require_perm("group.update") def set_permissions(group_id: str): gid = _parse_uuid_or_400(group_id) if gid is None: return jsonify({"error": "invalid_id"}), 400 try: payload = SetPermissionsPayload.model_validate(request.get_json(silent=True) or {}) except ValidationError as e: return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400 try: g = groups_svc.set_group_permissions(gid, payload.codes) except groups_svc.GroupNotFound: return jsonify({"error": "not_found"}), 404 except groups_svc.SystemGroupProtected as e: return jsonify({"error": "system_group_protected", "message": str(e)}), 409 except ValueError as e: return jsonify({"error": "invalid_request", "message": str(e)}), 400 log.info( "metamorph.group.permissions_set", extra={"group_id": str(gid), "count": len(payload.codes)}, ) return jsonify(_serialize(g))