Compare commits

...

22 Commits

Author SHA1 Message Date
ngfchl dfa757c0d9 update. 完善新闻接口权限处理 2026-04-11 05:29:51 +08:00
ngfchl 7dd59de036 update. 完善用户相关增删改查接口以及Schema模型 2026-04-11 05:29:27 +08:00
ngfchl a9de2d1f68 update. 优化细节 2026-04-11 05:28:56 +08:00
ngfchl ab6c0a9b49 add. 添加递归获取当前部门及所有子部门的 ID 列表工具方法 2026-04-10 19:00:21 +08:00
ngfchl 0f43e10c5b fixed. 修复角色权限分配接口 2026-04-10 18:56:32 +08:00
ngfchl c2b681a03a fixed. 修复接口raise仍然返回成功的BUG 2026-04-10 18:54:50 +08:00
ngfchl 6108dd3346 update. 升级上传接口,支持按文件种类分类 2026-04-10 12:39:23 +08:00
ngfchl ec38651899 add. 添加.env数据库模板 2026-04-10 12:38:06 +08:00
ngfchl 8161a27cf5 update. 初步完成用户管理接口 2026-04-09 13:33:40 +08:00
ngfchl 2ed7904ada update. 更新文章接口 2026-04-08 15:27:21 +08:00
ngfchl 5670ca57e6 update. 更新新闻接口 2026-04-08 15:27:15 +08:00
ngfchl b1e7488970 update. 更新站点基本信息接口 2026-04-08 15:27:08 +08:00
ngfchl b7cacbcf6a update. 更新 ninja 文件上传中间件 2026-04-08 08:35:24 +08:00
ngfchl 3ca5b4a18d update. 调整 admin 用户的 admin 后台权限 2026-04-08 08:34:15 +08:00
ngfchl 5588bbaac8 update. 完成导航菜单接口 2026-04-07 22:44:30 +08:00
ngfchl fdc10692af update. 更新数据填充脚本 2026-04-07 22:00:22 +08:00
ngfchl d25b099658 add. 添加友情链接增删改查接口 2026-04-07 21:45:58 +08:00
ngfchl 8dc2ce7780 add. 添加用户数据生成脚本 2026-04-07 14:07:38 +08:00
ngfchl ae80234f65 update. 完善用户相关基础数据的模型与接口配置 2026-04-07 12:52:26 +08:00
ngfchl 53e91fa458 update. 添加jwt配置和接口权限配置 2026-04-07 12:51:57 +08:00
ngfchl 402f7e512e update. 完成友链以及分类接口 2026-04-07 11:55:23 +08:00
ngfchl 60366b7eb1 update. 更新数据填充脚本 2026-04-05 12:55:57 +08:00
27 changed files with 2404 additions and 197 deletions
View File
+12
View File
@@ -0,0 +1,12 @@
# 启用PGSQL数据库
DB_REMOTE=True
# 数据库名称
DB_NAME=quasar
# 用户名
DB_USER=quasar
# 密码
DB_PASSWORD=123456
# 数据库地址
DB_HOST=192.168.1.2
# 数据库端口
DB_PORT=5432
+31 -30
View File
@@ -2,7 +2,6 @@ from datetime import datetime
from typing import Optional, List, Generic, T
from ninja import Schema
from pydantic import Field
# --- 假设的关联模型 Schema ---
@@ -16,47 +15,49 @@ class SectionSchema(Schema):
title: str # 假设 MainMenu 有 name 字段
class AttachmentSchema(Schema):
class ArticleAttachmentOut(Schema):
id: int
file: str # 返回文件 URL 路径,如 "articles/attachments/2026/04/03/file.pdf"
file: str
class PaginatedResponseSchema(Schema, Generic[T]):
items: List[T]
total: int
page: int
per_page: int
total_pages: int
# --- Article 主 Schema ---
class ArticleSchema(Schema):
class ArticleListOut(Schema):
id: int
title: str
content: str
cover: Optional[str] = None # 图片 URL 路径
section: SectionSchema
author: AuthorSchema
cover: Optional[str] = None
is_contribution: bool
is_published: bool
created_at: datetime
updated_at: datetime
# 关联字段
author: Optional[AuthorSchema] = None
section: Optional[SectionSchema] = None
attachments: List[AttachmentSchema] = Field(default_factory=list)
# --- 创建文章用的 Schema (不含只读字段) ---
class ArticleCreateSchema(Schema):
class ArticleDetailOut(Schema):
id: int
title: str
section_id: Optional[int] = None # 外键通过 ID 传递
section: SectionSchema
author: AuthorSchema
content: str
cover: Optional[str] = None
is_contribution: bool
is_published: bool
attachments: List[ArticleAttachmentOut] = []
class ArticleCreate(Schema):
title: str
section_id: Optional[int] = None
author_id: Optional[int] = None
content: str
cover: Optional[str] = None # 注意:文件上传通常需单独处理,此处仅为文本路径示例
is_contribution: bool = False
is_published: bool = True
# --- 更新文章用的 Schema ---
class ArticleUpdateSchema(ArticleCreateSchema):
title: Optional[str] = None
content: Optional[str] = None
class ArticleUpdate(ArticleCreate):
pass
+1 -1
View File
@@ -121,4 +121,4 @@ def seed_articles(count=20):
if __name__ == "__main__":
seed_articles(120)
seed_articles(1200)
+35 -19
View File
@@ -1,34 +1,50 @@
from typing import List
from django.shortcuts import get_object_or_404
from ninja import Router
from ninja.pagination import paginate, PageNumberPagination
from article.models import Article
from article.schema import ArticleSchema, ArticleCreateSchema
from article.schema import ArticleUpdate, ArticleCreate, ArticleListOut, ArticleDetailOut
# Create your views here.
# Create your views here.
router = Router(tags=['content'])
router = Router(tags=['Article'])
@router.get("/article", response=List[ArticleSchema], description="获取文章列表")
@paginate(PageNumberPagination, page_size=50)
def list_articles(request, ):
# 安全校验 per_page(防止过大
queryset = Article.objects.prefetch_related('attachments').select_related('author', 'section')
return queryset
@router.get("/article/", response=List[ArticleListOut], description="获取文章列表")
@paginate(PageNumberPagination, page_size=20)
def list_articles(request, title: str = '', section_id: int = 0):
qs = (Article.objects.filter(title__contains=title).prefetch_related('attachments')
.select_related('author', 'section').order_by("-id"))
if section_id > 0:
return qs.filter(section_id=section_id)
return qs
@router.post("/article", response=ArticleSchema)
def create_article(request, payload: ArticleCreateSchema):
# 注意:文件上传需额外处理(此处简化)
article = Article.objects.create(
title=payload.title,
section_id=payload.section_id,
author=request.auth, # 假设认证用户是作者
content=payload.content,
is_contribution=payload.is_contribution,
is_published=payload.is_published,
)
@router.get("/article/{id}", response=ArticleDetailOut)
def get_article(request, id: int):
return get_object_or_404(Article.objects.prefetch_related("attachments"), id=id)
@router.post("/article/", response=ArticleDetailOut)
def create_article(request, data: ArticleCreate):
article = Article.objects.create(**data.dict())
return article
@router.put("/article/{id}", response=ArticleDetailOut)
def update_article(request, id: int, data: ArticleUpdate):
article = get_object_or_404(Article, id=id)
for attr, value in data.dict().items():
setattr(article, attr, value)
article.save()
return article
@router.delete("/article/{id}")
def delete_article(request, id: int):
article = get_object_or_404(Article, id=id)
article.delete()
return {"success": True}
+121
View File
@@ -0,0 +1,121 @@
from ninja import Schema
from datetime import datetime
from typing import Optional, List
class LinkCategorySchema(Schema):
id: int
name: str
code: str
order: int
is_active: bool
created_at: Optional[datetime] = None
class FriendLinkSchema(Schema):
id: int
category_id: int
name: str
url: str
order: int
is_active: bool
created_at: Optional[datetime] = None
class LinkCategoryCreateSchema(Schema):
name: str
code: str
order: int = 0
is_active: bool = True
class FriendLinkCreateSchema(Schema):
category_id: int
name: str
url: str
order: int = 0
is_active: bool = True
# ================= 响应模式 (Output) =================
class PictureLinkSchema(Schema):
id: int
name: str
picture: str # 这里返回的是图片路径字符串
url: str
order: int
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
# 如果你的 Model 字段和 Schema 字段名一致,通常不需要 orm_mode
# 但为了保险,或者如果你直接返回 Model 实例,建议开启
from_attributes = True
# ================= 请求模式 (Input) =================
class PictureLinkCreateSchema(Schema):
name: str
picture: str
url: str
order: int = 0
is_active: bool = True
class PictureLinkUpdateSchema(Schema):
name: Optional[str] = None
picture: Optional[str] = None
url: Optional[str] = None
order: Optional[int] = None
is_active: Optional[bool] = None
class MainMenuBase(Schema):
"""基础菜单字段"""
title: str
code: str
url: str = ""
is_external: bool = False
is_carousel: bool = False
order: int = 0
visible: bool = True
class MainMenuCreate(MainMenuBase):
"""创建时的 Schema"""
parent_id: Optional[int] = None
class MainMenuUpdate(MainMenuBase):
"""更新时的 Schema"""
parent_id: Optional[int] = None
class MainMenuOut(MainMenuBase):
"""输出用的 Schema,包含 ID 和递归的子菜单"""
id: int
parent_id: Optional[int] = None
children: List['MainMenuOut'] = [] # 递归引用自身
# 告诉 Pydantic 如何处理递归模型
MainMenuOut.model_rebuild()
class BaseInfoIn(Schema):
title: str
e_title: str
address: str
postal: Optional[str] = ""
telephone: Optional[str] = ""
fax: Optional[str] = ""
unit: Optional[str] = ""
filing: Optional[str] = ""
is_active: bool = False
class BaseInfoOut(BaseInfoIn):
id: int
official_account: Optional[str] = None
weibo: Optional[str] = None
+321 -7
View File
@@ -1,15 +1,24 @@
from django.shortcuts import render
from ninja import Router
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
import hashlib
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional, List
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from loguru import logger
from ninja import Router, UploadedFile, File
from ninja.pagination import paginate, PageNumberPagination
from base.models import FriendLink, LinkCategory, PictureLink, MainMenu, BaseInfo
from base.schema import *
# Create your views here.
router = Router(tags=['content'])
router = Router(tags=['base'])
@csrf_exempt
@@ -43,3 +52,308 @@ def custom_upload_file(request):
"url": file_url,
"default": file_url
})
UPLOAD_DIR = Path("media/uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
@router.post("/upload/{category}")
def upload_file(request, category: str, file: UploadedFile = File(...), ):
try:
# 计算 md5
md5 = hashlib.md5(file.read()).hexdigest()
file.seek(0) # 重置文件指针
ext = file.name.split('.')[-1].lower()
filename = f"{md5}.{ext}"
upload_dir = os.path.join(settings.MEDIA_ROOT, f"uploads/{category}/")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, filename)
with open(file_path, "wb") as f:
f.write(file.read())
url = f"{settings.MEDIA_URL}uploads/{category}/{filename}"
return {"url": url}
except Exception as e:
logger.exception("Upload failed")
return {"error": str(e)}
# =========================
# 分类 CRUD
# =========================
@router.get("/link-categories", response=List[LinkCategorySchema])
def list_categories(request):
return LinkCategory.objects.all()
@router.post("/link-categories", response=LinkCategorySchema)
def create_category(request, data: LinkCategoryCreateSchema):
return LinkCategory.objects.create(**data.dict())
@router.put("/link-categories/{id}", response=LinkCategorySchema)
def update_category(request, id: int, data: LinkCategoryCreateSchema):
obj = LinkCategory.objects.get(id=id)
for k, v in data.dict().items():
setattr(obj, k, v)
obj.save()
return obj
@router.delete("/link-categories/{id}")
def delete_category(request, id: int):
LinkCategory.objects.get(id=id).delete()
return {"success": True}
# =========================
# 友情链接 CRUD
# =========================
@router.get("/friend-links", response=List[FriendLinkSchema])
def list_links(request, category_id: Optional[int] = None):
query = FriendLink.objects.all()
if category_id:
query = query.filter(category_id=category_id)
return query
@router.post("/friend-links", response=FriendLinkSchema)
def create_link(request, data: FriendLinkCreateSchema):
return FriendLink.objects.create(**data.dict())
@router.put("/friend-links/{id}", response=FriendLinkSchema)
def update_link(request, id: int, data: FriendLinkCreateSchema):
obj = FriendLink.objects.get(id=id)
for k, v in data.dict().items():
setattr(obj, k, v)
obj.save()
return obj
@router.delete("/friend-links/{id}")
def delete_link(request, id: int):
FriendLink.objects.get(id=id).delete()
return {"success": True}
# ================= 列表与创建 =================
@router.get("/picture-link/", response=List[PictureLinkSchema])
def get_picture_links(request):
"""
获取所有图片链接
GET /api/picture-link/
"""
# 这里的排序与 models.py 中的 Meta.ordering 保持一致
return PictureLink.objects.all().order_by('order', '-created_at')
@router.post("/picture-link/", response=PictureLinkSchema)
def create_picture_link(request, payload: PictureLinkCreateSchema):
"""
新增图片链接
POST /api/picture-link/
"""
# 直接利用 payload 创建对象
# 注意:这里调用 save() 会触发你 models.py 中定义的 "最多4个启用" 的逻辑
link = PictureLink.objects.create(
name=payload.name,
picture=payload.picture,
url=payload.url,
order=payload.order,
is_active=payload.is_active
)
return link
# ================= 单个对象操作 =================
@router.get("/picture-link/{link_id}", response=PictureLinkSchema)
def get_picture_link(request, link_id: int):
"""
获取单个图片链接详情
GET /api/picture-link/{id}/
"""
return get_object_or_404(PictureLink, id=link_id)
@router.put("/picture-link/{link_id}", response=PictureLinkSchema)
def update_picture_link(request, link_id: int, payload: PictureLinkUpdateSchema):
"""
更新图片链接
PUT /api/picture-link/{id}/
"""
link = get_object_or_404(PictureLink, id=link_id)
# 更新字段
if payload.name is not None: link.name = payload.name
if payload.picture is not None: link.picture = payload.picture
if payload.url is not None: link.url = payload.url
if payload.order is not None: link.order = payload.order
if payload.is_active is not None: link.is_active = payload.is_active
# 保存,同样会触发 models.py 中的限制逻辑
link.save()
return link
@router.delete("/picture-link/{link_id}")
def delete_picture_link(request, link_id: int):
"""
删除图片链接
DELETE /api/picture-link/{id}/
"""
link = get_object_or_404(PictureLink, id=link_id)
link.delete()
return {"success": True}
def build_menu_tree(menus: List[MainMenu]) -> List[MainMenuOut]:
"""
将平铺的菜单列表转换为嵌套的树形结构。
这是一个高效的 O(n) 算法。
"""
menu_map = {}
root_menus = []
# 第一步:将所有菜单放入字典,并初始化 children 列表
for menu in menus:
menu_data = MainMenuOut(
id=menu.id,
parent_id=menu.parent_id,
title=menu.title,
code=menu.code,
url=menu.url,
is_external=menu.is_external,
is_carousel=menu.is_carousel,
order=menu.order,
visible=menu.visible,
children=[]
)
menu_map[menu.id] = menu_data
if menu.parent_id is None:
root_menus.append(menu_data)
# 第二步:遍历字典,将子菜单挂载到父菜单上
for menu_id, menu_obj in menu_map.items():
if menu_obj.parent_id is not None and menu_obj.parent_id in menu_map:
parent = menu_map[menu_obj.parent_id]
parent.children.append(menu_obj)
# 第三步:对每个层级的菜单按 'order' 排序
def sort_children(menu_list):
menu_list.sort(key=lambda x: (x.order, x.title))
for menu in menu_list:
sort_children(menu.children)
sort_children(root_menus)
return root_menus
@router.get("main-menu/", response=List[MainMenuOut])
def get_menu_tree(request):
"""
获取完整的导航菜单树。
GET /api/main-menu/
"""
all_menus = MainMenu.objects.all().select_related('parent')
return build_menu_tree(all_menus)
@router.post("/main-menu/", response=MainMenuOut)
def create_menu(request, payload: MainMenuCreate):
"""
创建一个新的菜单项。
POST /api/main-menu/
"""
parent = None
if payload.parent_id:
parent = get_object_or_404(MainMenu, id=payload.parent_id)
menu = MainMenu.objects.create(
parent=parent,
**payload.dict(exclude={'parent_id'})
)
return menu
@router.put("/main-menu/{menu_id}", response=MainMenuOut)
def update_menu(request, menu_id: int, payload: MainMenuUpdate):
"""
更新一个菜单项。
PUT /api/main-menu/{menu_id}/
"""
menu = get_object_or_404(MainMenu, id=menu_id)
# 处理 parent_id
if payload.parent_id is not None:
if payload.parent_id != menu.parent_id:
new_parent = get_object_or_404(MainMenu, id=payload.parent_id)
menu.parent = new_parent
else:
menu.parent = None
# 更新其他字段
for attr, value in payload.dict(exclude={'parent_id'}).items():
setattr(menu, attr, value)
menu.save()
return menu
@router.delete("/main-menu/{menu_id}")
def delete_menu(request, menu_id: int):
"""
删除一个菜单项(及其所有子项)。
DELETE /api/main-menu/{menu_id}/
"""
menu = get_object_or_404(MainMenu, id=menu_id)
menu.delete() # Django 的级联删除会自动处理子项
return {"success": True}
# 查询列表
@router.get("/base-info/", response=List[BaseInfoOut])
@paginate(PageNumberPagination, page_size=20)
def list_base_info(request):
return BaseInfo.objects.all().order_by("-id")
# 获取详情
@router.get("/base-info/{pk}", response=BaseInfoOut)
def get_base_info(request, pk: int):
return get_object_or_404(BaseInfo, pk=pk)
# 创建
@router.post("/base-info/", response=BaseInfoOut)
def create_base_info(request, data: BaseInfoIn, ):
obj = BaseInfo.objects.create(**data.dict())
return obj
# 更新
@router.put("/base-info/{pk}", response=BaseInfoOut)
def update_base_info(
request,
pk: int,
data: BaseInfoIn,
):
obj = get_object_or_404(BaseInfo, pk=pk)
for attr, value in data.dict().items():
setattr(obj, attr, value)
obj.save()
return obj
# 删除
@router.delete("/base-info/{pk}")
def delete_base_info(request, pk: int):
obj = get_object_or_404(BaseInfo, pk=pk)
obj.delete()
return {"success": True}
+49
View File
@@ -0,0 +1,49 @@
from ninja import Schema
from typing import Optional
from pydantic import constr
# 新闻模块
class NewsSectionSchema(Schema):
id: int
title: str
code: str
order: int
visible: bool
class NewsSectionCreateSchema(Schema):
title: constr(max_length=100)
code: constr(max_length=50)
order: Optional[int] = 0
visible: Optional[bool] = True
class NewsSectionUpdateSchema(Schema):
title: Optional[constr(max_length=100)]
code: Optional[constr(max_length=50)]
order: Optional[int]
visible: Optional[bool]
# 新闻内容
class NewsSchema(Schema):
id: int
title: str
section_id: int
author_id: Optional[int]
content: str
cover: Optional[str]
is_published: bool
class NewsCreateSchema(Schema):
title: constr(max_length=200)
section_id: int
author_id: Optional[int]
content: str
cover: Optional[str]
is_published: Optional[bool] = True
class NewsUpdateSchema(Schema):
title: Optional[constr(max_length=200)]
section_id: Optional[int]
author_id: Optional[int]
content: Optional[str]
cover: Optional[str]
is_published: Optional[bool]
+22 -7
View File
@@ -3,6 +3,8 @@ import random
import sys
import django
from loguru import logger
from pypinyin import lazy_pinyin, Style
# 配置 Django 环境
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
@@ -17,18 +19,31 @@ from users.models import User
# 初始化 Faker (zh_CN 表示生成中文数据)
fake = Faker('zh_CN')
NEWS_SECTIONS = [
{"title": "消防要闻", "order": 1, "visible": True, },
{"title": "基层动态", "order": 2, "visible": True, },
{"title": "国务院新闻", "order": 3, "visible": True, },
]
def seed_news(count=20):
print(f"🚀 开始生成 {count} 条富文本新闻数据...")
logger.info(f"🚀 开始生成 {count} 条富文本新闻数据...")
# --- 1. 获取关联数据 ---
sections = list(NewsSection.objects.all())
users = list(User.objects.all())
if not sections:
print("❌ 错误:数据库中没有找到任何板块 (NewsSection)")
return
logger.warning("❌ 错误:数据库中没有找到任何板块 (NewsSection)")
for sec in NEWS_SECTIONS:
title = sec["title"]
NewsSection.objects.create(
title=title,
code="-".join(lazy_pinyin(title, style=Style.NORMAL)),
order=sec["order"],
visible=sec["visible"],
)
sections = list(NewsSection.objects.all())
created_count = 0
for i in range(count):
@@ -90,10 +105,10 @@ def seed_news(count=20):
)
created_count += 1
print(f" - [{section.title}] {news.title}")
logger.info(f" - [{section.title}] {news.title}")
print(f"✅ 成功生成 {created_count} 条富文本新闻数据!")
logger.info(f"✅ 成功生成 {created_count} 条富文本新闻数据!")
if __name__ == "__main__":
seed_news(120)
seed_news(600)
+113
View File
@@ -1,3 +1,116 @@
from django.shortcuts import render
from loguru import logger
# Create your views here.
from ninja import Router
from django.shortcuts import get_object_or_404
from typing import List
from ninja.errors import HttpError
from ninja.pagination import paginate, PageNumberPagination
from users.permission import has_permission, require_permissions
from utils.dept_utils import get_dept_children_ids
from .models import NewsSection, News
from .schema import (
NewsSectionSchema, NewsSectionCreateSchema, NewsSectionUpdateSchema,
NewsSchema, NewsCreateSchema, NewsUpdateSchema
)
router = Router(tags=['news'])
# ================= 新闻模块接口 =================
@router.get("/news_section/", response=List[NewsSectionSchema])
@paginate(PageNumberPagination, page_size=20)
def list_sections(request):
return NewsSection.objects.all()
@router.get("/news_section/{section_id}", response=NewsSectionSchema)
def retrieve_section(request, section_id: int):
return get_object_or_404(NewsSection, id=section_id)
@router.post("/news_section/", response=NewsSectionSchema)
def create_section(request, data: NewsSectionCreateSchema):
section = NewsSection.objects.create(**data.dict())
return section
@router.put("/news_section/{section_id}", response=NewsSectionSchema)
def update_section(request, section_id: int, data: NewsSectionUpdateSchema):
section = get_object_or_404(NewsSection, id=section_id)
for attr, value in data.dict(exclude_unset=True).items():
setattr(section, attr, value)
section.save()
return section
@router.delete("/news_section/{section_id}", response={"success": bool})
def delete_section(request, section_id: int):
section = get_object_or_404(NewsSection, id=section_id)
section.delete()
return {"success": True}
# ================= 新闻接口 =================
@router.get("/news/", response=List[NewsSchema])
@paginate(PageNumberPagination, page_size=20)
@require_permissions("news:list")
def list_news(request, section_id: int = 0, title: str = ''):
user = request.auth
if not has_permission(user.id, "news:list"):
raise
news = News.objects.all()
# 板块筛选
if section_id > 0:
return news.filter(section_id=section_id).all()
# 根据用户权限筛选
if has_permission(user.id, 'news:manage'):
dept_ids = get_dept_children_ids(user.dept_id)
# 筛选:作者属于这些部门中的任意一个
news = news.filter(author__dept_id__in=dept_ids)
else:
logger.info(user)
news = news.filter(author=user)
# 根据新闻标题筛选
if title:
news = news.filter(title__contains=title)
return news
@router.get("/news/{news_id}", response=NewsSchema)
@require_permissions("news:view")
def retrieve_news(request, news_id: int):
return get_object_or_404(News, id=news_id)
@router.post("/news/", response=NewsSchema)
@require_permissions("news:create")
def create_news(request, data: NewsCreateSchema):
news = News.objects.create(**data.dict())
return news
@router.put("/news/{news_id}", response=NewsSchema)
@require_permissions("news:update")
def update_news(request, news_id: int, data: NewsUpdateSchema):
news = get_object_or_404(News, id=news_id)
for attr, value in data.dict(exclude_unset=True).items():
setattr(news, attr, value)
news.save()
return news
@router.delete("/news/{news_id}", response={"success": bool})
@require_permissions("news:delete")
def delete_news(request, news_id: int):
news = get_object_or_404(News, id=news_id)
news.delete()
return {"success": True}
+44 -57
View File
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
import os
from datetime import timedelta
from pathlib import Path
import environ
@@ -92,6 +93,7 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'ninja.compatibility.files.fix_request_files_middleware',
# 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
@@ -125,7 +127,7 @@ ASGI_APPLICATION = 'server.asgi.application'
if env('DB_REMOTE', default=False):
# mysql_connect = dj_database_url.parse(os.getenv('MYSQL_CONNECTION'), conn_max_age=0)
# print("数据库连接信息", mysql_connect)
logger.info("正在使用 PGSQL 数据库连接信息")
DATABASES = {
'default': {
'ENGINE': env('DB_ENGINE', default='dj_db_conn_pool.backends.postgresql'),
@@ -147,6 +149,7 @@ if env('DB_REMOTE', default=False):
},
}
else:
logger.info("正在使用本地 SQLite 数据库", )
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
@@ -220,63 +223,47 @@ MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# 日志配置
LOGGING = {
'version': 1, # 版本
'disable_existing_loggers': False, # 是否禁用已经存在的日志器
'formatters': { # 日志信息显示的格式
'verbose': {
'format': '%(levelname)s %(asctime)s %(module)s %(lineno)d %(message)s'
},
'simple': {
'format': '%(levelname)s %(module)s %(lineno)d %(message)s'
},
},
'filters': { # 过滤器
'require_debug_true': { # django在debug模式下才输出日志
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': { # 日志处理方法
'console': { # 向终端中输出日志
'level': 'DEBUG', # 输出等级为“INFO”
'filters': ['require_debug_true'],
'class': 'logging.StreamHandler',
'formatter': 'simple'
},
'file': { # 向文件中输出日志
'level': os.getenv('LOGGER_LEVEL') if os.getenv('LOGGER_LEVEL') else 'INFO', # 输出等级为“INFO”
# 新增内容
# 'class': 'logging.handlers.TimedRotatingFileHandler',
# 'filename': os.path.join(BASE_DIR, 'logs/logs.log'),
# 'when': 'm',
# 'interval': 10,
# 'backupCount': 10,
'class': 'logging.handlers.RotatingFileHandler',
'filename': BASE_DIR / "db/logs/django_server.log", # 日志文件的位置
'maxBytes': 5 * 1024 * 1024, # 日志文件的大小(300*1024*1024为300MB
'backupCount': 10, # 日志文件的数量(超过设定的最大值会自动备份,备份数量最大值为10)
'formatter': 'verbose', # 日志输出格式:使用了在之前定义的'verbose'
'encoding': 'utf-8' # 新增此行,指定文件编码为UTF-8
},
},
'loggers': { # 日志器
'ptools': { # 定义了一个名为django的日志器
'handlers': ['console', 'file'], # 可以同时向终端与文件中输出日志
'propagate': True, # 是否继续传递日志信息
'level': os.getenv('LOGGER_LEVEL') if os.getenv('LOGGER_LEVEL') else 'INFO', # 日志器接收的最低日志级别
},
'requests': {
'level': 'WARNING',
'handlers': ['console'], # 或你定义的其他 handler
'propagate': False,
},
'urllib3': {
'level': 'WARNING',
'handlers': ['console'],
'propagate': False,
},
}
NINJA_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), # 访问令牌有效期
'REFRESH_TOKEN_LIFETIME': timedelta(days=3), # 刷新令牌有效期
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': False, # 旋转后列入黑名单
'UPDATE_LAST_LOGIN': True, # 更新最后登录
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'VERIFYING_KEY': None,
'AUDIENCE': None,
'ISSUER': None,
'JWK_URL': None,
'LEEWAY': 0,
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'USER_AUTHENTICATION_RULE': 'ninja_jwt.authentication.default_user_authentication_rule',
# 'AUTH_TOKEN_CLASSES': ('ninja_jwt.tokens.AccessToken',),
# 'TOKEN_TYPE_CLAIM': 'token_type',
# 'TOKEN_USER_CLASS': 'ninja_jwt.models.TokenUser',
# 'JTI_CLAIM': 'jti',
#
# 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
# 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
# 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
# For Controller Schemas
# FOR OBTAIN PAIR
# 'TOKEN_OBTAIN_PAIR_INPUT_SCHEMA': "ninja_jwt.schema.TokenObtainPairInputSchema",
# 'TOKEN_OBTAIN_PAIR_REFRESH_INPUT_SCHEMA': "ninja_jwt.schema.TokenRefreshInputSchema",
# FOR SLIDING TOKEN
# 'TOKEN_OBTAIN_SLIDING_INPUT_SCHEMA': "ninja_jwt.schema.TokenObtainSlidingInputSchema",
# 'TOKEN_OBTAIN_SLIDING_REFRESH_INPUT_SCHEMA': "ninja_jwt.schema.TokenRefreshSlidingInputSchema",
#
# 'TOKEN_BLACKLIST_INPUT_SCHEMA': "ninja_jwt.schema.TokenBlacklistInputSchema",
# 'TOKEN_VERIFY_INPUT_SCHEMA': "ninja_jwt.schema.TokenVerifyInputSchema",
}
# 调整POST传输数据文件大小限制
DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024 * 1024
+58 -12
View File
@@ -27,6 +27,7 @@ from django.urls import path, include
from loguru import logger
from ninja.errors import ValidationError
from ninja_extra import NinjaExtraAPI
from ninja_jwt.authentication import JWTAuth
from ninja_jwt.controller import NinjaJWTDefaultController
from base.views import custom_upload_file
@@ -63,41 +64,86 @@ def auto_load_routers(package_name_prefix=None):
# 检查是否存在 'router' 变量
if hasattr(module, 'router'):
router = getattr(module, 'router')
# 使用文件夹名称 (modname) 作为 URL 前缀
# 例如: 文件夹 'authorize' -> 前缀 '/authorize'
url_prefix = f"/{modname}"
api_v1.add_router(url_prefix, router)
print(f"✅ 自动注册: {url_prefix} (来自 {full_module_path})")
logger.info(f"✅ 自动注册: {url_prefix} (来自 {full_module_path})")
else:
print(f"⚠️ 跳过: {modname} (在 {full_module_path} 中未找到 'router' 变量)")
logger.warning(f"⚠️ 跳过: {modname} (在 {full_module_path} 中未找到 'router' 变量)")
except ImportError as e:
# 如果文件夹里没有 views.py,会报 ImportError,这是正常的,跳过即可
if "No module named" in str(e) and "views" in str(e):
print(f"️ 跳过: {modname} (没有 views.py 模块)")
logger.warning(f"️ 跳过: {modname} (没有 views.py 模块)")
else:
print(f"❌ 导入错误 {modname}: {e}")
logger.error(f"❌ 导入错误 {modname}: {e}")
except Exception as e:
print(f"❌ 注册失败 {modname}: {e}")
logger.error(f"❌ 注册失败 {modname}: {e}")
class UnifiedNinjaAPI(NinjaExtraAPI):
def create_response(self, request, data, *, status=None, headers=None, temporal_response=None):
if not isinstance(data, CommonResponse):
if isinstance(data, tuple) and len(data) == 2:
data, status = data
data = CommonResponse.success(data=data)
return super().create_response(request, data, status=status, temporal_response=temporal_response)
# 1. 如果已经是 CommonResponse,直接放行(避免递归)
if isinstance(data, CommonResponse):
return super().create_response(request, data, status=status, temporal_response=temporal_response)
# 2. 处理 (data, status) 元组
if isinstance(data, tuple) and len(data) == 2:
data, status = data
# 3. 确保 status 有值,默认为 200
if status is None:
status = 200
# ================= 核心修改开始 =================
# 4. 嗅探数据内容:判断是否是框架抛出的异常数据
# Django Ninja / DRF 的异常通常格式为 {"detail": "错误信息"}
# 或者 {"errors": [...]}
is_framework_error = False
error_message = ""
if isinstance(data, dict):
# 情况 A: 捕获到 {"detail": "权限不足"}
if "detail" in data:
is_framework_error = True
error_message = data.get("detail")
# 情况 B: 捕获到其他标准错误格式 (可选)
elif "message" in data and status >= 400:
is_framework_error = True
error_message = data.get("message")
# 5. 根据嗅探结果决定返回 success 还是 error
if is_framework_error:
# 强制返回 error 结构,succeed=False
# 注意:这里通常使用 403 或 500 作为 code,或者保留原始 status
return super().create_response(
request,
CommonResponse.error(code=status, data=data, msg=error_message),
status=status,
temporal_response=temporal_response
)
# 如果不是错误,继续正常的成功逻辑
if 200 <= status < 300:
wrapped_data = CommonResponse.success(data=data)
else:
# 其他非 200 状态码视为业务错误
wrapped_data = CommonResponse.error(code=status, data=data)
return super().create_response(request, wrapped_data, status=status, temporal_response=temporal_response)
api_v1 = UnifiedNinjaAPI(version='1.0.0')
api_v1 = UnifiedNinjaAPI(version='1.0.0', auth=JWTAuth())
api_v1.register_controllers(NinjaJWTDefaultController)
auto_load_routers()
# logger.debug(api_v1.urls)
@api_v1.exception_handler(ValidationError)
def validation_errors(request, exc):
logger.error(request.body)
+147 -24
View File
@@ -1,17 +1,109 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.html import format_html
from import_export.admin import ImportExportModelAdmin
from .models import Department, Role, Menu
from .models import User
from .models import Department
from .models import Role, Permission
# ========== 菜单管理 ==========
@admin.register(Menu)
class MenuAdmin(admin.ModelAdmin):
list_display = (
'name', 'code', 'menu_type_display', 'path', 'permission_code', 'parent_link', 'order_num', 'status')
list_filter = ('menu_type', 'status', 'parent')
search_fields = ('name', 'code', 'permission_code')
ordering = ('order_num', 'id')
list_editable = ('order_num', 'status') # 允许在列表页直接编辑
list_per_page = 20
# 自定义字段:显示菜单类型
def menu_type_display(self, obj):
types = {0: '目录', 1: '菜单', 2: '按钮'}
return types.get(obj.menu_type, '未知')
menu_type_display.short_description = '类型'
# 自定义字段:父菜单链接(可点击)
def parent_link(self, obj):
if obj.parent:
url = f"/admin/users/menu/{obj.parent.id}/change/"
return format_html('<a href="{}">{}</a>', url, obj.parent.name)
return '-'
parent_link.short_description = '父菜单'
# ========== 角色管理 ==========
class RoleMenuInline(admin.TabularInline):
"""
内联管理:在角色编辑页直接分配菜单
"""
model = Role.menus.through # 使用自动创建的中间表
verbose_name = "菜单权限"
verbose_name_plural = "菜单权限"
extra = 1 # 默认显示1个空行
# 优化显示:只显示关键字段
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "menu":
# 可以在这里过滤菜单选项,例如只显示状态为True的
kwargs["queryset"] = Menu.objects.filter(status=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.register(Role)
class RoleAdmin(admin.ModelAdmin):
list_display = ('name', 'code', 'description', 'user_count', 'menu_count', 'status', 'created_at')
list_filter = ('status', 'created_at')
search_fields = ('name', 'code', 'description')
ordering = ('-created_at',)
list_per_page = 20
# 使用内联管理菜单权限
inlines = [RoleMenuInline]
# 排除 menus 字段,因为已经在内联中处理
exclude = ('menus',)
# 自定义字段:关联用户数量
def user_count(self, obj):
count = obj.users.count()
if count:
url = f"/admin/users/user/?roles__id__exact={obj.id}"
return format_html('<a href="{}">{}</a>', url, count)
return 0
user_count.short_description = '用户数'
# 自定义字段:关联菜单数量
def menu_count(self, obj):
count = obj.menus.count()
if count:
url = f"/admin/users/menu/?roles__id__exact={obj.id}"
return format_html('<a href="{}">{}</a>', url, count)
return 0
menu_count.short_description = '菜单数'
# ========== 用户管理 ==========
class UserRoleInline(admin.TabularInline):
"""
内联管理:在用户编辑页直接分配角色
"""
model = User.roles.through
verbose_name = "角色"
verbose_name_plural = "角色"
extra = 1
@admin.register(User)
class UserAdmin(BaseUserAdmin, ImportExportModelAdmin):
list_display = ('username', 'cname', 'dept', 'is_active', 'is_staff')
list_display = ('username', 'cname', 'dept', 'phone', 'is_active', 'is_staff')
list_filter = ('is_active', 'is_staff', 'dept')
search_fields = ('username', 'cname')
search_fields = ('username', 'cname', 'phone',)
fieldsets = (
(None, {'fields': ('username', 'password')}),
('个人信息', {'fields': ('cname', 'dept')}),
@@ -24,33 +116,64 @@ class UserAdmin(BaseUserAdmin, ImportExportModelAdmin):
'fields': ('username', 'cname', 'password1', 'password2', 'dept'),
}),
)
ordering = ('username',)
ordering = ('username', '-date_joined',)
filter_horizontal = ('groups', 'user_permissions', 'roles') # 如果 roles 是 ManyToMany
list_per_page = 20
# 使用内联管理角色
inlines = [UserRoleInline]
# 排除 roles 字段,因为已经在内联中处理
exclude = ('roles',)
# 自定义字段:显示所有角色名称
def role_names(self, obj):
roles = obj.roles.all()
if roles:
names = ', '.join([role.name for role in roles])
return names[:50] + '...' if len(names) > 50 else names
return '-'
role_names.short_description = '角色'
# ========== 部门管理 ==========
@admin.register(Department)
class DepartmentAdmin(ImportExportModelAdmin):
list_display = ('name', 'parent', 'order')
list_filter = ('parent',)
search_fields = ('name',)
ordering = ('order', 'name')
class DepartmentAdmin(admin.ModelAdmin):
list_display = ('name_with_indent', 'parent_link', 'order', 'user_count')
list_editable = ('order',)
ordering = ('order', 'id')
list_per_page = 30
def get_queryset(self, request):
# 优化查询,避免 N+1
return super().get_queryset(request).select_related('parent')
@admin.register(Role)
class RoleAdmin(ImportExportModelAdmin):
list_display = ('name', 'permission_count')
search_fields = ('name',)
filter_horizontal = ('permissions',)
def name_with_indent(self, obj):
"""根据层级缩进显示部门名称"""
level = 0
current = obj.parent
while current:
level += 1
current = current.parent
indent = "=>" * level
return format_html('{}{}', indent, obj.name)
def permission_count(self, obj):
return obj.permissions.count()
name_with_indent.short_description = "部门名称"
name_with_indent.allow_tags = True
permission_count.short_description = "权限数量"
def parent_link(self, obj):
if obj.parent:
url = f"/admin/users/department/{obj.parent.id}/change/"
return format_html('<a href="{}">{}</a>', url, obj.parent.name)
return '-'
parent_link.short_description = "上级部门"
@admin.register(Permission)
class PermissionAdmin(ImportExportModelAdmin):
list_display = ('resource', 'action', 'desc')
list_filter = ('resource', 'action')
search_fields = ('resource', 'desc')
ordering = ('resource', 'action')
def user_count(self, obj):
count = obj.user_set.count()
if count:
url = f"/admin/users/user/?department__id__exact={obj.id}"
return format_html('<a href="{}">{}</a>', url, count)
return 0
user_count.short_description = "用户数"
+108
View File
@@ -0,0 +1,108 @@
# 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, require_permissions
from users.schema import DepartmentOut, DepartmentCreate, DepartmentUpdate
router = Router(tags=['depts'])
@router.get("/", response=List[DepartmentOut], summary="获取部门列表(扁平)")
@require_permissions("dept:list")
def list_departments(request):
return Department.objects.all()
@router.get("/tree/", response=List[dict], summary="获取部门树形结构")
@require_permissions("dept:list")
def get_department_tree(request):
depts = Department.objects.all().order_by('order')
return build_dept_tree(list(depts))
@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):
dept = get_object_or_404(Department, id=dept_id)
data = payload.dict()
parent_id = data.pop('parent_id', None)
if parent_id:
data['parent_id'] = parent_id
else:
data['parent'] = None
for attr, value in data.items():
setattr(dept, attr, value)
dept.save()
return dept
@router.delete("/{dept_id}/", summary="删除部门")
@require_permissions("dept:delete")
def delete_department(request, dept_id: int):
dept = get_object_or_404(Department, id=dept_id)
# 可选:检查是否有子部门或用户
if dept.user_set.exists() or Department.objects.filter(parent=dept).exists():
return 400, {"detail": "部门包含用户或子部门,无法删除"}
dept.delete()
return {"success": True}
# 工具函数:构建部门树
def build_dept_tree(depts, parent_id=None):
tree = []
for dept in depts:
if dept.parent_id == parent_id:
node = {
'id': dept.id,
'name': dept.name,
'parent_id': dept.parent_id,
'order': dept.order,
'children': build_dept_tree(depts, dept.id) or None
}
tree.append(node)
return tree
@@ -0,0 +1,526 @@
# generate_test_data.py
import os
import django
# 设置 Django 环境
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')
django.setup()
from django.core.management.base import BaseCommand
from django.db import transaction
from django.contrib.auth.hashers import make_password
from users.models import Department, Menu, Role, User
class Command(BaseCommand):
help = '生成有逻辑的测试数据(可重复运行,自动跳过已存在项)'
def handle(self, *args, **options):
self.stdout.write("🚀 开始生成测试数据(幂等模式)...")
try:
with transaction.atomic():
departments = self._create_departments()
menus = self._create_menus()
roles = self._create_roles(menus)
self._create_users(departments, roles)
except Exception as e:
self.stderr.write(f"❌ 生成失败: {e}")
return
self.stdout.write(
self.style.SUCCESS("✅ 测试数据生成完成!可安全重复运行。")
)
self.stdout.write("\n📌 登录信息:")
self.stdout.write(" - 超级管理员: admin / admin123")
self.stdout.write(" - 技术经理: techmgr / tech123")
self.stdout.write(" - HR专员: hr1 / hr123")
def _get_or_create_dept(self, name, parent=None, order=0):
"""获取或创建部门(按名称+父部门唯一)"""
dept, created = Department.objects.get_or_create(
name=name,
parent=parent,
defaults={'order': order}
)
if created:
self.stdout.write(f" 部门: {dept.name}")
return dept
def _create_departments(self):
self.stdout.write("🔄 同步部门结构...")
company = self._get_or_create_dept("总公司", None, 0)
tech = self._get_or_create_dept("技术部", company, 1)
hr = self._get_or_create_dept("人力资源部", company, 2)
finance = self._get_or_create_dept("财务部", company, 3)
backend = self._get_or_create_dept("后端开发组", tech, 1)
frontend = self._get_or_create_dept("前端开发组", tech, 2)
recruitment = self._get_or_create_dept("招聘组", hr, 1)
return {
'company': company,
'tech': tech,
'hr': hr,
'finance': finance,
'backend': backend,
'frontend': frontend,
'recruitment': recruitment,
}
def _get_or_create_menu(self, code, defaults=None):
"""按 code 获取或创建菜单"""
menu, created = Menu.objects.get_or_create(code=code, defaults=defaults)
if created:
self.stdout.write(f" 菜单: {menu.name} ({menu.code})")
return menu
def _create_menus(self):
self.stdout.write("🔄 同步菜单结构...")
# ========================
# 1. 系统管理(保持不变)
# ========================
system = self._get_or_create_menu("system", {
"name": "系统管理",
"menu_type": 0,
"permission_code": "system:settings",
"order_num": 1
})
user_menu = self._get_or_create_menu("user", {
"name": "用户管理",
"parent": system,
"path": "/system/user",
"component": "system/UserManage",
"permission_code": "user:list",
"menu_type": 1,
"order_num": 1
})
role_menu = self._get_or_create_menu("role", {
"name": "角色管理",
"parent": system,
"path": "/system/role",
"component": "system/RoleManage",
"permission_code": "role:list",
"menu_type": 1,
"order_num": 2
})
menu_menu = self._get_or_create_menu("menu", {
"name": "菜单管理",
"parent": system,
"path": "/system/menu",
"component": "system/Menu",
"permission_code": "menu:list",
"menu_type": 1,
"order_num": 3
})
dept_menu = self._get_or_create_menu("dept", {
"name": "部门管理",
"parent": system,
"path": "/system/dept",
"component": "system/DeptManage",
"permission_code": "dept:list",
"menu_type": 1,
"order_num": 4
})
# --- 系统管理: list 权限(关键补充!)---
self._get_or_create_menu("user_list", {
"name": "查看用户列表",
"parent": user_menu,
"permission_code": "user:list",
"menu_type": 2
})
self._get_or_create_menu("role_list", {
"name": "查看角色列表",
"parent": role_menu,
"permission_code": "role:list",
"menu_type": 2
})
self._get_or_create_menu("menu_list", {
"name": "查看菜单列表",
"parent": menu_menu,
"permission_code": "menu:list",
"menu_type": 2
})
# ================= 新增:菜单管理的按钮权限 =================
self._get_or_create_menu("menu_list", {
"name": "查看菜单列表",
"parent": menu_menu,
"permission_code": "menu:list",
"menu_type": 2
})
self._get_or_create_menu("menu_create", {
"name": "新增菜单",
"parent": menu_menu,
"permission_code": "menu:create",
"menu_type": 2
})
self._get_or_create_menu("menu_update", {
"name": "编辑菜单",
"parent": menu_menu,
"permission_code": "menu:update",
"menu_type": 2
})
self._get_or_create_menu("menu_delete", {
"name": "删除菜单",
"parent": menu_menu,
"permission_code": "menu:delete",
"menu_type": 2
})
self._get_or_create_menu("dept_list", {
"name": "查看部门列表",
"parent": dept_menu,
"permission_code": "dept:list",
"menu_type": 2
})
# ================= 新增:部门管理的按钮权限 =================
self._get_or_create_menu("dept_list", {
"name": "查看部门列表",
"parent": dept_menu,
"permission_code": "dept:list",
"menu_type": 2
})
self._get_or_create_menu("dept_create", {
"name": "新增部门",
"parent": dept_menu,
"permission_code": "dept:create",
"menu_type": 2
})
self._get_or_create_menu("dept_update", {
"name": "编辑部门",
"parent": dept_menu,
"permission_code": "dept:update",
"menu_type": 2
})
self._get_or_create_menu("dept_delete", {
"name": "删除部门",
"parent": dept_menu,
"permission_code": "dept:delete",
"menu_type": 2
})
# --- 系统管理: 其他按钮权限 ---
self._get_or_create_menu("user_create", {
"name": "新增用户",
"parent": user_menu,
"permission_code": "user:create",
"menu_type": 2
})
self._get_or_create_menu("user_update", {
"name": "编辑用户",
"parent": user_menu,
"permission_code": "user:update",
"menu_type": 2
})
self._get_or_create_menu("user_delete", {
"name": "删除用户",
"parent": user_menu,
"permission_code": "user:delete",
"menu_type": 2
})
self._get_or_create_menu("user_reset_pwd", {
"name": "重置密码",
"parent": user_menu,
"permission_code": "user:reset_password",
"menu_type": 2
})
self._get_or_create_menu("role_assign", {
"name": "分配角色",
"parent": role_menu,
"permission_code": "user:assign_roles",
"menu_type": 2
})
self._get_or_create_menu("role_create", {
"name": "新增角色",
"parent": role_menu,
"permission_code": "role:create",
"menu_type": 2
})
self._get_or_create_menu("role_update", {
"name": "编辑角色",
"parent": role_menu,
"permission_code": "role:update",
"menu_type": 2
})
self._get_or_create_menu("role_delete", {
"name": "删除角色",
"parent": role_menu,
"permission_code": "role:delete",
"menu_type": 2
})
self._get_or_create_menu("role_set_perms", {
"name": "设置权限",
"parent": role_menu,
"permission_code": "role:set_permissions",
"menu_type": 2
})
# ========================
# 2. 内容管理(仅保留动态内容类)
# ========================
content = self._get_or_create_menu("content", {
"name": "内容管理",
"menu_type": 0,
"permission_code": "system:content",
"order_num": 2
})
# 仅保留这些在“内容管理”下
dynamic_content_models = [
("article", "文章管理", 1, "ArticleList"),
("carousel", "轮播图管理", 2, "CarouselList"),
("notice", "公告管理", 3, "NoticeList"),
("newssection", "新闻栏目", 4, "NewsSection"),
("news", "新闻管理", 5, "NewsList"),
]
content_menus = {}
for code, name, order, component in dynamic_content_models:
menu = self._get_or_create_menu(code, {
"name": name,
"parent": content,
"path": f"/content/{code}",
"component": f"content/{component}",
"permission_code": f"{code}:list",
"menu_type": 1,
"order_num": order
})
content_menus[code] = menu
# 添加 list + CRUD 权限
self._get_or_create_menu(f"{code}_list", {
"name": f"查看{name}",
"parent": menu,
"permission_code": f"{code}:list",
"menu_type": 2
})
self._get_or_create_menu(f"{code}_create", {
"name": f"新增{name}",
"parent": menu,
"permission_code": f"{code}:create",
"menu_type": 2
})
self._get_or_create_menu(f"{code}_update", {
"name": f"编辑{name}",
"parent": menu,
"permission_code": f"{code}:update",
"menu_type": 2
})
self._get_or_create_menu(f"{code}_delete", {
"name": f"删除{name}",
"parent": menu,
"permission_code": f"{code}:delete",
"menu_type": 2
})
# --- 公告特殊权限(保持不变)---
notice_menu = content_menus["notice"]
self._get_or_create_menu("notice_mark_read", {
"name": "标记公告已读",
"parent": notice_menu,
"permission_code": "notice:mark_as_read",
"menu_type": 2
})
self._get_or_create_menu("notice_view_receipts", {
"name": "查看公告回执",
"parent": notice_menu,
"permission_code": "notice:view_receipts",
"menu_type": 2
})
# ========================
# 3. 新增:站点配置(原属于内容管理的静态配置类)
# ========================
site_config = self._get_or_create_menu("site_config", {
"name": "站点配置",
"menu_type": 0,
"order_num": 3 # 排在内容管理之后
})
site_config_models = [
("baseinfo", "基础信息", 1, "BaseInfo"),
("mainmenu", "主导航菜单", 2, "MainMenu"),
("picturelink", "图片链接", 3, "PictureLink"),
("friendlink", "友情链接", 4, "FriendLink"),
("linkcategory", "链接分类", 5, ""),
]
for code, name, order, component in site_config_models:
if not component:
menu = self._get_or_create_menu("friendlink")
else:
menu = self._get_or_create_menu(code, {
"name": name,
"parent": site_config,
"path": f"/config/{code}",
"component": f"config/{component}", # 建议组件路径也调整为 config/
"permission_code": f"{code}:list",
"menu_type": 1,
"order_num": order
})
content_menus[code] = menu # 仍加入字典以便返回
# 添加 list + CRUD 权限
self._get_or_create_menu(f"{code}_list", {
"name": f"查看{name}",
"parent": menu,
"permission_code": f"{code}:list",
"menu_type": 2
})
self._get_or_create_menu(f"{code}_create", {
"name": f"新增{name}",
"parent": menu,
"permission_code": f"{code}:create",
"menu_type": 2
})
self._get_or_create_menu(f"{code}_update", {
"name": f"编辑{name}",
"parent": menu,
"permission_code": f"{code}:update",
"menu_type": 2
})
self._get_or_create_menu(f"{code}_delete", {
"name": f"删除{name}",
"parent": menu,
"permission_code": f"{code}:delete",
"menu_type": 2
})
return {
'system': system,
'user': user_menu,
'role': role_menu,
'menu': menu_menu,
'dept': dept_menu,
'content': content,
'site_config': site_config, # 新增返回
**content_menus,
}
def _get_or_create_role(self, code, name, description, status=True):
"""按 code 获取或创建角色"""
role, created = Role.objects.get_or_create(
code=code,
defaults={
'name': name,
'description': description,
'status': status
}
)
if created:
self.stdout.write(f" 角色: {role.name}")
return role
def _create_roles(self, menus):
self.stdout.write("🔄 同步角色权限...")
admin_role = self._get_or_create_role(
"admin", "超级管理员", "拥有全部权限"
)
tech_manager_role = self._get_or_create_role(
"tech_manager", "技术部经理", "管理技术部用户"
)
hr_role = self._get_or_create_role(
"hr_staff", "HR专员", "查看员工信息"
)
employee_role = self._get_or_create_role(
"employee", "普通员工", "无管理权限"
)
# 分配菜单权限(幂等设置)
all_menus = Menu.objects.all()
admin_role.menus.set(all_menus)
tech_manager_role.menus.set([
menus['user'], menus['role'], menus['dept'],
Menu.objects.get(permission_code="user:create"),
Menu.objects.get(permission_code="user:delete"),
Menu.objects.get(permission_code="user:assign_roles"),
])
hr_role.menus.set([menus['user']])
employee_role.menus.set([])
return {
'admin': admin_role,
'tech_manager': tech_manager_role,
'hr': hr_role,
'employee': employee_role,
}
def _get_or_create_user(self, username, defaults, roles):
"""获取或创建用户,并设置角色"""
user, created = User.objects.get_or_create(
username=username,
defaults=defaults
)
if created:
self.stdout.write(f" 用户: {user.cname} ({user.username})")
# 无论是否新建,都确保角色正确(幂等)
user.roles.set(roles)
return user
def _create_users(self, departments, roles):
self.stdout.write("🔄 同步用户账号...")
self._get_or_create_user(
"admin",
{
"cname": "系统管理员",
"phone": "13800000000",
"password": make_password("adminadmin"),
"dept": departments['company'],
"is_active": True,
"is_staff": True,
"is_superuser": True,
},
[roles['admin']]
)
self._get_or_create_user(
"techmgr",
{
"cname": "张经理",
"phone": "13800000001",
"password": make_password("adminadmin"),
"dept": departments['tech'],
"is_active": True,
},
[roles['tech_manager']]
)
self._get_or_create_user(
"dev1",
{
"cname": "李开发",
"phone": "13800000002",
"password": make_password("adminadmin"),
"dept": departments['backend'],
"is_active": True,
},
[roles['employee']]
)
self._get_or_create_user(
"hr1",
{
"cname": "王HR",
"phone": "13800000003",
"password": make_password("adminadmin"),
"dept": departments['hr'],
"is_active": True,
},
[roles['hr']]
)
self._get_or_create_user(
"finance1",
{
"cname": "赵会计",
"phone": "13800000004",
"password": make_password("adminadmin"),
"dept": departments['finance'],
"is_active": True,
},
[roles['employee']]
)
+67
View File
@@ -0,0 +1,67 @@
from typing import List
from django.shortcuts import get_object_or_404
from loguru import logger
from ninja import Router
from users.models import Menu
from users.permission import require_permissions
from users.schema import MenuOut, MenuCreate, MenuUpdate
router = Router(tags=['menus'])
@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:
data['parent_id'] = parent_id
menu = Menu.objects.create(**data)
return menu
@router.get("/{menu_id}/", response=MenuOut, summary="获取菜单详情")
@require_permissions("menu:view")
def get_menu(request, menu_id: int):
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):
logger.debug(payload)
menu = get_object_or_404(Menu, id=menu_id)
data = payload.dict()
parent_id = data.pop('parent_id', None)
if parent_id:
data['parent_id'] = parent_id
else:
data['parent'] = None
logger.info(data)
for attr, value in data.items():
setattr(menu, attr, value)
logger.info(menu.parent_id)
menu.save()
return menu
@router.delete("/{menu_id}/", summary="删除菜单")
@require_permissions("menu:delete")
def delete_menu(request, menu_id: int):
menu = get_object_or_404(Menu, id=menu_id)
menu.delete()
return {"success": True}
@@ -0,0 +1,122 @@
# Generated by Django 6.0.3 on 2026-04-05 12:05
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='role',
name='permissions',
),
migrations.AlterModelOptions(
name='role',
options={'verbose_name': '角色', 'verbose_name_plural': '角色'},
),
migrations.AddField(
model_name='department',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='创建时间'),
preserve_default=False,
),
migrations.AddField(
model_name='department',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AddField(
model_name='role',
name='code',
field=models.CharField(default=django.utils.timezone.now, max_length=50, unique=True, verbose_name='角色编码'),
preserve_default=False,
),
migrations.AddField(
model_name='role',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='创建时间'),
preserve_default=False,
),
migrations.AddField(
model_name='role',
name='description',
field=models.CharField(blank=True, max_length=200, verbose_name='描述'),
),
migrations.AddField(
model_name='role',
name='status',
field=models.BooleanField(default=True, verbose_name='状态'),
),
migrations.AddField(
model_name='role',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AddField(
model_name='user',
name='avatar',
field=models.CharField(blank=True, max_length=255, verbose_name='头像'),
),
migrations.AddField(
model_name='user',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='创建时间'),
preserve_default=False,
),
migrations.AddField(
model_name='user',
name='phone',
field=models.CharField(blank=True, max_length=20, verbose_name='手机号'),
),
migrations.AddField(
model_name='user',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='user',
name='is_active',
field=models.BooleanField(default=True, verbose_name='状态'),
),
migrations.AlterField(
model_name='user',
name='roles',
field=models.ManyToManyField(blank=True, related_name='users', to='users.role', verbose_name='角色'),
),
migrations.CreateModel(
name='Menu',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('name', models.CharField(max_length=50, verbose_name='菜单名称')),
('code', models.CharField(max_length=50, unique=True, verbose_name='菜单编码')),
('path', models.CharField(blank=True, max_length=200, verbose_name='路由路径')),
('component', models.CharField(blank=True, max_length=200, verbose_name='组件路径')),
('icon', models.CharField(blank=True, max_length=50, verbose_name='图标')),
('menu_type', models.IntegerField(choices=[(0, '目录'), (1, '菜单'), (2, '按钮')], default=1, verbose_name='菜单类型')),
('order_num', models.IntegerField(default=0, verbose_name='显示顺序')),
('permission_code', models.CharField(blank=True, max_length=100, verbose_name='权限标识')),
('status', models.BooleanField(default=True, verbose_name='状态')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='users.menu', verbose_name='父菜单')),
],
options={
'verbose_name': '菜单',
'verbose_name_plural': '菜单',
},
),
migrations.AddField(
model_name='role',
name='menus',
field=models.ManyToManyField(blank=True, related_name='roles', to='users.menu', verbose_name='菜单权限'),
),
migrations.DeleteModel(
name='Permission',
),
]
+67 -27
View File
@@ -1,35 +1,30 @@
from django.contrib.auth.models import PermissionsMixin, AbstractUser
from django.db import models
from common.base import BaseEntity
# Create your models here.
class Permission(models.Model):
CODENAMES = [
('view', '查看'),
('add', '新增'),
('edit', '编辑'),
('delete', '删除'),
]
resource = models.CharField("资源标识", max_length=50) # 如 'article', 'menu', 'carousel'
action = models.CharField("操作", max_length=10, choices=CODENAMES)
desc = models.CharField("描述", max_length=100)
class Role(BaseEntity):
"""角色模型"""
name = models.CharField(max_length=50, unique=True, verbose_name="角色名称")
code = models.CharField(max_length=50, unique=True, verbose_name="角色编码")
description = models.CharField(max_length=200, blank=True, verbose_name="描述")
status = models.BooleanField(default=True, verbose_name="状态")
# 直接关联菜单(多对多)
menus = models.ManyToManyField(
'Menu',
blank=True,
related_name='roles',
verbose_name="菜单权限"
)
class Meta:
unique_together = ('resource', 'action')
def __str__(self):
return f"{self.resource}:{self.action}"
verbose_name = "角色"
verbose_name_plural = verbose_name
class Role(models.Model):
name = models.CharField("角色名称", max_length=50, unique=True)
permissions = models.ManyToManyField('Permission', blank=True)
def __str__(self):
return self.name
class Department(models.Model):
class Department(BaseEntity):
name = models.CharField("部门名称", max_length=100, unique=True)
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, verbose_name="上级部门")
order = models.IntegerField("排序", default=0)
@@ -38,15 +33,37 @@ class Department(models.Model):
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):
class User(AbstractUser, PermissionsMixin, BaseEntity):
username = models.CharField("英文名/账号", max_length=30, unique=True)
cname = models.CharField("中文名", max_length=30, blank=True)
phone = models.CharField(max_length=20, blank=True, verbose_name="手机号")
avatar = models.CharField(max_length=255, blank=True, verbose_name="头像")
dept = models.ForeignKey('Department', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属部门")
is_active = models.BooleanField(default=True)
is_active = models.BooleanField(default=True, verbose_name="状态")
# 直接在这里定义多对多关系 👈
roles = models.ManyToManyField(
Role,
blank=True,
related_name='users',
verbose_name="角色"
)
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = []
@@ -55,5 +72,28 @@ class User(AbstractUser, PermissionsMixin):
return f"{self.cname} ({self.username})"
# 给 User 关联 Role(多对多)
User.add_to_class('roles', models.ManyToManyField(Role, blank=True))
class Menu(BaseEntity):
"""菜单模型"""
MENU_TYPE_CHOICES = [
(0, '目录'),
(1, '菜单'),
(2, '按钮'),
]
name = models.CharField(max_length=50, verbose_name="菜单名称")
code = models.CharField(max_length=50, unique=True, verbose_name="菜单编码")
path = models.CharField(max_length=200, blank=True, verbose_name="路由路径")
component = models.CharField(max_length=200, blank=True, verbose_name="组件路径")
icon = models.CharField(max_length=50, blank=True, verbose_name="图标")
parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, verbose_name="父菜单")
menu_type = models.IntegerField(choices=MENU_TYPE_CHOICES, default=1, verbose_name="菜单类型")
order_num = models.IntegerField(default=0, verbose_name="显示顺序")
permission_code = models.CharField(max_length=100, blank=True, verbose_name="权限标识")
status = models.BooleanField(default=True, verbose_name="状态")
class Meta:
verbose_name = "菜单"
verbose_name_plural = verbose_name
def __str__(self):
return self.name
+148
View File
@@ -0,0 +1,148 @@
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]:
"""
获取用户所有按钮级权限标识(permission_code
利用 Django ORM 的多对多关系链式查询
"""
cache_key = f"user_permissions_{user_id}"
permissions = cache.get(cache_key)
logger.info(f"当前缓存的权限列表:{permissions}")
if permissions is None:
try:
# 链式查询:用户 → 角色 → 菜单(仅按钮类型)
permissions = list(
Menu.objects.filter(
roles__users__id=user_id, # 通过 roles__users 反向关联到用户
menu_type=2, # 2 = 按钮
status=True
)
.values_list('permission_code', flat=True)
.distinct()
)
logger.info(f"当前获取到的权限列表:{permissions}")
cache.set(cache_key, permissions, 3600) # 缓存1小时
except User.DoesNotExist:
return []
logger.info(f"当前用户的权限列表:{permissions}")
return permissions
def has_permission(user_id: int, permission_code: str) -> bool:
"""检查用户是否拥有指定权限"""
if not permission_code:
return True
return permission_code in get_user_permissions(user_id)
def clear_user_permissions_cache(user_id: int):
"""清除用户权限缓存(当角色或菜单变更时调用)"""
cache.delete(f"user_permissions_{user_id}")
# 批量清除缓存的辅助函数(可选)
def clear_all_users_permissions_cache():
"""清除所有用户权限缓存(谨慎使用)"""
# 实际项目中可通过信号或更精细的方式处理
cache.clear() # 或遍历已知用户ID
def build_menu_tree(menus):
"""高效构建菜单树(O(n) 时间复杂度)"""
# 1. 将菜单列表转为字典,key=id
menu_dict = {}
for menu in menus:
menu_dict[menu.id] = {
'id': menu.id,
'name': menu.name,
'code': menu.code,
'path': menu.path,
'component': menu.component,
'icon': menu.icon,
'order_num': menu.order_num,
'parent_id': menu.parent_id, # ✅ 只存 ID,避免对象引用
'permission_code': menu.permission_code,
'menu_type': menu.menu_type,
'children': [] # 初始化为空列表
}
# 2. 构建树结构
tree = []
for menu_id, node in menu_dict.items():
parent_id = node['parent_id']
if parent_id is None or parent_id not in menu_dict:
# 根节点(parent_id 为 None 或指向不存在的父节点)
tree.append(node)
else:
# 添加到父节点的 children
menu_dict[parent_id]['children'].append(node)
# 3. (可选)按 order_num 排序
def sort_children(nodes):
nodes.sort(key=lambda x: x['order_num'] or 0)
for node in nodes:
sort_children(node['children'])
sort_children(tree)
return tree
+29
View File
@@ -0,0 +1,29 @@
# api/permissions.py
from loguru import logger
from ninja import Router
from users.models import Menu
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,
menu_type__in=[0, 1],
status=True
).order_by('order_num')
tree_menus = build_menu_tree(list(menus))
logger.info(tree_menus)
return tree_menus
@router.get("/permissions/", summary="获取当前用户按钮权限")
@require_permissions("menu:list")
def get_user_permissions_api(request):
perms = get_user_permissions(request.auth.id)
return {"permissions": perms}
+63
View File
@@ -0,0 +1,63 @@
# api/roles.py
from typing import List
from django.shortcuts import get_object_or_404
from ninja import Router
from ninja.errors import HttpError
from users.models import Role
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):
role = Role.objects.create(**payload.dict())
return role
@router.get("/{role_id}/", response=RoleOut, summary="获取角色详情")
@require_permissions("role:view")
def get_role(request, role_id: int):
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):
role = get_object_or_404(Role, id=role_id)
for attr, value in payload.dict().items():
setattr(role, attr, value)
role.save()
return role
@router.delete("/{role_id}/", summary="删除角色")
@require_permissions("role:delete")
def delete_role(request, role_id: int):
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):
role = get_object_or_404(Role, id=role_id)
# 直接使用 ManyToManyField 的 set() 方法
role.menus.set(payload.menu_ids)
# 清除该角色下所有用户的权限缓存
clear_user_permissions_cache(role_id)
return {"success": True}
+138
View File
@@ -0,0 +1,138 @@
# schemas.py
from datetime import datetime
from typing import List, Optional
from ninja import Schema
# ========== 用户 Schema ==========
class UserRoleOut(Schema):
id: int
name: str
class UserDeptOut(Schema):
id: int
name: str
class UserOut(Schema):
id: int
username: str
cname: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
avatar: Optional[str] = None
dept: Optional[UserDeptOut] = None
roles: List[UserRoleOut] = []
is_active: bool
is_staff: bool
is_superuser: bool
last_login: Optional[datetime] = None
date_joined: datetime
class UserCreate(Schema):
username: str
password: Optional[str] = None
cname: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
dept: Optional[int] = None
roles: List[int] = []
is_active: bool = True
class UserResetPassword(Schema):
user_id: int
new_password: str
class UserUpdate(Schema):
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 ==========
class MenuBase(Schema):
name: str
code: str
path: Optional[str] = None
component: Optional[str] = None
icon: Optional[str] = None
parent_id: Optional[int] = None
menu_type: int = 1
order_num: int = 0
permission_code: Optional[str] = None
status: bool = True
class MenuCreate(MenuBase):
pass
class MenuUpdate(MenuBase):
pass
class MenuOut(MenuBase):
id: int
created_at: datetime
# ========== 角色 Schema ==========
class RoleBase(Schema):
name: str
code: str
description: Optional[str] = None
status: bool = True
menus: List[MenuOut]
class RoleCreate(RoleBase):
pass
class RoleUpdate(RoleBase):
pass
class RoleOut(RoleBase):
id: int
created_at: datetime
# ========== 关联 Schema ==========
class RoleMenuAssign(Schema):
menu_ids: List[int]
class UserRoleAssign(Schema):
role_ids: List[int]
class DepartmentBase(Schema):
name: str
parent_id: Optional[int] = None
order: int = 0
class DepartmentCreate(DepartmentBase):
pass
class DepartmentUpdate(DepartmentBase):
pass
class DepartmentOut(DepartmentBase):
id: int
+4 -3
View File
@@ -3,6 +3,7 @@ import sys
import django
from faker import Faker
from loguru import logger
# 配置 Django 环境
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
@@ -16,7 +17,7 @@ fake = Faker('zh_CN')
def seed_users(count=5):
print(f"正在生成 {count} 个用户...")
logger.info(f"正在生成 {count} 个用户...")
users = []
for i in range(count):
@@ -37,9 +38,9 @@ def seed_users(count=5):
last_name=fake.last_name()
)
users.append(user)
print(f" - 创建用户: {username} (密码: password123)")
logger.info(f" - 创建用户: {username} (密码: password123)")
print("用户生成完毕!")
logger.info("用户生成完毕!")
if __name__ == "__main__":
+132
View File
@@ -0,0 +1,132 @@
import secrets
from typing import List
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, 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)
@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.post("/")
@require_permissions("user:create")
def create_user(request, data: UserCreate):
try:
if not data.password:
data.password = secrets.token_urlsafe(8)[:8]
logger.debug(f"用户名:{data.username}, 密码:{data.password}")
user = User.objects.create_user(
username=data.username,
email=data.email,
password=make_password(data.password)
)
for k, v in data.dict(exclude={"password", "roles"}).items():
setattr(user, k, v)
user.save()
if data.roles:
user.roles.set(data.roles)
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__
data["roles"] = list(obj.roles.values_list("id", flat=True))
return data
@router.put("/{id}")
@require_permissions("user:update")
def update_user(request, id: int, data: UserUpdate):
user = get_object_or_404(User, id=id)
if data.password:
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()
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}
+13 -1
View File
@@ -1,3 +1,15 @@
from django.shortcuts import render
from ninja import Router
from .users import router as users_router
from .menus import router as menu_router
from .roles import router as role_router
from .permissions import router as perm_router
from .departments import router as dept_router
# Create your views here.
router = Router(tags=['users'])
router.add_router("/users", users_router) # 用户基础接口
router.add_router("/perms", perm_router) # 用户基础接口
router.add_router("/menus", menu_router) # /menus/
router.add_router("/roles", role_router) # /roles/
router.add_router("/dept", dept_router) # /roles/
+33
View File
@@ -0,0 +1,33 @@
from typing import List
from django.core.cache import cache
from users.models import Department
def get_dept_children_ids(dept_id: int) -> List[int]:
"""
递归获取当前部门及所有子部门的 ID 列表
"""
# 尝试从缓存获取
cache_key = f"dept_tree_{dept_id}"
cached_ids = cache.get(cache_key)
if cached_ids:
return cached_ids
# 缓存未命中,执行递归逻辑...
# 1. 初始化列表,包含当前部门 ID
dept_ids = [dept_id]
# 2. 查询直接子部门
children = Department.objects.filter(parent_id=dept_id) # 假设外键是 parent_id
# 3. 递归遍历子部门
for child in children:
# 递归获取子部门的子部门,并将结果扩展到列表中
dept_ids.extend(get_dept_children_ids(child.id))
# 将结果存入缓存,过期时间设为 12 小时
cache.set(cache_key, dept_ids, 12 * 60 * 60)
return dept_ids
-9
View File
@@ -1,9 +0,0 @@
def user_has_permission(user, resource, action):
if user.is_superuser:
return True
if not user.is_active:
return False
return user.roles.filter(
permissions__resource=resource,
permissions__action=action
).exists()