From 83e9960d1844fc1b97bacc0ef49d11862a25911b Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 1 Jul 2026 14:07:39 +0800 Subject: [PATCH] feat: add governance management api --- backend/apps/accounts/api.py | 236 +++++++++++++++++- backend/apps/accounts/schemas.py | 71 ++++++ .../accounts/tests/test_governance_api.py | 87 +++++++ 3 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 backend/apps/accounts/tests/test_governance_api.py diff --git a/backend/apps/accounts/api.py b/backend/apps/accounts/api.py index 4f3a722..86be3de 100644 --- a/backend/apps/accounts/api.py +++ b/backend/apps/accounts/api.py @@ -1,11 +1,31 @@ -from django.contrib.auth import authenticate, login, logout +from django.contrib.auth import authenticate, get_user_model, login, logout +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType from django.middleware.csrf import get_token from ninja import Router from ninja.errors import HttpError -from apps.accounts.models import MenuItem +from apps.accounts.models import MenuItem, UserGroup from apps.accounts.permissions import PERMISSIONS -from apps.accounts.schemas import CurrentUserOut, LoginIn +from apps.accounts.schemas import ( + BasicUserOut, + CurrentUserOut, + IdsIn, + LoginIn, + MenuOut, + MenuUpdateIn, + PasswordResetIn, + PermissionCodesIn, + PermissionOut, + RoleCreateIn, + RoleDetailOut, + RoleUpdateIn, + UserCreateIn, + UserGroupCreateIn, + UserGroupOut, + UserGroupUpdateIn, + UserUpdateIn, +) router = Router(tags=["accounts"]) @@ -20,6 +40,11 @@ def permission_codes_for_user(user) -> list[str]: return sorted(codes) +def permission_objects_for_codes(codes: list[str]): + content_type = ContentType.objects.get(app_label="system", model="platformpermission") + return list(Permission.objects.filter(content_type=content_type, codename__in=codes)) + + def serialize_menu(menu: MenuItem, children: list[dict]) -> dict: return { "id": menu.id, @@ -82,6 +107,52 @@ def serialize_current_user(user) -> dict: } +def serialize_basic_user(user) -> dict: + return { + "id": user.id, + "username": user.username, + "display_name": user.get_full_name() or user.username, + "email": user.email, + "is_active": user.is_active, + "is_staff": user.is_staff, + "is_superuser": user.is_superuser, + "roles": [{"id": group.id, "name": group.name} for group in user.groups.all()], + "user_groups": [ + { + "id": group.id, + "name": group.name, + "code": group.code, + "description": group.description, + "is_active": group.is_active, + "created_at": group.created_at, + "updated_at": group.updated_at, + } + for group in user.business_groups.all() + ], + } + + +def serialize_role(group: Group) -> dict: + codes = sorted(group.permissions.values_list("codename", flat=True)) + return {"id": group.id, "name": group.name, "permission_codes": codes} + + +def serialize_group(group) -> dict: + return { + "id": group.id, + "name": group.name, + "code": group.code, + "description": group.description, + "is_active": group.is_active, + "created_at": group.created_at, + "updated_at": group.updated_at, + } + + +def serialize_menu_flat(menu: MenuItem) -> dict: + return serialize_menu(menu, []) + + def require_login(request): if not request.user.is_authenticated: raise HttpError(401, "Authentication required") @@ -119,3 +190,162 @@ def logout_user(request): def current_user(request): user = require_login(request) return serialize_current_user(user) + + +@router.get("/users", response=list[BasicUserOut]) +def list_users(request): + require_permission(request, "system.user.view") + return [serialize_basic_user(user) for user in get_user_model().objects.all().order_by("id")] + + +@router.post("/users", response=BasicUserOut) +def create_user(request, payload: UserCreateIn): + require_permission(request, "system.user.manage") + user = get_user_model().objects.create_user( + username=payload.username, + email=payload.email, + password=payload.password, + is_active=payload.is_active, + ) + if payload.display_name: + user.first_name = payload.display_name + user.save(update_fields=["first_name"]) + return serialize_basic_user(user) + + +@router.get("/users/{user_id}", response=BasicUserOut) +def get_user(request, user_id: int): + require_permission(request, "system.user.view") + return serialize_basic_user(get_user_model().objects.get(id=user_id)) + + +@router.patch("/users/{user_id}", response=BasicUserOut) +def update_user(request, user_id: int, payload: UserUpdateIn): + require_permission(request, "system.user.manage") + user = get_user_model().objects.get(id=user_id) + update_fields = [] + if payload.display_name is not None: + user.first_name = payload.display_name + update_fields.append("first_name") + if payload.email is not None: + user.email = payload.email + update_fields.append("email") + if payload.is_active is not None: + user.is_active = payload.is_active + update_fields.append("is_active") + if update_fields: + user.save(update_fields=update_fields) + return serialize_basic_user(user) + + +@router.post("/users/{user_id}/reset-password", response=BasicUserOut) +def reset_user_password(request, user_id: int, payload: PasswordResetIn): + require_permission(request, "system.user.manage") + user = get_user_model().objects.get(id=user_id) + user.set_password(payload.password) + user.save(update_fields=["password"]) + return serialize_basic_user(user) + + +@router.post("/users/{user_id}/roles", response=BasicUserOut) +def set_user_roles(request, user_id: int, payload: IdsIn): + require_permission(request, "system.user.manage") + user = get_user_model().objects.get(id=user_id) + user.groups.set(Group.objects.filter(id__in=payload.role_ids)) + return serialize_basic_user(user) + + +@router.post("/users/{user_id}/user-groups", response=BasicUserOut) +def set_user_groups(request, user_id: int, payload: IdsIn): + require_permission(request, "system.user.manage") + user = get_user_model().objects.get(id=user_id) + user.business_groups.set(UserGroup.objects.filter(id__in=payload.user_ids)) + return serialize_basic_user(user) + + +@router.get("/user-groups", response=list[UserGroupOut]) +def list_user_groups(request): + require_permission(request, "system.user_group.view") + return [serialize_group(group) for group in UserGroup.objects.all()] + + +@router.post("/user-groups", response=UserGroupOut) +def create_user_group(request, payload: UserGroupCreateIn): + require_permission(request, "system.user_group.manage") + group = UserGroup.objects.create(**payload.dict()) + return serialize_group(group) + + +@router.patch("/user-groups/{group_id}", response=UserGroupOut) +def update_user_group(request, group_id: int, payload: UserGroupUpdateIn): + require_permission(request, "system.user_group.manage") + group = UserGroup.objects.get(id=group_id) + for field, value in payload.dict(exclude_unset=True).items(): + setattr(group, field, value) + group.save() + return serialize_group(group) + + +@router.post("/user-groups/{group_id}/members", response=UserGroupOut) +def set_user_group_members(request, group_id: int, payload: IdsIn): + require_permission(request, "system.user_group.manage") + group = UserGroup.objects.get(id=group_id) + group.users.set(get_user_model().objects.filter(id__in=payload.user_ids)) + return serialize_group(group) + + +@router.get("/roles", response=list[RoleDetailOut]) +def list_roles(request): + require_permission(request, "system.role.view") + return [serialize_role(group) for group in Group.objects.all().order_by("id")] + + +@router.post("/roles", response=RoleDetailOut) +def create_role(request, payload: RoleCreateIn): + require_permission(request, "system.role.manage") + group = Group.objects.create(name=payload.name) + group.permissions.set(permission_objects_for_codes(payload.permission_codes)) + return serialize_role(group) + + +@router.patch("/roles/{role_id}", response=RoleDetailOut) +def update_role(request, role_id: int, payload: RoleUpdateIn): + require_permission(request, "system.role.manage") + group = Group.objects.get(id=role_id) + if payload.name is not None: + group.name = payload.name + group.save(update_fields=["name"]) + return serialize_role(group) + + +@router.post("/roles/{role_id}/permissions", response=RoleDetailOut) +def set_role_permissions(request, role_id: int, payload: PermissionCodesIn): + require_permission(request, "system.role.manage") + group = Group.objects.get(id=role_id) + group.permissions.set(permission_objects_for_codes(payload.permission_codes)) + return serialize_role(group) + + +@router.get("/permissions", response=list[PermissionOut]) +def list_permissions(request): + require_permission(request, "system.role.view") + return [ + {"codename": permission.codename, "name": permission.name, "module": permission.module} + for permission in PERMISSIONS + ] + + +@router.get("/menus", response=list[MenuOut]) +def list_menus(request): + require_permission(request, "system.menu.view") + return [serialize_menu_flat(menu) for menu in MenuItem.objects.all()] + + +@router.patch("/menus/{menu_id}", response=MenuOut) +def update_menu(request, menu_id: int, payload: MenuUpdateIn): + require_permission(request, "system.menu.manage") + menu = MenuItem.objects.get(id=menu_id) + for field, value in payload.dict(exclude_unset=True).items(): + setattr(menu, field, value) + menu.save() + return serialize_menu_flat(menu) diff --git a/backend/apps/accounts/schemas.py b/backend/apps/accounts/schemas.py index be0d2dd..0e97dc7 100644 --- a/backend/apps/accounts/schemas.py +++ b/backend/apps/accounts/schemas.py @@ -57,3 +57,74 @@ class BasicUserOut(Schema): is_superuser: bool roles: list[RoleOut] = [] user_groups: list[UserGroupOut] = [] + + +class UserCreateIn(Schema): + username: str + display_name: str = "" + email: str = "" + password: str + is_active: bool = True + + +class UserUpdateIn(Schema): + display_name: str | None = None + email: str | None = None + is_active: bool | None = None + + +class PasswordResetIn(Schema): + password: str + + +class IdsIn(Schema): + user_ids: list[int] = [] + role_ids: list[int] = [] + + +class UserGroupCreateIn(Schema): + name: str + code: str + description: str = "" + is_active: bool = True + + +class UserGroupUpdateIn(Schema): + name: str | None = None + description: str | None = None + is_active: bool | None = None + + +class PermissionOut(Schema): + codename: str + name: str + module: str + + +class RoleDetailOut(Schema): + id: int + name: str + permission_codes: list[str] + + +class RoleCreateIn(Schema): + name: str + permission_codes: list[str] = [] + + +class RoleUpdateIn(Schema): + name: str | None = None + + +class PermissionCodesIn(Schema): + permission_codes: list[str] + + +class MenuUpdateIn(Schema): + title: str | None = None + path: str | None = None + icon: str | None = None + permission_code: str | None = None + is_visible: bool | None = None + is_enabled: bool | None = None + sort_order: int | None = None diff --git a/backend/apps/accounts/tests/test_governance_api.py b/backend/apps/accounts/tests/test_governance_api.py new file mode 100644 index 0000000..3a6b642 --- /dev/null +++ b/backend/apps/accounts/tests/test_governance_api.py @@ -0,0 +1,87 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.core.management import call_command + +from apps.accounts.models import MenuItem, UserGroup + + +def login_admin(client): + call_command("bootstrap_governance") + user = get_user_model().objects.get(username="admin") + client.force_login(user) + return user + + +def test_admin_manages_users_groups_roles_permissions_and_menus(client, db): + login_admin(client) + + created_user = client.post( + "/api/accounts/users", + data={ + "username": "support", + "display_name": "技术支持一号", + "email": "support@example.com", + "password": "support12345", + "is_active": True, + }, + content_type="application/json", + ) + assert created_user.status_code == 200 + user_id = created_user.json()["id"] + + user_group = client.post( + "/api/accounts/user-groups", + data={"name": "一线支持组", "code": "support_l1", "description": "处理一线工单"}, + content_type="application/json", + ) + assert user_group.status_code == 200 + user_group_id = user_group.json()["id"] + + members = client.post( + f"/api/accounts/user-groups/{user_group_id}/members", + data={"user_ids": [user_id]}, + content_type="application/json", + ) + assert members.status_code == 200 + assert UserGroup.objects.get(id=user_group_id).users.filter(id=user_id).exists() + + role = client.post( + "/api/accounts/roles", + data={"name": "工单观察员", "permission_codes": ["ticket.view"]}, + content_type="application/json", + ) + assert role.status_code == 200 + role_id = role.json()["id"] + + role_permissions = client.post( + f"/api/accounts/roles/{role_id}/permissions", + data={"permission_codes": ["ticket.view", "solution.view"]}, + content_type="application/json", + ) + assert role_permissions.status_code == 200 + assert set(role_permissions.json()["permission_codes"]) == {"ticket.view", "solution.view"} + + user_roles = client.post( + f"/api/accounts/users/{user_id}/roles", + data={"role_ids": [role_id]}, + content_type="application/json", + ) + assert user_roles.status_code == 200 + assert Group.objects.get(id=role_id).user_set.filter(id=user_id).exists() + + menus = client.get("/api/accounts/menus") + assert menus.status_code == 200 + menu_id = MenuItem.objects.get(code="system.users").id + updated_menu = client.patch( + f"/api/accounts/menus/{menu_id}", + data={"title": "账号管理", "permission_code": "system.user.view"}, + content_type="application/json", + ) + assert updated_menu.status_code == 200 + assert updated_menu.json()["title"] == "账号管理" + + +def test_anonymous_governance_access_is_denied(client, db): + response = client.get("/api/accounts/users") + + assert response.status_code == 401