diff --git a/.flaskenv b/.flaskenv index 66006b5cdbb308a9fc4a89a4898d61ee8f5c06a3..c6b022c4b0c09c4e796bb6f0fbec128bac6bc682 100644 --- a/.flaskenv +++ b/.flaskenv @@ -1,7 +1,7 @@ # flask配置 -FLASK_APP=app.py -FLASK_ENV=development -FLASK_DEBUG=1 +FLASK_APP = app.py +FLASK_ENV = development +FLASK_DEBUG = 1 FLASK_RUN_HOST = 127.0.0.1 FLASK_RUN_PORT = 5000 @@ -9,21 +9,24 @@ FLASK_RUN_PORT = 5000 SYSTEM_NAME = Pear Admin # MySql配置信息 -MYSQL_HOST=127.0.0.1 -# MYSQL_HOST=dbserver -MYSQL_PORT=3306 -MYSQL_DATABASE=PearAdminFlask -MYSQL_USERNAME=root -MYSQL_PASSWORD=root +MYSQL_HOST = 127.0.0.1 +# MYSQL_HOST = dbserver +MYSQL_PORT = 3306 +MYSQL_DATABASE = PearAdminFlask +MYSQL_USERNAME = root +MYSQL_PASSWORD = root # Redis 配置 # REDIS_HOST=127.0.0.1 # REDIS_PORT=6379 # 密钥配置(记得改) -SECRET_KEY='pear-admin-flask' +SECRET_KEY = 'pear-admin-flask' # 邮箱配置 -MAIL_SERVER='smtp.qq.com' -MAIL_USERNAME='123@qq.com' -MAIL_PASSWORD='XXXXX' # 生成的授权码 \ No newline at end of file +MAIL_SERVER = 'smtp.qq.com' +MAIL_USERNAME = '123@qq.com' +MAIL_PASSWORD = 'XXXXX' # 生成的授权码 + +# 插件配置 +PLUGIN_ENABLE_FOLDERS = ["helloworld"] \ No newline at end of file diff --git a/applications/configs/config.py b/applications/configs/config.py index e260b224874eeecca8ebf39a37c9d4e601cecae6..35459154569a7447cf8055ea5e6acf04c5d7cbf1 100644 --- a/applications/configs/config.py +++ b/applications/configs/config.py @@ -5,6 +5,18 @@ from urllib.parse import quote_plus as urlquote from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +# 强制读入 flaskenv 中的环境变量 +with open(".flaskenv", "r", encoding='utf-8') as f: + for line in f.read().split("\n"): + pos = line.find("#") + if pos != -1: + line = line[:pos] + line = line.strip() + if line == "": + continue + _ = line.split("=") + key, value = _[0], '='.join(_[1:]) + os.environ[key.strip()] = value.strip() class BaseConfig: @@ -75,6 +87,9 @@ class BaseConfig: 'max_instances': 3 } + # 插件配置 + PLUGIN_ENABLE_FOLDERS = os.getenv('PLUGIN_ENABLE_FOLDERS') + # 配置多个数据库连接的连接串写法示例 # HOSTNAME: 指数据库的IP地址、USERNAME:指数据库登录的用户名、PASSWORD:指数据库登录密码、PORT:指数据库开放的端口、DATABASE:指需要连接的数据库名称 # MSSQL: f"mssql+pymssql://{USERNAME}:{PASSWORD}@{HOSTNAME}:{PORT}/{DATABASE}?charset=cp936" diff --git a/applications/dev/__init__.py b/applications/dev/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..48191296e75d3ad80d8356a22a9a6b89495b8ed3 --- /dev/null +++ b/applications/dev/__init__.py @@ -0,0 +1,9 @@ +from applications.dev import user +from applications.dev import role +from applications.dev import power +from applications.dev import department +from applications.dev import console +from flask import Flask + +# 获取app应用实例,会被初始化插件时重新赋值 +app = None # type: Flask diff --git a/applications/dev/console.py b/applications/dev/console.py new file mode 100644 index 0000000000000000000000000000000000000000..b658135ee9d894424dd0a51421f248690ac1d3c2 --- /dev/null +++ b/applications/dev/console.py @@ -0,0 +1,89 @@ +""" +输出控制台日志 +""" +import sys +import time +import ctypes + +NONE = "\033[m" +RED = "\033[0;32;31m" +LIGHT_RED = "\033[1;31m" +GREEN = "\033[0;32;32m" +LIGHT_GREEN = "\033[1;32m" +BLUE = "\033[0;32;34m" +LIGHT_BLUE = "\033[1;34m" +DARY_GRAY = "\033[1;30m" +CYAN = "\033[0;36m" +LIGHT_CYAN = "\033[1;36m" +PURPLE = "\033[0;35m" +LIGHT_PURPLE = "\033[1;35m" +BROWN = "\033[0;33m" +YELLOW = "\033[1;33m" +LIGHT_GRAY = "\033[0;37m" +WHITE = "\033[1;37m" + +# 开启 Windows 下对于 ESC控制符 的支持 +if sys.platform == "win32": + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + + +def _print(level, msg): + time_ = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + + level_name = {10: "Plain", + 11: "Log", + 12: "Info", + 13: "Debug", + 14: "Success", + 15: "Warning", + 16: "Error"} + + color = {10: NONE, + 11: LIGHT_CYAN, + 12: LIGHT_BLUE, + 13: PURPLE, + 14: GREEN, + 15: YELLOW, + 16: RED} + + print(f'{color.get(level, NONE)}[{time_}]({level_name.get(level, "Plain")}):', msg, f"{NONE}") + + +def plain(*args, sep=' '): + msg = sep.join(str(_) for _ in args) + _print(10, msg) + + +def log(*args, sep=' '): + msg = sep.join(str(_) for _ in args) + _print(11, msg) + + +def info(*args, sep=' '): + msg = sep.join(str(_) for _ in args) + _print(12, msg) + + +def debug(*args, sep=' '): + msg = sep.join(str(_) for _ in args) + _print(13, msg) + + +def success(*args, sep=' '): + msg = sep.join(str(_) for _ in args) + _print(14, msg) + + +def warn(*args): + warning(*args) + + +def warning(*args, sep=' '): + msg = sep.join(str(_) for _ in args) + _print(15, msg) + + +def error(*args, sep=' '): + msg = sep.join(str(_) for _ in args) + _print(16, msg) diff --git a/applications/dev/department.py b/applications/dev/department.py new file mode 100644 index 0000000000000000000000000000000000000000..388e0f8e99450ad96e0991be1c287afd77714f6e --- /dev/null +++ b/applications/dev/department.py @@ -0,0 +1,108 @@ +""" +集成了对 Pear Admin Flask 的部门的操作,并给了相对应的示例。 +""" + +from applications.common import curd +from applications.extensions import db +from applications.models import Dept, User +from applications.schemas import DeptOutSchema + + +def get_all(): + """ + 获取全部权限,会返回一个列表,每个列表是一个字典。 + + 字典构成如下:: + + { + "address":"这是总公司", # 地址 + "deptId":1, # 公司ID + "deptName":"总公司", # 公司名 + "email":"1", # 公司 email + "leader":"", # 公司领导人 + "parentId":0, # 父ID + "phone":"", # 联系电话 + "sort":1, # 排序 + "status":"1" # 状态 1-开启 0-关闭 + } + + :return: 列表 + """ + dept = Dept.query.order_by(Dept.sort).all() + return curd.model_to_dicts(schema=DeptOutSchema, data=dept) + + +def add(parentId, deptName, sort, leader, phone, email, status, address): + """ + 添加一个公司 + + :param parentId: 父公司ID,0未总公司 + :param deptName: 公司名称 + :param sort: 排序 + :param leader: 负责人 + :param phone: 手机 + :param email: 邮箱 + :param status: 状态 1-打开 0-关闭 + :param address: 地址 + :return: 是否成功 + """ + dept = Dept( + parent_id=parentId, + dept_name=deptName, + sort=sort, + leader=leader, + phone=phone, + email=email, + status=status, + address=address + ) + r = db.session.add(dept) + db.session.commit() + if r: + return True + else: + return False + + +def update(deptId, data): + """ + 更新公司信息 + + 可更新内容如下:: + + "dept_name" + "sort" + "leader" + "phone" + "email" + "status" + "address" + + :param deptId: 公司ID + :param data: 要更新公司字典 + :return: 是否成功 + """ + d = Dept.query.filter_by(id=deptId).update(data) + if not d: + return False + db.session.commit() + return True + + +def delete(deptId): + """ + 删除公司 + + :param deptId: 公司ID + :return: 是否成功 + """ + d = Dept.query.filter_by(id=deptId).delete() + if not d: + return False + res = User.query.filter_by(dept_id=deptId).update({"dept_id": None}) + db.session.commit() + if res: + return True + else: + return False + diff --git a/applications/dev/mail.py b/applications/dev/mail.py new file mode 100644 index 0000000000000000000000000000000000000000..8c734746021ca9d65d6dd03d2a21ebc77952dd05 --- /dev/null +++ b/applications/dev/mail.py @@ -0,0 +1,83 @@ +""" +集成了对 Pear Admin Flask 二次开发的的邮件操作,并给了相对应的示例。 +""" +from flask import current_app +from flask_mail import Message + +from applications.common.curd import model_to_dicts +from applications.common.helper import ModelFilter +from applications.extensions import db, flask_mail +from applications.models import Mail +from applications.schemas import MailOutSchema + + +def get_all(receiver=None, subject=None, content=None): + """ + 获取邮件 + + 返回的列表中的字典构造如下:: + + { + "content": "", # html内容 + "create_at": "2022-12-25T10:51:17", # 时间 + "id": 17, # 邮件ID + "realname": "超级管理", # 创建者 + "receiver": "", # 接收者 + "subject": "" # 主题 + } + + :param receiver: 发送者 + :param subject: 邮件标题 + :param content: 邮件内容 + :return: 列表 + """ + # 查询参数构造 + mf = ModelFilter() + if receiver: + mf.contains(field_name="receiver", value=receiver) + if subject: + mf.contains(field_name="subject", value=subject) + if content: + mf.exact(field_name="content", value=content) + # orm查询 + # 使用分页获取data需要.items + mail = Mail.query.filter(mf.get_filter(Mail)).layui_paginate() + return model_to_dicts(schema=MailOutSchema, data=mail.items) + + +def add(receiver, subject, content, user_id): + """ + 发送一封邮件,若发送成功立刻提交数据库。 + + :param receiver: 接收者 多个用英文逗号隔开 + :param subject: 邮件主题 + :param content: 邮件 html + :param user_id: 发送用户ID(谁发送的?) 可以用 from flask_login import current_user ; current_user.id 来表示当前登录用户 + :return: 成功与否 + """ + try: + msg = Message(subject=subject, recipients=receiver.split(";"), html=content) + flask_mail.send(msg) + except BaseException as e: + current_app.log_exception(e) + return False + + mail = Mail(receiver=receiver, subject=subject, content=content, user_id=user_id) + + db.session.add(mail) + db.session.commit() + return True + + +def delete(id): + """ + 删除邮件记录,立刻写入数据库。 + + :param id: 邮件ID + :return: 成功与否 + """ + res = Mail.query.filter_by(id=id).delete() + if not res: + return False + db.session.commit() + return True diff --git a/applications/dev/power.py b/applications/dev/power.py new file mode 100644 index 0000000000000000000000000000000000000000..99d275723067a25f3da080a39a43877f36bde507 --- /dev/null +++ b/applications/dev/power.py @@ -0,0 +1,138 @@ +""" +集成了对 Pear Admin Flask 的权限操作,并给了相对应的示例。 +注意:此权限相当于添加后台菜单。 +""" + +from applications.common import curd +from applications.extensions import db +from applications.models import Power + +from applications.extensions import ma +from marshmallow import fields + + +class PowerOutSchema2(ma.Schema): # 序列化类 + powerId = fields.Str(attribute="id") + powerName = fields.Str(attribute="name") + powerType = fields.Str(attribute="type") + powerUrl = fields.Str(attribute="url") + powerCode = fields.Str(attribute="code") + openType = fields.Str(attribute="open_type") + parentId = fields.Str(attribute="parent_id") + icon = fields.Str() + sort = fields.Integer() + create_time = fields.DateTime() + update_time = fields.DateTime() + enable = fields.Integer() + + +def get_all(): + """ + 获取所有权限,会返回一个含有菜单的列表。每个列表都是一个字典。 + + 字典构成如下:: + { + "powerType": "0", # 权限类型 0目录 1菜单 2按钮 + "powerUrl": None, # 路径 + "powerCode": "", # 权限标识 + "update_time": None, # 更新时间 + "sort": 1, # 排序 + "openType": None, # 打开方式 _iframe框架 _blank新页面 + "icon": "layui-icon layui-icon-set-fill", # 图标 + "powerName": "系统管理", # 名称 + "create_time": None, # 创建时间 + "parentId": "0", # 父id + "powerId": "1", # 自己的id + "enable": 1 # 是否启用 + } + + :return: 菜单列表。 + """ + power = Power.query.all() + res = curd.model_to_dicts(schema=PowerOutSchema2, data=power) + res.append({"powerId": 0, "powerName": "顶级权限", "parentId": -1}) + return res + + +def add(parentId, powerName, powerType, icon, sort: int, enable: bool, powerCode="", powerUrl="", openType=""): + """ + 新建一个菜单权限。 + + 参考代码:: + + dev.power.add("0", "测试", "1", "layui-icon-time", 0, True, "testfor", "https://baidu.com", "_iframe") + + :param parentId: 父ID,0为顶级菜单ID + :param powerName: 菜单名称 + :param powerType: 权限类型(状态) 0目录 1菜单 2按钮 + :param icon: 图标,详细查看layui的图标 + :param sort: 排序 + :param enable: 是否启用 + + :param powerCode: 权限标识 + :param powerUrl: 权限URL,菜单打开的网址,或者是路径。可选,菜单和按钮类型必填。 + :param openType: 打开方式,_iframe框架 _blank新页面。可选,菜单和按钮类型必填。 + + + + :return: 返回新权限ID + """ + power = Power( + icon=icon, + open_type=openType, + parent_id=parentId, + code=powerCode, + name=powerName, + type=powerType, + url=powerUrl, + sort=sort, + enable=1 + ) + r = db.session.add(power) + db.session.commit() + return power.id + + +def update(powerId, data): + """ + 更新权限。 + + data可选:: + + "icon" + "open_type" + "parent_id" + "code" + "name" + "type" + "url" + "sort" + + :param powerId: 要更新的权限ID + + :return: 是否成功 + """ + res = Power.query.filter_by(id=powerId).update(data) + db.session.commit() + if res: + return True + else: + return False + + +def delete(powerId): + """ + 删除权限。 + + :param powerId: 要更新的权限ID + :return: 是否成功 + """ + power = Power.query.filter_by(id=powerId).first() + power.role = [] + + r = Power.query.filter_by(id=powerId).delete() + db.session.commit() + if r: + return True + else: + return False diff --git a/applications/dev/role.py b/applications/dev/role.py new file mode 100644 index 0000000000000000000000000000000000000000..a0cc28682c9eb818d05f7c1297b99e00b4d656be --- /dev/null +++ b/applications/dev/role.py @@ -0,0 +1,157 @@ +""" +集成了对 Pear Admin Flask 的角色操作,并给了相对应的示例。 +""" +from applications.extensions import db +from applications.models import Role, Power +from applications.schemas import PowerOutSchema2 + + +def filter_by(**kwargs): + """ + 检索角色字段信息,可用于获取角色ID等。系统中默认管理员角色id为1,普通用户为2。 + 内部采用的是使用 Role.query.filter_by(**kwargs) 进行数据库查询。 + + 注意:此函数返回的结果为构造的 SQL的查询字符串 ,以 role_filter 命名,但是并不是用户数据。 + + 返回的字段如下:: + + id name code enable remark details sort create_time update_time power + 具体参考 applications/models/admin_role.py 中的模型定义。 + + 参考调用如下:: + + roleinfo = dev.role.filter_by(code='admin').first() # 第一个符合要求的角色信息 + print(role.id, role.name) # 输出角色名称与角色标识 + + # 找出所有角色id + for role in dev.role.filter_by().all(): + print(role.id, role.name) + + :param kwargs: 查询参数 + :return: 角色SQL的查询字符串 + """ + return Role.query.filter_by(**kwargs) + + +def add(roleName, roleCode, enable, sort, details): + """ + 添加一个角色。此函数直接写入数据库。此函数不会检测角色是否已经存在(官方在API接口中也没有检测)。 + + :param roleName: 角色名称 (如管理员) + :param roleCode: 角色标识 (如admin) + :param enable: 是否启用 True or False + :param sort: 排序 + :param details: 描述 + :return: None + """ + role = Role( + details=details, + enable=enable, + code=roleCode, + name=roleName, + sort=sort + ) + db.session.add(role) + db.session.commit() + + +def get_power(role_filter, detail=False, p=0): + """ + 获取角色的权限。 + + 如果是非详细数据,会返回一个含有权限id的列表。 + 如果是详细数据返回此函数,将会返回一个列表,列表中会包含字典,字典的键如下:: + + { + "checkArr": "1", # 是否有权限 1 为有 0为无 + "create_time": null, # 权限创建 + "enable": 1, # 权限是否启用 + "icon": "layui-icon layui-icon-set-fill", # 权限图标 + "openType": null, # 开启状态 + "parentId": "0", # 父权限ID + "powerId": "1", # 权限ID + "powerName": "系统管理", # 权限名称 + "powerType": "0", # 权限类型 + "powerUrl": null, # 权限URL + "sort": 1, # 权限排序 + "update_time": null # 权限更新时间 + } + + + :param role_filter: dev.role.filter_by() 返回结果。 + :param detail: 返回详细数据 + :param p: 如果有多个结果被找到,p可以确定使用第几个结果。内部使用 role_filter.all()[p] + :return: 用户拥有的权限列表。 + """ + role = role_filter.all()[p] + check_powers = role.power + check_powers_list = [] + for cp in check_powers: + check_powers_list.append(cp.id) + if not detail: + return check_powers_list + powers = Power.query.all() + power_schema = PowerOutSchema2(many=True) # 用已继承ma.ModelSchema类的自定制类生成序列化类 + output = power_schema.dump(powers) # 生成可序列化对象 + for i in output: + if int(i.get("powerId")) in check_powers_list: + i["checkArr"] = "1" + else: + i["checkArr"] = "0" + return output + + +def set_power(role_filter, powerIds, p=0): + """ + 保存角色权限。此函数会直接写入数据库。 + + :param role_filter: dev.role.filter_by() 返回结果。 + :param powerIds: 必须是一个包含权限ID的列表。如 [1, 2, 3] + :param p: 如果有多个结果被找到,p可以确定使用第几个结果。内部使用 role_filter.all()[p] + :return: None + """ + role = role_filter.all()[p] + powers = Power.query.filter(Power.id.in_(powerIds)).all() + role.power = powers + + db.session.commit() + + +def update(role_filter, data): + """ + 更新角色数据。此功能将直接写入数据库。 + + 可更新的字段如下:: + + id name code enable remark details sort create_time update_time power + 具体参考 applications/models/admin_role.py 中的模型定义。 + + 参考调用如下:: + + role_filter = dev.role.filter_by(id=0) # 获取指定角色ID的角色,注意不要使用会引起歧义的查询条件,否则会匹配到多个角色。 + dev.role.update(role_filter, {enable: 0}) # 禁用 + + :param role_filter: dev.role.filter_by() 返回结果。 + :param data: 要更新的数据,必须是字典。 + :return: None + """ + role_filter.update(data) + db.session.commit() + + +def delete(role_filter): + """ + 删除角色。此功能将直接写入数据库。 + + :param role_filter: dev.role.filter_by() 返回结果。 + :return: 是否成功。 + """ + role = role_filter.first() + # 删除该角色的权限和用户 + role.power = [] + role.user = [] + + r = role_filter.delete() + db.session.commit() + return r + diff --git a/applications/dev/user.py b/applications/dev/user.py new file mode 100644 index 0000000000000000000000000000000000000000..00688a3ccee296138f247cbc705798914f211dad --- /dev/null +++ b/applications/dev/user.py @@ -0,0 +1,160 @@ +""" +集成了对 Pear Admin Flask 的用户操作,并给了相对应的示例。 + +调用示例:: + + from applications import dev + dev.user.login_required # 用户是否登录 + dev.user.current_user # 当前登录用户 + dev.user.authorize("XXX", log=True) # 用户是否有此权限 + +""" + +from applications.extensions import db +from applications.models import Role +from applications.models import User + + +def filter_by(**kwargs): + """ + 用于在用户数据中查询用户信息,可以通过用户名、用户id等进行检索。(建议使用id检索) + 内部采用的是使用 User.query.filter_by(**kwargs) 进行数据库查询 + + 注意:此函数返回的结果为构造的 SQL的查询字符串 ,以 user_filter 命名,但是并不是用户数据。 + + 返回的字段如下:: + + id username password_hash create_at update_at + enable realname remark avatar dept_id + 具体参考 applications/models/admin_user.py 中的模型定义。 + + + 参考调用如下:: + + userinfo = dev.user.filter_by(username='zsq').first() # 查询符合要求的第一个用户 + print(userinfo.realname) # 获取用户真实名字 + + + :param kwargs: 查询参数 + :return: 用户数据SQL的查询字符串 + """ + return User.query.filter_by(**kwargs) + + +def update(user_filter, data): + """ + 更新用户数据,修改将直接保存到数据库中。 + 注意:更新用户角色(role)请使用 dev.user.update_role() 函数。 + + 可更新的字段如下:: + + id username password_hash create_at update_at + enable realname remark avatar dept_id + 具体参考 applications/models/admin_user.py 中的模型定义。 + + 参考调用如下:: + + user_filter = dev.user.filter_by(id=0) # 获取指定用户ID的用户,注意不要使用会引起歧义的查询条件,否则会匹配到多个用户。 + dev.user.update(user_filter, {username: 'zsq1314'}) # 更新其用户名 + + + + :param user_filter: dev.user.filter_by() 的结果。 + :param data: 要更新的数据,必须是字典。 + :return: None + """ + user_filter.update(data) + db.session.commit() + + +def update_role(user_filter, roleIds): + """ + 更新用户角色,修改将直接保存到数据库中。 + + 参考调用如下:: + + user_filter = dev.user.filter_by(username='zsq') # 获取符合要求的第一个用户 + roleIds = [] + roleIds.append(dev.role.filter_by(code='admin').first().id) # 管理员角色ID + roleIds.append(dev.role.filter_by(code='common').first().id) # 普通用户角色ID + dev.user.update_role(user_filter, roleIds) + + + :param user_filter: dev.user.filter_by() 的结果。 + :param roleIds: 要更新的角色ID,作为列表传入。 + :return: None + """ + user_filter.first().role = Role.query.filter(Role.id.in_(roleIds)).all() + db.session.commit() + + +def get_role(user_filter): + """ + 获取用户的所有角色ID,将会返回一个整数列表。 + + :param user_filter: dev.user.filter_by() 的结果。 + :return: 列表 (roleIds) + """ + checked_roles = [] + for r in user_filter.first().role: + checked_roles.append(r.id) + return checked_roles + + +def set_password(user_filter, password): + """ + 设置用户密码,此函数不会验证用户原始密码哈希值,直接写入新密码哈希值。 + + 参考调用如下:: + + user_filter = dev.user.filter_by(username='zsq') # 获取符合要求的用户 + dev.user.set_password(user_filter, 'zsq1314') # 设置密码 + + :param user_filter: dev.user.filter_by() 的结果。 + :param password: 新密码。 + :return: None + """ + user = user_filter.first() + user.set_password(password) + db.session.add(user) + db.session.commit() + + +def add(username, realname, password, roleIds): + """ + 添加一个新用户。函数会判断用户名是否已存在,存在返回 False ,成功返回用户数据(userinfo)。此函数会直接写入数据库。 + 注意:此函数创建出来的用户默认是禁用的,可以使用 enable 启用。 + + :param username: 新用户名 + :param realname: 真实名字 + :param password: 密码字符串 + :param roleIds: 角色id列表,如 [1, 2],角色id具体查看 dev.role.get_all() 函数。 + :return: 是否成功 + """ + if bool(User.query.filter_by(username=username).count()): + return False + user = User(username=username, realname=realname) + user.set_password(password) + db.session.add(user) + roles = Role.query.filter(Role.id.in_(roleIds)).all() + for r in roles: + user.role.append(r) + db.session.commit() + return user + + +def delete(user_filter): + """ + 删除一个用户。此函数立刻写入数据库。 + + :param user_filter: dev.user.filter_by() 的结果。 + :return: 是否成功 + """ + user = user_filter.first() + user.role = [] + res = user_filter.delete() + db.session.commit() + return res + + + diff --git a/applications/view/__init__.py b/applications/view/__init__.py index bec798960b046575ba34748b589a3e50027eb1b0..6b5cf78b6fe07f81df34aeba4829e5a1639a64d5 100644 --- a/applications/view/__init__.py +++ b/applications/view/__init__.py @@ -3,7 +3,7 @@ from applications.view.index import register_index_views from applications.view.passport import register_passport_views from applications.view.rights import register_rights_view from applications.view.department import register_dept_views - +from applications.view.plugin import register_plugin_views def init_view(app): register_admin_views(app) @@ -11,3 +11,4 @@ def init_view(app): register_rights_view(app) register_passport_views(app) register_dept_views(app) + register_plugin_views(app) diff --git a/applications/view/admin/monitor.py b/applications/view/admin/monitor.py index 40f1e9e54615547c4a921a2d8c8d25f0730d893f..d25d8c65fbe7e71faf32f67a6f7995c6771fe20e 100644 --- a/applications/view/admin/monitor.py +++ b/applications/view/admin/monitor.py @@ -1,9 +1,10 @@ import os -import platform import re -from datetime import datetime +import sys import time import psutil +import platform +from datetime import datetime from flask import Blueprint, render_template, jsonify from applications.common.utils.rights import authorize @@ -22,8 +23,8 @@ def main(): python_version = platform.python_version() # 逻辑cpu数量 cpu_count = psutil.cpu_count() - # cup使用率 - cpus_percent = psutil.cpu_percent(interval=0.1) + # cpu使用率 + cpus_percent = psutil.cpu_percent(interval=0.1, percpu=False) # percpu 获取主使用率 # 内存 memory_information = psutil.virtual_memory() # 内存使用率 @@ -79,10 +80,19 @@ def main(): @admin_monitor_bp.get('/polling') @authorize("admin:monitor:main") def ajax_polling(): - # 获取cup使用率 - cpus_percent = psutil.cpu_percent(interval=0.1) + # 获取cpu使用率 + cpus_percent = psutil.cpu_percent(interval=0.1, percpu=False) # percpu 获取主使用率 # 获取内存使用率 memory_information = psutil.virtual_memory() memory_usage = memory_information.percent time_now = time.strftime('%H:%M:%S ', time.localtime(time.time())) return jsonify(cups_percent=cpus_percent, memory_used=memory_usage, time_now=time_now) + +# 关闭程序 +@admin_monitor_bp.get('/kill') +@authorize("admin:monitor:main") +def kill(): + for proc in psutil.process_iter(): + if proc.pid == os.getpid(): + proc.kill() + sys.exit(1) \ No newline at end of file diff --git a/applications/view/plugin/__init__.py b/applications/view/plugin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2aa2d479e6d706043ca046232f18f84ff65b3c01 --- /dev/null +++ b/applications/view/plugin/__init__.py @@ -0,0 +1,168 @@ +import shutil + +from flask import Flask +from flask import Blueprint, render_template, request, jsonify, escape +from applications.common.utils.http import table_api, fail_api, success_api +import os +import json +import traceback +import importlib + +import applications.dev +from applications.common.utils.rights import authorize + +plugin_bp = Blueprint('plugin', __name__, url_prefix='/plugin') +PLUGIN_ENABLE_FOLDERS = [] + +def register_plugin_views(app: Flask): + global PLUGIN_ENABLE_FOLDERS + applications.dev.app = app # 对app重新赋值 便于插件简单调用 + app.register_blueprint(plugin_bp) + # 载入插件过程 + # plugin_folder 配置的是插件的文件夹名 + PLUGIN_ENABLE_FOLDERS = json.loads(app.config['PLUGIN_ENABLE_FOLDERS']) + for plugin_folder in PLUGIN_ENABLE_FOLDERS: + plugin_info = {} + try: + with open("plugins/" + plugin_folder + "/__init__.json", "r", encoding='utf-8') as f: + plugin_info = json.loads(f.read()) + # 初始化完成事件 + try: + getattr(importlib.import_module('plugins.' + plugin_folder), "event_init")(app) + except AttributeError: # 没有插件启用事件就不调用 + pass + except BaseException as error: + return fail_api(msg="Crash a error! Info: " + str(error)) + print(f" * Plugin: Loaded plugin: {plugin_info['plugin_name']} .") + except BaseException as e: + info = f" * Plugin: Crash a error when loading {plugin_info['plugin_name'] if len(plugin_info) != 0 else 'plugin'} :" + "\n" + info += 'str(Exception):\t' + str(Exception) + "\n" + info += 'str(e):\t\t' + str(e) + "\n" + info += 'repr(e):\t' + repr(e) + "\n" + info += 'traceback.format_exc():\n%s' + traceback.format_exc() + print(info) + + +@plugin_bp.get('/') +@authorize("admin:plugin:main", log=True) +def main(): + """此处渲染管理模板""" + return render_template('admin/plugin/main.html') + + +@plugin_bp.get('/data') +@authorize("admin:plugin:main", log=True) +def data(): + """请求插件数据""" + plugin_name = escape(request.args.get("plugin_name")) + all_plugins = [] + count = 0 + for filename in os.listdir("plugins"): + try: + with open("plugins/" + filename + "/__init__.json", "r", encoding='utf-8') as f: + info = json.loads(f.read()) + + if plugin_name is None: + if info['plugin_name'].find(plugin_name) == -1: + continue + + all_plugins.append( + { + "plugin_name": info["plugin_name"], + "plugin_version": info["plugin_version"], + "plugin_description": info["plugin_description"], + "developer_name": info["developer_name"], + "developer_website": info["developer_website"], + "developer_email": info["developer_email"], + "developer_phone": info["developer_phone"], + "plugin_folder_name": filename, + "enable": "1" if filename in PLUGIN_ENABLE_FOLDERS else "0" + } + ) + count += 1 + except BaseException as error: + print(filename, error) + continue + return table_api(data=all_plugins, count=count) + + +@plugin_bp.put('/enable') +@authorize("admin:plugin:enable", log=True) +def enable(): + """启用插件""" + plugin_folder_name = request.json.get('plugin_folder_name') + if plugin_folder_name: + try: + if plugin_folder_name not in PLUGIN_ENABLE_FOLDERS: + PLUGIN_ENABLE_FOLDERS.append(plugin_folder_name) + with open(".flaskenv", "r", encoding='utf-8') as f: + flaskenv = f.read() # type: str + pos1 = flaskenv.find("PLUGIN_ENABLE_FOLDERS") + pos2 = flaskenv.find("\n", pos1) + with open(".flaskenv", "w", encoding='utf-8') as f: + if pos2 == -1: + f.write(flaskenv[:pos1] + "PLUGIN_ENABLE_FOLDERS = " + json.dumps(PLUGIN_ENABLE_FOLDERS)) + else: + f.write( + flaskenv[:pos1] + "PLUGIN_ENABLE_FOLDERS = " + json.dumps(PLUGIN_ENABLE_FOLDERS) + flaskenv[ + pos2:]) + # 启用插件事件 + try: + getattr(importlib.import_module('plugins.' + plugin_folder_name), "event_enable")() + except AttributeError: # 没有插件启用事件就不调用 + pass + except BaseException as error: + return fail_api(msg="Crash a error! Info: " + str(error)) + + except BaseException as error: + return fail_api(msg="Crash a error! Info: " + str(error)) + return success_api(msg="启用成功,要使修改生效需要重启程序。") + return fail_api(msg="数据错误") + + +@plugin_bp.put('/disable') +@authorize("admin:plugin:enable", log=True) +def disable(): + """禁用插件""" + plugin_folder_name = request.json.get('plugin_folder_name') + if plugin_folder_name: + try: + if plugin_folder_name in PLUGIN_ENABLE_FOLDERS: + PLUGIN_ENABLE_FOLDERS.remove(plugin_folder_name) + with open(".flaskenv", "r", encoding='utf-8') as f: + flaskenv = f.read() # type: str + pos1 = flaskenv.find("PLUGIN_ENABLE_FOLDERS") + pos2 = flaskenv.find("\n", pos1) + with open(".flaskenv", "w", encoding='utf-8') as f: + if pos2 == -1: + f.write(flaskenv[:pos1] + "PLUGIN_ENABLE_FOLDERS = " + json.dumps(PLUGIN_ENABLE_FOLDERS)) + else: + f.write( + flaskenv[:pos1] + "PLUGIN_ENABLE_FOLDERS = " + json.dumps(PLUGIN_ENABLE_FOLDERS) + flaskenv[ + pos2:]) + + # 禁用插件事件 + try: + getattr(importlib.import_module('plugins.' + plugin_folder_name), "event_disable")() + except AttributeError: # 没有插件禁用事件就不调用 + pass + except BaseException as error: + return fail_api(msg="Crash a error! Info: " + str(error)) + + except BaseException as error: + return fail_api(msg="Crash a error! Info: " + str(error)) + return success_api(msg="禁用成功,要使修改生效需要重启程序。") + return fail_api(msg="数据错误") + + +# 删除 +@plugin_bp.delete('/remove/') +@authorize("admin:mail:remove", log=True) +def delete(plugin_folder_name): + if plugin_folder_name in PLUGIN_ENABLE_FOLDERS: + return fail_api(msg="您必须先禁用插件!") + try: + shutil.rmtree(os.path.abspath("plugins/" + plugin_folder_name)) + return success_api(msg="删除成功") + except BaseException as error: + return fail_api(msg="删除失败!原因:" + str(error)) diff --git a/applications/view/plugin/init_plugins.py b/applications/view/plugin/init_plugins.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/pear.sql b/pear.sql index 6c29bc4093c40c7d2df1ea1e2ff83d153086afd3..e33d78afb9593deb0fd9bb42d959e1574f7a266c 100644 --- a/pear.sql +++ b/pear.sql @@ -211,6 +211,10 @@ INSERT INTO `admin_power` VALUES (56, '任务删除', '2', 'admin:task:remove', INSERT INTO `admin_power` VALUES (57, '邮件管理', '1', 'admin:mail:main', '/admin/mail', '_iframe', '1', 'layui-icon layui-icon layui-icon-release', 7, '2022-10-11 11:21:05', '2022-10-11 11:21:22', 1); INSERT INTO `admin_power` VALUES (58, '邮件发送', '2', 'admin:mail:add', '', '', '57', 'layui-icon layui-icon-ok-circle', 1, '2022-10-11 11:22:26', '2022-10-11 11:22:26', 1); INSERT INTO `admin_power` VALUES (59, '邮件删除', '2', 'admin:mail:remove', '', '', '57', 'layui-icon layui-icon layui-icon-close', 2, '2022-10-11 11:23:06', '2022-10-11 11:23:18', 1); +INSERT INTO `admin_power` VALUES (60, '拓展插件', '0', '', '', '', '0', 'layui-icon layui-icon layui-icon-senior', 2, '2022-12-18 12:28:19', '2022-12-18 12:30:25', 1); +INSERT INTO `admin_power` VALUES (61, '插件管理', '1', 'admin:plugin:main', '/plugin', '_iframe', '60', 'layui-icon layui-icon layui-icon layui-icon ', 1, '2022-12-18 12:30:13', '2022-12-18 13:57:20', 1); +INSERT INTO `admin_power` VALUES (62, '启禁插件', '2', 'admin:plugin:enable', '', '', '61', 'layui-icon ', 1, '2022-12-18 13:25:37', '2022-12-18 13:25:37', 1); +INSERT INTO `admin_power` VALUES (63, '删除插件', '2', 'admin:plugin:remove', '', '', '61', 'layui-icon layui-icon ', 2, '2022-12-18 13:26:30', '2022-12-18 13:27:17', 1); -- ---------------------------- -- Table structure for admin_role @@ -299,6 +303,10 @@ INSERT INTO `admin_role_power` VALUES (366, 56, 1); INSERT INTO `admin_role_power` VALUES (367, 57, 1); INSERT INTO `admin_role_power` VALUES (368, 58, 1); INSERT INTO `admin_role_power` VALUES (369, 59, 1); +INSERT INTO `admin_role_power` VALUES (370, 60, 1); +INSERT INTO `admin_role_power` VALUES (371, 61, 1); +INSERT INTO `admin_role_power` VALUES (372, 62, 1); +INSERT INTO `admin_role_power` VALUES (373, 63, 1); -- ---------------------------- -- Table structure for admin_user diff --git a/plugins/helloworld/__init__.json b/plugins/helloworld/__init__.json new file mode 100644 index 0000000000000000000000000000000000000000..110f9f70e2583f1faaf8e32a8e2c0ba8bd4631d7 --- /dev/null +++ b/plugins/helloworld/__init__.json @@ -0,0 +1,9 @@ +{ + "plugin_name": "Hello World", + "plugin_version": "1.0.0.1", + "plugin_description": "一个测试的插件。", + "developer_name": "Yishang", + "developer_website": "https://lovepikachu.top", + "developer_email": "422880152@qq.com", + "developer_phone": "-" +} \ No newline at end of file diff --git a/plugins/helloworld/__init__.py b/plugins/helloworld/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b972aa9b9b85c538a7e6c22c1fc37a34a14b2fb3 --- /dev/null +++ b/plugins/helloworld/__init__.py @@ -0,0 +1,24 @@ +""" +初始化插件 +""" +import os + +from flask import Flask +from .main import helloworld_blueprint + +# 获取插件所在的目录(结尾没有分割符号) +dir_path = os.path.dirname(__file__).replace("\\", "/") +folder_name = dir_path[dir_path.rfind("/") + 1:] # 插件文件夹名称 + +def event_enable(): + """当此插件被启用时会调用此处""" + print(f"启用插件,dir_path: {dir_path} ; folder_name: {folder_name}") + + +def event_disable(): + """当此插件被禁用时会调用此处""" + print(f"禁用插件,dir_path: {dir_path} ; folder_name: {folder_name}") + +def event_init(app: Flask): + """初始化完成时会调用这里""" + app.register_blueprint(helloworld_blueprint) \ No newline at end of file diff --git a/plugins/helloworld/main.py b/plugins/helloworld/main.py new file mode 100644 index 0000000000000000000000000000000000000000..0f8f56ed9103264ecdf67e449ead70fe2243fbe1 --- /dev/null +++ b/plugins/helloworld/main.py @@ -0,0 +1,12 @@ +from applications.dev import * + +from flask import render_template, Blueprint + +# 创建蓝图 +helloworld_blueprint = Blueprint('hello_world', __name__, template_folder='templates', static_folder="static", + url_prefix="/hello_world") + +@helloworld_blueprint.route("/") +def index(): + return render_template("helloworld_index.html") + diff --git a/plugins/helloworld/templates/helloworld_index.html b/plugins/helloworld/templates/helloworld_index.html new file mode 100644 index 0000000000000000000000000000000000000000..0982b2448d51d22dc2d61716a4c8bdb68b750f8a --- /dev/null +++ b/plugins/helloworld/templates/helloworld_index.html @@ -0,0 +1,67 @@ + + + + + pear-admin-flask + + {% include 'admin/common/header.html' %} + + + + + + + +
+ +
+ + +
+
+ +
+
+
+

