diff --git a/backend/app/api/v1/menu.py b/backend/app/api/v1/menu.py index f32f6265..c21570ab 100644 --- a/backend/app/api/v1/menu.py +++ b/backend/app/api/v1/menu.py @@ -1,7 +1,54 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from fastapi import APIRouter +from typing import Annotated + +from fastapi import APIRouter, Query, Request + +from backend.app.common.casbin_rbac import DependsRBAC +from backend.app.common.jwt import DependsJwtAuth +from backend.app.common.response.response_schema import response_base +from backend.app.schemas.menu import GetAllMenu, CreateMenu, UpdateMenu +from backend.app.services.menu_service import MenuService +from backend.app.utils.serializers import select_to_json router = APIRouter() -# TODO: 添加 menu 相关接口 +@router.get('/{pk}', summary='获取目录详情', dependencies=[DependsJwtAuth]) +async def get_menu(pk: int): + menu = await MenuService.get(pk=pk) + data = GetAllMenu(**select_to_json(menu)) + return await response_base.success(data=data) + + +@router.get('', summary='获取所有目录展示树', dependencies=[DependsJwtAuth]) +async def get_all_menus( + name: Annotated[str | None, Query()] = None, + status: Annotated[bool | None, Query()] = None, +): + menu = await MenuService.get_select(name=name, status=status) + return await response_base.success(data=menu) + + +@router.post('', summary='创建目录', dependencies=[DependsRBAC]) +async def create_menu(request: Request, obj: CreateMenu): + await MenuService.create(obj=obj, user_id=request.user.id) + return await response_base.success() + + +@router.put('/{pk}', summary='更新目录', dependencies=[DependsRBAC]) +async def update_menu(request: Request, pk: int, obj: UpdateMenu): + count = await MenuService.update(pk=pk, obj=obj, user_id=request.user.id) + if count > 0: + return await response_base.success() + return await response_base.fail() + + +@router.delete('{pk}', summary='删除目录', dependencies=[DependsRBAC]) +async def delete_menu(pk: int): + count = await MenuService.delete(pk=pk) + if count > 0: + return await response_base.success() + return await response_base.fail() + + + diff --git a/backend/app/crud/crud_dept.py b/backend/app/crud/crud_dept.py index 657b4868..f86f8222 100644 --- a/backend/app/crud/crud_dept.py +++ b/backend/app/crud/crud_dept.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from typing import Any -from sqlalchemy import select, desc, and_ +from sqlalchemy import select, desc, and_, asc from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -21,7 +21,7 @@ class CRUDDept(CRUDBase[Dept, CreateDept, UpdateDept]): async def get_all( self, db: AsyncSession, name: str = None, leader: str = None, phone: str = None, status: bool = None ) -> Any: - se = select(self.model).order_by(desc(self.model.sort)) + se = select(self.model).order_by(asc(self.model.sort)) where_list = [self.model.del_flag == 0] if name: where_list.append(self.model.name.like(f'%{name}%')) diff --git a/backend/app/crud/crud_menu.py b/backend/app/crud/crud_menu.py index 95817e7b..ae57e445 100644 --- a/backend/app/crud/crud_menu.py +++ b/backend/app/crud/crud_menu.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +from sqlalchemy import select, asc, and_ + from backend.app.crud.base import CRUDBase from backend.app.models import Menu from backend.app.schemas.menu import CreateMenu, UpdateMenu @@ -9,5 +11,35 @@ class CRUDMenu(CRUDBase[Menu, CreateMenu, UpdateMenu]): async def get(self, db, menu_id: int) -> Menu | None: return await self.get_(db, pk=menu_id) + async def get_by_name(self, db, name: str) -> Menu | None: + return await self.get_(db, name=name) + + async def get_all(self, db, name: str | None = None, status: bool | None = None) -> list[Menu]: + se = select(self.model).order_by(asc(self.model.sort)) + where_list = [] + if name: + where_list.append(self.model.name.like(f'%{name}%')) + if status is not None: + where_list.append(self.model.status == status) + if where_list: + se = se.where(and_(*where_list)) + menu = await db.execute(se) + return menu.scalars().all() + + async def create(self, db, obj_in: dict) -> None: + obj = self.model(**obj_in) + db.add(obj) + + async def update(self, db, menu_id: int, obj_in: dict) -> int: + return await self.update_(db, menu_id, obj_in) + + async def delete(self, db, menu_id: int) -> int: + return await self.delete_(db, menu_id) + + async def get_children(self, db, menu_id: int) -> list[Menu]: + result = await db.execute(select(self.model).where(self.model.id == menu_id)) + menu = result.scalars().first() + return menu.children + MenuDao: CRUDMenu = CRUDMenu(Menu) diff --git a/backend/app/models/sys_menu.py b/backend/app/models/sys_menu.py index 1aa9c4f7..e27e4c59 100644 --- a/backend/app/models/sys_menu.py +++ b/backend/app/models/sys_menu.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from sqlalchemy import String +from typing import Union + +from sqlalchemy import String, ForeignKey from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -15,14 +17,21 @@ class Menu(Base): id: Mapped[id_key] = mapped_column(init=False) name: Mapped[str] = mapped_column(String(50), unique=True, comment='菜单名称') - parent_id: Mapped[int] = mapped_column(default=0, comment='父菜单ID') level: Mapped[int] = mapped_column(default=0, comment='菜单层级') - sort: Mapped[int] = mapped_column(default=0, comment='显示顺序') - path: Mapped[str] = mapped_column(String(200), default='', comment='路由地址') + sort: Mapped[int] = mapped_column(default=0, comment='排序') + icon: Mapped[str | None] = mapped_column(String(100), default=None, comment='菜单图标') + path: Mapped[str | None] = mapped_column(String(200), default=None, comment='路由地址') menu_type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮)') - icon: Mapped[str | None] = mapped_column(String(100), default='#', comment='菜单图标') + component: Mapped[str | None] = mapped_column(String(255), default=None, comment='组件路径') + perms: Mapped[str | None] = mapped_column(String(100), default=None, comment='权限标识') + status: Mapped[bool] = mapped_column(default=True, comment='菜单状态(0停用 1正常)') remark: Mapped[str | None] = mapped_column(LONGTEXT, default=None, comment='备注') - del_flag: Mapped[bool] = mapped_column(default=False, comment='删除标志(0删除 1存在)') + parent_id: Mapped[int | None] = mapped_column( + ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID' + ) + # 父级菜单一对多 + parent: Mapped[Union['Menu', None]] = relationship(init=False, back_populates='children', remote_side=[id]) + children: Mapped[list['Menu'] | None] = relationship(init=False, back_populates='parent') # 菜单角色多对多 roles: Mapped[list['Role']] = relationship( # noqa: F821 init=False, secondary=sys_role_menu, back_populates='menus' diff --git a/backend/app/models/sys_role.py b/backend/app/models/sys_role.py index 85887d20..b49fb8e2 100644 --- a/backend/app/models/sys_role.py +++ b/backend/app/models/sys_role.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- from sqlalchemy import String +from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.database.base_class import Base, id_key @@ -16,7 +17,8 @@ class Role(Base): id: Mapped[id_key] = mapped_column(init=False) name: Mapped[str] = mapped_column(String(20), unique=True, comment='角色名称') data_scope: Mapped[int | None] = mapped_column(default=2, comment='数据范围(1:全部数据权限 2:自定数据权限)') - del_flag: Mapped[bool] = mapped_column(default=False, comment='删除标志(0删除 1存在)') + status: Mapped[bool] = mapped_column(default=True, comment='角色状态(0停用 1正常)') + remark: Mapped[str | None] = mapped_column(LONGTEXT, default=None, comment='备注') # 角色用户多对多 users: Mapped[list['User']] = relationship( # noqa: F821 init=False, secondary=sys_user_role, back_populates='roles' diff --git a/backend/app/schemas/menu.py b/backend/app/schemas/menu.py index 5e484d18..0db062c5 100644 --- a/backend/app/schemas/menu.py +++ b/backend/app/schemas/menu.py @@ -9,19 +9,20 @@ from backend.app.common.enums import MenuType class MenuBase(BaseModel): name: str - parent_id: int = Field(default=0, ge=0, description='菜单父级ID') - level: int = Field(default=0, ge=0, description='菜单层级') + parent_id: int = Field(default=None, ge=1, description='菜单父级ID') sort: int = Field(default=0, ge=0, description='排序') - path: str = Field(..., description='路由地址') - menu_type: int = Field(default=MenuType.directory, ge=0, description='菜单类型(0目录 1菜单 2按钮)') icon: str | None = None + path: str | None = None + menu_type: int = Field(default=MenuType.directory, ge=0, description='菜单类型(0目录 1菜单 2按钮)') + component: str | None = None + perms: str | None = None + status: bool remark: str | None = None - del_flag: bool @validator('menu_type') def menu_type_validator(cls, v): if v not in MenuType.get_member_values(): - raise ValueError('菜单类型只能是0、1、2') + raise ValueError('菜单类型只能是 0,1,2') return v diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py index 4fac9486..bc505c52 100644 --- a/backend/app/schemas/role.py +++ b/backend/app/schemas/role.py @@ -10,8 +10,9 @@ from backend.app.schemas.menu import GetAllMenu class RoleBase(BaseModel): name: str - data_scope: int | None = Field(default=RoleDataScope.custom, description='数据范围(1:全部数据权限 2:自定数据权限)') # noqa: E501 - del_flag: bool + data_scope: int | None = Field(default=RoleDataScope.custom, description='数据范围(1:全部数据权限 2:自定数据权限)') + status: bool + remark: str | None = None @validator('data_scope') def data_scope_validator(cls, v): diff --git a/backend/app/services/menu_service.py b/backend/app/services/menu_service.py index 778e575b..89b579d7 100644 --- a/backend/app/services/menu_service.py +++ b/backend/app/services/menu_service.py @@ -1,7 +1,57 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +from backend.app.common.exception import errors +from backend.app.crud.crud_menu import MenuDao +from backend.app.database.db_mysql import async_db_session +from backend.app.schemas.menu import CreateMenu, UpdateMenu +from backend.app.utils.build_tree import get_tree_data class MenuService: - # TODO: 添加 menu 相关服务 - pass + @staticmethod + async def get(pk: int): + async with async_db_session() as db: + menu = await MenuDao.get(db, menu_id=pk) + if not menu: + raise errors.NotFoundError(msg='菜单不存在') + return menu + + @staticmethod + async def get_select(name: str | None = None, status: bool | None = None): + async with async_db_session() as db: + menu_select = await MenuDao.get_all(db, name=name, status=status) + menu_tree = await get_tree_data(menu_select) + return menu_tree + + @staticmethod + async def create(obj: CreateMenu, user_id: int): + async with async_db_session.begin() as db: + menu = await MenuDao.get_by_name(db, obj.name) + if menu: + raise errors.ForbiddenError(msg='菜单名称已存在') + new_obj = obj.dict() + new_obj.update({'level': obj.parent_id + 1 if obj.parent_id else 1, 'create_user': user_id}) + await MenuDao.create(db, new_obj) + + @staticmethod + async def update(pk: int, obj: UpdateMenu, user_id: int): + async with async_db_session.begin() as db: + menu = await MenuDao.get(db, pk) + if not menu: + raise errors.NotFoundError(msg='菜单不存在') + if menu.name != obj.name: + if await MenuDao.get_by_name(db, obj.name): + raise errors.ForbiddenError(msg='菜单名称已存在') + new_obj = obj.dict() + new_obj.update({'level': obj.parent_id + 1 if obj.parent_id else 1, 'update_user': user_id}) + count = await MenuDao.update(db, pk, new_obj) + return count + + @staticmethod + async def delete(pk: int): + async with async_db_session.begin() as db: + children = await MenuDao.get_children(db, pk) + if children: + raise errors.ForbiddenError(msg='菜单下存在子菜单,无法删除') + count = await MenuDao.delete(db, pk) + return count