feat: add governance management api

This commit is contained in:
hailin
2026-07-01 14:07:39 +08:00
parent 18319c1fc8
commit 83e9960d18
3 changed files with 391 additions and 3 deletions

View File

@@ -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 django.middleware.csrf import get_token
from ninja import Router from ninja import Router
from ninja.errors import HttpError 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.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"]) router = Router(tags=["accounts"])
@@ -20,6 +40,11 @@ def permission_codes_for_user(user) -> list[str]:
return sorted(codes) 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: def serialize_menu(menu: MenuItem, children: list[dict]) -> dict:
return { return {
"id": menu.id, "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): def require_login(request):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise HttpError(401, "Authentication required") raise HttpError(401, "Authentication required")
@@ -119,3 +190,162 @@ def logout_user(request):
def current_user(request): def current_user(request):
user = require_login(request) user = require_login(request)
return serialize_current_user(user) 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)

View File

@@ -57,3 +57,74 @@ class BasicUserOut(Schema):
is_superuser: bool is_superuser: bool
roles: list[RoleOut] = [] roles: list[RoleOut] = []
user_groups: list[UserGroupOut] = [] 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

View File

@@ -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