diff --git a/users/departments.py b/users/departments.py index 7fc11c5..e86f2d1 100644 --- a/users/departments.py +++ b/users/departments.py @@ -1,60 +1,71 @@ # api/departments.py from typing import List +from django.db import IntegrityError from django.shortcuts import get_object_or_404 from ninja import Router from ninja.errors import HttpError from users.models import Department -from users.permission import has_permission +from users.permission import has_permission, require_permissions from users.schema import DepartmentOut, DepartmentCreate, DepartmentUpdate router = Router(tags=['depts']) -@router.post("/", response=DepartmentOut, summary="创建部门") -def create_department(request, payload: DepartmentCreate): - if not has_permission(request.auth.id, "dept:create"): - raise HttpError(403, "权限不足") - - data = payload.dict() - parent_id = data.pop('parent_id', None) - if parent_id: - data['parent_id'] = parent_id - - dept = Department.objects.create(**data) - return dept - - @router.get("/", response=List[DepartmentOut], summary="获取部门列表(扁平)") +@require_permissions("dept:list") def list_departments(request): - if not has_permission(request.auth.id, "dept:list"): - raise HttpError(403, "权限不足") return Department.objects.all() @router.get("/tree/", response=List[dict], summary="获取部门树形结构") +@require_permissions("dept:list") def get_department_tree(request): - if not has_permission(request.auth.id, "dept:list"): - raise HttpError(403, "权限不足") - depts = Department.objects.all().order_by('order') return build_dept_tree(list(depts)) -@router.get("/{dept_id}/", response=DepartmentOut, summary="获取部门详情") -def get_department(request, dept_id: int): - if not has_permission(request.auth.id, "dept:view"): +@router.post("/", response=DepartmentOut, summary="创建部门") +@require_permissions("dept:create") +def create_department(request, payload: DepartmentCreate): + if not has_permission(request.auth.id, "dept:create"): raise HttpError(403, "权限不足") + + # 👇 新增:校验根部门唯一性 + if payload.parent_id is None: + if Department.objects.filter(parent__isnull=True).exists(): + raise HttpError(400, "根部门已存在,无法创建新的根部门") + + data = payload.dict() + parent_id = data.pop('parent_id', None) + if parent_id: + # 可以额外校验 parent_id 是否真实存在 + if not Department.objects.filter(id=parent_id).exists(): + raise HttpError(400, "指定的上级部门不存在") + data['parent_id'] = parent_id + + try: + dept = Department.objects.create(**data) + return dept + except IntegrityError as e: + # 捕获数据库层面的唯一性冲突,提供统一错误信息 + if "unique_root_department" in str(e): + raise HttpError(400, "根部门已存在,无法创建新的根部门") + else: + raise HttpError(400, "创建部门失败,请检查数据") + + +@router.get("/{dept_id}/", response=DepartmentOut, summary="获取部门详情") +@require_permissions("dept:view") +def get_department(request, dept_id: int): dept = get_object_or_404(Department, id=dept_id) return dept @router.put("/{dept_id}/", response=DepartmentOut, summary="更新部门") +@require_permissions("dept:update") def update_department(request, dept_id: int, payload: DepartmentUpdate): - if not has_permission(request.auth.id, "dept:update"): - raise HttpError(403, "权限不足") - dept = get_object_or_404(Department, id=dept_id) data = payload.dict() parent_id = data.pop('parent_id', None) @@ -70,10 +81,8 @@ def update_department(request, dept_id: int, payload: DepartmentUpdate): @router.delete("/{dept_id}/", summary="删除部门") +@require_permissions("dept:delete") def delete_department(request, dept_id: int): - if not has_permission(request.auth.id, "dept:delete"): - raise HttpError(403, "权限不足") - dept = get_object_or_404(Department, id=dept_id) # 可选:检查是否有子部门或用户 if dept.user_set.exists() or Department.objects.filter(parent=dept).exists(): diff --git a/users/menus.py b/users/menus.py index b451e37..e5aba2b 100644 --- a/users/menus.py +++ b/users/menus.py @@ -1,23 +1,29 @@ -# api/menus.py from typing import List from django.shortcuts import get_object_or_404 from loguru import logger from ninja import Router -from ninja.errors import HttpError from users.models import Menu -from users.permission import has_permission +from users.permission import require_permissions from users.schema import MenuOut, MenuCreate, MenuUpdate router = Router(tags=['menus']) -@router.post("/", response=MenuOut, summary="创建菜单") -def create_menu(request, payload: MenuCreate): - if not has_permission(request.auth.id, "menu:create"): - raise HttpError(403, "权限不足") +@router.get("/", response=List[MenuOut], summary="获取菜单列表") +@require_permissions("menu:list") +def list_menus(request): + menus = Menu.objects.all() + logger.info(menus) + permissions = [m.permission_code for m in menus] + logger.info(permissions) + return menus + +@router.post("/", response=MenuOut, summary="创建菜单") +@require_permissions("menu:create") +def create_menu(request, payload: MenuCreate): data = payload.dict() parent_id = data.pop('parent_id', None) if parent_id: @@ -27,29 +33,16 @@ def create_menu(request, payload: MenuCreate): return menu -@router.get("/", response=List[MenuOut], summary="获取菜单列表") -def list_menus(request): - if not has_permission(request.auth.id, "menu:list"): - raise HttpError(403, "权限不足") - menus = Menu.objects.all() - logger.info(menus) - permissions = [m.permission_code for m in menus] - logger.info(permissions) - return menus - - @router.get("/{menu_id}/", response=MenuOut, summary="获取菜单详情") +@require_permissions("menu:view") def get_menu(request, menu_id: int): - if not has_permission(request.auth.id, "menu:view"): - raise HttpError(403, "权限不足") menu = get_object_or_404(Menu, id=menu_id) return menu @router.put("/{menu_id}/", response=MenuOut, summary="更新菜单") +@require_permissions("menu:update") def update_menu(request, menu_id: int, payload: MenuUpdate): - # if not has_permission(request.auth.id, "menu:update"): - # raise HttpError(403, "权限不足") logger.debug(payload) menu = get_object_or_404(Menu, id=menu_id) data = payload.dict() @@ -67,9 +60,8 @@ def update_menu(request, menu_id: int, payload: MenuUpdate): @router.delete("/{menu_id}/", summary="删除菜单") +@require_permissions("menu:delete") def delete_menu(request, menu_id: int): - if not has_permission(request.auth.id, "menu:delete"): - raise HttpError(403, "权限不足") menu = get_object_or_404(Menu, id=menu_id) menu.delete() return {"success": True} diff --git a/users/models.py b/users/models.py index 30b50ef..2cf0d1e 100644 --- a/users/models.py +++ b/users/models.py @@ -33,9 +33,22 @@ class Department(BaseEntity): verbose_name = "部门" verbose_name_plural = "部门管理" + # 👇 核心:添加部分唯一约束 + constraints = [ + models.UniqueConstraint( + fields=['parent'], + condition=models.Q(parent__isnull=True), + name='unique_root_department' + ) + ] + def __str__(self): return self.name + @property + def is_root(self): + return self.parent is None + class User(AbstractUser, PermissionsMixin, BaseEntity): username = models.CharField("英文名/账号", max_length=30, unique=True) diff --git a/users/permission.py b/users/permission.py index 0ccdbc7..ff43b79 100644 --- a/users/permission.py +++ b/users/permission.py @@ -2,8 +2,63 @@ from typing import List from django.core.cache import cache from loguru import logger +from ninja.errors import HttpError from users.models import User, Menu # 替换为你的实际 app 名 +from functools import wraps +import asyncio +from asgiref.sync import sync_to_async + + +# 你的权限检查函数(不变) +def has_permission(user_id: int, permission: str) -> bool: + # 你原来的逻辑 + return True + + +# ====================== 多权限通用装饰器 ====================== +def require_permissions(*perms: str, match_all: bool = True): + """ + 多权限检查装饰器 + :param perms: 权限字符串,可传多个 + :param match_all: True = 必须拥有所有权限(AND),False = 拥有任意一个即可(OR) + 用法: + @require_permissions("role:update", "role:delete") # 两个都要有 + @require_permissions("role:view", "role:list", match_all=False) # 有一个就行 + """ + + def decorator(view_func): + @wraps(view_func) + async def async_wrapper(request, *args, **kwargs): + user_id = request.auth.id + check = sync_to_async(has_permission) + + # 检查所有权限 + results = [await check(user_id, perm) for perm in perms] + allowed = all(results) if match_all else any(results) + + if not allowed: + raise HttpError(403, "权限不足") + + return await view_func(request, *args, **kwargs) + + @wraps(view_func) + def sync_wrapper(request, *args, **kwargs): + user_id = request.auth.id + results = [has_permission(user_id, perm) for perm in perms] + allowed = all(results) if match_all else any(results) + + if not allowed: + raise HttpError(403, "权限不足") + + return view_func(request, *args, **kwargs) + + # 自动识别同步/异步 + if asyncio.iscoroutinefunction(view_func): + return async_wrapper + return sync_wrapper + + return decorator def get_user_permissions(user_id: int) -> List[str]: diff --git a/users/permissions.py b/users/permissions.py index e6d838e..01253c6 100644 --- a/users/permissions.py +++ b/users/permissions.py @@ -3,13 +3,14 @@ from loguru import logger from ninja import Router from users.models import Menu -from users.permission import build_menu_tree, get_user_permissions +from users.permission import build_menu_tree, get_user_permissions, require_permissions router = Router(tags=['perms']) # ========== 用户专属接口 ========== @router.get("/menus/", summary="获取当前用户菜单树") +@require_permissions("menu:list") def get_user_menu_tree(request): menus = Menu.objects.filter( roles__users__id=request.auth.id, @@ -22,6 +23,7 @@ def get_user_menu_tree(request): @router.get("/permissions/", summary="获取当前用户按钮权限") +@require_permissions("menu:list") def get_user_permissions_api(request): perms = get_user_permissions(request.auth.id) return {"permissions": perms} diff --git a/users/roles.py b/users/roles.py index 700eb48..f24b277 100644 --- a/users/roles.py +++ b/users/roles.py @@ -6,39 +6,35 @@ from ninja import Router from ninja.errors import HttpError from users.models import Role -from users.permission import has_permission, clear_user_permissions_cache +from users.permission import clear_user_permissions_cache, require_permissions from users.schema import RoleOut, RoleCreate, RoleMenuAssign, RoleUpdate router = Router(tags=['roles']) +@router.get("/", response=List[RoleOut], summary="获取角色列表") +@require_permissions("role:list") +def list_roles(request): + return Role.objects.all() + + @router.post("/", response=RoleOut, summary="创建角色") +@require_permissions("role:create") def create_role(request, payload: RoleCreate): - if not has_permission(request.auth.id, "role:create"): - raise HttpError(403, "权限不足") role = Role.objects.create(**payload.dict()) return role -@router.get("/", response=List[RoleOut], summary="获取角色列表") -def list_roles(request): - if not has_permission(request.auth.id, "role:list"): - raise HttpError(403, "权限不足") - return Role.objects.all() - - @router.get("/{role_id}/", response=RoleOut, summary="获取角色详情") +@require_permissions("role:view") def get_role(request, role_id: int): - if not has_permission(request.auth.id, "role:view"): - raise HttpError(403, "权限不足") role = get_object_or_404(Role, id=role_id) return role @router.put("/{role_id}/", response=RoleOut, summary="更新角色") +@require_permissions("role:update") def update_role(request, role_id: int, payload: RoleUpdate): - if not has_permission(request.auth.id, "role:update"): - raise HttpError(403, "权限不足") role = get_object_or_404(Role, id=role_id) for attr, value in payload.dict().items(): setattr(role, attr, value) @@ -47,19 +43,16 @@ def update_role(request, role_id: int, payload: RoleUpdate): @router.delete("/{role_id}/", summary="删除角色") +@require_permissions("role:delete") def delete_role(request, role_id: int): - if not has_permission(request.auth.id, "role:delete"): - raise HttpError(403, "权限不足") role = get_object_or_404(Role, id=role_id) role.delete() return {"success": True} @router.post("/{role_id}/menus/", summary="分配角色菜单权限") +@require_permissions("role:assign_permissions") def assign_role_menus(request, role_id: int, payload: RoleMenuAssign): - if not has_permission(request.auth.id, "role:assign_permissions"): - raise HttpError(403, "权限不足") - role = get_object_or_404(Role, id=role_id) # 直接使用 ManyToManyField 的 set() 方法 role.menus.set(payload.menu_ids) diff --git a/users/schema.py b/users/schema.py index 1008188..eacaff5 100644 --- a/users/schema.py +++ b/users/schema.py @@ -2,12 +2,18 @@ from datetime import datetime from typing import List, Optional -from ninja import Schema, ModelSchema - -from users.models import User +from ninja import Schema # ========== 用户 Schema ========== +class UserRoleOut(Schema): + id: int + name: str + + +class UserDeptOut(Schema): + id: int + name: str class UserOut(Schema): @@ -18,8 +24,8 @@ class UserOut(Schema): email: Optional[str] = None avatar: Optional[str] = None - # dept: Optional[int] = None - # roles: List[int] = [] + dept: Optional[UserDeptOut] = None + roles: List[UserRoleOut] = [] is_active: bool is_staff: bool @@ -31,7 +37,7 @@ class UserOut(Schema): class UserCreate(Schema): username: str - password: str + password: Optional[str] = None cname: Optional[str] = None phone: Optional[str] = None email: Optional[str] = None @@ -40,15 +46,20 @@ class UserCreate(Schema): is_active: bool = True +class UserResetPassword(Schema): + user_id: int + new_password: str + + class UserUpdate(Schema): - username: Optional[str] - password: Optional[str] - cname: Optional[str] - phone: Optional[str] - email: Optional[str] - dept: Optional[int] - roles: Optional[List[int]] - is_active: Optional[bool] + username: Optional[str] = None + password: Optional[str] = None + cname: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + dept: Optional[int] = None + roles: Optional[List[int]] = None + is_active: Optional[bool] = None # ========== 菜单 Schema ========== diff --git a/users/users.py b/users/users.py index 802a2b8..c233b2d 100644 --- a/users/users.py +++ b/users/users.py @@ -1,50 +1,100 @@ +import secrets from typing import List -from django.db.models import Prefetch +from django.contrib.auth.hashers import make_password +from django.db import IntegrityError +from django.db.models import Q from django.shortcuts import get_object_or_404 +from loguru import logger from ninja import Router +from ninja.errors import HttpError from ninja.pagination import paginate, PageNumberPagination -from users.models import User, Role -from users.schema import UserUpdate, UserOut, UserCreate +from users.models import User, Department +from users.permission import require_permissions +from users.schema import UserUpdate, UserOut, UserCreate, UserResetPassword +from utils.dept_utils import get_dept_children_ids router = Router(tags=['users']) @router.get("/", response=List[UserOut], summary="获取用户列表") @paginate(PageNumberPagination, page_size=20) -def list_users(request): - # if not has_permission(request.auth.id, "user:list"): - # raise HttpError(403, "权限不足") - return User.objects.all() +@require_permissions("user:list") +def list_users(request, dept_id: int = None, q: str = None): + users = User.objects.all() + logger.debug(f"users: {len(users)}") + + logger.debug(f"dept_id: {dept_id}, q: {q}") + if dept_id: + current_dept = get_object_or_404(Department, id=dept_id) + dept_ids = get_dept_children_ids(dept_id) + if current_dept.is_root: + # 包含未分配用户 + users = users.filter(Q(dept_id__in=dept_ids) | Q(dept__isnull=True)) + else: + users = users.filter(dept_id__in=dept_ids) + logger.debug(f"users: {len(users)}") + + if q: + users = users.filter( + Q(username__icontains=q) | + Q(cname__icontains=q) | + Q(phone__icontains=q) + ) + + logger.debug(f"users: {len(users)}") + return users.order_by("dept_id") -@router.get("/", response=dict) -def list_users(request, page: int = 1, page_size: int = 10, username: str = None): - qs = User.objects.all().order_by("-id") +@router.post("/") +@require_permissions("user:create") +def create_user(request, data: UserCreate): + try: + if not data.password: + data.password = secrets.token_urlsafe(8)[:8] - if username: - qs = qs.filter(username__icontains=username) + logger.debug(f"用户名:{data.username}, 密码:{data.password}") + user = User.objects.create_user( + username=data.username, + email=data.email, + password=make_password(data.password) + ) - total = qs.count() - start = (page - 1) * page_size - end = start + page_size + for k, v in data.dict(exclude={"password", "roles"}).items(): + setattr(user, k, v) - items = list(qs[start:end].values( - "id", "username", "cname", "phone", "email", - "avatar", "dept", - "is_active", "is_staff", "is_superuser", - "last_login", "date_joined" - )) + user.save() - # roles 需要手动补 - for i, obj in enumerate(qs[start:end]): - items[i]["roles"] = list(obj.roles.values_list("id", flat=True)) + if data.roles: + user.roles.set(data.roles) - return {"items": items, "count": total} + return { + "detail": f"用户创建成功!密码:{data.password}" + } + except IntegrityError as e: + raise HttpError(500, "用户已存在!") + + +@router.post("/reset-password/", summary="重置用户密码") +@require_permissions("user:reset_password") +def reset_password(request, payload: UserResetPassword): + user = get_object_or_404(User, id=payload.user_id) + + if payload.new_password: + new_pass = payload.new_password + else: + new_pass = secrets.token_urlsafe(8)[:12] # 自动生成 + + user.password = make_password(new_pass) + user.save() + + # 返回新密码(仅用于通知,前端可显示) + return {"detail": f"密码已重置: {new_pass}"} @router.get("/{id}", response=UserOut) +@require_permissions("user:view") def get_user(request, id: int): obj = get_object_or_404(User, id=id) data = obj.__dict__ @@ -52,25 +102,8 @@ def get_user(request, id: int): return data -@router.post("/", response=UserOut) -def create_user(request, data: UserCreate): - user = User.objects.create_user( - username=data.username, - password=data.password - ) - - for k, v in data.dict(exclude={"password"}).items(): - setattr(user, k, v) - - user.save() - - if data.roles: - user.roles.set(data.roles) - - return user - - @router.put("/{id}") +@require_permissions("user:update") def update_user(request, id: int, data: UserUpdate): user = get_object_or_404(User, id=id) @@ -78,6 +111,8 @@ def update_user(request, id: int, data: UserUpdate): user.set_password(data.password) for k, v in data.dict(exclude_unset=True, exclude={"password", "roles"}).items(): + if k == "dept": + k = "dept_id" setattr(user, k, v) user.save() @@ -85,10 +120,13 @@ def update_user(request, id: int, data: UserUpdate): if data.roles is not None: user.roles.set(data.roles) + if data.password: + return {"detail": f"密码已重置: {data.password}"} return {"success": True} @router.delete("/{id}") +@require_permissions("user:delete") def delete_user(request, id: int): User.objects.filter(id=id).delete() return {"success": True}