diff --git a/applications/common/helper.py b/applications/common/helper.py index 6b82cb8..ebbf202 100644 --- a/applications/common/helper.py +++ b/applications/common/helper.py @@ -1,10 +1,10 @@ -from sqlalchemy import and_ +from sqlalchemy import and_, func from applications.extensions import db class ModelFilter: """ - ORM 多条件查询构造器,支持多种查询条件组合。 + ORM 多条件查询构造器,支持多种查询条件组合,自动转义特殊字符防止SQL注入。 示例: mf = ModelFilter() @@ -13,14 +13,14 @@ class ModelFilter: query = User.query.filter(mf.get_filter(User)) """ filter_field = {} # 存储字段过滤条件 - filter_list = [] # 存储最终的过滤条件列表 + filter_list = [] # 存储最终的过滤条件列表 # 查询类型常量 - type_exact = "exact" # 精确匹配 - type_neq = "neq" # 不等于 + type_exact = "exact" # 精确匹配 + type_neq = "neq" # 不等于 type_greater = "greater" # 大于 - type_less = "less" # 小于 - type_vague = "vague" # 模糊匹配 + type_less = "less" # 小于 + type_vague = "vague" # 模糊匹配 type_contains = "contains" # 包含 type_between = "between" # 范围查询 @@ -29,117 +29,145 @@ class ModelFilter: self.filter_field = {} self.filter_list = [] + @staticmethod + def escape_like(value: str, escape_char: str = '\\') -> str: + """ + 转义LIKE查询中的特殊字符(%, _ 和转义字符本身) + + :param value: 需要转义的原始字符串 + :param escape_char: 转义字符(默认反斜杠) + :return: 转义后的安全字符串 + """ + return ( + value.replace(escape_char, escape_char * 2) + .replace('%', escape_char + '%') + .replace('_', escape_char + '_') + ) + def exact(self, field_name, value): """ - 添加精确匹配条件。 + 添加精确匹配条件(自动处理字符串类型参数) - :param field_name: 模型字段名称。 - :param value: 匹配的值。 + :param field_name: 模型字段名称 + :param value: 匹配的值(自动过滤空字符串) """ - if value and value != '': - self.filter_field[field_name] = {"data": value, "type": self.type_exact} + if value is not None and value != '': + # 字符串类型自动调用escape_like(防止特殊字符影响精确匹配) + processed_value = self.escape_like(str(value)) if isinstance(value, str) else value + self.filter_field[field_name] = {"data": processed_value, "type": self.type_exact} def neq(self, field_name, value): """ - 添加不等于条件。 + 添加不等于条件(自动处理字符串类型参数) - :param field_name: 模型字段名称。 - :param value: 不匹配的值。 + :param field_name: 模型字段名称 + :param value: 不匹配的值(自动过滤空字符串) """ - if value and value != '': - self.filter_field[field_name] = {"data": value, "type": self.type_neq} + if value is not None and value != '': + # 字符串类型自动调用escape_like + processed_value = self.escape_like(str(value)) if isinstance(value, str) else value + self.filter_field[field_name] = {"data": processed_value, "type": self.type_neq} def greater(self, field_name, value): """ - 添加大于条件。 + 添加大于条件(数值/日期比较) - :param field_name: 模型字段名称。 - :param value: 大于的值。 + :param field_name: 模型字段名称 + :param value: 比较的数值/日期 """ - if value and value != '': + if value is not None and value != '': self.filter_field[field_name] = {"data": value, "type": self.type_greater} def less(self, field_name, value): """ - 添加小于条件。 + 添加小于条件(数值/日期比较) - :param field_name: 模型字段名称。 - :param value: 小于的值。 + :param field_name: 模型字段名称 + :param value: 比较的数值/日期 """ - if value and value != '': + if value is not None and value != '': self.filter_field[field_name] = {"data": value, "type": self.type_less} def vague(self, field_name, value: str): """ - 添加模糊匹配条件(左右模糊)。 + 添加安全模糊匹配(自动转义特殊字符,左右加%) - :param field_name: 模型字段名称。 - :param value: 模糊匹配的值。 + :param field_name: 模型字段名称 + :param value: 需要模糊匹配的字符串(自动过滤空值) """ if value and value != '': - self.filter_field[field_name] = {"data": ('%' + value + '%'), "type": self.type_vague} + escaped_value = self.escape_like(value) + self.filter_field[field_name] = {"data": f'%{escaped_value}%', "type": self.type_vague} def left_vague(self, field_name, value: str): """ - 添加左模糊匹配条件。 + 添加安全左模糊匹配(自动转义特殊字符,左侧加%) - :param field_name: 模型字段名称。 - :param value: 左模糊匹配的值。 + :param field_name: 模型字段名称 + :param value: 需要左模糊匹配的字符串 """ if value and value != '': - self.filter_field[field_name] = {"data": ('%' + value), "type": self.type_vague} + escaped_value = self.escape_like(value) + self.filter_field[field_name] = {"data": f'%{escaped_value}', "type": self.type_vague} def right_vague(self, field_name, value: str): """ - 添加右模糊匹配条件。 + 添加安全右模糊匹配(自动转义特殊字符,右侧加%) - :param field_name: 模型字段名称。 - :param value: 右模糊匹配的值。 + :param field_name: 模型字段名称 + :param value: 需要右模糊匹配的字符串 """ if value and value != '': - self.filter_field[field_name] = {"data": (value + '%'), "type": self.type_vague} + escaped_value = self.escape_like(value) + self.filter_field[field_name] = {"data": f'{escaped_value}%', "type": self.type_vague} def contains(self, field_name, value: str): """ - 添加包含条件。 + 添加安全包含条件(自动转义特殊字符,等效于vague) - :param field_name: 模型字段名称。 - :param value: 包含的值。 + :param field_name: 模型字段名称 + :param value: 需要包含的字符串 """ if value and value != '': - self.filter_field[field_name] = {"data": value, "type": self.type_contains} + escaped_value = self.escape_like(value) + self.filter_field[field_name] = {"data": f'%{escaped_value}%', "type": self.type_contains} def between(self, field_name, value1, value2): """ - 添加范围查询条件。 + 添加范围查询条件(自动过滤无效值) - :param field_name: 模型字段名称。 - :param value1: 范围起始值。 - :param value2: 范围结束值。 + :param field_name: 模型字段名称 + :param value1: 范围起始值 + :param value2: 范围结束值 """ - if value1 and value2 and value1 != '' and value2 != '': + if all([v is not None and v != '' for v in [value1, value2]]): self.filter_field[field_name] = {"data": [value1, value2], "type": self.type_between} def get_filter(self, model: db.Model): """ - 获取最终的 SQLAlchemy 过滤条件。 + 生成安全的SQLAlchemy过滤条件 - :param model: SQLAlchemy 模型类。 - :return: 返回组合后的过滤条件。 + :param model: SQLAlchemy 模型类 + :return: 组合后的过滤条件(使用and_连接) """ for k, v in self.filter_field.items(): - if v.get("type") == self.type_vague: - self.filter_list.append(getattr(model, k).like(v.get("data"))) - if v.get("type") == self.type_contains: - self.filter_list.append(getattr(model, k).contains(v.get("data"))) - if v.get("type") == self.type_exact: - self.filter_list.append(getattr(model, k) == v.get("data")) - if v.get("type") == self.type_neq: - self.filter_list.append(getattr(model, k) != v.get("data")) - if v.get("type") == self.type_greater: - self.filter_list.append(getattr(model, k) > v.get("data")) - if v.get("type") == self.type_less: - self.filter_list.append(getattr(model, k) < v.get("data")) - if v.get("type") == self.type_between: - self.filter_list.append(getattr(model, k).between(v.get("data")[0], v.get("data")[1])) + field = getattr(model, k) + data = v.get("data") + query_type = v.get("type") + + if query_type == self.type_vague: + self.filter_list.append(field.like(data, escape='\\')) + elif query_type == self.type_contains: + self.filter_list.append(field.like(data, escape='\\')) + elif query_type == self.type_exact: + self.filter_list.append(field == data) + elif query_type == self.type_neq: + self.filter_list.append(field != data) + elif query_type == self.type_greater: + self.filter_list.append(field > data) + elif query_type == self.type_less: + self.filter_list.append(field < data) + elif query_type == self.type_between: + self.filter_list.append(field.between(data[0], data[1])) + return and_(*self.filter_list) \ No newline at end of file diff --git a/applications/extensions/init_sqlalchemy.py b/applications/extensions/init_sqlalchemy.py index 729ce6e..62dbb89 100644 --- a/applications/extensions/init_sqlalchemy.py +++ b/applications/extensions/init_sqlalchemy.py @@ -65,29 +65,47 @@ class Query(BaseQuery): page = request.args.get('page', type=int) if limit is None: limit = request.args.get('limit', type=int) + return self.paginate(page=page, per_page=limit, - error_out=False) + error_out=False + ) + + def layui_paginate_json(self, schema, page=None, limit=None): + if page is None: + page = request.args.get('page', 1, type=int) # 添加默认值 + if limit is None: + limit = request.args.get('limit', 10, type=int) # 添加默认值 - def layui_paginate_json(self, schema: Marshmallow().Schema): - """ - 返回dict - """ _res = self.paginate( - page=request.args.get('page', type=int), - per_page=request.args.get('limit', type=int), + page=page, + per_page=limit, error_out=False ) return schema(many=True).dump(_res.items), _res.total, _res.page, _res.per_page - def layui_paginate_db_json(self): - """ - db.query(A.name).layui_paginate_db_json() - """ - _res = self.paginate(page=request.args.get('page', type=int), - per_page=request.args.get('limit', type=int), - error_out=False) - return [dict(i) for i in _res.items], _res.total + def layui_paginate_db_json(self, page=None, limit=None): + if page is None: + page = request.args.get('page', 1, type=int) # 添加默认值 + if limit is None: + limit = request.args.get('limit', 10, type=int) # 添加默认值 + + _res = self.paginate( + page=page, + per_page=limit, + error_out=False + ) + + # 获取查询的列名列表 + column_names = [col["name"] for col in self.column_descriptions] + + # 将元组转换为字典(支持单列或多列) + data = [ + dict(zip(column_names, row)) + for row in _res.items + ] + + return data, _res.total, _res.page, _res.per_page db = SQLAlchemy(query_class=Query) diff --git a/docs/source/_static/Pear Admin Flask 启动流程图.drawio b/docs/source/_static/Pear Admin Flask 启动流程图.drawio index ffb2f75..c82500e 100644 --- a/docs/source/_static/Pear Admin Flask 启动流程图.drawio +++ b/docs/source/_static/Pear Admin Flask 启动流程图.drawio @@ -1,52 +1,52 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -55,192 +55,198 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + diff --git a/docs/source/_static/Pear Admin Flask 启动流程图.png b/docs/source/_static/Pear Admin Flask 启动流程图.png index 2d7a71e..c99b896 100644 Binary files a/docs/source/_static/Pear Admin Flask 启动流程图.png and b/docs/source/_static/Pear Admin Flask 启动流程图.png differ diff --git a/docs/source/_static/兑换码添加页面.png b/docs/source/_static/兑换码添加页面.png new file mode 100644 index 0000000..f51f41b Binary files /dev/null and b/docs/source/_static/兑换码添加页面.png differ diff --git a/docs/source/_static/兑换码管理页面.png b/docs/source/_static/兑换码管理页面.png new file mode 100644 index 0000000..a907c60 Binary files /dev/null and b/docs/source/_static/兑换码管理页面.png differ diff --git a/docs/source/_static/规划模板存放位置.png b/docs/source/_static/规划模板存放位置.png new file mode 100644 index 0000000..91a3858 Binary files /dev/null and b/docs/source/_static/规划模板存放位置.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py index b9732fc..33746dc 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -26,3 +26,7 @@ language = 'zh_CN' html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] + +suppress_warnings = [ + 'misc.highlighting_failure' # 忽略代码块高亮失败警告 +] \ No newline at end of file diff --git a/docs/source/function/curd.rst b/docs/source/function/curd.rst index 2e6e8a6..755a067 100644 --- a/docs/source/function/curd.rst +++ b/docs/source/function/curd.rst @@ -1,3 +1,5 @@ +.. _简单增删改查模块: + :mod:`curd` -- 简单增删改查模块 ======================================= diff --git a/docs/source/function/helper.rst b/docs/source/function/helper.rst index 9795748..572e54b 100644 --- a/docs/source/function/helper.rst +++ b/docs/source/function/helper.rst @@ -39,6 +39,14 @@ 初始化过滤条件存储字典和列表。 + .. method:: escape_like(value: str, escape_char: str = '\\') + + 转义LIKE查询中的特殊字符(%, _ 和转义字符本身) + + :param value: 需要转义的原始字符串 + :param escape_char: 转义字符(默认反斜杠) + :return: 转义后的安全字符串 + .. method:: exact(field_name, value) diff --git a/docs/source/function/index.rst b/docs/source/function/index.rst index ac13421..25bd111 100644 --- a/docs/source/function/index.rst +++ b/docs/source/function/index.rst @@ -2,7 +2,7 @@ 目录索引 -公共函数 +公共模块 ------------ .. toctree:: @@ -12,7 +12,7 @@ curd helper -辅助函数 +辅助模块 ------------ .. toctree:: diff --git a/docs/source/function/utils/index.rst b/docs/source/function/utils/index.rst index 36decf2..1206f1c 100644 --- a/docs/source/function/utils/index.rst +++ b/docs/source/function/utils/index.rst @@ -1,4 +1,4 @@ -.. title:: 辅助函数 +.. title:: 辅助模块 目录索引 diff --git a/docs/source/practices/backend.rst b/docs/source/practices/backend.rst index 2e4b95a..00bcdcd 100644 --- a/docs/source/practices/backend.rst +++ b/docs/source/practices/backend.rst @@ -1,7 +1,13 @@ +.. _后端页面编写: + 后端页面编写 ====================== -该章节将介绍如何在 Pear Admin Flask 中编写一个新后端页面,此章节将会以编写积分兑奖系统为例,编写一个兑换码管理的后台页面。 +该章节将介绍如何在 Pear Admin Flask 中编写一个新后端页面,此章节将会以编写兑换码管理页面为例,编写一个兑换码管理的后台页面。 + +.. note:: + + 前端页面制作,请参考 :ref:`简单前端页面示例` 章节。 .. _项目初始化逻辑: @@ -22,6 +28,468 @@ 由此可以看出,我们想要添加自己的后端页面,可以在 “注册项目的视图函数”(蓝框) 的地方添加,当然,同样页面可以作为插件的方式接入以提高项目的拓展性。 下面将介绍如何在这两种方式下添加自己的后台页面。 +设计数据库 +----------------------- + +兑换码一定是保存在数据库中的,我们现在要求改程序至少有以下几个功能: + +* 可以通过 flask admin init 或者等价的命令初始化数据库 +* 数据库存在统一管理的模型 +* 数据库的内容方便数据转化 + +数据的字段可以定为: + +* id -- 唯一主键 +* key -- 兑换码 +* content -- 具体的内容 +* enable -- 是否启用 +* used -- 是否使用 +* create_at -- 创建时间 + +根据上述需求,我们可以设计出这样一个数据库 Model : + +.. code-block:: python + + import datetime + from applications.extensions import db + class Gift(db.Model): + __tablename__ = 'admin_gift' + id = db.Column(db.Integer, primary_key=True, comment="唯一ID") + key = db.Column(db.String(50), comment="兑换码") + content = db.Column(db.String(), comment="具体的内容") + enable = db.Column(db.Integer, default=0, comment='是否启用') + used = db.Column(db.Integer, default=0, comment='是否已经使用') + create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间') +我们将该文件命名为 `admin_gift.py` 放置在 `applications/models/admin_gift.py` ,而后为了使程序可以调用到这个模型, +需要在 `applications/models/__init__.py` 中导入这个模型。 + + +.. code-block:: python + + from .admin_gift import Gift + +.. note:: + + 由于 Gift 模型是 db.Model 的子类,在使用 `flask db init` 等命令行初始化数据库时,自动对 Gift 表进行创建,前提是这个类已经被 Python 加载, + 换而言之,Python 会自动将 db.Model 的子类作为数据库的一部分,加入到数据库初始化中。 + + +初始化数据库 +----------------------- + +通过上面的操作,我们已经成功将数据库中的 admin_gift 表进行创建。现在我们希望在使用 `flask admin init` 的时候,可以将我们已经定义的数据写入到数据表中。 + +在 `applications/common/script` 目录中,撰写了默认数据的写入脚本(也就是 Flask 启动最后加载的项目), +我们要做的是在 `applications/common/script/admin.py` 中添加自己需要的数据,我们可以对其进行修改,添加如下的代码: + +.. code-block:: python + + ... + from applications.models import Gift + + ... + now_time = datetime.datetime.now() + ... + powerdata = [ + ... + Power( + id=60, + name='兑换码管理', + type='1', + code='system:gift:main', + url='/system/gift/', + open_type='_iframe', + parent_id='1', + icon='layui-icon layui-icon layui-icon layui-icon-diamond', + sort=8, + create_time=now_time, + enable=1 + ), Power( + id=61, + name='兑换码添加', + type='2', + code='system:gift:add', + url='', + open_type='', + parent_id='60', + icon='', + sort=0, + create_time=now_time, + enable=1 + ), Power( + id=62, + name='兑换码删除', + type='2', + code='system:gift:remove', + url='', + open_type='', + parent_id='60', + icon='', + sort=0, + create_time=now_time, + enable=1 + ), Power( + id=63, + name='兑换码编辑', + type='2', + code='system:gift:edit', + url='', + open_type='', + parent_id='60', + icon='', + sort=0, + create_time=now_time, + enable=1 + ), + ... + ] + giftdata = [ + Gift( + id=0, + key='myTestCode', + content='8折优惠', + enable=1, + used=0, + create_at=now_time + ), + Gift( + id=1, + key='DisableCode', + content='1折优惠', + enable=0, + used=0, + create_at=now_time + ) + ] + + ... + def add_role_power(): + admin_powers = Power.query.filter(Power.id.in_([1, 3, 4, 9, 12, 13, 17, 18, 44, 48, 60])).all() + ... + + @admin_cli.command("init") + def init_db(): + ... + db.session.add_all(giftdata) + ... + +这样就可以将数据库内容写入了。 + +.. note:: + + powerdata 中添加了对兑换码的操作权限,可以先去“权限管理”中添加,而后再从数据中抄取。 + 或者忽略初始化时对 powerdata 的添加,在初始化之后,手动在权限管理中添加。 + +使用 Schema 序列化 +--------------------------- + +与前端交互大多用的是 JSON 格式的数据,这就涉及到将数据库查询的结果对象(Query)转化为 JSON 这一步骤。 +我们可以使用 flask_marshmallow 中的 SQLAlchemyAutoSchema 将数据库查询对象转换为 JSON 格式。 + +创建文件 `applications/schemas/admin_gift.py` ,并继承 SQLAlchemyAutoSchema ,更改其中的目标模型为我们创建的 Gift 模型。 + +.. code-block:: python + + from flask_marshmallow.sqla import SQLAlchemyAutoSchema + from applications.models import Gift + + + class GiftSchema(SQLAlchemyAutoSchema): + class Meta: + model = Gift # table = models.Album.__table__ + include_fk = True # 序列化阶段是否也一并返回主键 + +.. note:: + + 更多序列化参数可以参考 :ref:`Schema 序列化` 章节。 + +随后,在 `applications/schemas/__init__.py` 引用, + +.. code-block:: python + + ... + from .admin_gift import GiftSchema + +这一步不是必须的,但是应通过这一步的引用,可以在蓝图页面中,方便的使用 `from applications.schemas import *` 的方式导入。 + +编写后端视图函数 +----------------------- + +注册蓝图 +~~~~~~~~~~~~~~ + +接着我们需要设计后端的数据增删改查部分的视图函数,创建文件 `applications/view/system/gift.py` ,并写上基本的蓝图初始化逻辑: + +.. code-block:: python + + from flask import Blueprint + + bp = Blueprint('gift', __name__, url_prefix='/gift') + +根据流程图,我们需要在 `applications/view/system/__init__.py` 中,注册 `gift.py` 的蓝图: + +.. code-block:: + + ... + from applications.view.system.gift import bp as gift_bp + ... + + def register_system_bps(app: Flask): + ... + system_bp.register_blueprint(gift_bp) + ... + + +.. _编写数据获取路由: + +编写数据获取路由 +~~~~~~~~~~~~~~~~~~~~ + +.. important:: + + 因为目标是让前端 layui 的动态表格获取数据,而根据 layui 的文档,表格将会提供 limit 和 page 两个查询参数来进行分页查询,所以要对 limit 和 page 进行处理。 + +现在开始编写数据获取路由,路由是以 JSON 格式响应数据库中 `admin_gift` 的数据,下面提供一种实现方法: + +.. code-block:: python + + from flask import Blueprint + + from applications.models import Gift + from applications.schemas import GiftSchema + from applications.extensions import db + + from applications.common.utils.http import table_api + from applications.common.utils.rights import authorize + + bp = Blueprint('gift', __name__, url_prefix='/gift') + + + @bp.get('/data') + @authorize("system:gift:main") + def data(): + + query = db.session.query(Gift).layui_paginate() + + return table_api( + data=GiftSchema(many=True).dump(query), + count=query.total, + limit=query.per_page + ) + +可以发现,在没有搜索的情况下,正确处理前端的分页查询,实际上只有简单 5 行代码就可以完成(自动处理了 limit 和 page 参数), +另外,也可以采用已经封装好的 `layui_paginate_json` 方法: + +.. code-block:: python + + ... + @bp.get('/data') + @authorize("system:gift:main") + def data(): + + data, total, page, limit = db.session.query(Gift).layui_paginate_json(GiftSchema) + + return table_api( + data=data, + count=total, + limit=limit + ) + +`layui_paginate_json` 函数完成了分页、解析与转化,适用于一些比较简单数据转化场景。 + +.. warning:: + + 对于任何形式的后台管理员路由,切记不要忘记添加 `authorize` 装饰函数对请求效验权限!!!!!! + +.. note:: + + 对于 layui_paginate 方法定义,可以查看 :ref:`与 layui 的数据格式同步` 章节。 + +访问路由 `/system/gift/data` 可以获得如下数据: + +.. code-block:: json + + { + "code": 0, + "count": 2, + "data": [ + { + "content": "8折优惠", + "create_at": "2025-01-28T19:10:48.607165", + "enable": 1, + "id": 0, + "key": "myTestCode", + "used": 0 + }, + { + "content": "1折优惠", + "create_at": "2025-01-28T19:10:48.607165", + "enable": 0, + "id": 1, + "key": "DisableCode", + "used": 0 + } + ], + "limit": 10, + "msg": "" + } + +随后,我们加入查询: + +.. code-block:: python + + ... + @bp.get('/data') + @authorize("system:gift:main") + def data(): + key = request.args.get('key', type=str) + + mf = ModelFilter() + if key: + mf.vague('key', key) # 模糊查询 + + data, total, page, limit = db.session.query(Gift).filter(mf.get_filter(Gift)).layui_paginate_json(GiftSchema) + + return table_api( + data=data, + count=total, + limit=limit + ) + +.. _编写启用与禁用视图函数: + +编写启用与禁用视图函数 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +启用与禁用的基本思路是通过 ID 筛选到合适的记录行并设置其 enable 为 1 或者 0。在设计数据库时,我们有意将表示启用禁用字段设置为 `enable` 以此可以使用项目已经封装好的函数。 + +.. code-block:: python + + from applications.common.curd import enable_status, disable_status + + @bp.put('/enable') + @authorize("system:gift:edit") + def enable_api(): + data = request.get_json(force=True) + + if enable_status(Gift, data.get('id')): + return success_api(msg="启用成功") + + return success_api(msg="启用失败") + + + @bp.put('/disable') + @authorize("system:gift:edit") + def disable_api(): + req_json = request.get_json(force=True) + + if disable_status(Gift, req_json.get('id')): + return success_api(msg="禁用成功") + + return success_api(msg="禁用失败") + +.. note:: + + 对于上述的 `enable_status` `disable_status` 函数,可以参考文档 :ref:`简单增删改查模块` 章节。 + +数据的修改经历如下步骤:获取目标兑换码 ID、获取对应修改的新数据、应用修改,而数据的添加仅没有“获取目标兑换码 ID”这一步骤。 + +我们先来撰写添加这一部分的视图函数, + +编写删除视图函数 +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +删除的视图函数,实则和启用禁用是一样的,这里直接给出代码: + +.. code-block:: python + + @bp.delete('/remove/') + @authorize("system:gift:remove") + def remove_api(_id): + + if delete_one_by_id(Gift, _id): + return success_api(msg="删除成功") + + return success_api(msg="删除失败") + + +你会注意到,由于 `curd` 模块的封装,使编写路由变的简洁。 + +.. _编写增加视图函数: + +编写增加视图函数 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +添加视图函数经历下面几个步骤:获取参数、效验参数、写入数据库。 + +.. code-block:: python + + @bp.post('/save') + @authorize("system:gift:add", log=True) + def save(): + req_json = request.get_json(force=True) + + data = { + 'key': req_json.get('key'), + 'content': req_json.get('content'), + 'enable': req_json.get('enable'), + 'used': 0 + } + + # 效验参数 + if not all(list(data.keys())): + return fail_api(msg="参数不全") + + if not data['enable'].isdigit(): + return fail_api(msg="参数 enable 错误") + + try: + db.session.add(Gift(**data)) + db.session.commit() + return success_api(msg="添加成功") + except Exception as e: + return fail_api(msg="添加失败") + +.. important:: + + 效验参数是必不可少的,要尽可能一切不相信用户的输入。 + +.. _编写修改视图函数: + +编写修改视图函数 +~~~~~~~~~~~~~~~~~~~~~~~~ + +修改视图函数的编写就照葫芦画瓢即可,代码如下: + +.. code-block:: python + + @bp.post('/update') + @authorize("system:gift:edit", log=True) + def update(): + req_json = request.get_json(force=True) + + _id = req_json.get('id') + + data = { + 'key': req_json.get('key'), + 'content': req_json.get('content'), + 'enable': req_json.get('enable'), + 'used': 0 + } + + # 效验参数 + if not all(list(data.keys())): + return fail_api(msg="参数不全") + + if not data['enable'].isdigit(): + return fail_api(msg="参数 enable 错误") + + try: + db.session.query(Gift).filter(Gift.id == _id).update(data) + db.session.commit() + return success_api(msg="编辑成功") + except Exception as e: + return fail_api(msg="编辑失败") diff --git a/docs/source/practices/frontend.rst b/docs/source/practices/frontend.rst index f85f58c..791ca48 100644 --- a/docs/source/practices/frontend.rst +++ b/docs/source/practices/frontend.rst @@ -77,3 +77,481 @@ Pear Admin Layui 的控制主题色逻辑是通过设置全局的 css 属性:` border-color: #4C4D4F; } + +.. _简单前端页面示例: + +简单前端页面示例 +------------------------- + +此部分我们来以制作一个兑换码管理的前端页面为例。 + +.. note:: + + 配套后端的制作可以查看 :ref:`后端页面编写` 章节。 + +规划模板存放位置 +~~~~~~~~~~~~~~~~~~ + +模板一般存放在 `templates/system` 的目录下,该目录下的每一个子文件(夹)都是一个特定功能的实现的网页模板。 + +我们在其中创建一个 `gift` 文件夹,并放入 `main.html` 、`add.html` 和 `edit.html` 。 + +| + +.. image:: ../_static/规划模板存放位置.png + :align: center + +| + +加入动态表格与查询表单 +~~~~~~~~~~~~~~~~~~~~~~~~ + +随后,我们可以制作一个写一个简单的页面,设想是页面中存在一个查询表单和一个动态表格: + +.. code-block:: html + + + + + 兑换码管理 + {% include 'system/common/header.html' %} + + + + {# 查询表单 #} +
+
+
+
+ +
+ +
+ + +
+
+
+
+ + {# 用户表格 #} +
+
+
+
+
+
+
+ + + + {% raw %} + + + + + + {% endraw %} + + + + + + + + + {% include 'system/common/footer.html' %} + + + + +注意还要在 Python 中加上渲染路由: + +.. code-block:: python + + @bp.get('/') + @authorize("system:gift:main") + def index(): + return render_template('system/gift/main.html') + +前端的效果如下: + +| + +.. image:: ../_static/兑换码管理页面.png + :align: center + +| + +完善查询功能 +~~~~~~~~~~~~~~~~~~~~~~~ + +.. note:: + + 查询数据库的视图函数可以参考 :ref:`编写数据获取路由` 章节。 + +接着,我们完善查询功能,确保获取的路由在有查询功能之后,我们在前端编写表单提交的处理。 + +.. code-block:: javascript + + layui.use(['table', 'form'], function () { + ... + let form = layui.form; + + ... + // 表单查询 + form.on('submit(gift-query)', function (data) { + table.reload('gift-table', {where: data.field}) + return false; + }) + } + +监听启用和禁用事件 +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +在动态表格中存在启用与禁用的切换开关,我们需要对开关进行监听,在用户切换开关状态时,自动在数据库中设置兑换码的启用与禁用状态。 + +.. note:: + + 后台视图函数,参考 :ref:`编写启用与禁用视图函数` 章节。 + +.. code-block:: python + + let $ = layui.jquery; + ley popup = layui.popup; + ... + + // 启用与禁用 + form.on('switch(gift-enable)', function (obj) { + let operate; + if (obj.elem.checked) { + operate = 'enable' + } else { + operate = 'disable' + } + let loading = layer.load() + $.ajax({ + url: '/system/gift/' + operate, + data: JSON.stringify({id: this.value}), + dataType: 'json', + contentType: 'application/json', + type: 'put', + success: function (result) { + layer.close(loading) + if (result.success) { + popup.success(result.msg) + } else { + popup.failure(result.msg) + } + } + }) + }) + +.. important:: + + 如果对前端编写存在问题,可以自行查阅 `layui 官方文档 `_ ,需要注意的是,由于页面中的组件元素增多, + 最好使用准确无误的表示区分这些表单组件,以便在监听时正确绑定到事件。 + +监听删除数据事件 +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +删除数据事件就与监听启用禁用其实是同理的,这里直接给出代码, + +.. code-block:: javascript + + // 表格各行工具事件 + table.on('tool(gift-table)', function (obj) { + if (obj.event === 'remove') { + + layer.confirm('确定要删除该兑换码?', {icon: 3, title: '提示'}, function (index) { + layer.close(index) + let loading = layer.load() + $.ajax({ + url: '/system/gift/remove/' + obj.data['id'], + dataType: 'json', + type: 'delete', + success: function (result) { + layer.close(loading) + if (result.success) { + popup.success(result.msg, function () { + obj.del() + }) + } else { + popup.failure(result.msg) + } + } + }) + }) + + } else if (obj.event === 'edit') { + // 待定 + } + }) + + +编写新建与编辑页面 +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. note:: + + 对应的视图函数,可以查看 :ref:`编写增加视图函数` 章节。 + +编写这一部分涉及到设计表单,编辑实际上就是已经填好值的新建页面。由于目前项目暂未进行前后端分离,所以为了方便直接使用模板渲染的方式,直接将内容渲染到编辑页面上。 +这就导致需要保留这两个略微有差别的页面,后续的更新,将会尝试将渲染的方式剥离项目,直接动态请求,可以实现动态分离。 + +此处给出表单页面基本的写法,所有的新建页面表单可以参考这个模板: + +.. code-block:: html + + + + + 激活码管理 + {% include 'system/common/header.html' %} + + +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+ {% include 'system/common/footer.html' %} + + + + + +撰写表单的工作较为简单,代码如下: + +.. code-block:: html + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+
+ +注意还要在管理页面加上窗口弹出的绑定: + +.. code-block:: javascript + + // 顶部工具栏 + table.on('toolbar(gift-table)', function (obj) { + if (obj.event === 'add') { + layer.open({ + type: 2, + title: '新增', + shade: 0.1, + area: ['550px', '550px'], + content: '/system/gift/add' + }) + } + }) + +| + +.. image:: ../_static/兑换码添加页面.png + :align: center + +| + + +现在编写编辑页面,编辑页面相较于新建页面仅有两个区别:增加了 ID 编辑框、修改了提交的地址,最重要的是将后端传入的内容渲染到页面上。 + +我们先编写如下的路由视图: + +.. code-block:: python + + @bp.get('/edit/') + @authorize("system:gift:edit", log=True) + def edit(_id): + gift = get_one_by_id(Gift, _id) + return render_template('system/gift/edit.html', gift=gift) + +绑定编辑事件(就是在上面 “// 待定” 的地方添加内容): + +.. code-block:: javascript + + } else if (obj.event === 'edit') { + + layer.open({ + type: 2, + title: '修改', + shade: 0.1, + area: ['550px', '500px'], + content: '/system/gift/edit/' + obj.data['id'] + }) + + } + +设计新表单: + +.. code-block:: html + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+
+ +.. important:: + + 要注意把内容渲染到网页上哦,其中 id 字段设置为禁用。随后不要忘记,将表单的提交地址改为 `/system/gift/update` 。 + +.. note:: + + 对应的视图函数,可以查看 :ref:`编写修改视图函数` 章节。 + + + + diff --git a/docs/source/practices/plugin.rst b/docs/source/practices/plugin.rst index 56874fc..a2590c0 100644 --- a/docs/source/practices/plugin.rst +++ b/docs/source/practices/plugin.rst @@ -6,7 +6,7 @@ 所有插件放置在 `plugins` 文件夹中,项目提供了三个示例插件,分别是 `helloworld` 、 `realip` 和 `replacePage` ,分别用于示例页面的注册、修改 Flask 上下文和页面替换。 像项目自带的用户管理、部门管理等基本功能属于程序自身的“功能插件”,对于大多数衍生项目来说,多的是修改字符串和删除部分不需要的功能, -而插件开发主要可以用于添加自己的视图函数和功能,可以完美于项目融合,增加可拓展性。 +而插件开发主要可以用于添加自己的视图函数和功能,可以完美于项目融合,增加可拓展性,尤其是用于既想保留原项目功能又想填写新功能的项目开发。 插件的启用 ----------------- diff --git a/docs/source/practices/trick.rst b/docs/source/practices/trick.rst index f823a0f..1d12611 100644 --- a/docs/source/practices/trick.rst +++ b/docs/source/practices/trick.rst @@ -40,11 +40,18 @@ 在 Pear Admin Flask 中,菜单的管理归属于 “权限管理” ,这样做的原因是为了使不同用户可以使用不同的访问控制。所以需要修改菜单,需要在 “权限管理” 页面编辑即可。 +配置后台站内消息 +------------------- + +后台站内消息是异步获取的,其路由在 `applications/view/system/rights.py` 的 `message` 函数中,后续会考虑写入数据库,并添加管理函数进行统一的管理。 + 权限效验 ------------ 在开发后台管理模板的过程中会涉及到权限效验,即访问控制。Pear Admin Flask 中提供了方便的函数用于进行权限效验。详情请查看 :ref:`权限验证模块` 章节。 +.. _Schema 序列化: + Schema 序列化 --------------- @@ -79,6 +86,12 @@ Schema 序列化 # fields= ["id","name"] # 启动的字段列表 # exclude = ["id","name"] # 排除字段列表 +.. note:: + + 更多参数可以参考官方文档对其的解释,链接如下:`SQLAlchemyAutoSchema `_ + + +.. _与 layui 的数据格式同步: 与 layui 的数据格式同步 ------------------------------ @@ -133,6 +146,8 @@ Schema 序列化 **需要注意的是,如果不提供 page 和 limit 则该函数必须在视图函数中使用,该函数会自动获取 GET 请求中的 limit 和 page 参数构成查询。** + :param page: 页码 + :param limit: 页数据个数 :return: 返回分页对象。 **示例:** @@ -144,25 +159,30 @@ Schema 序列化 return model_to_dicts(schema=MailOutSchema, data=mail.items) - .. method:: layui_paginate_json(schema: Schema) + .. method:: layui_paginate_json(schema: Schema, page=None, limit=None) - 分页查询并返回 JSON 格式数据,适用于 Layui 表格。 + 分页查询并通过 Marshmallow Schema 类 转化为 JSON,适用于 Layui 表格。 :param schema: Marshmallow Schema 类。 + :param page: 页码 + :param limit: 页数据个数 :return: 返回包含序列化数据、总数、当前页码和每页条数的元组。 - .. method:: layui_paginate_db_json() + .. method:: layui_paginate_db_json(page=None, limit=None) 分页查询并返回数据库原始数据的 JSON 格式,适用于 Layui 表格。 - :return: 返回包含序列化数据和总数的元组。 + :param page: 页码 + :param limit: 页数据个数 + :return: 返回包含序列化数据(列表)、总数、当前页码和每页条数的元组。 **示例:** .. code-block:: python - db.query(User.name).layui_paginate_db_json() + >> db.session.query(Gift.id, Gift.key).layui_paginate_db_json() + ([{'id': 0, 'key': 'myTestCode'}, {'id': 1, 'key': 'DisableCode'}], 2, 1, 10) 进行字段构造 diff --git a/docs/source/welcome/update.rst b/docs/source/welcome/update.rst index 84b5cbb..2b34038 100644 --- a/docs/source/welcome/update.rst +++ b/docs/source/welcome/update.rst @@ -42,6 +42,7 @@ * 优化代码结构,新增函数 `normal_log` 减少代码复用 * 添加了新插件的事件 * 修改了验证码生成路由 +* 为 ModelFilter 增加了字符转义 已知问题以及解决方式 ~~~~~~~~~~~~~~~~~~~~~~~