自定义CRM系统
Posted 青山应回首
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了自定义CRM系统相关的知识,希望对你有一定的参考价值。
写在前面
之前在windows上写代码逻辑、搞前端等花了很长时间,跑通之后一直没往centos上部署,
昨天尝试部署下,结果发现静态文件找不到 ==\'\'
由于写了2个组件:
- arya model的增删改查,模拟django admin
- rbac 基于角色的访问控制
并且每个组件下都有自己的静态文件,层次结构如下:
[root@standby crm_rbac_arya]# tree -I "statics|*pyc|migrations" . -L 3 . ├── arya │ ├── admin.py │ ├── apps.py │ ├── __init__.py │ ├── models.py │ ├── __pycache__ │ ├── service │ │ ├── arya.py │ │ ├── arya_v1.py │ │ └── __pycache__ │ ├── static │ │ └── arya │ ├── templates │ │ └── arya │ ├── tests.py │ ├── utils │ │ ├── pager.py │ │ └── __pycache__ │ └── views.py ├── bin │ ├── uwsgi.ini │ ├── uwsgi.log │ ├── uwsgi.pid │ └── uwsgi.sock ├── crm │ ├── admin.py │ ├── apps.py │ ├── arya.py │ ├── __init__.py │ ├── middleware │ │ ├── login_required.py │ │ └── __pycache__ │ ├── models.py │ ├── __pycache__ │ ├── tests.py │ └── views.py ├── crm_rbac_arya │ ├── __init__.py │ ├── __pycache__ │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── db.sqlite3 ├── manage.py ├── rbac │ ├── admin.py │ ├── apps.py │ ├── arya.py │ ├── __init__.py │ ├── middleware │ │ ├── __pycache__ │ │ └── rbac.py │ ├── models.py │ ├── __pycache__ │ ├── service │ │ ├── init_permission.py │ │ └── __pycache__ │ ├── static │ │ └── rbac │ ├── templates │ │ └── rbac │ ├── templatetags │ │ ├── __init__.py │ │ ├── menu_gennerator.py │ │ └── __pycache__ │ ├── tests.py │ └── views.py └── templates ├── arya │ ├── layout.html.simple │ └── layout_old.html ├── index.html └── login.html 31 directories, 42 files [root@standby crm_rbac_arya]#
开始纠结:
STATIC_URL = \'/static/\' STATIC_ROOT = os.path.join(BASE_DIR, \'rbac/static\') STATIC_ROOT = os.path.join(BASE_DIR, \'arya/static\')
之前这样写的,没有写 STATICFILES_DIRS , 并且在urls.py里增加了如下几行:
from django.conf import settings from django.conf.urls.static import static urlpatterns = [ url(r\'^arya/\', arya.site.urls), url(r\'^login/\', views.login), url(r\'^index/\', views.index), url(r\'^clear/\', views.clear), ] urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
这样是可以找到arya的静态文件,找不到rbac的静态文件。
后来想把多个app下的静态文件都移出来放一个目录下,但是又不想破坏每个组件的完整性。。。
看了官网Managing static files 苦逼了好一会,瞎搞了一会还是没搞定。
今早在地铁上,又上网查了下,突然灵机一动想起了 STATICFILES_DIRS ,必须有 django.contrib.staticfiles 这个app,然后
python manage.py collectstatic
最后在nginx和uwsgi上配置好路径即可!
环境:
Python 3.5.2 django 1.11.4 CentOS release 6.4 (Final) nginx/1.10.3
废话到此为止,上代码:
arya/service/arya.py
from django.shortcuts import HttpResponse,render,redirect from django.conf.urls import url, include from django.urls import reverse from django.utils.safestring import mark_safe from django.forms import ModelForm from ..utils.pager import Paginator from copy import deepcopy from django.db.models import ForeignKey, ManyToManyField import functools from types import FunctionType from django.db.models import Q from django.http.request import QueryDict class FilterRow(object): """ 组合搜索项 """ def __init__(self, option, change_list, data_list, param_dict=None, is_choices=None): self.option = option self.change_list = change_list self.data_list = data_list self.param_dict = deepcopy(param_dict) self.param_dict._mutable = True self.is_choices = is_choices def __iter__(self): base_url = self.change_list.config.reverse_list_url tpl = "<a href=\'{0}\' class=\'{1}\'>{2}</a>" """ 点击 课程2 和 性别1 这两个条件进行筛选的情况下: self.option.name 分别是 consultant course gender self.param_dict 是 <QueryDict: {\'gender\': [\'1\'], \'course\': [\'2\']}> """ # 这里是给 全部btn 创建url链接 if self.option.name in self.param_dict: # 注意这里需要先把option.name对应的item pop掉,再做 urlencode()操作! pop_value = self.param_dict.pop(self.option.name) url = "{0}?{1}".format(base_url, self.param_dict.urlencode()) val = tpl.format(url, \'\', \'全部\') self.param_dict.setlist(self.option.name, pop_value) else: url = "{0}?{1}".format(base_url, self.param_dict.urlencode()) val = tpl.format(url, \'active\', \'全部\') # self.param_dict yield mark_safe("<div class=\'whole\'>") yield mark_safe(val) yield mark_safe("</div>") yield mark_safe("<div class=\'others\'>") for obj in self.data_list: param_dict = deepcopy(self.param_dict) if self.is_choices: # ((1, \'男\'), (2, \'女\')) pk = str(obj[0]) text = obj[1] else: # url上要传递的值 pk = self.option.val_func_name(obj) if self.option.val_func_name else obj.pk pk = str(pk) # a标签上显示的内容 text = self.option.text_func_name(obj) if self.option.text_func_name else str(obj) exist = False if pk in param_dict.getlist(self.option.name): exist = True if self.option.is_multi: if exist: values = param_dict.getlist(self.option.name) values.remove(pk) param_dict.setlist(self.option.name,values) else: param_dict.appendlist(self.option.name, pk) else: param_dict[self.option.name] = pk url = "{0}?{1}".format(base_url, param_dict.urlencode()) val = tpl.format(url, \'active\' if exist else \'\', text) yield mark_safe(val) yield mark_safe("</div>") class FilterOption(object): def __init__(self, field_or_func, condition=None, is_multi=False, text_func_name=None, val_func_name=None): """ :param field: 字段名称或函数 :param is_multi: 是否支持多选 :param text_func_name: 在Model中定义函数,显示文本名称,默认使用 str(对象) :param val_func_name: 在Model中定义函数,显示文本名称,默认使用 对象.pk """ self.field_or_func = field_or_func self.condition = condition # 筛选条件 self.is_multi = is_multi # 是否允许多选 self.text_func_name = text_func_name self.val_func_name = val_func_name @property def is_func(self): if isinstance(self.field_or_func, FunctionType): return True @property def name(self): if self.is_func: return self.field_or_func.__name__ else: return self.field_or_func @property def get_condition(self): if self.condition: return self.condition con = Q() return con class ChangeList(object): """ 专门用来处理列表页面部分的代码逻辑,简化 AryaConfig.changelist_view() """ def __init__(self,config,queryset): self.config = config self.list_display = config.get_list_display() self.show_add = config.get_show_add() self.add_url = config.reverse_add_url # 模糊搜索 self.search_list = config.get_search_list() self.keyword = config.keyword self.actions = config.get_actions() # 分页相关 current_page = config.request.GET.get(\'page\',1) all_count = queryset.count() base_url = config.reverse_list_url per_page = config.per_page per_page_count = config.per_page_count # 用于首先模糊查找了下数据的情况下要保留原来的 ?keyword=xxx ,在这基础上再进行分页 # 但是如果在这里修改query_params则会影响 request.GET ,所以这里要进行深拷贝 # 注意:request.GET 不是字典类型,而是django自己的QueryDict类型 query_params = deepcopy(config.request.GET) query_params._mutable = True pager = Paginator(all_count,current_page,base_url,per_page,per_page_count,query_params) self.queryset = queryset[pager.start:pager.end] self.page_html = pager.page_html # 组合筛选 self.list_filter = config.get_list_filter() # 获取表头第一版 \'\'\' header_data = [] for str_or_func in self.get_list_display(): if isinstance(str_or_func,str): val = self.model._meta.get_field(str_or_func).verbose_name else: val = str_or_func(self, is_header=True) header_data.append(val) \'\'\' # 获取表头改进版 def table_header(self): for str_or_func in self.list_display: if isinstance(str_or_func, str): val = self.config.model._meta.get_field(str_or_func).verbose_name else: val = str_or_func(self.config, is_header=True) yield val # 获取表内容 # def table_body(self): # table_data = [] # for row in self.queryset: # if not self.list_display: # # 用列表把对象做成列表集合是为了兼容有list_display的情况在前端展示(前端用2层循环展示) # table_data.append([row, ]) # else: # tmp = [] # for str_or_func in self.list_display: # if isinstance(str_or_func, str): # # 如果是字符串则通过反射取值 # tmp.append(getattr(row, str_or_func)) # else: # # 否则就是函数,获取函数执行的结果 # tmp.append(str_or_func(self.config, row)) # table_data.append(tmp) # return table_data def table_body(self): for row in self.queryset: if not self.list_display: # 用列表把对象做成列表集合是为了兼容有list_display的情况在前端展示(前端用2层循环展示) yield [row, ] else: tmp = [] for str_or_func in self.list_display: if isinstance(str_or_func, str): # 如果是字符串则通过反射取值 tmp.append(getattr(row, str_or_func)) else: # 否则就是函数,获取函数执行的结果 tmp.append(str_or_func(self.config, row)) yield tmp # 定制批量操作的actions def action_options(self): options = [] for func in self.actions: tmp = {\'value\':func.__name__, \'text\':func.text} options.append(tmp) return options # 定制组合筛选 def gen_list_filter(self): for option in self.list_filter: if option.is_func: data_list = option.field_or_func(self.config, self, option) else: _field = self.config.model._meta.get_field(option.field_or_func) """ option.field_or_func course 咨询的课程 _field crm.Customer.course type <class \'django.db.models.fields.related.ManyToManyField\'> _field.rel <ManyToManyRel: crm.customer> type <class \'django.db.models.fields.reverse_related.ManyToManyRel\'> option.field_or_func consultant 课程顾问 _field crm.Customer.consultant type <class \'django.db.models.fields.related.ForeignKey\'> _field.rel <ManyToOneRel: crm.customer> type <class \'django.db.models.fields.reverse_related.ManyToOneRel\'> """ if isinstance(_field, ForeignKey): data_list = FilterRow(option, self, _field.rel.model.objects.filter(option.get_condition), self.config.request.GET) elif isinstance(_field, ManyToManyField): data_list = FilterRow(option, self, _field.rel.model.objects.filter(option.get_condition), self.config.request.GET) else: # print(_field.choices) # ((1, \'男\'), (2, \'女\')) data_list = FilterRow(option, self, _field.choices, self.config.request.GET, is_choices=True) yield data_list def add_html(self): """ 添加按钮 :return: """ add_html = mark_safe(\'<a class="btn btn-primary" href="%s">添加</a>\' % (self.config.add_url_params,)) return add_html def search_attr(self): val = self.config.request.GET.get(self.keyword) return {"value": val, \'name\': self.keyword} class AryaConfig(object): # 借助继承特性,实现定制列展示 list_display = [] # 定制是否显示添加按钮 show_add = False def get_show_add(self): return self.show_add # 使用ModelForm model_form_class = None def get_model_form_class(self): if self.model_form_class: return self.model_form_class class DynamicModelForm(ModelForm): class Meta: model = self.model fields = \'__all__\' return DynamicModelForm """ 也可以使用 type 来生成 def get_model_form_class(self): model_form_cls = self.model_form if not model_form_cls: _meta = type(\'Meta\', (object,), {\'model\': self.model, "fields": "__all__"}) model_form_cls = type(\'DynamicModelForm\', (ModelForm,), {\'Meta\': _meta}) return model_form_cls """ # 分页相关配置 per_page = 10 per_page_count = 11 # 定制actions,即结合checkbox进行批量操作 actions = [] def get_actions(self): result = [] result.extend(self.actions) return result # 模糊搜索字段列表 (默认不支持搜索) search_list = [] def get_search_list(self): result = [] result.extend(self.search_list) return result @property def get_search_condition(self): con = Q() con.connector = "OR" # 加入搜索关键字是 kk, 并且如果我们在search_list里规定的只有 qq 和 name 这俩字段可以提供搜索条件 # 那么 kk 这个关键字要么在 name里,要么在qq这个字段里,二者之间是 或 的关系 val = self.request.GET.get(self.keyword) if not val: return con # [\'qq\',\'name\'] 精确搜索 # [\'qq__contains\',\'name__contains\'] 模糊搜索 field_list = self.get_search_list() for field in field_list: field = "{0}__contains".format(field) con.children.append((field,val)) return con @property def get_search_condition2(self): \'\'\' search_list = [ {\'key\': \'qq\', \'type\': None}, {\'key\': \'name\', \'type\': None}, {\'key\': \'course__name\', \'type\': None}, ] \'\'\' # condition = {} # keyword = request.GET.get(\'keyword\') # search_list = self.get_search_list() # if keyword and search_list: # # [\'username\',\'email\',\'ut\',] # for field in search_list: # condition[field] = keyword # condition = { # \'username\':keyword, # \'email\':keyword, # \'ut\':keyword, # } # 这样去 filter(**condition) 过滤的时候是按照 且 关系过滤, 这样不太好,应该改成 或 关系过滤 # 即 Django里的 Q 查询 : from django.db.models import Q # queryset = self.model.objects.all() # queryset = self.model.objects.filter(**condition) # 增加这个属性,用于在ChangeList类里获取到查询的关键字(即通过self参数把request传递给ChangeList) condition = Q() condition.connector = "OR" keyword = self.request.GET.get(self.keyword) if not keyword: return condition search_list = self.get_search_list() for field_dict in search_list: field = "{0}__contains".format(field_dict.get(\'key\')) field_type = field_dict.get(\'type\') if field_type: try: keyword = field_type(keyword) except Exception as e: continue condition.children.append((field, keyword)) return condition """定制查询组合条件""" list_filter = [] def get_list_filter(self): return self.list_filter @property def get_list_filter_condition(self): # 获取model的字段,FK,choice,但是没有多对多的字段 # fields1 = [obj.name for obj in self.model._meta.fields] # 只获取获取多对多的字段 # fields2 = [obj.name for obj in self.model._meta.many_to_many] # 还包含了反向关联字段 fields3 = [obj.name for obj in self.model._meta._get_fields()] """ [\'internal_referral\', \'consultrecord\', \'paymentrecord\', \'student\', \'id\', \'qq\', \\ \'name\', \'gender\', \'education\', \'graduation_school\', \'major\', \'experience\', \'work_status\', \\ \'company\', \'salary\', \'source\', \'referral_from\', \'status\', \'consultant\', \'date\', \'last_consult_date\', \'course\'] """ # fields = dir(self.model._meta) """ [\'FORWARD_PROPERTIES\', \'REVERSE_PROPERTIES\', \'__class__\', \'__delattr__\', \'__dict__\', \'__dir__\', \\ \'__doc__\', \'__eq__\', \'__format__\', \'__ge__\', \'__getattribute__\', \'__gt__\', \'__hash__\', \'__init__\', \\ \'__le__\', \'__lt__\', \'__module__\', \'__ne__\', \'__new__\', \'__reduce__\', \'__reduce_ex__\', \'__repr__\', \\ \'__setattr__\', \'__sizeof__\', \'__str__\', \'__subclasshook__\', \'__weakref__\', \'_expire_cache\', \'_forward_fields_map\', \\ \'_get_fields\', \'_get_fields_cache\', \'_ordering_clash\', \'_populate_directed_relation_graph\', \'_prepare\', \\ \'_property_names\', \'_relation_tree\', \'abstract\', \'add_field\', \'add_manager\', \'app_config\', \'app_label\', \'apps\', \\ \'auto_created\', \'auto_field\', \'base_manager\', \'base_manager_name\', \'can_migrate\', \'concrete_fields\', \'concrete_model\', \\ \'contribute_to_class\', \'db_table\', \'db_tablespace\', \'default_apps\', \'default_manager\', \'default_manager_name\', \\ \'default_permissions\', \'default_related_name\', \'fields\', \'fields_map\', \'get_ancestor_link\', \'get_base_chain\', \\ \'get_field\', \'get_fields\', \'get_latest_by\', \'get_parent_list\', \'get_path_from_parent\', \'get_path_to_parent\', \\ \'has_auto_field\', \'index_together\', \'indexes\', \'installed\', \'label\', \'label_lower\', \'local_concrete_fields\', \\ \'local_fields\', \'local_managers\', \'local_many_to_many\', \'managed\', \'manager_inheritance_from_future\', \'managers\', \\ \'managers_map\', \'many_to_many\', \'model\', \'model_name\', \'object_name\', \'order_with_respect_to\', \'ordering\', \\ \'original_attrs\', \'parents\', \'permissions\', \'pk\', \'private_fields\', \'proxy\', \'proxy_for_model\', \'related_fkey_lookups\', \\ \'related_objects\', \'required_db_features\', \'required_db_vendor\', \'select_on_save\', \'setup_pk\', \'setup_proxy\', \\ \'swappable\', \'swapped\', \'unique_together\', \'verbose_name\', \'verbose_name_plural\', \'verbose_name_raw\', \'virtual_fields\'] """ # 去请求URL中获取参数 # 根据参数生成条件 con = {} params = self.request.GET # self.request.GET <QueryDict: {\'gender\': [\'1\'], \'course\': [\'1\', \'2\']}> for k in params: # 判断k是否在数据库字段支持 if k not in fields3: continue v = params.getlist(k) k = "{0}__in".format(k) con[k] = v """ 比如按照课程2和性别1这俩条件进行筛选的时候: {\'gender__in\': [\'1\'], \'course__in\': [\'2\']} 并且课程可以多选 注意:这里课程之间是 或 的关系,即如果一个客户只咨询了课程1,但是筛选条件是 课程1和课程2,这种情况下,当前客户也会被筛选出来, 尽管该用户并没有咨询课程2 <QueryDict: {\'gender\': [\'2\'], \'course\': [\'1\', \'2\']}> {\'course__in\': [\'1\', \'2\'], \'gender__in\': [\'2\']} """ return con def __init__(self, model, arya_site): self.model = model self.arya_site = arya_site self.app_label = model._meta.app_label self.model_name = model._meta.model_name self.change_filter_name = "_change_filter" self.keyword = \'keyword\' self.request = None # 定制 编辑 按钮 def row_edit(self, row=None, is_header=None): if is_header: return "编辑" # 反向生成URL edit_a = mark_safe("<a href=\'{0}?{1}\'>编辑</a>".format(self.reverse_edit_url(row.id), self.back_url_param)) return edit_a # 定制 删除 按钮 def row_del(self, row=None, is_header=None): if is_header: return "删除" # 反向生成URL del_a = mark_safe("<a href=\'{0}?{1}\'>删除</a>".format(self.reverse_del_url(row.id), self.back_url_param)) return del_a # 定制 checkbox def check_box(self, row=None, is_header=None): if is_header: return "选项" checkbox = mark_safe("<input type=\'checkbox\' name=\'item_id\' value=\'{0}\' />".format(row.id)) return checkbox def get_list_display(self): result = [] result.extend(self.list_display) # 如果有编辑权限 """ 注意这里的参数不是方法self.row_edit 而是函数AryaConfig.row_edit class Foo(object): def func(self): print(\'方法\') 方法和函数的区别: # - 如果被对象调用,则self不用传值 # obj = Foo() # obj.func() # - 如果被类 调用,则self需要主动传值 # obj = Foo() # Foo.func(obj) """ result.append(AryaConfig.row_edit) # 如果有删除权限 result.append(AryaConfig.row_del) # 加上checkbox result.insert(0, AryaConfig.check_box) return result # 装饰器:给 changelist_view add_view delete_view change_view 增加 self.request = request # 这样就不用在每个view里都写一遍 self.request = request # 每次请求进来记录下这个request,这样就能拿到rbac请求验证中间里面的permission_code_list def wrapper(self, func): @functools.wraps(func) def inner(request, *args, **kwargs): self.request = request return func(request, *args, **kwargs) return inner def get_urls(self): app_model_name = self.model._meta.app_label,self.model._meta.model_name urlpatterns = [ url(r\'^$\', self.wrapper(self.changelist_view), name=\'%s_%s_list\' % app_model_name), url(r\'^add/$\', self.wrapper(self.add_view), name=\'%s_%s_add\' % app_model_name), url(r\'^(.+)/delete/$\', self.wrapper(self.delete_view), name=\'%s_%s_delete\' % app_model_name), url(r\'^(.+)/change/$\', self.wrapper(self.change_view), name=\'%s_%s_change\' % app_model_name) ] urlpatterns += self.extra_urls() return urlpatterns def extra_urls(self): """ 扩展URL预留的钩子函数 :return: """ return [] @property def urls(self): return self.get_urls(), None, None def changelist_view(self, request): """ 列表页面 :param request: :return: """ # 执行批量actions,比如批量删除 if \'POST\' == request.method: func_name = request.POST.get(\'select_action\') if func_name: # 通过反射获取要批量执行的函数对象 func = getattr(self, func_name) func(request) \'\'\'先过滤组合搜索,然后过滤模糊搜索,最后去重拿到最后结果\'\'\' queryset = self.model.objects.filter(**self.get_list_filter_condition).filter(self.get_search_condition2).distinct() cl = ChangeList(self,queryset) return render(request,\'arya/item_list.html\',{\'cl\':cl}) def add_view(self, request): """ 添加页面 :param request: :return: """ model_form_cls = self.get_model_form_class() if \'GET\' == request.method: # 返回对应的添加页面 form = model_form_cls() return render(request,\'arya/add_view.html\',{\'form\':form}) else: # 保存 form = model_form_cls(data=request.POST) if form.is_valid(): form.save() # 获取反向生成URL,跳转回列表页面 return redirect(self.list_url_with_params) return render(request,\'arya/add_view.html\',{\'form\':form}) def delete_view(self, request, uid): """ 删除页面 :param request: :param uid: :return: """ obj = self.model.objects.filter(id=uid).first() if not obj: return redirect(self.reverse_list_url) if \'GET\' == request.method: return render(request,\'arya/delete_view.html\') else: obj.delete() return redirect(self.list_url_with_params) def change_view(self, request, uid): """ 编辑页面 :param request: :param uid: :return: """ obj = self.model.objects.filter(id=uid).first() if not obj: return redirect(self.reverse_list_url) model_form_cls = self.get_model_form_class() if \'GET\' == request.method: # 在input框里显示原来的值 form = model_form_cls(instance=obj) return render(request,\'arya/change_view.html\',{\'form\':form}) else: # 更新某个实例 form = model_form_cls(instance=obj,data=request.POST) if form.is_valid(): form.save() return redirect(self.list_url_with_params) return render(request, \'arya/change_view.html\', {\'form\': form}) # 反向生成url相关 @property def back_url_param(self): \'\'\'反向生成base_url之外的其他参数,用于保留之前的操作\'\'\' query = QueryDict(mutable=True) if self.request.GET: """ self.request.GET <QueryDict: {\'gender\': [\'1\'], \'course\': [\'1\', \'2\']}> self.request.GET.urlencode() gender=1&course=1&course=2 query.urlencode() _change_filter=gender%3D1%26course%3D1%26course%3D2 对应的编辑按钮的地址: /arya/crm/customer/obj.id/change/?_change_filter=gender%3D1%26course%3D1%26course%3D2 """ query[self.change_filter_name] = self.request.GET.urlencode() # gender=2&course=2&course=1 return query.urlencode() def reverse_del_url(self, pk): \'\'\'反向生成删除按钮对应的基础URL(不带额外参数的),需要传入obj的id\'\'\' base_del_url = reverse(viewname=\'{0}:{1}_{2}_delete\'.format(self.arya_site.namespace, self.app_label, self.model_name),args=(pk,)) return base_del_url def reverse_edit_url(self, pk): \'\'\'反向生成编辑按钮对应的基础URL(不带额外参数的),需要传入obj的id\'\'\' base_edit_url = reverse(viewname=\'{0}:{1}_{2}_change\'.format(self.arya_site.namespace, self.app_label, self.model_name),args=(pk,)) return base_edit_url @property def reverse_add_url(self): \'\'\'反向生成添加按钮对应的基础URL(不带额外参数的)\'\'\' base_add_url = reverse(viewname=\'{0}:{1}_{2}_add\'.format(self.arya_site.namespace, self.app_label, self.model_name)) return base_add_url @property def reverse_list_url(self): \'\'\'反向生成列表页面对应的基础URL(不带额外参数的)\'\'\' base_list_url = reverse(viewname=\'{0}:{1}_{2}_list\'.format(self.arya_site.namespace, self.app_label, self.model_name)) return base_list_url @property def list_url_with_params(self): \'\'\'反向生成列表页面对应的URL(带了之前用户操作的一些参数)\'\'\' base_url = self.reverse_list_url query = self.request.GET.get(self.change_filter_name) return "{0}?{1}".format(base_url, query if query else "") @property def add_url_params(self): base_url = self.reverse_add_url if self.request.GET: return base_url else: query = QueryDict(mutable=True) query[self.change_filter_name] = self.request.GET.urlencode() return "{0}?{1}".format(base_url, query.urlencode()) class AryaSite(object): def __init__(self, name=\'arya\'): self.name = name self.namespace = name self._registy = {} def register(self,class_name,config_class): self._registy[class_name] = config_class(class_name,self) def get_urls(self): urlpatterns = [ url(r\'^login/$\', self.login), url(r\'^logout/$\', self.logout), ] for model, config_class in self._registy.items(): pattern = r\'^{0}/{1}/\'.format(model._meta.app_label, model._meta.model_name) urlpatterns.append(url(pattern, config_class.urls)) # return urlpatterns,None,None # 指定名称空间名字为 arya return urlpatterns @property def urls(self): return self.get_urls(),self.name,self.namespace def login(self, request): return HttpResponse("登录页面") def logout(self, request): return HttpResponse("登出页面") # 基于Python文件导入特性实现的单例模式 site = AryaSite()
arya/apps.py
from django.apps import AppConfig from django.utils.module_loading import autodiscover_modules from django.contrib.admin.sites import site class AryaConfig(AppConfig): name = \'arya\' def ready(self): autodiscover_modules(\'arya\', register_to=site)
crm/models.py里的顾客model
class Customer(models.Model): """ 客户表 """ qq = models.CharField(verbose_name=\'qq\', max_length=64, unique=True, help_text=\'QQ号必须唯一\') name = models.CharField(verbose_name=\'学生姓名\', max_length=16) gender_choices = ((1, \'男\'), (2, \'女\')) gender = models.SmallIntegerField(verbose_name=\'性别\', choices=gender_choices) education_choices = ( (1, \'重点大学\'), (2, \'普通本科\'), (3, \'独立院校\'), (4, \'民办本科\'), (5, \'大专\'), (6, \'民办专科\'), (7, \'高中\'), (8, \'其他\') ) education = models.IntegerField(verbose_name=\'学历\', choices=education_choices, blank=True, null=True, ) graduation_school = models.CharField(verbose_name=\'毕业学校\', max_length=64, blank=True, null=True) major = models.CharField(verbose_name=\'所学专业\', max_length=64, blank=True, null=True) experience_choices = [ (1, \'在校生\'), (2, \'应届毕业\'), (3, \'半年以内\'), (4, \'半年至一年\'), (5, \'一年至三年\'), (6, \'三年至五年\'), (7, \'五年以上\'), ] experience = models.IntegerField(verbose_name=\'工作经验\', blank=True, null=True, choices=experience_choices) work_status_choices = [ (1, \'在职\'), (2, \'无业\') ] work_status = models.IntegerField(verbose_name="职业状态", choices=work_status_choices, default=1, blank=True, null=True) company = models.CharField(verbose_name="目前就职公司", max_length=64, blank=True, null=True) salary = models.CharField(verbose_name="当前薪资", max_length=64, blank=True, null=True) source_choices = [ (1, "qq群"), (2, "内部转介绍"), (3, "官方网站"), (4, "百度推广"), (5, "360推广"), (6, "搜狗推广"), (7, "腾讯课堂"), (8, "广点通"), (9, "高校宣讲"), (10, "渠道代理"), (11, "51cto"), (12, "智汇推"), (13, "网盟"), (14, "DSP"), (15, "SEO"), (16, "其它"), ] source = models.SmallIntegerField(\'客户来源\', choices=source_choices, default=1) referral_from = models.ForeignKey( \'self\', blank=True, null=True, verbose_name="转介绍自学员", help_text="若此客户是转介绍自内部学员,请在此处选择内部学员姓名", related_name="internal_referral" ) course = models.ManyToManyField(verbose_name="咨询课程", to="Course") status_choices = [ (1, "已报名"), (2, "未报名") ] status = models.IntegerField( verbose_name="状态", choices=status_choices, default=2, help_text=u"选择客户此时的状态" ) consultant = models.ForeignKey(verbose_name="课程顾问", to=\'UserInfo\', related_name=\'consultant\') date = models.DateField(verbose_name="咨询日期", auto_now_add=True) last_consult_date = models.DateField(verbose_name="最后跟进日期", auto_now_add=True) def __str__(self): return "姓名:{0},QQ:{1}".format(self.name, self.qq, )
crm/arya.py里顾客部分
from arya.service import arya from . import models from django.forms import ModelForm,fields from django.forms import widgets as form_widgets from django.utils.safestring import mark_safe from django.shortcuts import HttpResponse,render,redirect from django.db.models import Q class CustomerModelForm(ModelForm): # 也可以自己在这里添加一个字段 # phone = fields.CharField() # city = fields.ChoiceField(choices=[(1,"北京"),(2,"上海"),(3,"深圳")]) # 注意:这里扩展的字段名如果和 models.Customer 里面的字段名相同就会覆盖 models.Customer的字段,否则则会添加一个新的字段 class Meta: model = models.Customer fields = \'__all__\' error_messages = { \'qq\':{ \'required\':\'qq不能为空!\', }, \'name\': { \'required\': \'客户姓名不能为空!\', }, \'gender\': { \'required\': \'性别不能为空!\', }, \'source\': { \'required\': \'客户来源不能为空!\', }, \'course\': { \'required\': \'咨询的课程不能为空!\', }, \'status\': { \'required\': \'客户状态不能为空!\', }, \'consultant\':{ \'required\': \'课程顾问不能为空!\', } } class CustomerConfig(PermissionConfig, arya.AryaConfig): def show_gender(self, row=None, is_header=None): if is_header: return "性别" # gender_choices = ((1, \'男\'), (2, \'女\')) # gender = models.SmallIntegerField(verbose_name=\'性别\', choices=gender_choices) # obj.get_字段_display() 这个方法可以拿到 数字在元组里对应的描述 return row.get_gender_display() def show_education(self, row=None, is_header=None): if is_header: return "学历" # obj.get_字段_display() 这个方法可以拿到 数字在元组里对应的描述 return row.get_education_display() def show_work_status(self, row=None, is_header=None): if is_header: return "职业状态" # obj.get_字段_display() 这个方法可以拿到 数字在元组里对应的描述 return row.get_work_status_display() def show_experience(self, row=None, is_header=None): if is_header: return "工作经验" # obj.get_字段_display() 这个方法可以拿到 数字在元组里对应的描述 return row.get_experience_display() def show_course(self, row=None, is_header=None): if is_header: return "咨询的课程" tpl = "<span style=\'display:inline-block;padding:3px;margin:2px;border:1px solid #ddd;\'>{0}</span>" course_obj_list = row.course.all() courses = [tpl.format(course.name) for course in course_obj_list] return mark_safe(\' \'.join(courses)) def show_record(self, row=None, is_header=None): if is_header: return "跟进记录" return mark_safe("<a href=\'xxx/{0}\'>查看跟进记录</a>".format(row.id)) list_display = [\'qq\',\'name\',show_gender,show_course,\'consultant\',show_record] model_form_class = CustomerModelForm # 定制批量删除的actions def multi_delete(self, request): item_list = request.POST.getlist(\'item_id\') # 注意:filter(id__in=item_list) 这样写就不用使用for循环了 self.model.objects.filter(id__in=item_list).delete() multi_delete.text = "批量删除" # 可以这样赋值 actions = [multi_delete,] # search_list = [ # {\'key\': \'qq__contains\', \'type\': None}, # {\'key\': \'name__contains\', \'type\': None}, # {\'key\': \'course__name__contains\', \'type\': None}, # ] search_list = [ {\'key\': \'qq\', \'type\': None}, {\'key\': \'name\', \'type\': None}, {\'key\': \'course__name\', \'type\': None}, ] list_filter = [ arya.FilterOption(\'consultant\', condition=Q(depart_id=1)), arya.FilterOption(\'course\', is_multi=True), arya.FilterOption(\'gender\'), ] arya.site.register(models.Customer, CustomerConfig)
rbac/arya.py里权限部分
from arya.service import arya from . import models from django.forms import ModelForm,fields,widgets from django.urls.resolvers import RegexURLPattern from crm.arya import PermissionConfig as PermissionControl # 获取全部url def get_all_url(patterns,prev,is_first=False, result=[]): if is_first: result.clear() for item in patterns: v = item._regex.strip("^$") if isinstance(item, RegexURLPattern): val = prev + v result.append((val,val,)) # result.append(val) else: get_all_url(item.urlconf_name, prev + v) return result class PermissionModelForm(ModelForm): # 也可以自己在这里添加扩展字段 # phone = fields.CharField() # city = fields.ChoiceField(choices=[(1,"北京"),(2,"上海"),(3,"深圳")]) # city = fields.MultipleChoiceField(choices=[(1,"北京"),(2,"上海"),(3,"深圳")]) # 注意:这里扩展的字段名如果和 models.Customer 里面的字段名相同就会覆盖 models.Customer的字段,否则则会添加一个新的字段 url = fields.ChoiceField() class Meta: model = models.Permission fields = \'__all__\' # fields = [\'title\',\'url\'] # exclude = [\'title\'] error_messages = { \'title\':{ \'required\':\'用户名不能为空!\', }, \'url\': { \'required\': \'密码不能为空!\', }, \'code\': { \'required\': \'密码不能为空!\', }, \'group\': { \'invalid\': \'邮箱格式不正确!\', }, } # 也可以自定义前端标签样式 # widgets = { # \'username\': form_widgets.Textarea(attrs={\'class\': \'c1\'}) # \'username\': form_widgets.Input(attrs={\'class\': \'some_class\'}) # } def __init__(self, *args, **kwargs): super(PermissionModelForm,self).__init__(*args, **kwargs) from crm_rbac_arya.urls import urlpatterns # 获取全部url,并以下拉框的形式显示在前端 # 也可以进一步把未加入权限的url列出来,就需要查一遍数据库过滤下。 self.fields[\'url\'].choices = get_all_url(urlpatterns, \'/\', True) """ # 在用Form的时候遇到过这个问题,即用户关联部门(外键关联)的时候: # depart = fields.ChoiceField(choices=models.Department.objects.values_list(\'id\',\'title\')) # 如果按照上面方式写,那么如果在部门表新添加数据后,则在用户关联的时候是无法显示新添加的部门信息的!!!只有程序重启才能获得新添加的数据! # 因为 depart 在 UserInfoForm 类里属于静态字段,在程序刚启动的时候会从上到下执行一遍,把当前数据加载到内存。 # 所以采用了 __init__() 方法,每次都去数据库拿最新的数据 手动挡: depart = fields.ChoiceField() def __init__(self, *args, **kwargs): super(UserInfoForm,self).__init__(*args, **kwargs) self.fields[\'depart\'].choices = models.Department.objects.values_list(\'id\',\'title\') 自动挡: from django.forms.models import ModelChoiceField depart = ModelChoiceField(queryset=models.Department.objects.all()) # 这种方式虽然简单,但是在前端<option value=pk>object</option>,即显示的是object,还依赖model里的 __str__方法。 上面说的是Form的问题,而ModelForm是Form和Model的结合体,也存在这个问题,所以这里也采用 __init__() 的方式 """ class PermissionConfig(PermissionControl, arya.AryaConfig): list_display = [\'title\',\'url\',\'group\',] # 定制添加权限页面 model_form_class = PermissionModelForm arya.site.register(models.Permission, PermissionConfig)
rbac/middleware/rbac.py权限验证中间件
# 这是页面权限验证的中间件 from django.shortcuts import HttpResponse,redirect from django.conf import settings import re class MiddlewareMixin(object): def __init__(self, get_response=None): self.get_response = get_response super(MiddlewareMixin, self).__init__() def __call__(self, request): response = None if hasattr(self, \'process_request\'): response = self.process_request(request) if not response: response = self.get_response(request) if hasattr(self, \'process_response\'): response = self.process_response(request, response) return response class RbacMiddleware(MiddlewareMixin): def process_request(self,request): # 1. 获取当前请求的 uri current_request_url = request.path_info # 2. 判断是否在白名单里,在则不进行验证,直接放行 for url in settings.VALID_URL_LIST: if re.match(url, current_request_url): return None # 3. 验证用户是否有访问权限 flag = False permission_dict = request.session.get(settings.PERMISSION_DICT) # 如果没有登录过就直接跳转到登录页面 if not permission_dict: return redirect(settings.RBAC_LOGIN_URL) """ { 1: { \'codes\': [\'list\', \'add\'], \'urls\': [\'/userinfo/\', \'/userinfo/add/\'] }, 2: { \'codes\': [\'list\'], \'urls\': [\'/order/\'] } } """ for group_id, values in permission_dict.items(): for url in values[\'urls\']: # 必须精确匹配 URL : "^{0}$" patten = settings.URL_FORMAT.format(url) if re.match(patten, current_request_url): # 获取当前用户所具有的权限的代号列表,用于之后控制是否展示相关操作 request.permission_code_list = values[\'codes\'] flag = True break if flag: break if not flag: return HttpResponse("无权访问")
settings.py
""" Django settings for crm_rbac_arya project. Generated by \'django-admin startproject\' using Django 1.11.4. For more information on this file, see https://docs.djangoproject.com/en/1.11/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/1.11/ref/settings/ """ import os # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = \'6-s)^llfgdh3jl-d682cb55ef2a@&&k7po_7rvqi%c8%=#&4(f\' # SECURITY WARNING: don\'t run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = [\'*\'] # Application definition INSTALLED_APPS = [ \'django.contrib.admin\', \'django.contrib.auth\', \'django.contrib.contenttypes\', \'django.contrib.sessions\', \'django.contrib.messages\', \'django.contrib.staticfiles\', \'rbac.apps.RbacConfig\', \'arya.apps.AryaConfig\', \'crm.apps.CrmConfig\', ] MIDDLEWARE = [ \'django.middleware.security.SecurityMiddleware\', \'django.contrib.sessions.middleware.SessionMiddleware\', \'django.middleware.common.CommonMiddleware\', \'django.middleware.csrf.CsrfViewMiddleware\', \'django.contrib.auth.middleware.AuthenticationMiddleware\', \'django.contrib.messages.middleware.MessageMiddleware\', \'django.middleware.clickjacking.XFrameOptionsMiddleware\', \'crm.middleware.login_required.UserAuthMiddleware\', \'rbac.middleware.rbac.RbacMiddleware\', ] ROOT_URLCONF = \'crm_rbac_arya.urls\' TEMPLATES = [ { \'BACKEND\': \'django.template.backends.django.DjangoTemplates\', \'DIRS\': [os.path.join(BASE_DIR, \'templates\')] , \'APP_DIRS\': True, \'OPTIONS\': { \'context_processors\': [ \'django.template.context_processors.debug\', \'django.template.context_processors.request\', \'django.contrib.auth.context_processors.auth\', \'django.contrib.messages.context_processors.messages\', ], }, }, ] WSGI_APPLICATION = \'crm_rbac_arya.wsgi.application\' # Database # https://docs.djangoproject.com/en/1.11/ref/settings/#databases DATABASES = { \'default\': { \'ENGINE\': \'django.db.backends.sqlite3\', \'NAME\': os.path.join(BASE_DIR, \'db.sqlite3\'), } } # Password validation # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { \'NAME\': \'django.contrib.auth.password_validation.UserAttributeSimilarityValidator\', }, { \'NAME\': \'django.contrib.auth.password_validation.MinimumLengthValidator\', }, { \'NAME\': \'django.contrib.auth.password_validation.CommonPasswordValidator\', }, { \'NAME\': \'django.contrib.auth.password_validation.NumericPasswordValidator\', }, ] # Internationalization # https://docs.djangoproject.com/en/1.11/topics/i18n/ LANGUAGE_CODE = \'en-us\' TIME_ZONE = \'UTC\' USE_I18N = True USE_L10N = True USE_TZ = True # Static files (CSS, javascript, Images) # https://docs.djangoproject.com/en/1.11/howto/static-files/ STATIC_URL = \'/static/\' STATIC_ROOT = os.path.join(BASE_DIR, \'statics\') #STATIC_ROOT = os.path.join(BASE_DIR, \'rbac/static\') #STATIC_ROOT = os.path.join(BASE_DIR, \'arya/static\') #STATICFILES_DIRS = ( # os.path.join(BASE_DIR,"common_static"), # \'/data/www/crm_rbac_arya/arya/static/\', #) ########################## Private config ################################## PERMISSION_DICT = "permission_dict" PERMISSION_MENU_LIST = "permission_menu_list" URL_FORMAT = "^{0}$" RBAC_LOGIN_URL = "/login/" LOGIN_SESSION_KEY = "user_info" VALID_URL_LIST = [ "^/login/$", "^/admin.*", "^/clear/$", "^/static/*", ]
主模板
{% load static %} <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>新起点</title> <link rel="Shortcut Icon" href="{% static \'arya/img/header.png\' %}"/> <link rel="stylesheet" href="{% static \'arya/plugin/layui/css/layui.css\' %}"> {% block css %} {% endblock %} </head> <body class="layui-layout-body"> <div class="layui-layout layui-layout-admin"> <div class="layui-header"> <div class="layui-logo">在线教育CRM</div> <!-- 头部区域(可配合layui已有的水平导航) --> <ul class="layui-nav layui-layout-left"> <li class="layui-nav-item"><a href="">虚拟化</a></li> <li class="layui-nav-item"><a href="">大数据</a></li> <li class="layui-nav-item"><a href="">图像识别</a></li> <li class="layui-nav-item"> <a href="javascript:;">其它方向</a> <dl class="layui-nav-child"> <dd><a href="">邮件管理</a></dd> <dd><a href="">消息管理</a></dd> <dd><a href="">授权管理</a></dd> </dl> </li> </ul> <ul class="layui-nav layui-layout-right"> <li class="layui-nav-item"> <a href="javascript:;"> <img src="{% static \'arya/img/avatar.jpg\' %}" class="layui-nav-img"> standby </a> <dl class="layui-nav-child"> <dd><a href="">基本资料</a></dd> <dd><a href="">安全设置</a></dd> </dl> </li> <li class="layui-nav-item"><a href="/clear/">退出</a></li> </ul> </div> {% load menu_gennerator %} <div class="layui-side layui-bg-black"> <div class="layui-side-scroll"> <!-- 左侧导航区域(可配合layui已有的垂直导航) --> <div class="left_menu"> {% menu_show request %} </div> </div> </div> <div class="layui-body"> <!-- 内容主体区域 --> <div style="padding: 15px;"> {% block content %} {% endblock %} </div> </div> <div class="layui-footer" style="text-align: center;"> <!-- 底部固定区域 --> Copyright@<a href="http://www.cnblogs.com/standby/" target="_blank">71standby</a> </div> </div> <script src="{% static \'arya/plugin/jquery/js/jquery-3.2.1.js\' %}"></script> <script src="{% static \'arya/plugin/layui/layui.all.js\' %}"></script> <script src="{% static \'rbac/js/rbac_layui.js\' %}"></script> {% block js %} {% endblock %} <script> ;!function () { //无需再执行layui.use()方法加载模块,直接使用即可 var form = layui.form , layer = layui.layer; //… }(); </script> </body> </html>
列表页面模板
{% extends "arya/layout.html" %} {% load static %} {% block css %} <link rel="stylesheet" href="{% static \'arya/plugin/bootstrap/css/bootstrap.css\' %}"> <link rel="stylesheet" href="{% static \'arya/css/filter.css\' %}"> <link rel="stylesheet" href="{% static \'arya/css/option.css\' %}"> {% endblock %} {% block content %} <div class="breadcrumb"> <span class="layui-breadcrumb"> <a href="/index/">首页</a> <a href="" class="breadcrumb_menu_title"></a> <a href="" class="breadcrumb_menu_item"><cite></cite></a> </span> </div> <div> <!-- 组合筛选 --> {% if cl.list_filter %} <div class="comb-search"> {% for row in cl.gen_list_filter %} <div class="row"> {% for col in row %} {{ col }} {% endfor %} </div> {% endfor %} </div> {% endif %} <!-- 模糊搜索 --> {% if cl.search_list %} <div class="search_option"> <form action="" method="get"> <input class="form-control" id="key_input" name="{{ cl.search_attr.name }}" value="{{ cl.search_attr.value }}" type="text" placeholder="请输入关键字..." /> <button class="btn btn-success"> <span class="glyphicon glyphicon-search"></span> </button> </form> </div> {% endif %} <!-- 模糊搜索方式2 --> {# <div class="search_option">#} {# {% if cl.search_list %}#} {# <form method="get">#} {# <input type="text" name="keyword" id="key_input" class="form-control" placeholder="请输入搜索关键字..." value="{{ cl.keyword }}">#} {# <input type="submit" value="搜索" class="btn btn-primary">#} {# </form>#} {# {% endif %}#} {# </div>#} <!-- 添加button --> {# {% if cl.show_add %}#} {# {{ cl.add_html }}#} {# {% endif %}#} <!-- 定制Action和表格数据 --> <form method="post"> {% csrf_token %} {% if cl.actions %} <div class="multi_option"> <select name="select_action" class="form-control" style="width: 300px; display: inline-block"> {% for action in cl.action_options %} <option value="{{ action.value }}">{{ action.text }}</option> {% endfor %} </select> <input type="submit" value="执行" class="btn btn-success"> </div> {% endif %} <table class="table table-striped table-hover"> <thead> <tr> {% for val in cl.table_header %} <th>{{ val }}</th> {% endfor %} </tr> </thead> <tbody> {% for item in cl.table_body %} <tr> {% for col in item %} <td>{{ col }}</td> {% endfor %} </tr> {% endfor %} </tbody> </table> </form> <div style="text-align: right"> <ul class="pagination"> {{ cl.page_html|safe }} </ul> </div> </div> {% endblock %} {% block js %} <script src="{% static \'arya/plugin/bootstrap/js/bootstrap.js\' %}"></script> <script src="{% static \'arya/js/breadcrumb.js\' %}"></script> {% endblock %}
uwsgi.ini
# uwsig使用配置文件启动 [uwsgi] # 项目目录 chdir=/data/www/crm_rbac_arya/ # 指定项目的application module=crm_rbac_arya.wsgi:application # 指定sock的文件路径 socket=/data/www/crm_rbac_arya/bin/uwsgi.sock # 进程个数 workers=6 pidfile=/data/www/crm_rbac_arya/bin/uwsgi.pid # 指定IP端口 http=ip:port # 指定静态文件 static-map=/static=/data/www/crm_rbac_arya/statics # 启动uwsgi的用户名和用户组 uid=root gid=root # 启用主进程 master=true # 自动移除unix Socket和pid文件当服务停止的时候 vacuum=true # 序列化接受的内容,如果可能的话 thunder-lock=true # 启用线程 enable-threads=true # 设置自中断时间 harakiri=30 # 设置缓冲 post-buffering=4096 # 设置日志目录 daemonize=/data/www/crm_rbac_arya/bin/uwsgi.log
crm.conf
server { listen 80; access_log logs/crm.log main; root /data/www/crm_rbac_arya; location /static { alias /data/www/crm_rbac_arya/statics; } location / { include uwsgi_params; # uwsgi_pass 127.0.0.1:80; uwsgi_pass unix:/data/www/crm_rbac_arya/bin/uwsgi.sock; } }
成果截图:
并且针对修改和删除操作,使用QueryDict(mutable=True)对象实例记录操作前的参数,保留了之前的操作步骤。
扩展
QueryDict的mutable参数 :
更多请参考官方文档:Django的Request 对象和Response 对象
遗留的bug
如果先按照关键字搜索, 然后翻页, 然后再做组合筛选的话,由于page参数停留在翻页之后所以会导致组合筛选的时候可能会搜索不到。
项目源码已托管至 Github
以上是关于自定义CRM系统的主要内容,如果未能解决你的问题,请参考以下文章