Compare commits
22 Commits
5174407da5
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dfa757c0d9 | |||
| 7dd59de036 | |||
| a9de2d1f68 | |||
| ab6c0a9b49 | |||
| 0f43e10c5b | |||
| c2b681a03a | |||
| 6108dd3346 | |||
| ec38651899 | |||
| 8161a27cf5 | |||
| 2ed7904ada | |||
| 5670ca57e6 | |||
| b1e7488970 | |||
| b7cacbcf6a | |||
| 3ca5b4a18d | |||
| 5588bbaac8 | |||
| fdc10692af | |||
| d25b099658 | |||
| 8dc2ce7780 | |||
| ae80234f65 | |||
| 53e91fa458 | |||
| 402f7e512e | |||
| 60366b7eb1 |
@@ -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
@@ -2,7 +2,6 @@ from datetime import datetime
|
|||||||
from typing import Optional, List, Generic, T
|
from typing import Optional, List, Generic, T
|
||||||
|
|
||||||
from ninja import Schema
|
from ninja import Schema
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
|
|
||||||
# --- 假设的关联模型 Schema ---
|
# --- 假设的关联模型 Schema ---
|
||||||
@@ -16,47 +15,49 @@ class SectionSchema(Schema):
|
|||||||
title: str # 假设 MainMenu 有 name 字段
|
title: str # 假设 MainMenu 有 name 字段
|
||||||
|
|
||||||
|
|
||||||
class AttachmentSchema(Schema):
|
class ArticleAttachmentOut(Schema):
|
||||||
id: int
|
id: int
|
||||||
file: str # 返回文件 URL 路径,如 "articles/attachments/2026/04/03/file.pdf"
|
file: str
|
||||||
|
|
||||||
|
|
||||||
class PaginatedResponseSchema(Schema, Generic[T]):
|
class ArticleListOut(Schema):
|
||||||
items: List[T]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
per_page: int
|
|
||||||
total_pages: int
|
|
||||||
|
|
||||||
|
|
||||||
# --- Article 主 Schema ---
|
|
||||||
class ArticleSchema(Schema):
|
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
content: str
|
|
||||||
cover: Optional[str] = None # 图片 URL 路径
|
section: SectionSchema
|
||||||
|
|
||||||
|
author: AuthorSchema
|
||||||
|
|
||||||
|
cover: Optional[str] = None
|
||||||
is_contribution: bool
|
is_contribution: bool
|
||||||
is_published: 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 ArticleDetailOut(Schema):
|
||||||
class ArticleCreateSchema(Schema):
|
id: int
|
||||||
title: str
|
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
|
content: str
|
||||||
cover: Optional[str] = None # 注意:文件上传通常需单独处理,此处仅为文本路径示例
|
|
||||||
is_contribution: bool = False
|
is_contribution: bool = False
|
||||||
is_published: bool = True
|
is_published: bool = True
|
||||||
|
|
||||||
|
|
||||||
# --- 更新文章用的 Schema ---
|
class ArticleUpdate(ArticleCreate):
|
||||||
class ArticleUpdateSchema(ArticleCreateSchema):
|
pass
|
||||||
title: Optional[str] = None
|
|
||||||
content: Optional[str] = None
|
|
||||||
|
|||||||
+1
-1
@@ -121,4 +121,4 @@ def seed_articles(count=20):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
seed_articles(120)
|
seed_articles(1200)
|
||||||
|
|||||||
+35
-19
@@ -1,34 +1,50 @@
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
from ninja.pagination import paginate, PageNumberPagination
|
from ninja.pagination import paginate, PageNumberPagination
|
||||||
|
|
||||||
from article.models import Article
|
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.
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
router = Router(tags=['content'])
|
router = Router(tags=['Article'])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/article", response=List[ArticleSchema], description="获取文章列表")
|
@router.get("/article/", response=List[ArticleListOut], description="获取文章列表")
|
||||||
@paginate(PageNumberPagination, page_size=50)
|
@paginate(PageNumberPagination, page_size=20)
|
||||||
def list_articles(request, ):
|
def list_articles(request, title: str = '', section_id: int = 0):
|
||||||
# 安全校验 per_page(防止过大
|
qs = (Article.objects.filter(title__contains=title).prefetch_related('attachments')
|
||||||
queryset = Article.objects.prefetch_related('attachments').select_related('author', 'section')
|
.select_related('author', 'section').order_by("-id"))
|
||||||
return queryset
|
if section_id > 0:
|
||||||
|
return qs.filter(section_id=section_id)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
@router.post("/article", response=ArticleSchema)
|
@router.get("/article/{id}", response=ArticleDetailOut)
|
||||||
def create_article(request, payload: ArticleCreateSchema):
|
def get_article(request, id: int):
|
||||||
# 注意:文件上传需额外处理(此处简化)
|
return get_object_or_404(Article.objects.prefetch_related("attachments"), id=id)
|
||||||
article = Article.objects.create(
|
|
||||||
title=payload.title,
|
|
||||||
section_id=payload.section_id,
|
@router.post("/article/", response=ArticleDetailOut)
|
||||||
author=request.auth, # 假设认证用户是作者
|
def create_article(request, data: ArticleCreate):
|
||||||
content=payload.content,
|
article = Article.objects.create(**data.dict())
|
||||||
is_contribution=payload.is_contribution,
|
|
||||||
is_published=payload.is_published,
|
|
||||||
)
|
|
||||||
return article
|
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
@@ -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
@@ -1,15 +1,24 @@
|
|||||||
from django.shortcuts import render
|
import hashlib
|
||||||
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 os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
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.
|
# Create your views here.
|
||||||
router = Router(tags=['content'])
|
router = Router(tags=['base'])
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@@ -43,3 +52,308 @@ def custom_upload_file(request):
|
|||||||
"url": file_url,
|
"url": file_url,
|
||||||
"default": 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}
|
||||||
|
|||||||
@@ -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
@@ -3,6 +3,8 @@ import random
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
import django
|
import django
|
||||||
|
from loguru import logger
|
||||||
|
from pypinyin import lazy_pinyin, Style
|
||||||
|
|
||||||
# 配置 Django 环境
|
# 配置 Django 环境
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -17,18 +19,31 @@ from users.models import User
|
|||||||
# 初始化 Faker (zh_CN 表示生成中文数据)
|
# 初始化 Faker (zh_CN 表示生成中文数据)
|
||||||
fake = 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):
|
def seed_news(count=20):
|
||||||
print(f"🚀 开始生成 {count} 条富文本新闻数据...")
|
logger.info(f"🚀 开始生成 {count} 条富文本新闻数据...")
|
||||||
|
|
||||||
# --- 1. 获取关联数据 ---
|
# --- 1. 获取关联数据 ---
|
||||||
sections = list(NewsSection.objects.all())
|
sections = list(NewsSection.objects.all())
|
||||||
users = list(User.objects.all())
|
users = list(User.objects.all())
|
||||||
|
|
||||||
if not sections:
|
if not sections:
|
||||||
print("❌ 错误:数据库中没有找到任何板块 (NewsSection)!")
|
logger.warning("❌ 错误:数据库中没有找到任何板块 (NewsSection)!")
|
||||||
return
|
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
|
created_count = 0
|
||||||
|
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
@@ -90,10 +105,10 @@ def seed_news(count=20):
|
|||||||
)
|
)
|
||||||
|
|
||||||
created_count += 1
|
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__":
|
if __name__ == "__main__":
|
||||||
seed_news(120)
|
seed_news(600)
|
||||||
|
|||||||
+113
@@ -1,3 +1,116 @@
|
|||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
# Create your views here.
|
# 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
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
|||||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import environ
|
import environ
|
||||||
@@ -92,6 +93,7 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'ninja.compatibility.files.fix_request_files_middleware',
|
||||||
# 'django.middleware.csrf.CsrfViewMiddleware',
|
# 'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
@@ -125,7 +127,7 @@ ASGI_APPLICATION = 'server.asgi.application'
|
|||||||
|
|
||||||
if env('DB_REMOTE', default=False):
|
if env('DB_REMOTE', default=False):
|
||||||
# mysql_connect = dj_database_url.parse(os.getenv('MYSQL_CONNECTION'), conn_max_age=0)
|
# mysql_connect = dj_database_url.parse(os.getenv('MYSQL_CONNECTION'), conn_max_age=0)
|
||||||
# print("数据库连接信息", mysql_connect)
|
logger.info("正在使用 PGSQL 数据库连接信息")
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': env('DB_ENGINE', default='dj_db_conn_pool.backends.postgresql'),
|
'ENGINE': env('DB_ENGINE', default='dj_db_conn_pool.backends.postgresql'),
|
||||||
@@ -147,6 +149,7 @@ if env('DB_REMOTE', default=False):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
logger.info("正在使用本地 SQLite 数据库", )
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
@@ -220,63 +223,47 @@ MEDIA_URL = '/media/'
|
|||||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||||
|
|
||||||
# 日志配置
|
# 日志配置
|
||||||
LOGGING = {
|
NINJA_JWT = {
|
||||||
'version': 1, # 版本
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), # 访问令牌有效期
|
||||||
'disable_existing_loggers': False, # 是否禁用已经存在的日志器
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=3), # 刷新令牌有效期
|
||||||
'formatters': { # 日志信息显示的格式
|
'ROTATE_REFRESH_TOKENS': False,
|
||||||
'verbose': {
|
'BLACKLIST_AFTER_ROTATION': False, # 旋转后列入黑名单
|
||||||
'format': '%(levelname)s %(asctime)s %(module)s %(lineno)d %(message)s'
|
'UPDATE_LAST_LOGIN': True, # 更新最后登录
|
||||||
},
|
|
||||||
'simple': {
|
'ALGORITHM': 'HS256',
|
||||||
'format': '%(levelname)s %(module)s %(lineno)d %(message)s'
|
'SIGNING_KEY': SECRET_KEY,
|
||||||
},
|
'VERIFYING_KEY': None,
|
||||||
},
|
'AUDIENCE': None,
|
||||||
'filters': { # 过滤器
|
'ISSUER': None,
|
||||||
'require_debug_true': { # django在debug模式下才输出日志
|
'JWK_URL': None,
|
||||||
'()': 'django.utils.log.RequireDebugTrue',
|
'LEEWAY': 0,
|
||||||
},
|
|
||||||
},
|
'USER_ID_FIELD': 'id',
|
||||||
'handlers': { # 日志处理方法
|
'USER_ID_CLAIM': 'user_id',
|
||||||
'console': { # 向终端中输出日志
|
'USER_AUTHENTICATION_RULE': 'ninja_jwt.authentication.default_user_authentication_rule',
|
||||||
'level': 'DEBUG', # 输出等级为“INFO”
|
|
||||||
'filters': ['require_debug_true'],
|
# 'AUTH_TOKEN_CLASSES': ('ninja_jwt.tokens.AccessToken',),
|
||||||
'class': 'logging.StreamHandler',
|
# 'TOKEN_TYPE_CLAIM': 'token_type',
|
||||||
'formatter': 'simple'
|
# 'TOKEN_USER_CLASS': 'ninja_jwt.models.TokenUser',
|
||||||
},
|
|
||||||
'file': { # 向文件中输出日志
|
# 'JTI_CLAIM': 'jti',
|
||||||
'level': os.getenv('LOGGER_LEVEL') if os.getenv('LOGGER_LEVEL') else 'INFO', # 输出等级为“INFO”
|
#
|
||||||
# 新增内容
|
# 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
|
||||||
# 'class': 'logging.handlers.TimedRotatingFileHandler',
|
# 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
|
||||||
# 'filename': os.path.join(BASE_DIR, 'logs/logs.log'),
|
# 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
|
||||||
# 'when': 'm',
|
|
||||||
# 'interval': 10,
|
# For Controller Schemas
|
||||||
# 'backupCount': 10,
|
# FOR OBTAIN PAIR
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
# 'TOKEN_OBTAIN_PAIR_INPUT_SCHEMA': "ninja_jwt.schema.TokenObtainPairInputSchema",
|
||||||
'filename': BASE_DIR / "db/logs/django_server.log", # 日志文件的位置
|
# 'TOKEN_OBTAIN_PAIR_REFRESH_INPUT_SCHEMA': "ninja_jwt.schema.TokenRefreshInputSchema",
|
||||||
'maxBytes': 5 * 1024 * 1024, # 日志文件的大小(300*1024*1024为300MB)
|
# FOR SLIDING TOKEN
|
||||||
'backupCount': 10, # 日志文件的数量(超过设定的最大值会自动备份,备份数量最大值为10)
|
# 'TOKEN_OBTAIN_SLIDING_INPUT_SCHEMA': "ninja_jwt.schema.TokenObtainSlidingInputSchema",
|
||||||
'formatter': 'verbose', # 日志输出格式:使用了在之前定义的'verbose'
|
# 'TOKEN_OBTAIN_SLIDING_REFRESH_INPUT_SCHEMA': "ninja_jwt.schema.TokenRefreshSlidingInputSchema",
|
||||||
'encoding': 'utf-8' # 新增此行,指定文件编码为UTF-8
|
#
|
||||||
},
|
# 'TOKEN_BLACKLIST_INPUT_SCHEMA': "ninja_jwt.schema.TokenBlacklistInputSchema",
|
||||||
},
|
# 'TOKEN_VERIFY_INPUT_SCHEMA': "ninja_jwt.schema.TokenVerifyInputSchema",
|
||||||
'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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 调整POST传输数据文件大小限制
|
# 调整POST传输数据文件大小限制
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024 * 1024
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024 * 1024
|
||||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024 * 1024
|
FILE_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024 * 1024
|
||||||
|
|||||||
+58
-12
@@ -27,6 +27,7 @@ from django.urls import path, include
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from ninja.errors import ValidationError
|
from ninja.errors import ValidationError
|
||||||
from ninja_extra import NinjaExtraAPI
|
from ninja_extra import NinjaExtraAPI
|
||||||
|
from ninja_jwt.authentication import JWTAuth
|
||||||
from ninja_jwt.controller import NinjaJWTDefaultController
|
from ninja_jwt.controller import NinjaJWTDefaultController
|
||||||
|
|
||||||
from base.views import custom_upload_file
|
from base.views import custom_upload_file
|
||||||
@@ -63,41 +64,86 @@ def auto_load_routers(package_name_prefix=None):
|
|||||||
# 检查是否存在 'router' 变量
|
# 检查是否存在 'router' 变量
|
||||||
if hasattr(module, 'router'):
|
if hasattr(module, 'router'):
|
||||||
router = getattr(module, 'router')
|
router = getattr(module, 'router')
|
||||||
|
|
||||||
# 使用文件夹名称 (modname) 作为 URL 前缀
|
# 使用文件夹名称 (modname) 作为 URL 前缀
|
||||||
# 例如: 文件夹 'authorize' -> 前缀 '/authorize'
|
# 例如: 文件夹 'authorize' -> 前缀 '/authorize'
|
||||||
url_prefix = f"/{modname}"
|
url_prefix = f"/{modname}"
|
||||||
|
|
||||||
api_v1.add_router(url_prefix, router)
|
api_v1.add_router(url_prefix, router)
|
||||||
print(f"✅ 自动注册: {url_prefix} (来自 {full_module_path})")
|
logger.info(f"✅ 自动注册: {url_prefix} (来自 {full_module_path})")
|
||||||
else:
|
else:
|
||||||
print(f"⚠️ 跳过: {modname} (在 {full_module_path} 中未找到 'router' 变量)")
|
logger.warning(f"⚠️ 跳过: {modname} (在 {full_module_path} 中未找到 'router' 变量)")
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# 如果文件夹里没有 views.py,会报 ImportError,这是正常的,跳过即可
|
# 如果文件夹里没有 views.py,会报 ImportError,这是正常的,跳过即可
|
||||||
if "No module named" in str(e) and "views" in str(e):
|
if "No module named" in str(e) and "views" in str(e):
|
||||||
print(f"ℹ️ 跳过: {modname} (没有 views.py 模块)")
|
logger.warning(f"ℹ️ 跳过: {modname} (没有 views.py 模块)")
|
||||||
else:
|
else:
|
||||||
print(f"❌ 导入错误 {modname}: {e}")
|
logger.error(f"❌ 导入错误 {modname}: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 注册失败 {modname}: {e}")
|
logger.error(f"❌ 注册失败 {modname}: {e}")
|
||||||
|
|
||||||
|
|
||||||
class UnifiedNinjaAPI(NinjaExtraAPI):
|
class UnifiedNinjaAPI(NinjaExtraAPI):
|
||||||
def create_response(self, request, data, *, status=None, headers=None, temporal_response=None):
|
def create_response(self, request, data, *, status=None, headers=None, temporal_response=None):
|
||||||
if not isinstance(data, CommonResponse):
|
# 1. 如果已经是 CommonResponse,直接放行(避免递归)
|
||||||
if isinstance(data, tuple) and len(data) == 2:
|
if isinstance(data, CommonResponse):
|
||||||
data, status = data
|
return super().create_response(request, data, status=status, temporal_response=temporal_response)
|
||||||
data = CommonResponse.success(data=data)
|
|
||||||
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)
|
api_v1.register_controllers(NinjaJWTDefaultController)
|
||||||
|
|
||||||
auto_load_routers()
|
auto_load_routers()
|
||||||
|
|
||||||
|
|
||||||
|
# logger.debug(api_v1.urls)
|
||||||
|
|
||||||
|
|
||||||
@api_v1.exception_handler(ValidationError)
|
@api_v1.exception_handler(ValidationError)
|
||||||
def validation_errors(request, exc):
|
def validation_errors(request, exc):
|
||||||
logger.error(request.body)
|
logger.error(request.body)
|
||||||
|
|||||||
+147
-24
@@ -1,17 +1,109 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
|
from django.utils.html import format_html
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|
||||||
|
from .models import Department, Role, Menu
|
||||||
from .models import User
|
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)
|
@admin.register(User)
|
||||||
class UserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
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')
|
list_filter = ('is_active', 'is_staff', 'dept')
|
||||||
search_fields = ('username', 'cname')
|
search_fields = ('username', 'cname', 'phone',)
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('username', 'password')}),
|
(None, {'fields': ('username', 'password')}),
|
||||||
('个人信息', {'fields': ('cname', 'dept')}),
|
('个人信息', {'fields': ('cname', 'dept')}),
|
||||||
@@ -24,33 +116,64 @@ class UserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
|||||||
'fields': ('username', 'cname', 'password1', 'password2', 'dept'),
|
'fields': ('username', 'cname', 'password1', 'password2', 'dept'),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
ordering = ('username',)
|
ordering = ('username', '-date_joined',)
|
||||||
filter_horizontal = ('groups', 'user_permissions', 'roles') # 如果 roles 是 ManyToMany
|
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)
|
@admin.register(Department)
|
||||||
class DepartmentAdmin(ImportExportModelAdmin):
|
class DepartmentAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'parent', 'order')
|
list_display = ('name_with_indent', 'parent_link', 'order', 'user_count')
|
||||||
list_filter = ('parent',)
|
list_editable = ('order',)
|
||||||
search_fields = ('name',)
|
ordering = ('order', 'id')
|
||||||
ordering = ('order', 'name')
|
list_per_page = 30
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
# 优化查询,避免 N+1
|
||||||
|
return super().get_queryset(request).select_related('parent')
|
||||||
|
|
||||||
@admin.register(Role)
|
def name_with_indent(self, obj):
|
||||||
class RoleAdmin(ImportExportModelAdmin):
|
"""根据层级缩进显示部门名称"""
|
||||||
list_display = ('name', 'permission_count')
|
level = 0
|
||||||
search_fields = ('name',)
|
current = obj.parent
|
||||||
filter_horizontal = ('permissions',)
|
while current:
|
||||||
|
level += 1
|
||||||
|
current = current.parent
|
||||||
|
indent = "=>" * level
|
||||||
|
return format_html('{}{}', indent, obj.name)
|
||||||
|
|
||||||
def permission_count(self, obj):
|
name_with_indent.short_description = "部门名称"
|
||||||
return obj.permissions.count()
|
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)
|
def user_count(self, obj):
|
||||||
class PermissionAdmin(ImportExportModelAdmin):
|
count = obj.user_set.count()
|
||||||
list_display = ('resource', 'action', 'desc')
|
if count:
|
||||||
list_filter = ('resource', 'action')
|
url = f"/admin/users/user/?department__id__exact={obj.id}"
|
||||||
search_fields = ('resource', 'desc')
|
return format_html('<a href="{}">{}</a>', url, count)
|
||||||
ordering = ('resource', 'action')
|
return 0
|
||||||
|
|
||||||
|
user_count.short_description = "用户数"
|
||||||
|
|||||||
@@ -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']]
|
||||||
|
)
|
||||||
@@ -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
@@ -1,35 +1,30 @@
|
|||||||
from django.contrib.auth.models import PermissionsMixin, AbstractUser
|
from django.contrib.auth.models import PermissionsMixin, AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from common.base import BaseEntity
|
||||||
|
|
||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
class Permission(models.Model):
|
class Role(BaseEntity):
|
||||||
CODENAMES = [
|
"""角色模型"""
|
||||||
('view', '查看'),
|
name = models.CharField(max_length=50, unique=True, verbose_name="角色名称")
|
||||||
('add', '新增'),
|
code = models.CharField(max_length=50, unique=True, verbose_name="角色编码")
|
||||||
('edit', '编辑'),
|
description = models.CharField(max_length=200, blank=True, verbose_name="描述")
|
||||||
('delete', '删除'),
|
status = models.BooleanField(default=True, verbose_name="状态")
|
||||||
]
|
# 直接关联菜单(多对多)
|
||||||
resource = models.CharField("资源标识", max_length=50) # 如 'article', 'menu', 'carousel'
|
menus = models.ManyToManyField(
|
||||||
action = models.CharField("操作", max_length=10, choices=CODENAMES)
|
'Menu',
|
||||||
desc = models.CharField("描述", max_length=100)
|
blank=True,
|
||||||
|
related_name='roles',
|
||||||
|
verbose_name="菜单权限"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('resource', 'action')
|
verbose_name = "角色"
|
||||||
|
verbose_name_plural = verbose_name
|
||||||
def __str__(self):
|
|
||||||
return f"{self.resource}:{self.action}"
|
|
||||||
|
|
||||||
|
|
||||||
class Role(models.Model):
|
class Department(BaseEntity):
|
||||||
name = models.CharField("角色名称", max_length=50, unique=True)
|
|
||||||
permissions = models.ManyToManyField('Permission', blank=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class Department(models.Model):
|
|
||||||
name = models.CharField("部门名称", max_length=100, unique=True)
|
name = models.CharField("部门名称", max_length=100, unique=True)
|
||||||
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, verbose_name="上级部门")
|
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, verbose_name="上级部门")
|
||||||
order = models.IntegerField("排序", default=0)
|
order = models.IntegerField("排序", default=0)
|
||||||
@@ -38,15 +33,37 @@ class Department(models.Model):
|
|||||||
verbose_name = "部门"
|
verbose_name = "部门"
|
||||||
verbose_name_plural = "部门管理"
|
verbose_name_plural = "部门管理"
|
||||||
|
|
||||||
|
# 👇 核心:添加部分唯一约束
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=['parent'],
|
||||||
|
condition=models.Q(parent__isnull=True),
|
||||||
|
name='unique_root_department'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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)
|
username = models.CharField("英文名/账号", max_length=30, unique=True)
|
||||||
cname = models.CharField("中文名", max_length=30, blank=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="所属部门")
|
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'
|
USERNAME_FIELD = 'username'
|
||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = []
|
||||||
@@ -55,5 +72,28 @@ class User(AbstractUser, PermissionsMixin):
|
|||||||
return f"{self.cname} ({self.username})"
|
return f"{self.cname} ({self.username})"
|
||||||
|
|
||||||
|
|
||||||
# 给 User 关联 Role(多对多)
|
class Menu(BaseEntity):
|
||||||
User.add_to_class('roles', models.ManyToManyField(Role, blank=True))
|
"""菜单模型"""
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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
@@ -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
@@ -3,6 +3,7 @@ import sys
|
|||||||
|
|
||||||
import django
|
import django
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
# 配置 Django 环境
|
# 配置 Django 环境
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -16,7 +17,7 @@ fake = Faker('zh_CN')
|
|||||||
|
|
||||||
|
|
||||||
def seed_users(count=5):
|
def seed_users(count=5):
|
||||||
print(f"正在生成 {count} 个用户...")
|
logger.info(f"正在生成 {count} 个用户...")
|
||||||
|
|
||||||
users = []
|
users = []
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
@@ -37,9 +38,9 @@ def seed_users(count=5):
|
|||||||
last_name=fake.last_name()
|
last_name=fake.last_name()
|
||||||
)
|
)
|
||||||
users.append(user)
|
users.append(user)
|
||||||
print(f" - 创建用户: {username} (密码: password123)")
|
logger.info(f" - 创建用户: {username} (密码: password123)")
|
||||||
|
|
||||||
print("用户生成完毕!")
|
logger.info("用户生成完毕!")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+132
@@ -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
@@ -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.
|
# 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/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
|
||||||
Reference in New Issue
Block a user