Django之Rbac组件开发小记
一. 简介
RBAC 是基于角色的访问控制(Role-Based Access Control )在 RBAC 中,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限。这就极大地简化了权限的管理。这样管理都是层级相互依赖的,权限赋予给角色,而把角色又赋予用户,这样的权限设计很清楚,管理起来很方便。
二. 表结构设计
from django.db import models # Create your models here. __all__ = ['Menu', 'Permission', 'Role', 'UserInfo'] class Menu(models.Model): title = models.CharField(verbose_name='一级菜单名称', max_length=32) icon = models.CharField(verbose_name='图标', max_length=32) def __str__(self): return self.title class Permission(models.Model): title = models.CharField(verbose_name='标题', max_length=32) url = models.CharField(verbose_name='含正则的URL', max_length=128) name = models.CharField(verbose_name='别名', max_length=32) menu = models.ForeignKey('Menu', verbose_name='所属菜单', null=True, blank=True,help_text='null表示不是二级菜单,' '非null表示是二级菜单',on_delete=models.CASCADE) pid = models.ForeignKey('self', verbose_name='关联的权限', null=True, blank=True, on_delete=models.CASCADE) def __str__(self): return self.title class Role(models.Model): title = models.CharField(max_length=32, verbose_name='角色名称') permissions = models.ManyToManyField('Permission', blank=True, verbose_name='拥有的权限') def __str__(self): return self.title class UserInfo(models.Model): name = models.CharField(verbose_name='用户名', max_length=32) password = models.CharField(verbose_name='密码', max_length=64) email = models.CharField(verbose_name='邮箱', max_length=32) roles = models.ManyToManyField('Role', blank=True, verbose_name='拥有的角色') def __str__(self): return self.name
permission表一部分截图:
注意:
1.写路由的时候,如果用path的话,有名分组要用pk为参数,否则,自动注入路由时,无法帮你转换成正则。
path('customer/edit/<int:pk>/', customer.customer_edit, name='customer_edit'),
2. 在models中定义一个__all__列表,可以在admin中注册时,通过反射快速注册。
for table in models.__all__: admin.site.register(getattr(models, table))
三. 权限初始化和中间件校验
权限初始化
我们在写完业务逻辑中的登录后,可以通过init_permission方法进行权限的初始化,比如:
init_permission中主要做的事情:
1.获取当前用户的所有权限(用户---> 角色 ----> 权限)
2.通过权限的queryset, 生成permission_dict和menu_dict 两个字典(除了权限校验功能,还是为了权限粒度到按钮级别,生成导航条)
3.把这两个字典写入session。(这里直接通过数据库的sessio表简单存)
from django.conf import settings def init_permission(current_user, request): permission_queryset = current_user.roles.filter(permissions__isnull=False).values('permissions__id', 'permissions__title', 'permissions__url', 'permissions__name', 'permissions__pid_id', 'permissions__pid__title', 'permissions__pid__url', 'permissions__menu_id', 'permissions__menu__title', 'permissions__menu__icon' ).distinct() permission_dict = {} menu_dict = {} for item in permission_queryset: permission_dict[item['permissions__name']] = { 'id': item['permissions__id'], 'title': item['permissions__title'], 'url': item['permissions__url'], 'pid': item['permissions__pid_id'], 'pid_title': item['permissions__pid__title'], 'pid_url': item['permissions__pid__url'] } menu_id = item['permissions__menu_id'] if not menu_id: continue node = {'id': item['permissions__id'], 'title': item['permissions__title'], 'url': item['permissions__url']} if menu_id in menu_dict: menu_dict[menu_id]['children'].append(node) else: menu_dict[menu_id] = { 'title': item['permissions__menu__title'], 'icon': item['permissions__menu__icon'], 'children': [node, ] } # 权限和菜单信息放入session request.session[settings.PERMISSION_SESSION_KEY] = permission_dict request.session[settings.MENU_SESSION_KEY] = menu_dict
登录校验
通过中间件的process_request,对每一个请求到来之前做权限校验。
import re from django.utils.deprecation import MiddlewareMixin from django.shortcuts import HttpResponse from django.conf import settings class RbacMiddleWare(MiddlewareMixin): def process_request(self, request): current_path = request.path_info # print(current_path) for item in settings.VALID_URL_LIST: if re.match(item, current_path): return None permission_dict = request.session.get(settings.PERMISSION_SESSION_KEY) if not permission_dict: return HttpResponse('未获取到权限信息,请先登录!') flag = False url_record = [ {'title': '首页', 'url': '#'} ] for item in settings.NO_PERMISSION_LIST: # 需要登陆,但是无需进行权限校验 if re.match(item, current_path): request.current_selected_permission = 0 request.breadcrumb = url_record return None for item in permission_dict.values(): reg = '^%s$' % item['url'] if re.match(reg, current_path): flag = True request.current_selected_permission = item['pid'] or item['id'] if not item['pid']: # 是一个二级菜单 url_record.extend([{'title': item['title'], 'url': item['url'], 'class': 'active'}]) else: url_record.extend([{'title': item['pid_title'], 'url': item['pid_url']}]) url_record.extend([{'title': item['title'], 'url': item['url'], 'class': 'active'}]) request.breadcrumb = url_record break if not flag: return HttpResponse('无权访问')
注意:
# 白名单表示不需要登录,直接就可以访问
VALID_URL_LIST = [ '/web/login/', '/admin/.*' ]
# 需要登录,但是可以是不需要任何权限的页面 NO_PERMISSION_LIST = [ '/web/index/', '/web/logout/', ]
四. 二级菜单和路劲导航
先看一下效果:
具体代码
新建一个templatetags文件夹,通过inclusion_tag来实现:
from collections import OrderedDict from django import template from django.conf import settings from utils import reverse_url register = template.Library() @register.inclusion_tag('rbac/multi_menu.html') def multi_menu(request): menu_dict = request.session.get(settings.MENU_SESSION_KEY) key_list = sorted(menu_dict) ordered_dict = OrderedDict() for key in key_list: val = menu_dict[key] val['class'] = 'hide' for per in val['children']: if per['id'] == request.current_selected_permission: per['class'] = 'active' val['class'] = '' ordered_dict[key] = val return {'menu_dict': ordered_dict} @register.inclusion_tag('rbac/breadcrumb.html') def breadcrumb(request): return {'record_list':request.breadcrumb}
multi_menu.html:
<div class="multi-menu"> {% for item in menu_dict.values %} <div class="item"> <div class="title"><span class="icon-wrap"><i class="fa {{ item.icon }}"></i></span> {{ item.title }}</div> <div class="body {{ item.class }}"> {% for per in item.children %} <a href="{{ per.url }}" class="{{ per.class }}">{{ per.title }}</a> {% endfor %} </div> </div> {% endfor %} </div>
breadcrumb.html:
<div> <ol class="breadcrumb no-radius no-margin" style="border-bottom: 1px solid #ddd;"> {% for item in record_list %} {% if item.class %} <li class="{{ item.class }}">{{ item.title }}</li> {% else %} <li><a href="{{ item.url }}">{{ item.title }}</a></li> {% endif %} {% endfor %} </ol> </div>
粒度控制到按钮
@register.filter def has_permission(request, name): if name in request.session.get(settings.PERMISSION_SESSION_KEY): return True
页面中就可以这样使用:
注意:使用之前除了导入你的tags文件,还要在settings中注册:
{% if request|has_permission:'customer_add' %} <a class="btn btn-default" href="{% url 'web:customer_add' %}"> <i class="fa fa-plus-square" aria-hidden="true"></i> 添加客户 </a> {% endif %}
五 .角色管理,用户管理
主要用到modelform知识点
注意:
# 第一: 对自定义的新增字段添加属性只能通过重写init方法来实现
class UserAddModelForm(BaseModelForm):
confirm_password = forms.CharField(label='确认密码', max_length=64)
class Meta:
model = models.UserInfo
fields = ['name','password','confirm_password', 'email']
widgets = {
'password': forms.PasswordInput(),
}
def __init__(self, *args, **kwargs):
super(UserAddModelForm, self).__init__(*args, **kwargs)
self.fields['confirm_password'].widget = forms.PasswordInput()
self.fields['confirm_password'].widget.attrs['class'] = 'form-control'
六. 一二三级菜单管理
也是主要是通过modelform来操作增删改查。
注意:
# 第一点:给Charfield字段定制可选项图标: 插件用的是RadioSelect ICON_LIST = [ ['fa-hand-scissors-o', '<i aria-hidden="true" class="fa fa-hand-scissors-o"></i>'], ['fa-hand-spock-o', '<i aria-hidden="true" class="fa fa-hand-spock-o"></i>'], ] for item in ICON_LIST: item[1] =mark_safe(item[1]) class MenuModelForm(forms.ModelForm): class Meta: model = models.Menu fields = ['title', 'icon'] widgets = { 'title': forms.TextInput(attrs={'class': 'form-control'}), 'icon': forms.RadioSelect( choices=ICON_LIST, attrs={'class': 'clearfix'} ) }
# 第二点: 对于二级菜单,新增的时候,默认归属于自己的一级菜单: 通过initial参数传一个字典来指定
def secondmenu_add(request, menu_id):
menu_object = models.Menu.objects.filter(pk=menu_id).first()
if not menu_object:
return HttpResponse('一级菜单不存在!')
if request.method == 'GET':
form = SecondMenuModelForm(initial={'menu': menu_object})
return render(request, 'rbac/change.html', {'form': form})
form = SecondMenuModelForm(data=request.POST)
if form.is_valid():
form.save()
return redirect(reverse_url(request, 'rbac:menu_list'))
return render(request, 'rbac/change.html', {'form': form})
# 第三点:通过form.instance.字段在save之前更新值
def permissions_add(request, secondmenu_id):
second_menu_obj = models.Permission.objects.filter(pk=secondmenu_id).first()
if not second_menu_obj:
return HttpResponse('二级菜单不存在!')
if request.method == 'GET':
form = PermissionModelForm()
return render(request, 'rbac/change.html', {'form': form})
form = PermissionModelForm(data=request.POST)
if form.is_valid():
form.instance.pid = second_menu_obj
form.save()
return redirect(reverse_url(request, 'rbac:menu_list'))
return render(request, 'rbac/change.html', {'form': form})
七. 批量操作权限
主要用到是Django的formset,对一批form进行操作。
步骤:
1.创建form类,定义字段。
class MultiAddPermissionForm(forms.Form): title = forms.CharField( widget=forms.TextInput(attrs={'class': 'form-control'}) ) url = forms.CharField( widget=forms.TextInput(attrs={'class': 'form-control'}) ) name = forms.CharField( widget=forms.TextInput(attrs={'class': 'form-control'}) ) menu_id = forms.ChoiceField( choices=[(None, '----')], widget=forms.Select(attrs={'class': 'form-control'}), required=False ) pid_id = forms.ChoiceField( choices=[(None, '----')], widget=forms.Select(attrs={'class': 'form-control'}), required=False ) def __init__(self, *args, **kwargs): super(MultiAddPermissionForm, self).__init__(*args, **kwargs) self.fields['menu_id'].choices += models.Menu.objects.values_list('id', 'title') self.fields['pid_id'].choices += models.Permission.objects.filter(pid_id__isnull=True).\ exclude(menu_id__isnull=True).values_list('id','title')
2. 创建formset_factory对象:
from django.forms import formset_factory
generate_formset_class = formset_factory(MultiAddPermissionForm, extra=0)
3. 把包含form中定义字段的对象,通过initial参数传入generate_formset_class中:
if not generate_formset: generate_name_list = router_name_set - permissions_name_set generate_formset = generate_formset_class( initial=[row_dict for name, row_dict in all_url_dict.items() if name in generate_name_list] )
4. 在页面中使用:
<form action="?type=generate" method="post"> {% csrf_token %} {{ generate_formset.management_form }} # 别忘记了加上这个 <div class="panel panel-default"> <!-- Default panel contents --> <div class="panel-heading"> <i class="fa fa-th-list" aria-hidden="true"></i> 待新建权限列表 <button href="#" class="right btn btn-primary btn-xs" style="padding: 2px 8px;margin: -3px;"> <i class="fa fa-save" aria-hidden="true"></i> 保存 </button> </div> <!-- Table --> <table class="table"> <thead> <tr> <th>序号</th> <th>名称</th> <th>URL</th> <th>别名</th> <th>菜单</th> <th>父权限</th> </tr> </thead> <tbody> {% for form in generate_formset %} <tr> <td>{{ forloop.counter }}</td> {% for field in form %} <td>{{ field }}<span style="color: red;">{{ field.errors.0 }}</span></td> {% endfor %} </tr> {% endfor %} </tbody> </table> </div> </form>
5. 后台,添加和更新代码:
if request.method == 'POST' and post_type == 'generate': formset = generate_formset_class(data=request.POST) if formset.is_valid(): object_list = [] post_row_dict = formset.cleaned_data has_error = False for i in range(0, formset.total_form_count()): row_dict = post_row_dict[i] try: new_object = models.Permission(**row_dict) new_object.validate_unique() # 检查唯一性约束是否满足 object_list.append(new_object) except Exception as e: formset.errors[i].update(e) generate_formset = formset has_error = True if not has_error: models.Permission.objects.bulk_create(object_list, batch_size=100) else: generate_formset = formset if request.method == 'POST' and post_type == 'update': formset = update_formset_class(data=request.POST) if formset.is_valid(): post_row_dict = formset.cleaned_data for i in range(0, formset.total_form_count()): row_dict = post_row_dict[i] permission_id = row_dict.pop('id') try: row_object = models.Permission.objects.filter(pk=permission_id).first() for k,v in row_dict.items(): setattr(row_object, k, v) # 通过反射进行对象的值更新 row_object.validate_unique() row_object.save() except Exception as e: formset.errors[i].update(e) update_formset = formset else: update_formset =formset
注意:
# 第一点: 从数据库中,对choiceField字段中的choices增加选项:
class MultiEditPermissionForm(forms.Form):
id = forms.IntegerField(
widget=forms.HiddenInput()
)
title = forms.CharField(
widget=forms.TextInput(attrs={'class': 'form-control'})
)
url = forms.CharField(
widget=forms.TextInput(attrs={'class': 'form-control'})
)
name = forms.CharField(
widget=forms.TextInput(attrs={'class': 'form-control'})
)
menu_id = forms.ChoiceField(
choices=[(None, '-----')],
widget=forms.Select(attrs={'class': 'form-control'}),
required=False
)
pid_id = forms.ChoiceField(
choices=[(None, '-----')],
widget=forms.Select(attrs={'class': 'form-control'}),
required=False,
)
def __init__(self, *args, **kwargs):
super(MultiEditPermissionForm, self).__init__(*args, **kwargs)
self.fields['menu_id'].choices += models.Menu.objects.values_list('id', 'title')
self.fields['pid_id'].choices += models.Permission.objects.filter(pid__isnull=True).exclude(
menu__isnull=True
).values_list('id', 'title')
注意:
id = forms.IntegerField(
widget=forms.HiddenInput()
)
这是一个隐藏的input框,作用是通过formset来批量操作form的时候,可以pop掉这个id,来filter找到这个对象。
permission_id = row_dict.pop('id')
row_object = models.Permission.objects.filter(pk=permission_id).first()
# 第二点: 同一个页面有多个form表单,都需要通过post提交数据,后台怎么区分是哪一个form的数据?
方法一:在action中通过参数的形式进行区分
<form action="?type=generate" method="post">
方法二:在form中定义一个隐藏的input标签,通过name属性和value属性进行区分。
<input type="hidden" name="type" value="permission">
八. 分配权限
构造一个数据结构:
menu_list = [ {'id': 1, 'title': '菜单1'}, {'id': 2, 'title': '菜单2'}, {'id': 3, 'title': '菜单3'}, ] menu_dict = {} """ { 1:{'id': 1, 'title': '菜单1'}, 2:{'id': 2, 'title': '菜单2'}, 3:{'id': 3, 'title': '菜单3'}, } """ for item in menu_list: menu_dict[item['id']] = item # menu_dict[2]['title'] = '666' menu_dict[2]['children'] = [11,22] print(menu_list) # 就会多一个'children': [11,22]的键值对