你好世界!这是一个测试页面!

+
+
+
+
+
+ + + + + + + + + + + + + diff --git a/plugins/realip/__init__.json b/plugins/realip/__init__.json new file mode 100644 index 0000000000000000000000000000000000000000..2f7e77fe9cafa02709ca0eb6fdc912109e9a072b --- /dev/null +++ b/plugins/realip/__init__.json @@ -0,0 +1,9 @@ +{ + "plugin_name": "真实IP插件", + "plugin_version": "1.0.0.1", + "plugin_description": "在上游更改访客IP地址,并采用自定义日志输出。", + "developer_name": "Yishang", + "developer_website": "https://lovepikachu.top", + "developer_email": "422880152@qq.com", + "developer_phone": "-" +} \ No newline at end of file diff --git a/plugins/realip/__init__.py b/plugins/realip/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f1aee919ab10be73d201f64a4b3bde8491caa5f3 --- /dev/null +++ b/plugins/realip/__init__.py @@ -0,0 +1,66 @@ +""" +初始化插件 +""" +import os +import logging +from flask import Flask, request +from applications.dev import console + +# 获取插件所在的目录(结尾没有分割符号) +dir_path = os.path.dirname(__file__).replace("\\", "/") +folder_name = dir_path[dir_path.rfind("/") + 1:] # 插件文件夹名称 + +def event_enable(): + """当此插件被启用时会调用此处""" + print(f"启用插件,dir_path: {dir_path} ; folder_name: {folder_name}") + + +def event_disable(): + """当此插件被禁用时会调用此处""" + print(f"禁用插件,dir_path: {dir_path} ; folder_name: {folder_name}") + +def event_init(app: Flask): + """初始化完成时会调用这里""" + # 移除原有的输出日志 + app.logger = None + log = logging.getLogger('werkzeug') + log.setLevel(logging.ERROR) + + # 更改IP地址,只有在最新版的flask中才能生效 + @app.before_request + def before_request(): + request.remote_addr = get_user_ip(request) + + + # 使用自定义的日志输出 + @app.after_request + def after_request(rep): + if rep.status_code == 200: + console.success(f"{request.remote_addr} -- {request.full_path} 200") + elif rep.status_code == 404: + console.error(f"{request.remote_addr} -- {request.full_path} 404") + elif rep.status_code == 500: + console.warning(f"{request.remote_addr} -- {request.full_path} 500") + else: + console.info(f"{request.remote_addr} -- {request.full_path} {rep.status_code}") + return rep + +def get_user_ip(request): + """获取用户真实IP""" + if 'HTTP_X_FORWARDED_FOR' in request.headers: + arr = request.headers['HTTP_X_FORWARDED_FOR'].strip().split(",") + i = 0 + while i < len(arr): + if arr[i].find("unknown") != -1: + del arr[i] + else: + i += 1 + if len(arr) != 0: + return arr[0].strip() + elif 'HTTP_CLIENT_IP' in request.headers: + return request.headers['HTTP_CLIENT_IP'] + elif 'REMOTE_ADDR' in request.headers: + return request.headers['REMOTE_ADDR'] + elif 'X-Forwarded-For' in request.headers: + return request.headers['X-Forwarded-For'] + return request.remote_addr \ No newline at end of file diff --git a/plugins/replacePage/__init__.json b/plugins/replacePage/__init__.json new file mode 100644 index 0000000000000000000000000000000000000000..d5558828bde4bb71c69017f0f4faf74f8b3c04fa --- /dev/null +++ b/plugins/replacePage/__init__.json @@ -0,0 +1,9 @@ +{ + "plugin_name": "页面替换插件", + "plugin_version": "1.0.0.1", + "plugin_description": "在不更改原有框架视图函数的情况下更改渲染输出页面。", + "developer_name": "Yishang", + "developer_website": "https://lovepikachu.top", + "developer_email": "422880152@qq.com", + "developer_phone": "-" +} \ No newline at end of file diff --git a/plugins/replacePage/__init__.py b/plugins/replacePage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e973e203f22c57a4f43ac2d4307833105d2f6cf1 --- /dev/null +++ b/plugins/replacePage/__init__.py @@ -0,0 +1,37 @@ +""" +初始化插件 +""" +import os +import logging +from flask import Flask, render_template_string +from applications.dev import console + +# 获取插件所在的目录(结尾没有分割符号) +dir_path = os.path.dirname(__file__).replace("\\", "/") +folder_name = dir_path[dir_path.rfind("/") + 1:] # 插件文件夹名称 + +def event_enable(): + """当此插件被启用时会调用此处""" + print(f"启用插件,dir_path: {dir_path} ; folder_name: {folder_name}") + + +def event_disable(): + """当此插件被禁用时会调用此处""" + print(f"禁用插件,dir_path: {dir_path} ; folder_name: {folder_name}") + +def event_init(app: Flask): + """初始化完成时会调用这里""" + # 使用下面的代码 查看所有注册的视图函数。对于 Flask app.route 函数的实现,请参考 https://www.jianshu.com/p/dff3bc2f4836 + # print(app.view_functions) + + # 定义新视图函数 + def new_index(): + # 规避 render_template 的做法 + with open(dir_path + "/templates/replacePage_index.html", "r", encoding='utf-8') as f: + return render_template_string(f.read()) + + # Index.index 是主页的视图函数对应的名称,原视图函数位于 applications/view/index/index.py + del app.view_functions['Index.index'] # 释放原视图函数 + app.view_functions['Index.index'] = new_index # 替换原视图函数 + + \ No newline at end of file diff --git a/plugins/replacePage/templates/replacePage_index.html b/plugins/replacePage/templates/replacePage_index.html new file mode 100644 index 0000000000000000000000000000000000000000..06d8e06c314ea521be543ff5f9c92f3486501ac4 --- /dev/null +++ b/plugins/replacePage/templates/replacePage_index.html @@ -0,0 +1,67 @@ + + + + + pear-admin-flask + + {% include 'admin/common/header.html' %} + + + + + + + +
+ +
+ + +
+
+ +
+
+
+

主页被我替换了哈哈哈!

+
+
+
+
+
+ + + + + + + + + + + + + diff --git a/templates/admin/index.html b/templates/admin/index.html index 330044c26df046534969d014ed2b74d3e7a86951..50fb042035d5455e5ca9ee13e7df5dcf9f61418e 100644 --- a/templates/admin/index.html +++ b/templates/admin/index.html @@ -1,114 +1,129 @@ - - - - Pear Admin Flask - - - - - - - - - -
-
- -
    -
  • -
  • -
- -
- -
- -
- - - -
-
-
-
-
-
-
-
- -
- -
- -
-
-
- -{% include 'admin/common/footer.html' %} - - - + + {% include 'admin/common/header.html' %} + Pear Admin Layui + + + + + + + +
+ +
+ + + +
    +
  • +
  • +
+ +
+ + +
+ +
+ + + +
+
+
+
+ +
+ +
+
+ + + +
+ +
+ +
+
+
+ +
+ +
+ + {% include 'admin/common/footer.html' %} + + + + \ No newline at end of file diff --git a/templates/admin/login.html b/templates/admin/login.html index 580ed96e925a70612c08d90fb2a3f9620cc4d854..ba19e1d27dd419c142c22038273f75ab0ae02cd6 100644 --- a/templates/admin/login.html +++ b/templates/admin/login.html @@ -1,8 +1,8 @@ - 登录 + {% include 'admin/common/header.html' %} diff --git a/templates/admin/mail/main.html b/templates/admin/mail/main.html index f4f0c5314acdc298e0a1c959ea218e24c4a78c62..ea429a7f5d362d4c73c593db7baf93f9a2f3df22 100644 --- a/templates/admin/mail/main.html +++ b/templates/admin/mail/main.html @@ -118,7 +118,7 @@ skin: 'line', height: 'full-148', toolbar: '#mail-toolbar', /*工具栏*/ - text: { none: '暂无人员信息' }, + text: { none: '暂无邮件信息' }, defaultToolbar: [{ layEvent: 'refresh', icon: 'layui-icon-refresh' }, 'filter', 'print', 'exports'] /*默认工具栏*/ }) diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 99428f6f27c80646a5053dad228d764def4aa94a..77ab0cef9c551c0490fa381e374d214f4014fcf4 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -145,6 +145,13 @@ python版本 {{ python_version }} + + 程序操作 + + 关闭程序 + + diff --git a/templates/admin/plugin/main.html b/templates/admin/plugin/main.html new file mode 100644 index 0000000000000000000000000000000000000000..4ba74657a52175b9a7644fa6fc1f1de027a11b4f --- /dev/null +++ b/templates/admin/plugin/main.html @@ -0,0 +1,292 @@ + + + + 插件管理 + {% include 'admin/common/header.html' %} + + + +{# 查询表单 #} +
+
+
+
+ +
+ +
+ + +
+
+
+
+{# 用户表格 #} +
+
+
+
+
+
+
+ +{# 表格操作 #} + + +{# 用户修改操作 #} + + +{# 启动与禁用 #} + + +{# 用户注册时间 #} + + +{% include 'admin/common/footer.html' %} + + + \ No newline at end of file diff --git a/templates/index/index.html b/templates/index/index.html index bfb07a7e4ff4c85bdbc64e706e8eea47a6baea4c..6563339f1e72cd74637510bcc8b7680c9854c35e 100644 --- a/templates/index/index.html +++ b/templates/index/index.html @@ -4,9 +4,7 @@ pear-admin-flask - - - + {% include 'admin/common/header.html' %}