From 09859d4d8057e64029a29d4ad6dd9d0a268d8602 Mon Sep 17 00:00:00 2001 From: ktianc Date: Thu, 24 Aug 2023 23:14:45 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E5=A4=A7=E6=9B=B4=E6=96=B0=E5=88=B02.?= =?UTF-8?q?0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 25 +- .../src/views/Login/components/LoginForm.vue | 6 +- .../vadmin/auth/role/components/Write.vue | 4 +- .../vadmin/system/settings/agreement.vue | 2 +- .../views/vadmin/system/settings/baidu.vue | 2 +- .../views/vadmin/system/settings/basic.vue | 2 +- .../views/vadmin/system/settings/email.vue | 2 +- .../views/vadmin/system/settings/privacy.vue | 2 +- .../views/vadmin/system/settings/wxServer.vue | 2 +- kinit-api/README.md | 245 ++++----- kinit-api/application/settings.py | 12 +- kinit-api/apps/vadmin/auth/crud.py | 152 +++--- kinit-api/apps/vadmin/auth/models/__init__.py | 2 +- kinit-api/apps/vadmin/auth/models/m2m.py | 31 +- kinit-api/apps/vadmin/auth/models/menu.py | 76 +-- kinit-api/apps/vadmin/auth/models/role.py | 28 +- kinit-api/apps/vadmin/auth/models/user.py | 55 +- kinit-api/apps/vadmin/auth/utils/current.py | 7 +- kinit-api/apps/vadmin/auth/utils/login.py | 2 +- .../vadmin/auth/utils/validation/login.py | 18 +- kinit-api/apps/vadmin/auth/views.py | 29 +- kinit-api/apps/vadmin/help/crud.py | 40 +- kinit-api/apps/vadmin/help/models/issue.py | 46 +- kinit-api/apps/vadmin/help/views.py | 17 +- kinit-api/apps/vadmin/record/crud.py | 4 +- kinit-api/apps/vadmin/record/models/login.py | 39 +- kinit-api/apps/vadmin/record/models/sms.py | 16 +- kinit-api/apps/vadmin/record/views.py | 9 +- kinit-api/apps/vadmin/system/crud.py | 26 +- .../apps/vadmin/system/models/__init__.py | 3 +- kinit-api/apps/vadmin/system/models/dict.py | 32 +- .../apps/vadmin/system/models/settings.py | 36 +- .../apps/vadmin/system/models/settings_tab.py | 25 - kinit-api/apps/vadmin/system/views.py | 23 +- kinit-api/core/crud.py | 476 +++++++++++------- kinit-api/core/database.py | 65 ++- kinit-api/db/db_base.py | 24 +- kinit-api/requirements.txt | Bin 3502 -> 3502 bytes kinit-api/scripts/create_app/main.py | 4 +- kinit-api/scripts/initialize/data/init.xlsx | Bin 44262 -> 47700 bytes kinit-api/scripts/initialize/initialize.py | 33 +- kinit-api/utils/excel/excel_manage.py | 53 +- kinit-api/utils/excel/excel_schema.py | 65 +++ kinit-api/utils/send_email.py | 4 +- 44 files changed, 1001 insertions(+), 743 deletions(-) delete mode 100644 kinit-api/apps/vadmin/system/models/settings_tab.py create mode 100644 kinit-api/utils/excel/excel_schema.py diff --git a/README.md b/README.md index 49bf376..765927f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Kinit 是一套全部开源的快速开发平台,毫无保留给个人及企业免费使用。 -- 后端采用现代、快速(高性能) [FastAPI](https://fastapi.tiangolo.com/zh/) 异步框架 + 自动生成交互式API文档 + (强制类型约束)[Pydantic](https://docs.pydantic.dev/1.10/) + (高效率)[SQLAlchemy](https://www.sqlalchemy.org/) ; +- 后端采用现代、快速(高性能) [FastAPI](https://fastapi.tiangolo.com/zh/) 异步框架 + 自动生成交互式API文档 + (强制类型约束)[Pydantic](https://docs.pydantic.dev/1.10/) + (高效率)[SQLAlchemy 2.0]([SQLAlchemy Documentation — SQLAlchemy 2.0 Documentation](https://docs.sqlalchemy.org/en/20/index.html)) ; - PC端采用 [vue-element-plus-admin](https://gitee.com/kailong110120130/vue-element-plus-admin) 、[Vue3](https://cn.vuejs.org/guide/introduction.html)、[Element Plus](https://element-plus.gitee.io/zh-CN/guide/design.html)、[TypeScript](https://www.tslang.cn/)等主流技术开发; - 移动端采用 [uni-app](https://uniapp.dcloud.net.cn/component/),[Vue2](https://v2.cn.vuejs.org/v2/guide/),[uView 2](https://www.uviewui.com/components/intro.html)为主要技术开发; - 后端加入 [Typer](https://typer.tiangolo.com/) 命令行应用,简单化数据初始化,数据表模型迁移等操作; @@ -34,24 +34,14 @@ Kinit 是一套全部开源的快速开发平台,毫无保留给个人及企 [vue-element-plus-admin](https://gitee.com/kailong110120130/vue-element-plus-admin):一套基于vue3、element-plus、typescript4、vite3的后台集成方案 -[RuoYi 若依官方网站](http://www.ruoyi.vip/):RuoYi 是一个后台管理系统,基于经典技术组合(Spring Boot、Apache Shiro、MyBatis、Thymeleaf)主要目的让开发者注重专注业务,降低技术难度,从而节省人力成本,缩短项目周期,提高软件安全质量。 +[RuoYi 若依官方网站](http://www.ruoyi.vip/):RuoYi 是一个优秀的 Java 后台管理系统 -[django-vue-admin](https://gitee.com/liqianglog/django-vue-admin):基于RBAC模型的权限控制的一整套基础开发平台,前后端分离,后端采用 django+django-rest-framework,前端采用 vue+ElementUI。 +[django-vue-admin](https://gitee.com/liqianglog/django-vue-admin):django-vue-admin 是一个优秀的基于 Django 开发后台管理系统 [Ant Design Pro](https://preview.pro.ant.design/dashboard/analysis):开箱即用的中台前端/设计解决方案 -[Gin-Vue-Admin](https://demo.gin-vue-admin.com):基于vite+vue3+gin搭建的开发基础平台(支持TS,JS混用),集成jwt鉴权,权限管理,动态路由,显隐可控组件,分页封装,多点登录拦截,资源权限,上传下载,代码生成器,表单生成器等开发必备功能。 - -[Vben Admin](https://doc.vvbin.cn/guide/introduction.html):Vue Vben Admin 是一个免费开源的中后台模版。使用了最新的`vue3`,`vite2`,`TypeScript`等主流技术开发,开箱即用的中后台前端解决方案,也可用于学习参考。 - -[中华人民共和国行政区划 (github.com)](https://github.com/modood/Administrative-divisions-of-China):省级(省份)、 地级(城市)、 县级(区县)、 乡级(乡镇街道)、 村级(村委会居委会) ,中国省市区镇村二级三级四级五级联动地址数据。 - -[Vue Admin Plus](https://vue-admin-beautiful.com/admin-plus/#/index):vue-admin-better是github开源admin中最优秀的集成框架之一,它是国内首个基于vue3.0的开源admin项目,同时支持电脑,手机,平板,默认分支使用vue3.x+antdv开发,master分支使用的是vue2.x+element开发。 - [小诺开源技术 (xiaonuo.vip)](https://www.xiaonuo.vip/):国内首个国密前后端分离快速开发平台 -[my-web](https://gitee.com/newgateway/my-web):MyWeb 是一个企业级中后台前端/设计解决方案的的项目工程模板,它可以帮助你快速搭建企业级中后台产品原型 - ## 在线体验 PC端演示地址:http://kinit.ktianc.top @@ -134,18 +124,13 @@ github地址:https://github.com/vvandk/kinit - [x] 我的基础功能:编辑资料、头像修改、密码修改、常见问题、关于我们等 -## TODO - -- [ ] 多租户方案 -- [ ] 自动化编排服务:使用docker-compose部署项目 -- [ ] 可视化低代码表单:接入低代码表单,[vform3](https://vform666.com/vform3.html?from=element_plus) - ## 前序准备 ### 后端技术 - [Python3](https://www.python.org/downloads/windows/):熟悉 python3 基础语法 -- [FastAPI](https://fastapi.tiangolo.com/zh/) - 熟悉后台接口 Web 框架. +- [FastAPI](https://fastapi.tiangolo.com/zh/) - 熟悉后台接口 Web 框架 +- [SQLAlchemy 2.0](https://docs.sqlalchemy.org/en/20/index.html) - 数据数据库操作 - [Typer](https://typer.tiangolo.com/) - 熟悉命令行工具的使用 - [MySQL](https://www.mysql.com/) 和 [MongoDB](https://www.mongodb.com/) 和 [Redis](https://redis.io/) - 熟悉数据存储数据库 - [iP查询接口文档](https://user.ip138.com/ip/doc):IP查询第三方服务,有1000次的免费次数 diff --git a/kinit-admin/src/views/Login/components/LoginForm.vue b/kinit-admin/src/views/Login/components/LoginForm.vue index f8b01b0..00725c8 100644 --- a/kinit-admin/src/views/Login/components/LoginForm.vue +++ b/kinit-admin/src/views/Login/components/LoginForm.vue @@ -2,7 +2,7 @@ import { reactive, ref, unref, watch } from 'vue' import { Form } from '@/components/Form' import { useI18n } from '@/hooks/web/useI18n' -import { ElButton, ElCheckbox, ElLink } from 'element-plus' +import { ElButton, ElLink } from 'element-plus' import { useForm } from '@/hooks/web/useForm' import { getRoleMenusApi } from '@/api/login' import { useAuthStoreWithOut } from '@/store/modules/auth' @@ -106,7 +106,7 @@ const schema = reactive([ ]) const iconSize = 30 -const remember = ref(false) +// const remember = ref(false) const { register, elFormRef, methods } = useForm() const loading = ref(false) const iconColor = '#999' @@ -189,7 +189,7 @@ const toTelephoneSignIn = () => { diff --git a/kinit-admin/src/views/vadmin/auth/role/components/Write.vue b/kinit-admin/src/views/vadmin/auth/role/components/Write.vue index 960dfc3..8874d6c 100644 --- a/kinit-admin/src/views/vadmin/auth/role/components/Write.vue +++ b/kinit-admin/src/views/vadmin/auth/role/components/Write.vue @@ -75,7 +75,7 @@ defineExpose({ let selectAll = ref(false) let defaultExpandAll = ref(true) -let checkStrictly = ref(true) // 父子联动 +let checkStrictly = ref(false) // 父子联动,有bug // 获取所有节点的key const getTreeNodeKeys = (nodes: Recordable[]): number[] => { @@ -119,7 +119,7 @@ function handleCheckedTreeNodeAll() { label="全选/全不选" size="large" /> - +
- 立即保存 + 立即保存
diff --git a/kinit-admin/src/views/vadmin/system/settings/baidu.vue b/kinit-admin/src/views/vadmin/system/settings/baidu.vue index 895d324..1d0c19b 100644 --- a/kinit-admin/src/views/vadmin/system/settings/baidu.vue +++ b/kinit-admin/src/views/vadmin/system/settings/baidu.vue @@ -59,7 +59,7 @@ getData() diff --git a/kinit-admin/src/views/vadmin/system/settings/basic.vue b/kinit-admin/src/views/vadmin/system/settings/basic.vue index 34519d1..f36b8a4 100644 --- a/kinit-admin/src/views/vadmin/system/settings/basic.vue +++ b/kinit-admin/src/views/vadmin/system/settings/basic.vue @@ -152,7 +152,7 @@ getData() diff --git a/kinit-admin/src/views/vadmin/system/settings/email.vue b/kinit-admin/src/views/vadmin/system/settings/email.vue index 0f4eaad..3aa3cf0 100644 --- a/kinit-admin/src/views/vadmin/system/settings/email.vue +++ b/kinit-admin/src/views/vadmin/system/settings/email.vue @@ -59,7 +59,7 @@ getData() diff --git a/kinit-admin/src/views/vadmin/system/settings/privacy.vue b/kinit-admin/src/views/vadmin/system/settings/privacy.vue index 4766991..7763615 100644 --- a/kinit-admin/src/views/vadmin/system/settings/privacy.vue +++ b/kinit-admin/src/views/vadmin/system/settings/privacy.vue @@ -73,7 +73,7 @@ getData() :editorConfig="editorConfig" />
- 立即保存 + 立即保存
diff --git a/kinit-admin/src/views/vadmin/system/settings/wxServer.vue b/kinit-admin/src/views/vadmin/system/settings/wxServer.vue index 5ec341a..979408c 100644 --- a/kinit-admin/src/views/vadmin/system/settings/wxServer.vue +++ b/kinit-admin/src/views/vadmin/system/settings/wxServer.vue @@ -59,7 +59,7 @@ getData() diff --git a/kinit-api/README.md b/kinit-api/README.md index 209d3b1..9be3162 100644 --- a/kinit-api/README.md +++ b/kinit-api/README.md @@ -1,8 +1,8 @@ # FastAPI 项目 -fastapi 源代码:https://github.com/tiangolo/fastapi +fastapi Github:https://github.com/tiangolo/fastapi -fastapi 中文文档:https://fastapi.tiangolo.com/zh/ +fastapi 官方文档:https://fastapi.tiangolo.com/zh/ fastapi 更新说明:https://fastapi.tiangolo.com/zh/release-notes/ @@ -16,25 +16,50 @@ alembic 中文文档:https://hellowac.github.io/alembic_doc/zh/_front_matter.h Typer 官方文档:https://typer.tiangolo.com/ +SQLAlchemy 2.0 (官方): https://docs.sqlalchemy.org/en/20/intro.html#installation + +SQLAlchemy 1.4 迁移到 2.0 (官方):https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html#whatsnew-20-orm-declarative-typing + +PEP 484 语法(官方):https://peps.python.org/pep-0484/ + ## 项目结构 使用的是仿照 Django 项目结构: - alembic:数据库迁移配置目录 + - versions_dev:开发环境数据库迁移文件目录 + - versions_pro:生产环境数据库迁移文件目录 + - env.py:映射类配置文件 - application:主项目配置目录,也存放了主路由文件 + - config:基础环境配置文件 + - development.py:开发环境 + - production.py:生产环境 - settings.py:主项目配置文件 - urls.py:主路由文件 - apps:项目的app存放目录 + - vadmin:基础服务 + - auth:用户 - 角色 - 菜单接口服务 + - models:ORM 模型目录 + - params:查询参数依赖项目录 + - schemas:pydantic 模型,用于数据库序列化操作目录 + - utils:登录认证功能接口服务 + - curd.py:数据库操作 + - views.py:视图函数 - core:核心文件目录 + - crud.py:关系型数据库操作核心封装 - database.py:关系型数据库核心配置 + - data_types.py:自定义数据类型 - exception.py:异常处理 - logger:日志处理核心配置 - middleware.py:中间件核心配置 + - dependencies.py:常用依赖项 + - event.py:全局事件 + - mongo_manage.py:mongodb 数据库操作核心封装 + - validator.py:pydantic 模型重用验证器 - db:ORM模型基类 - logs:日志目录 - static:静态资源存放目录 -- tests:测试接口文件目录 - utils:封装的一些工具类目录 - main.py:主程序入口文件 - alembic.ini:数据库迁移配置文件 @@ -43,7 +68,9 @@ Typer 官方文档:https://typer.tiangolo.com/ 开发语言:Python 3.10 -开发框架:Fastapi 0.95.0 +开发框架:Fastapi 0.101.1 + +ORM 框架:SQLAlchemy 2.0.20 ## 开发工具 @@ -66,14 +93,28 @@ pip3 install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ # 第三方源: 1. 阿里源: https://mirrors.aliyun.com/pypi/simple/ + +# 线上安装更新依赖库 +/opt/env/kinit-pro-310/bin/pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ ``` ### 数据初始化 ```shell -# 项目根目录下执行,需提前创建好数据库 +# 项目根目录下执行,需提前创建好数据库,并且数据库应该为空 # 会自动将模型迁移到数据库,并生成初始化数据 + +# 在执行前一定要确认要操作的环境与application/settings.DEBUG 设置的环境是一致的, +# 不然会导致创建表和生成数据不在一个数据库中!!!!!!!!!!!!!!!!!!!!!! + +# 比如要初始化开发环境,那么env参数应该为 dev,并且 application/settings.DEBUG 应该 = True +# 比如要初始化生产环境,那么env参数应该为 pro,并且 application/settings.DEBUG 应该 = False + +# 生产环境 python main.py init + +# 开发环境 +python main.py init --env dev ``` ### 运行启动 @@ -92,7 +133,6 @@ http://127.0.0.1:9000/docs ``` Git更新ignore文件直接修改gitignore是不会生效的,需要先去掉已经托管的文件,修改完成之后再重新添加并提交。 - ``` 第一步: git rm -r --cached . @@ -112,15 +152,19 @@ git commit -m "clear cached" # 执行命令(生产环境): python main.py migrate -# 执行命令(测试环境): +# 执行命令(开发环境): python main.py migrate --env dev + +# 开发环境的原命令 +alembic --name dev revision --autogenerate -m 2.0 +alembic --name dev upgrade head ``` 生成迁移文件后,会在alembic迁移目录中的version目录中多个迁移文件 ## 查询数据 -### 查询过滤 +### 自定义的一些查询过滤 ```python # 日期查询 @@ -160,7 +204,49 @@ python main.py migrate --env dev 代码部分: -![image-20230514113859232](D:\programming\ktianc\project\kinit-pro\images\image-20230514113859232.png) +```python +def __dict_filter(self, **kwargs) -> list[BinaryExpression]: + """ + 字典过滤 + :param model: + :param kwargs: + """ + conditions = [] + for field, value in kwargs.items(): + if value is not None and value != "": + attr = getattr(self.model, field) + if isinstance(value, tuple): + if len(value) == 1: + if value[0] == "None": + conditions.append(attr.is_(None)) + elif value[0] == "not None": + conditions.append(attr.isnot(None)) + else: + raise CustomException("SQL查询语法错误") + elif len(value) == 2 and value[1] not in [None, [], ""]: + if value[0] == "date": + # 根据日期查询, 关键函数是:func.time_format和func.date_format + conditions.append(func.date_format(attr, "%Y-%m-%d") == value[1]) + elif value[0] == "like": + conditions.append(attr.like(f"%{value[1]}%")) + elif value[0] == "in": + conditions.append(attr.in_(value[1])) + elif value[0] == "between" and len(value[1]) == 2: + conditions.append(attr.between(value[1][0], value[1][1])) + elif value[0] == "month": + conditions.append(func.date_format(attr, "%Y-%m") == value[1]) + elif value[0] == "!=": + conditions.append(attr != value[1]) + elif value[0] == ">": + conditions.append(attr > value[1]) + elif value[0] == "<=": + conditions.append(attr <= value[1]) + else: + raise CustomException("SQL查询语法错误") + else: + conditions.append(attr == value) + return conditions +``` 示例: @@ -168,120 +254,51 @@ python main.py migrate --env dev ```python users = UserDal(db).get_datas(limit=0, id=("in", [1,2,4,6]), email=("not None"), name=("like", "李")) + +# limit=0:表示返回所有结果数据 +# 这里的 get_datas 默认返回的是 pydantic 模型数据 +# 如果需要返回用户对象列表,使用如下语句: +users = UserDal(db).get_datas( + limit=0, + id=("in", [1,2,4,6]), + email=("not None"), + name=("like", "李"), + v_return_objs=True +) ``` -### v_join_query +查询所有用户id为1或2或 4或6,并且email不为空,并且名称包括李: -外键字段查询,内连接 - -以常见问题类别表为例: - -首先需要在 `crud.py/IssueCategoryDal` 的 `__init__` 方法中定义 `key_models`: +查询第一页,每页两条数据,并返回总数,同样可以通过 `get_datas` 实现原始查询方式: ```python -class IssueCategoryDal(DalBase): +v_where = [VadminUser.id.in_([1,2,4,6]), VadminUser.email.isnot(None), VadminUser.name.like(f"%李%")] +users, count = UserDal(db).get_datas(limit=2, v_where=v_where, v_return_count=True) - def __init__(self, db: AsyncSession): - key_models = { - # 外键字段名,也可以自定义 - "create_user": { - # 外键对应的orm模型 - "model": vadminAuthModels.VadminUser, - # 如果对同一个模型只有一个外键关联时,下面这个 onclause 可以省略不写,一个以上时必须写,需要分清楚要查询的是哪个 - # 这里其实可以省略不写,但是为了演示这里写出来了 - "onclause": models.VadminIssueCategory.create_user_id == vadminAuthModels.VadminUser.id - } - } - super(IssueCategoryDal, self).__init__( - db, - models.VadminIssueCategory, - schemas.IssueCategorySimpleOut, - key_models - ) +# 这里的 get_datas 默认返回的是 pydantic 模型数据 +# 如果需要返回用户对象列表,使用如下语句: +users, count = UserDal(db).get_datas( + limit=2, + v_where=v_where, + v_return_count=True + v_return_objs=True +) ``` -使用案例: +### 外键查询示例 + +以常见问题表为主表,查询出创建用户名称为kinit的用户,创建了哪些常见问题,并加载出用户信息: ```python -async def test(self): - """ - v_join_query 示例方法 - 获取用户名称包含李 创建出的常见问题类别 - """ - v_join_query = { - # 与 key_models 中定义的外键字段名定义的一样 - "create_user": { - # 外键表字段名:查询值 - "name": ("like", "李") - } - } - v_options = [joinedload(self.model.create_user)] - datas = self.get_datas(limit=0, v_join_query=v_join_query, v_options=v_options) -``` - -完整案例: - -```python -class IssueCategoryDal(DalBase): - - def __init__(self, db: AsyncSession): - key_models = { - # 外键字段名,也可以自定义 - "create_user": { - # 外键对应的orm模型 - "model": vadminAuthModels.VadminUser, - # 如果对同一个模型只有一个外键关联时,下面这个 onclause 可以省略不写,一个以上时必须写,需要分清楚要查询的是哪个 - # 这里其实可以省略不写,但是为了演示这里写出来了 - "onclause": models.VadminIssueCategory.create_user_id == vadminAuthModels.VadminUser.id - } - } - super(IssueCategoryDal, self).__init__( - db, - models.VadminIssueCategory, - schemas.IssueCategorySimpleOut, - key_models - ) - - async def test(self): - """ - v_join_query 示例方法 - 获取用户名称包含李 创建出的常见问题类别 - """ - v_join_query = { - # 与 key_models 中定义的外键字段名定义的一样 - "create_user": { - # 外键表字段名:查询值 - "name": ("like", "李") - } - } - v_options = [joinedload(self.model.create_user)] - datas = self.get_datas(limit=0, v_join_query=v_join_query, v_options=v_options) -``` - -### v_or - -或逻辑运算查询 - -语法: - -```python -# 普通查询 -v_or = [(字段名称, 值), (字段名称, 值), ... ] - -# 模糊查询 -v_or = [(字段名称, ("like", 值)), (字段名称, ("like", 值)), ... ] - -# 组合查询 -v_or = [(字段名称, ("like", 值)), (字段名称, ("in", [值, 值, 值, ...])), ... ] - -# 外键查询,需要先定义 key_models -v_or = [("fk", key_models 中定义的外键字段名, 外键表字段名称, ("like", 值)), ("fk", key_models 中定义的外键字段名, 外键表字段名称, ("like", 值)), ... ] -``` - -比如查询一个用户手机号为`13409090909`或者`15390909090`: - -```python -v_or = [("telephone", "13409090909"), ("telephone", "15390909090") ] -user = UserDal(db).get_data(v_or=v_or) +v_options = [joinedload(VadminIssue.create_user)] +v_join = [["create_user"]] +v_where = [VadminUser.name == "kinit"] +datas = await crud.IssueCategoryDal(auth.db).get_datas( + limit=0, + v_options=options, + v_join=v_join, + v_where=v_where, + v_return_objs=True +) ``` diff --git a/kinit-api/application/settings.py b/kinit-api/application/settings.py index 667a5ad..a97eab8 100644 --- a/kinit-api/application/settings.py +++ b/kinit-api/application/settings.py @@ -11,7 +11,7 @@ from fastapi.security import OAuth2PasswordBearer """ 系统版本 """ -VERSION = "1.10.5" +VERSION = "2.0.0" """安全警告: 不要在生产中打开调试运行!""" DEBUG = True @@ -44,8 +44,14 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 如果是与认证关联性比较强的接口,则无法使用 """ OAUTH_ENABLE = True -"""登录认证视图""" -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/api/login", auto_error=True) if OAUTH_ENABLE else lambda: "" +""" +配置 OAuth2 密码流认证方式 +官方文档:https://fastapi.tiangolo.com/zh/tutorial/security/first-steps/#fastapi-oauth2passwordbearer +auto_error:(bool) 可选参数,默认为 True。当验证失败时,如果设置为 True,FastAPI 将自动返回一个 401 未授权的响应,如果设置为 False,你需要自己处理身份验证失败的情况。 +这里的 auto_error 设置为 False 是因为存在 OpenAuth:开放认证,无认证也可以访问, +如果设置为 True,那么 FastAPI 会自动报错,即无认证时 OpenAuth 会失效,所以不能使用 True。 +""" +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/api/login", auto_error=False) if OAUTH_ENABLE else lambda: "" """安全的随机密钥,该密钥将用于对 JWT 令牌进行签名""" SECRET_KEY = 'vgb0tnl9d58+6n-6h-ea&u^1#s0ccp!794=kbvqacjq75vzps$' """用于设定 JWT 令牌签名算法""" diff --git a/kinit-api/apps/vadmin/auth/crud.py b/kinit-api/apps/vadmin/auth/crud.py index 75ac002..4fb5578 100644 --- a/kinit-api/apps/vadmin/auth/crud.py +++ b/kinit-api/apps/vadmin/auth/crud.py @@ -10,6 +10,7 @@ from typing import Any from aioredis import Redis from fastapi import UploadFile from sqlalchemy.orm import joinedload +from sqlalchemy.orm.strategy_options import _AbstractLoad from core.exception import CustomException from fastapi.encoders import jsonable_encoder from sqlalchemy import select @@ -26,10 +27,11 @@ from utils.tools import test_password from . import models, schemas from application import settings from utils.excel.excel_manage import ExcelManage -from apps.vadmin.system import crud as vadminSystemCRUD +from apps.vadmin.system import crud as vadmin_system_crud import copy from utils import status from utils.wx.oauth import WXOAuth +from datetime import datetime class UserDal(DalBase): @@ -45,13 +47,24 @@ class UserDal(DalBase): def __init__(self, db: AsyncSession): super(UserDal, self).__init__(db, models.VadminUser, schemas.UserSimpleOut) + async def update_login_info(self, user: models.VadminUser, last_ip: str) -> None: + """ + 更新当前登录信息 + :param user: 用户对象 + :param last_ip: 最近一次登录 IP + :return: + """ + user.last_ip = last_ip + user.last_login = datetime.now() + await self.db.flush() + async def create_data( self, data: schemas.UserIn, - v_options: list = None, + v_options: list[_AbstractLoad] = None, v_return_obj: bool = False, v_schema: Any = None - ): + ) -> Any: """ 创建用户 """ @@ -65,7 +78,7 @@ class UserDal(DalBase): if data.role_ids: roles = await RoleDal(self.db).get_datas(limit=0, id=("in", data.role_ids), v_return_objs=True) for role in roles: - obj.roles.append(role) + obj.roles.add(role) await self.flush(obj) return await self.out_dict(obj, v_options, v_return_obj, v_schema) @@ -73,10 +86,10 @@ class UserDal(DalBase): self, data_id: int, data: schemas.UserUpdate, - v_options: list = None, + v_options: list[_AbstractLoad] = None, v_return_obj: bool = False, v_schema: Any = None - ): + ) -> Any: """ 更新用户信息 """ @@ -84,18 +97,18 @@ class UserDal(DalBase): data_dict = jsonable_encoder(data) for key, value in data_dict.items(): if key == "role_ids": - if obj.roles: - obj.roles.clear() if value: roles = await RoleDal(self.db).get_datas(limit=0, id=("in", value), v_return_objs=True) + if obj.roles: + obj.roles.clear() for role in roles: - obj.roles.append(role) + obj.roles.add(role) continue setattr(obj, key, value) await self.flush(obj) return await self.out_dict(obj, None, v_return_obj, v_schema) - async def reset_current_password(self, user: models.VadminUser, data: schemas.ResetPwd): + async def reset_current_password(self, user: models.VadminUser, data: schemas.ResetPwd) -> None: """ 重置密码 """ @@ -107,9 +120,8 @@ class UserDal(DalBase): user.password = self.model.get_password_hash(data.password) user.is_reset_password = True await self.flush(user) - return True - async def update_current_info(self, user: models.VadminUser, data: schemas.UserUpdateBaseInfo): + async def update_current_info(self, user: models.VadminUser, data: schemas.UserUpdateBaseInfo) -> Any: """ 更新当前用户基本信息 """ @@ -125,7 +137,7 @@ class UserDal(DalBase): await self.flush(user) return await self.out_dict(user) - async def export_query_list(self, header: list, params: UserParams): + async def export_query_list(self, header: list, params: UserParams) -> dict: """ 导出用户查询列表为excel """ @@ -133,7 +145,7 @@ class UserDal(DalBase): # 获取表头 row = list(map(lambda i: i.get("label"), header)) rows = [] - options = await vadminSystemCRUD.DictTypeDal(self.db).get_dicts_details(["sys_vadmin_gender"]) + options = await vadmin_system_crud.DictTypeDal(self.db).get_dicts_details(["sys_vadmin_gender"]) for user in datas: data = [] for item in header: @@ -142,6 +154,8 @@ class UserDal(DalBase): value = getattr(user, field, "") if field == "is_active": value = "可用" if value else "停用" + elif field == "is_staff": + value = "是" if value else "否" elif field == "gender": result = list(filter(lambda i: i["value"] == value, options["sys_vadmin_gender"])) value = result[0]["label"] if result else "" @@ -154,7 +168,7 @@ class UserDal(DalBase): em.close() return {"url": file_url, "filename": "用户列表.xlsx"} - async def get_import_headers_options(self): + async def get_import_headers_options(self) -> None: """ 补全表头数据选项 """ @@ -165,13 +179,13 @@ class UserDal(DalBase): role_options["options"] = [{"label": role.name, "value": role.id} for role in roles] # 性别选择项 - dict_types = await vadminSystemCRUD.DictTypeDal(self.db).get_dicts_details(["sys_vadmin_gender"]) + dict_types = await vadmin_system_crud.DictTypeDal(self.db).get_dicts_details(["sys_vadmin_gender"]) gender_options = self.import_headers[3] assert isinstance(gender_options, dict) sys_vadmin_gender = dict_types.get("sys_vadmin_gender") gender_options["options"] = [{"label": item["label"], "value": item["value"]} for item in sys_vadmin_gender] - async def download_import_template(self): + async def download_import_template(self) -> dict: """ 下载用户最新版导入模板 """ @@ -181,7 +195,7 @@ class UserDal(DalBase): em.close() return {"url": em.file_url, "filename": "用户导入模板.xlsx"} - async def import_users(self, file: UploadFile): + async def import_users(self, file: UploadFile) -> dict: """ 批量导入用户数据 """ @@ -206,7 +220,7 @@ class UserDal(DalBase): "error_url": im.generate_error_url() } - async def init_password(self, ids: list[int]): + async def init_password(self, ids: list[int]) -> list: """ 初始化所选用户密码 将用户密码改为系统默认密码,并将初始化密码状态改为false @@ -226,7 +240,7 @@ class UserDal(DalBase): await self.db.flush() return result - async def init_password_send_sms(self, ids: list[int], rd: Redis): + async def init_password_send_sms(self, ids: list[int], rd: Redis) -> list: """ 初始化所选用户密码并发送通知短信 将用户密码改为系统默认密码,并将初始化密码状态改为false @@ -242,13 +256,13 @@ class UserDal(DalBase): try: send_result = (await sms.main_async(password=password))[0] user["send_sms_status"] = send_result - user["send_sms_msg"] = "" if send_result else "发送失败,请联系管理员" + user["send_sms_msg"] = "" if send_result else "短信发送失败,请联系管理员" except CustomException as e: user["send_sms_status"] = False user["send_sms_msg"] = e.msg return result - async def init_password_send_email(self, ids: list[int], rd: Redis): + async def init_password_send_email(self, ids: list[int], rd: Redis) -> list: """ 初始化所选用户密码并发送通知邮件 将用户密码改为系统默认密码,并将初始化密码状态改为false @@ -268,7 +282,7 @@ class UserDal(DalBase): try: send_result = await es.send_email([email], subject, body) user["send_sms_status"] = send_result - user["send_sms_msg"] = "" if send_result else "发送失败,请联系管理员" + user["send_sms_msg"] = "" if send_result else "短信发送失败,请联系管理员" except CustomException as e: user["send_sms_status"] = False user["send_sms_msg"] = e.msg @@ -277,7 +291,7 @@ class UserDal(DalBase): user["send_sms_msg"] = "未获取到邮箱地址" return result - async def update_current_avatar(self, user: models.VadminUser, file: UploadFile): + async def update_current_avatar(self, user: models.VadminUser, file: UploadFile) -> str: """ 更新当前用户头像 """ @@ -286,7 +300,7 @@ class UserDal(DalBase): await self.flush(user) return result - async def update_wx_server_openid(self, code: str, user: models.VadminUser, redis: Redis): + async def update_wx_server_openid(self, code: str, user: models.VadminUser, redis: Redis) -> bool: """ 更新用户服务端微信平台openid """ @@ -298,8 +312,8 @@ class UserDal(DalBase): user.wx_server_openid = openid await self.flush(user) return True - - async def delete_datas(self, ids: list[int], v_soft: bool = False, **kwargs): + + async def delete_datas(self, ids: list[int], v_soft: bool = False, **kwargs) -> None: """ 删除多个用户,软删除 删除后清空所关联的角色 @@ -323,16 +337,16 @@ class RoleDal(DalBase): async def create_data( self, data: schemas.RoleIn, - v_options: list = None, + v_options: list[_AbstractLoad] = None, v_return_obj: bool = False, v_schema: Any = None - ): + ) -> Any: """创建数据""" obj = self.model(**data.model_dump(exclude={'menu_ids'})) - menus = await MenuDal(db=self.db).get_datas(limit=0, id=("in", data.menu_ids), v_return_objs=True) if data.menu_ids: + menus = await MenuDal(db=self.db).get_datas(limit=0, id=("in", data.menu_ids), v_return_objs=True) for menu in menus: - obj.menus.append(menu) + obj.menus.add(menu) await self.flush(obj) return await self.out_dict(obj, v_options, v_return_obj, v_schema) @@ -340,37 +354,37 @@ class RoleDal(DalBase): self, data_id: int, data: schemas.RoleIn, - v_options: list = None, + v_options: list[_AbstractLoad] = None, v_return_obj: bool = False, v_schema: Any = None - ): + ) -> Any: """更新单个数据""" obj = await self.get_data(data_id, v_options=[joinedload(self.model.menus)]) obj_dict = jsonable_encoder(data) for key, value in obj_dict.items(): if key == "menu_ids": - if obj.menus: - obj.menus.clear() if value: menus = await MenuDal(db=self.db).get_datas(limit=0, id=("in", value), v_return_objs=True) + if obj.menus: + obj.menus.clear() for menu in menus: - obj.menus.append(menu) + obj.menus.add(menu) continue setattr(obj, key, value) await self.flush(obj) return await self.out_dict(obj, None, v_return_obj, v_schema) - async def get_role_menu_tree(self, role_id: int): + async def get_role_menu_tree(self, role_id: int) -> list: role = await self.get_data(role_id, v_options=[joinedload(self.model.menus)]) return [i.id for i in role.menus] - async def get_select_datas(self): + async def get_select_datas(self) -> list: """获取选择数据,全部数据""" sql = select(self.model) - queryset = await self.db.execute(sql) - return [schemas.RoleSelectOut.model_validate(i).model_dump() for i in queryset.scalars().all()] + queryset = await self.db.scalars(sql) + return [schemas.RoleSelectOut.model_validate(i).model_dump() for i in queryset.all()] - async def delete_datas(self, ids: list[int], v_soft: bool = False, **kwargs): + async def delete_datas(self, ids: list[int], v_soft: bool = False, **kwargs) -> None: """ 删除多个角色,硬删除 如果存在用户关联则无法删除 @@ -378,8 +392,8 @@ class RoleDal(DalBase): :param v_soft: 是否执行软删除 :param kwargs: 其他更新字段 """ - objs = await self.get_datas(limit=0, id=("in", ids), user_total_number=(">", 0), v_return_objs=True) - if objs: + user_count = await UserDal(self.db).get_count(v_join=[["roles"]], v_where=[models.VadminRole.id.in_(ids)]) + if user_count > 0: raise CustomException("无法删除存在用户关联的角色", code=400) return await super(RoleDal, self).delete_datas(ids, v_soft, **kwargs) @@ -389,7 +403,7 @@ class MenuDal(DalBase): def __init__(self, db: AsyncSession): super(MenuDal, self).__init__(db, models.VadminMenu, schemas.MenuSimpleOut) - async def get_tree_list(self, mode: int): + async def get_tree_list(self, mode: int) -> list: """ 1:获取菜单树列表 2:获取菜单树选择项,添加/修改菜单时使用 @@ -399,8 +413,8 @@ class MenuDal(DalBase): sql = select(self.model).where(self.model.disabled == 0, self.model.is_delete == False) else: sql = select(self.model).where(self.model.is_delete == False) - queryset = await self.db.execute(sql) - datas = queryset.scalars().all() + queryset = await self.db.scalars(sql) + datas = list(queryset.all()) roots = filter(lambda i: not i.parent_id, datas) if mode == 1: menus = self.generate_tree_list(datas, roots) @@ -410,7 +424,7 @@ class MenuDal(DalBase): raise CustomException("获取菜单失败,无可用选项", code=400) return self.menus_order(menus) - async def get_routers(self, user: models.VadminUser): + async def get_routers(self, user: models.VadminUser) -> list: """ 获取路由表 declare interface AppCustomRouteRecordRaw extends Omit { @@ -423,12 +437,12 @@ class MenuDal(DalBase): } """ if any([i.is_admin for i in user.roles]): - sql = select(self.model)\ + sql = select(self.model) \ .where(self.model.disabled == 0, self.model.menu_type != "2", self.model.is_delete == False) - queryset = await self.db.execute(sql) - datas = queryset.scalars().all() + queryset = await self.db.scalars(sql) + datas = list(queryset.all()) else: - options = [joinedload(models.VadminUser.roles), joinedload("roles.menus")] + options = [joinedload(models.VadminUser.roles).subqueryload(models.VadminRole.menus)] user = await UserDal(self.db).get_data(user.id, v_options=options) datas = set() for role in user.roles: @@ -492,7 +506,7 @@ class MenuDal(DalBase): return data @classmethod - def menus_order(cls, datas: list, order: str = "order", children: str = "children"): + def menus_order(cls, datas: list, order: str = "order", children: str = "children") -> list: """ 菜单排序 """ @@ -502,7 +516,7 @@ class MenuDal(DalBase): item[children] = sorted(item[children], key=lambda menu: menu[order]) return result - async def delete_datas(self, ids: list[int], v_soft: bool = False, **kwargs): + async def delete_datas(self, ids: list[int], v_soft: bool = False, **kwargs) -> None: """ 删除多个菜单 如果存在角色关联则无法删除 @@ -510,10 +524,30 @@ class MenuDal(DalBase): :param v_soft: 是否执行软删除 :param kwargs: 其他更新字段 """ - options = [joinedload(self.model.roles)] - objs = await self.get_datas(limit=0, id=("in", ids), v_return_objs=True, v_options=options) - for obj in objs: - if obj.roles: - raise CustomException("无法删除存在角色关联的菜单", code=400) - return await super(MenuDal, self).delete_datas(ids, v_soft, **kwargs) + count = await RoleDal(self.db).get_count(v_join=[["menus"]], v_where=[self.model.id.in_(ids)]) + if count > 0: + raise CustomException("无法删除存在角色关联的菜单", code=400) + await super(MenuDal, self).delete_datas(ids, v_soft, **kwargs) + +class TestDal(DalBase): + + def __init__(self, db: AsyncSession): + super(TestDal, self).__init__(db, models.VadminUser, schemas.UserSimpleOut) + + async def test(self): + # print("-----------------------开始------------------------") + options = [joinedload(self.model.roles)] + v_where = [self.model.id == 1, models.VadminRole.id == 1] + v_join = [[self.model.roles]] + v_start_sql = select(self.model) + result, count = await self.get_datas( + v_start_sql=v_start_sql, + v_join=v_join, + v_options=options, + v_where=v_where + ) + if result: + print(result) + print(count) + # print("-----------------------结束------------------------") diff --git a/kinit-api/apps/vadmin/auth/models/__init__.py b/kinit-api/apps/vadmin/auth/models/__init__.py index 425867d..b520efe 100644 --- a/kinit-api/apps/vadmin/auth/models/__init__.py +++ b/kinit-api/apps/vadmin/auth/models/__init__.py @@ -7,7 +7,7 @@ # @desc : 简要说明 -from .m2m import vadmin_user_roles, vadmin_role_menus +from .m2m import vadmin_auth_user_roles, vadmin_auth_role_menus from .menu import VadminMenu from .role import VadminRole from .user import VadminUser diff --git a/kinit-api/apps/vadmin/auth/models/m2m.py b/kinit-api/apps/vadmin/auth/models/m2m.py index 7da5e4a..ee70e90 100644 --- a/kinit-api/apps/vadmin/auth/models/m2m.py +++ b/kinit-api/apps/vadmin/auth/models/m2m.py @@ -6,27 +6,22 @@ # @IDE : PyCharm # @desc : 关联中间表 -""" -Table 操作博客:http://www.ttlsa.com/python/sqlalchemy-concise-guide/ -""" - -from db.db_base import Model -from sqlalchemy import Column, Table, Integer, ForeignKey, INT +from db.db_base import Base +from sqlalchemy import ForeignKey, Column, Table, Integer -vadmin_user_roles = Table( - 'vadmin_auth_user_roles', - Model.metadata, - Column("id", INT, primary_key=True, unique=True, comment='主键ID', index=True, autoincrement=True), - Column('user_id', Integer, ForeignKey('vadmin_auth_user.id', ondelete='CASCADE'), primary_key=True), - Column('role_id', Integer, ForeignKey('vadmin_auth_role.id', ondelete='CASCADE'), primary_key=True), +vadmin_auth_user_roles = Table( + "vadmin_auth_user_roles", + Base.metadata, + Column("user_id", Integer, ForeignKey("vadmin_auth_user.id", ondelete="CASCADE")), + Column("role_id", Integer, ForeignKey("vadmin_auth_role.id", ondelete="CASCADE")), ) -vadmin_role_menus = Table( - 'vadmin_auth_role_menus', - Model.metadata, - Column("id", INT, primary_key=True, unique=True, comment='主键ID', index=True, autoincrement=True), - Column('role_id', Integer, ForeignKey('vadmin_auth_role.id', ondelete='CASCADE'), primary_key=True), - Column('menu_id', Integer, ForeignKey('vadmin_auth_menu.id', ondelete='CASCADE'), primary_key=True), +vadmin_auth_role_menus = Table( + "vadmin_auth_role_menus", + Base.metadata, + Column("role_id", Integer, ForeignKey("vadmin_auth_role.id", ondelete="CASCADE")), + Column("menu_id", Integer, ForeignKey("vadmin_auth_menu.id", ondelete="CASCADE")), ) + diff --git a/kinit-api/apps/vadmin/auth/models/menu.py b/kinit-api/apps/vadmin/auth/models/menu.py index a85dec8..74fc228 100644 --- a/kinit-api/apps/vadmin/auth/models/menu.py +++ b/kinit-api/apps/vadmin/auth/models/menu.py @@ -7,39 +7,59 @@ # @desc : 菜单模型 -from sqlalchemy.orm import relationship -from .m2m import vadmin_role_menus from db.db_base import BaseModel -from sqlalchemy import Column, String, Boolean, Integer, ForeignKey +from sqlalchemy import String, Boolean, Integer, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column class VadminMenu(BaseModel): __tablename__ = "vadmin_auth_menu" __table_args__ = ({'comment': '菜单表'}) - # class MenuTypes(Enum): - # dir = "0" - # menu = "1" - # button = "2" - - title = Column(String(50), index=True, nullable=False, comment="名称") - icon = Column(String(50), comment="菜单图标") - redirect = Column(String(100), comment="重定向地址") - component = Column(String(50), comment="前端组件地址") - path = Column(String(50), comment="前端路由地址") - disabled = Column(Boolean, default=False, comment="是否禁用") - hidden = Column(Boolean, default=False, comment="是否隐藏") - order = Column(Integer, comment="排序") - menu_type = Column(String(8), comment="菜单类型") - parent_id = Column(ForeignKey("vadmin_auth_menu.id", ondelete='CASCADE'), comment="父菜单") - perms = Column(String(50), comment="权限标识", unique=False, nullable=True, index=True) - noCache = Column(Boolean, comment="如果设置为true,则不会被 缓存(默认 false)", default=False) - breadcrumb = Column(Boolean, comment="如果设置为false,则不会在breadcrumb面包屑中显示(默认 true)", default=True) - affix = Column(Boolean, comment="如果设置为true,则会一直固定在tag项中(默认 false)", default=False) - noTagsView = Column(Boolean, comment="如果设置为true,则不会出现在tag中(默认 false)", default=False) - canTo = Column(Boolean, comment="设置为true即使hidden为true,也依然可以进行路由跳转(默认 false)", default=False) - alwaysShow = Column(Boolean, comment="""当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式, + title: Mapped[str] = mapped_column(String(50), comment="名称") + icon: Mapped[str | None] = mapped_column(String(50), comment="菜单图标") + redirect: Mapped[str | None] = mapped_column(String(100), comment="重定向地址") + component: Mapped[str | None] = mapped_column(String(50), comment="前端组件地址") + path: Mapped[str | None] = mapped_column(String(50), comment="前端路由地址") + disabled: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否禁用") + hidden: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否隐藏") + order: Mapped[int] = mapped_column(Integer, comment="排序") + menu_type: Mapped[str] = mapped_column(String(8), comment="菜单类型") + parent_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("vadmin_auth_menu.id", ondelete='CASCADE'), + comment="父菜单" + ) + perms: Mapped[str | None] = mapped_column(String(50), comment="权限标识", unique=False, index=True) + noCache: Mapped[bool] = mapped_column( + Boolean, + comment="如果设置为true,则不会被 缓存(默认 false)", + default=False + ) + breadcrumb: Mapped[bool] = mapped_column( + Boolean, + comment="如果设置为false,则不会在breadcrumb面包屑中显示(默认 true)", + default=True + ) + affix: Mapped[bool] = mapped_column( + Boolean, + comment="如果设置为true,则会一直固定在tag项中(默认 false)", + default=False + ) + noTagsView: Mapped[bool] = mapped_column( + Boolean, + comment="如果设置为true,则不会出现在tag中(默认 false)", + default=False + ) + canTo: Mapped[bool] = mapped_column( + Boolean, + comment="设置为true即使hidden为true,也依然可以进行路由跳转(默认 false)", + default=False + ) + alwaysShow: Mapped[bool] = mapped_column( + Boolean, + comment="""当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式, 只有一个时,会将那个子路由当做根路由显示在侧边栏,若你想不管路由下面的 children 声明的个数都显示你的根路由, - 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由(默认 true)""", default=True) - - roles = relationship("VadminRole", back_populates='menus', secondary=vadmin_role_menus) + 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由(默认 true)""", + default=True + ) diff --git a/kinit-api/apps/vadmin/auth/models/role.py b/kinit-api/apps/vadmin/auth/models/role.py index a957117..e5ac2c7 100644 --- a/kinit-api/apps/vadmin/auth/models/role.py +++ b/kinit-api/apps/vadmin/auth/models/role.py @@ -6,28 +6,22 @@ # @IDE : PyCharm # @desc : 角色模型 -from sqlalchemy.orm import relationship -from sqlalchemy_utils import aggregated -from .user import VadminUser +from sqlalchemy.orm import relationship, Mapped, mapped_column from db.db_base import BaseModel -from sqlalchemy import Column, String, Boolean, Integer, func -from .m2m import vadmin_user_roles, vadmin_role_menus +from sqlalchemy import String, Boolean, Integer +from .menu import VadminMenu +from .m2m import vadmin_auth_role_menus class VadminRole(BaseModel): __tablename__ = "vadmin_auth_role" __table_args__ = ({'comment': '角色表'}) - name = Column(String(50), index=True, nullable=False, comment="名称") - role_key = Column(String(50), index=True, nullable=False, comment="权限字符") - disabled = Column(Boolean, default=False, comment="是否禁用") - order = Column(Integer, comment="排序") - desc = Column(String(255), comment="描述") - is_admin = Column(Boolean, comment="是否为超级角色", default=False) + name: Mapped[str] = mapped_column(String(50), index=True, nullable=False, comment="名称") + role_key: Mapped[str] = mapped_column(String(50), index=True, nullable=False, comment="权限字符") + disabled: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否禁用") + order: Mapped[int | None] = mapped_column(Integer, comment="排序") + desc: Mapped[str | None] = mapped_column(String(255), comment="描述") + is_admin: Mapped[bool] = mapped_column(Boolean, comment="是否为超级角色", default=False) - users = relationship("VadminUser", back_populates='roles', secondary=vadmin_user_roles) - menus = relationship("VadminMenu", back_populates='roles', secondary=vadmin_role_menus) - - @aggregated('users', Column(Integer, default=0, comment="用户总数")) - def user_total_number(self): - return func.count(VadminUser.id) + menus: Mapped[set[VadminMenu]] = relationship(secondary=vadmin_auth_role_menus) diff --git a/kinit-api/apps/vadmin/auth/models/user.py b/kinit-api/apps/vadmin/auth/models/user.py index 43d7876..cb85ff0 100644 --- a/kinit-api/apps/vadmin/auth/models/user.py +++ b/kinit-api/apps/vadmin/auth/models/user.py @@ -6,13 +6,13 @@ # @IDE : PyCharm # @desc : 用户模型 -import datetime -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import relationship +from datetime import datetime +from sqlalchemy.orm import relationship, Mapped, mapped_column from db.db_base import BaseModel -from sqlalchemy import Column, String, Boolean, DateTime +from sqlalchemy import String, Boolean, DateTime from passlib.context import CryptContext -from .m2m import vadmin_user_roles +from .role import VadminRole +from .m2m import vadmin_auth_user_roles pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') @@ -21,22 +21,26 @@ class VadminUser(BaseModel): __tablename__ = "vadmin_auth_user" __table_args__ = ({'comment': '用户表'}) - avatar = Column(String(500), nullable=True, comment='头像') - telephone = Column(String(11), nullable=False, index=True, comment="手机号", unique=False) - email = Column(String(50), nullable=True, comment="邮箱地址") - name = Column(String(50), index=True, nullable=False, comment="姓名") - nickname = Column(String(50), nullable=True, comment="昵称") - password = Column(String(255), nullable=True, comment="密码") - gender = Column(String(8), nullable=True, comment="性别") - is_active = Column(Boolean, default=True, comment="是否可用") - is_reset_password = Column(Boolean, default=False, comment="是否已经重置密码,没有重置的,登陆系统后必须重置密码") - last_ip = Column(String(50), nullable=True, comment="最后一次登录IP") - last_login = Column(DateTime, nullable=True, comment="最近一次登录时间") - is_staff = Column(Boolean, default=False, comment="是否为工作人员") - wx_server_openid = Column(String(255), comment="服务端微信平台openid") - is_wx_server_openid = Column(Boolean, default=False, comment="是否已有服务端微信平台openid") + avatar: Mapped[str | None] = mapped_column(String(500), comment='头像') + telephone: Mapped[str] = mapped_column(String(11), nullable=False, index=True, comment="手机号", unique=False) + email: Mapped[str | None] = mapped_column(String(50), comment="邮箱地址") + name: Mapped[str] = mapped_column(String(50), index=True, nullable=False, comment="姓名") + nickname: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="昵称") + password: Mapped[str] = mapped_column(String(255), nullable=True, comment="密码") + gender: Mapped[str | None] = mapped_column(String(8), nullable=True, comment="性别") + is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否可用") + is_reset_password: Mapped[bool] = mapped_column( + Boolean, + default=False, + comment="是否已经重置密码,没有重置的,登陆系统后必须重置密码" + ) + last_ip: Mapped[str | None] = mapped_column(String(50), comment="最后一次登录IP") + last_login: Mapped[datetime | None] = mapped_column(DateTime, comment="最近一次登录时间") + is_staff: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否为工作人员") + wx_server_openid: Mapped[str | None] = mapped_column(String(255), comment="服务端微信平台openid") + is_wx_server_openid: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否已有服务端微信平台openid") - roles = relationship("VadminRole", back_populates='users', secondary=vadmin_user_roles) + roles: Mapped[set[VadminRole]] = relationship(secondary=vadmin_auth_user_roles) # generate hash password @staticmethod @@ -48,17 +52,6 @@ class VadminUser(BaseModel): def verify_password(password: str, hashed_password: str) -> bool: return pwd_context.verify(password, hashed_password) - async def update_login_info(self, db: AsyncSession, last_ip: str): - """ - 更新当前登录信息 - :param db: 数据库 - :param last_ip: 最近一次登录 IP - :return: - """ - self.last_ip = last_ip - self.last_login = datetime.datetime.now() - await db.flush() - def is_admin(self) -> bool: """ 获取该用户是否拥有最高权限 diff --git a/kinit-api/apps/vadmin/auth/utils/current.py b/kinit-api/apps/vadmin/auth/utils/current.py index 4f65f93..802e06a 100644 --- a/kinit-api/apps/vadmin/auth/utils/current.py +++ b/kinit-api/apps/vadmin/auth/utils/current.py @@ -5,10 +5,11 @@ # @IDE : PyCharm # @desc : 获取认证后的信息工具 +from typing import Annotated from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from apps.vadmin.auth.crud import UserDal -from apps.vadmin.auth.models import VadminUser +from apps.vadmin.auth.models import VadminUser, VadminRole from core.exception import CustomException from utils import status from .validation import AuthValidation @@ -28,7 +29,7 @@ class OpenAuth(AuthValidation): async def __call__( self, request: Request, - token: str = Depends(settings.oauth2_scheme), + token: Annotated[str, Depends(settings.oauth2_scheme)], db: AsyncSession = Depends(db_getter) ): """ @@ -93,7 +94,7 @@ class FullAdminAuth(AuthValidation): if not settings.OAUTH_ENABLE: return Auth(db=db) telephone = self.validate_token(request, token) - options = [joinedload(VadminUser.roles), joinedload("roles.menus")] + options = [joinedload(VadminUser.roles).subqueryload(VadminRole.menus)] user = await UserDal(db).get_data(telephone=telephone, v_return_none=True, v_options=options, is_staff=True) result = await self.validate_user(request, user, db) permissions = self.get_user_permissions(user) diff --git a/kinit-api/apps/vadmin/auth/utils/login.py b/kinit-api/apps/vadmin/auth/utils/login.py index c67bf1d..b6884b6 100644 --- a/kinit-api/apps/vadmin/auth/utils/login.py +++ b/kinit-api/apps/vadmin/auth/utils/login.py @@ -126,7 +126,7 @@ async def wx_login_for_access_token( return ErrorResponse(msg=str(e)) # 更新登录时间 - await user.update_login_info(db, request.client.host) + await UserDal(db).update_login_info(user, request.client.host) # 登录成功创建 token access_token = LoginManage.create_token({"sub": user.telephone, "is_refresh": False}) diff --git a/kinit-api/apps/vadmin/auth/utils/validation/login.py b/kinit-api/apps/vadmin/auth/utils/validation/login.py index 195cc81..27148ed 100644 --- a/kinit-api/apps/vadmin/auth/utils/validation/login.py +++ b/kinit-api/apps/vadmin/auth/utils/validation/login.py @@ -7,13 +7,12 @@ # @desc : 登录验证装饰器 from fastapi import Request -from pydantic import BaseModel, validator, field_validator +from pydantic import BaseModel, field_validator from sqlalchemy.ext.asyncio import AsyncSession -from application.settings import DEFAULT_AUTH_ERROR_MAX_NUMBER, DEMO +from application.settings import DEFAULT_AUTH_ERROR_MAX_NUMBER, DEMO, REDIS_DB_ENABLE from apps.vadmin.auth import crud, schemas from core.database import redis_getter from core.validator import vali_telephone -from typing import Optional from utils.count import Count @@ -64,12 +63,15 @@ class LoginValidation: result = await self.func(self, data=data, user=user, request=request) - count_key = f"{data.telephone}_password_auth" if data.method == '0' else f"{data.telephone}_sms_auth" - count = Count(redis_getter(request), count_key) + if REDIS_DB_ENABLE: + count_key = f"{data.telephone}_password_auth" if data.method == '0' else f"{data.telephone}_sms_auth" + count = Count(redis_getter(request), count_key) + else: + count = None if not result.status: self.result.msg = result.msg - if not DEMO: + if not DEMO and count: number = await count.add(ex=86400) if number >= DEFAULT_AUTH_ERROR_MAX_NUMBER: await count.reset() @@ -81,10 +83,10 @@ class LoginValidation: elif data.platform in ["0", "1"] and not user.is_staff: self.result.msg = "此手机号无权限!" else: - if not DEMO: + if not DEMO and count: await count.delete() self.result.msg = "OK" self.result.status = True self.result.user = schemas.UserSimpleOut.model_validate(user) - await user.update_login_info(db, request.client.host) + await crud.UserDal(db).update_login_info(user, request.client.host) return self.result diff --git a/kinit-api/apps/vadmin/auth/views.py b/kinit-api/apps/vadmin/auth/views.py index 6725eb8..932d203 100644 --- a/kinit-api/apps/vadmin/auth/views.py +++ b/kinit-api/apps/vadmin/auth/views.py @@ -5,6 +5,7 @@ # @File : views.py # @IDE : PyCharm # @desc : 简要说明 + from aioredis import Redis from fastapi import APIRouter, Depends, Body, UploadFile, Request from sqlalchemy.orm import joinedload @@ -12,13 +13,22 @@ from core.database import redis_getter from utils.response import SuccessResponse, ErrorResponse from . import schemas, crud, models from core.dependencies import IdList -from apps.vadmin.auth.utils.current import AllUserAuth, FullAdminAuth +from apps.vadmin.auth.utils.current import AllUserAuth, FullAdminAuth, OpenAuth from apps.vadmin.auth.utils.validation.auth import Auth from .params import UserParams, RoleParams app = APIRouter() +########################################################### +# 接口测试 +########################################################### +@app.get("/test", summary="接口测试") +async def test(auth: Auth = Depends(OpenAuth())): + await crud.TestDal(auth.db).test() + return SuccessResponse() + + ########################################################### # 用户管理 ########################################################### @@ -30,8 +40,12 @@ async def get_users( model = models.VadminUser options = [joinedload(model.roles)] schema = schemas.UserOut - datas = await crud.UserDal(auth.db).get_datas(**params.dict(), v_options=options, v_schema=schema) - count = await crud.UserDal(auth.db).get_count(**params.to_count()) + datas, count = await crud.UserDal(auth.db).get_datas( + **params.dict(), + v_options=options, + v_schema=schema, + v_return_count=True + ) return SuccessResponse(datas, count=count) @@ -67,7 +81,7 @@ async def get_user( model = models.VadminUser options = [joinedload(model.roles)] schema = schemas.UserOut - return SuccessResponse(await crud.UserDal(auth.db).get_data(data_id, options, v_schema=schema)) + return SuccessResponse(await crud.UserDal(auth.db).get_data(data_id, v_options=options, v_schema=schema)) @app.post("/user/current/reset/password", summary="重置当前用户密码") @@ -145,8 +159,7 @@ async def get_roles( params: RoleParams = Depends(), auth: Auth = Depends(FullAdminAuth(permissions=["auth.role.list"])) ): - datas = await crud.RoleDal(auth.db).get_datas(**params.dict()) - count = await crud.RoleDal(auth.db).get_count(**params.to_count()) + datas, count = await crud.RoleDal(auth.db).get_datas(**params.dict(), v_return_count=True) return SuccessResponse(datas, count=count) @@ -187,7 +200,7 @@ async def get_role( model = models.VadminRole options = [joinedload(model.menus)] schema = schemas.RoleOut - return SuccessResponse(await crud.RoleDal(auth.db).get_data(data_id, options, v_schema=schema)) + return SuccessResponse(await crud.RoleDal(auth.db).get_data(data_id, v_options=options, v_schema=schema)) ########################################################### @@ -239,7 +252,7 @@ async def put_menus( auth: Auth = Depends(FullAdminAuth(permissions=["auth.menu.view", "auth.menu.update"])) ): schema = schemas.MenuSimpleOut - return SuccessResponse(await crud.MenuDal(auth.db).get_data(data_id, None, v_schema=schema)) + return SuccessResponse(await crud.MenuDal(auth.db).get_data(data_id, v_schema=schema)) @app.get("/role/menus/tree/{role_id}", summary="获取菜单列表树信息以及角色菜单权限ID,角色权限使用") diff --git a/kinit-api/apps/vadmin/help/crud.py b/kinit-api/apps/vadmin/help/crud.py index 722ac9d..1e0b371 100644 --- a/kinit-api/apps/vadmin/help/crud.py +++ b/kinit-api/apps/vadmin/help/crud.py @@ -7,11 +7,8 @@ # @desc : 帮助中心 - 增删改查 from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import joinedload - from core.crud import DalBase from . import models, schemas -from apps.vadmin.auth import models as vadminAuthModels class IssueDal(DalBase): @@ -19,47 +16,16 @@ class IssueDal(DalBase): def __init__(self, db: AsyncSession): super(IssueDal, self).__init__(db, models.VadminIssue, schemas.IssueSimpleOut) - async def add_view_number(self, data_id: int): + async def add_view_number(self, data_id: int) -> None: """ 更新常见问题查看次数+1 """ - obj = await self.get_data(data_id) + obj: models.VadminIssue = await self.get_data(data_id) obj.view_number = obj.view_number + 1 if obj.view_number else 1 await self.flush(obj) - return True class IssueCategoryDal(DalBase): def __init__(self, db: AsyncSession): - key_models = { - # 外键字段名,也可以自定义 - "create_user": { - # 外键对应的orm模型 - "model": vadminAuthModels.VadminUser, - # 如果对同一个模型只有一个外键关联时,下面这个 onclause 可以省略不写,一个以上时必须写,需要分清楚要查询的是哪个 - # 这里其实可以省略不写,但是为了演示这里写出来了 - "onclause": models.VadminIssueCategory.create_user_id == vadminAuthModels.VadminUser.id - } - } - super(IssueCategoryDal, self).__init__( - db, - models.VadminIssueCategory, - schemas.IssueCategorySimpleOut, - key_models - ) - - async def test(self): - """ - v_join_query 示例方法 - 获取用户名称包含李 创建出的常见问题类别 - """ - v_join_query = { - # 与 key_models 中定义的外键字段名定义的一样 - "create_user": { - # 外键表字段名:查询值 - "name": ("like", "李") - } - } - v_options = [joinedload(self.model.create_user)] - datas = self.get_datas(limit=0, v_join_query=v_join_query, v_options=v_options) + super(IssueCategoryDal, self).__init__(db, models.VadminIssueCategory, schemas.IssueCategorySimpleOut) diff --git a/kinit-api/apps/vadmin/help/models/issue.py b/kinit-api/apps/vadmin/help/models/issue.py index cc7ebbd..f0e906b 100644 --- a/kinit-api/apps/vadmin/help/models/issue.py +++ b/kinit-api/apps/vadmin/help/models/issue.py @@ -6,37 +6,49 @@ # @IDE : PyCharm # @desc : 常见问题 -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, Mapped, mapped_column +from apps.vadmin.auth.models import VadminUser from db.db_base import BaseModel -from sqlalchemy import Column, String, Boolean, Integer, ForeignKey, Text +from sqlalchemy import String, Boolean, Integer, ForeignKey class VadminIssueCategory(BaseModel): __tablename__ = "vadmin_help_issue_category" __table_args__ = ({'comment': '常见问题类别表'}) - name = Column(String(50), index=True, nullable=False, comment="类别名称") - platform = Column(String(8), index=True, nullable=False, comment="展示平台") - is_active = Column(Boolean, default=True, comment="是否可见") + name: Mapped[str] = mapped_column(String(50), index=True, nullable=False, comment="类别名称") + platform: Mapped[str] = mapped_column(String(8), index=True, nullable=False, comment="展示平台") + is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否可见") - issues = relationship("VadminIssue", back_populates='category') + issues: Mapped[list["VadminIssue"]] = relationship(back_populates='category') - create_user_id = Column(ForeignKey("vadmin_auth_user.id", ondelete='SET NULL'), comment="创建人") - create_user = relationship("VadminUser", foreign_keys=create_user_id) + create_user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("vadmin_auth_user.id", ondelete='RESTRICT'), + comment="创建人" + ) + create_user: Mapped[VadminUser] = relationship(foreign_keys=create_user_id) class VadminIssue(BaseModel): __tablename__ = "vadmin_help_issue" __table_args__ = ({'comment': '常见问题记录表'}) - category_id = Column(ForeignKey("vadmin_help_issue_category.id", ondelete='CASCADE'), comment="类别") - category = relationship("VadminIssueCategory", foreign_keys=category_id, back_populates='issues') + category_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("vadmin_help_issue_category.id", ondelete='CASCADE'), + comment="类别" + ) + category: Mapped[list["VadminIssueCategory"]] = relationship(foreign_keys=category_id, back_populates='issues') - title = Column(String(255), index=True, nullable=False, comment="标题") - content = Column(Text, comment="内容") - view_number = Column(Integer, default=0, comment="查看次数") - is_active = Column(Boolean, default=True, comment="是否可见") - - create_user_id = Column(ForeignKey("vadmin_auth_user.id", ondelete='SET NULL'), comment="创建人") - create_user = relationship("VadminUser", foreign_keys=create_user_id) + title: Mapped[str] = mapped_column(String(255), index=True, nullable=False, comment="标题") + content: Mapped[str] = mapped_column(String(5000), comment="内容") + view_number: Mapped[int] = mapped_column(Integer, default=0, comment="查看次数") + is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否可见") + create_user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("vadmin_auth_user.id", ondelete='RESTRICT'), + comment="创建人" + ) + create_user: Mapped[VadminUser] = relationship(foreign_keys=create_user_id) diff --git a/kinit-api/apps/vadmin/help/views.py b/kinit-api/apps/vadmin/help/views.py index 004d08f..617399a 100644 --- a/kinit-api/apps/vadmin/help/views.py +++ b/kinit-api/apps/vadmin/help/views.py @@ -27,8 +27,12 @@ async def get_issue_categorys(p: params.IssueCategoryParams = Depends(), auth: A model = models.VadminIssueCategory options = [joinedload(model.create_user)] schema = schemas.IssueCategoryListOut - datas = await crud.IssueCategoryDal(auth.db).get_datas(**p.dict(), v_options=options, v_schema=schema) - count = await crud.IssueCategoryDal(auth.db).get_count(**p.to_count()) + datas, count = await crud.IssueCategoryDal(auth.db).get_datas( + **p.dict(), + v_options=options, + v_schema=schema, + v_return_count=True + ) return SuccessResponse(datas, count=count) @@ -67,6 +71,7 @@ async def get_issue_category_platform(platform: str, db: AsyncSession = Depends( options = [joinedload(model.issues)] schema = schemas.IssueCategoryPlatformOut result = await crud.IssueCategoryDal(db).get_datas( + limit=0, platform=platform, is_active=True, v_schema=schema, @@ -83,8 +88,12 @@ async def get_issues(p: params.IssueParams = Depends(), auth: Auth = Depends(All model = models.VadminIssue options = [joinedload(model.create_user), joinedload(model.category)] schema = schemas.IssueListOut - datas = await crud.IssueDal(auth.db).get_datas(**p.dict(), v_options=options, v_schema=schema) - count = await crud.IssueDal(auth.db).get_count(**p.to_count()) + datas, count = await crud.IssueDal(auth.db).get_datas( + **p.dict(), + v_options=options, + v_schema=schema, + v_return_count=True + ) return SuccessResponse(datas, count=count) diff --git a/kinit-api/apps/vadmin/record/crud.py b/kinit-api/apps/vadmin/record/crud.py index 0955d62..f9b9f55 100644 --- a/kinit-api/apps/vadmin/record/crud.py +++ b/kinit-api/apps/vadmin/record/crud.py @@ -5,10 +5,8 @@ # @File : crud.py # @IDE : PyCharm # @desc : 数据库 增删改查操作 + import random -# sqlalchemy 查询操作:https://segmentfault.com/a/1190000016767008 -# sqlalchemy 关联查询:https://www.jianshu.com/p/dfad7c08c57a -# sqlalchemy 关联查询详细:https://blog.csdn.net/u012324798/article/details/103940527 from motor.motor_asyncio import AsyncIOMotorDatabase from sqlalchemy.ext.asyncio import AsyncSession from . import models, schemas diff --git a/kinit-api/apps/vadmin/record/models/login.py b/kinit-api/apps/vadmin/record/models/login.py index 91a04a5..b4631ff 100644 --- a/kinit-api/apps/vadmin/record/models/login.py +++ b/kinit-api/apps/vadmin/record/models/login.py @@ -6,12 +6,15 @@ # @IDE : PyCharm # @desc : 登录记录模型 import json + +from sqlalchemy.orm import Mapped, mapped_column + from application.settings import LOGIN_LOG_RECORD from apps.vadmin.auth.utils.validation import LoginForm, WXLoginForm from utils.ip_manage import IPManage from sqlalchemy.ext.asyncio import AsyncSession from db.db_base import BaseModel -from sqlalchemy import Column, String, Boolean, TEXT +from sqlalchemy import String, Boolean from fastapi import Request from starlette.requests import Request as StarletteRequest from user_agents import parse @@ -21,23 +24,23 @@ class VadminLoginRecord(BaseModel): __tablename__ = "vadmin_record_login" __table_args__ = ({'comment': '登录记录表'}) - telephone = Column(String(255), index=True, nullable=False, comment="手机号") - status = Column(Boolean, default=True, comment="是否登录成功") - platform = Column(String(8), comment="登陆平台") - login_method = Column(String(8), comment="认证方式") - ip = Column(String(50), comment="登陆地址") - address = Column(String(255), comment="登陆地点") - country = Column(String(255), comment="国家") - province = Column(String(255), comment="县") - city = Column(String(255), comment="城市") - county = Column(String(255), comment="区/县") - operator = Column(String(255), comment="运营商") - postal_code = Column(String(255), comment="邮政编码") - area_code = Column(String(255), comment="地区区号") - browser = Column(String(50), comment="浏览器") - system = Column(String(50), comment="操作系统") - response = Column(TEXT, comment="响应信息") - request = Column(TEXT, comment="请求信息") + telephone: Mapped[str] = mapped_column(String(255), index=True, nullable=False, comment="手机号") + status: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否登录成功") + platform: Mapped[str] = mapped_column(String(8), comment="登陆平台") + login_method: Mapped[str] = mapped_column(String(8), comment="认证方式") + ip: Mapped[str | None] = mapped_column(String(50), comment="登陆地址") + address: Mapped[str | None] = mapped_column(String(255), comment="登陆地点") + country: Mapped[str | None] = mapped_column(String(255), comment="国家") + province: Mapped[str | None] = mapped_column(String(255), comment="县") + city: Mapped[str | None] = mapped_column(String(255), comment="城市") + county: Mapped[str | None] = mapped_column(String(255), comment="区/县") + operator: Mapped[str | None] = mapped_column(String(255), comment="运营商") + postal_code: Mapped[str | None] = mapped_column(String(255), comment="邮政编码") + area_code: Mapped[str | None] = mapped_column(String(255), comment="地区区号") + browser: Mapped[str | None] = mapped_column(String(50), comment="浏览器") + system: Mapped[str | None] = mapped_column(String(50), comment="操作系统") + response: Mapped[str | None] = mapped_column(String(5000), comment="响应信息") + request: Mapped[str | None] = mapped_column(String(5000), comment="请求信息") @classmethod async def create_login_record( diff --git a/kinit-api/apps/vadmin/record/models/sms.py b/kinit-api/apps/vadmin/record/models/sms.py index a75a8c7..236449f 100644 --- a/kinit-api/apps/vadmin/record/models/sms.py +++ b/kinit-api/apps/vadmin/record/models/sms.py @@ -6,18 +6,18 @@ # @IDE : PyCharm # @desc : 短信发送记录模型 - +from sqlalchemy.orm import Mapped, mapped_column from db.db_base import BaseModel -from sqlalchemy import Column, String, Boolean, ForeignKey +from sqlalchemy import Integer, String, Boolean, ForeignKey class VadminSMSSendRecord(BaseModel): __tablename__ = "vadmin_record_sms_send" __table_args__ = ({'comment': '短信发送记录表'}) - user_id = Column(ForeignKey("vadmin_auth_user.id", ondelete='CASCADE'), comment="操作人") - status = Column(Boolean, default=True, comment="发送状态") - content = Column(String(255), comment="发送内容") - telephone = Column(String(11), comment="目标手机号") - desc = Column(String(255), comment="失败描述") - scene = Column(String(50), comment="发送场景") + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("vadmin_auth_user.id", ondelete='CASCADE'), comment="操作人") + status: Mapped[bool] = mapped_column(Boolean, default=True, comment="发送状态") + content: Mapped[str] = mapped_column(String(255), comment="发送内容") + telephone: Mapped[str] = mapped_column(String(11), comment="目标手机号") + desc: Mapped[str | None] = mapped_column(String(255), comment="失败描述") + scene: Mapped[str | None] = mapped_column(String(50), comment="发送场景") diff --git a/kinit-api/apps/vadmin/record/views.py b/kinit-api/apps/vadmin/record/views.py index 3a12321..4002fed 100644 --- a/kinit-api/apps/vadmin/record/views.py +++ b/kinit-api/apps/vadmin/record/views.py @@ -7,9 +7,8 @@ from fastapi import APIRouter, Depends from motor.motor_asyncio import AsyncIOMotorDatabase - from utils.response import SuccessResponse -from . import crud, schemas +from . import crud from apps.vadmin.auth.utils.current import AllUserAuth from apps.vadmin.auth.utils.validation.auth import Auth from .params import LoginParams, OperationParams, SMSParams @@ -23,8 +22,7 @@ app = APIRouter() ########################################################### @app.get("/logins", summary="获取登录日志列表") async def get_record_login(p: LoginParams = Depends(), auth: Auth = Depends(AllUserAuth())): - datas = await crud.LoginRecordDal(auth.db).get_datas(**p.dict()) - count = await crud.LoginRecordDal(auth.db).get_count(**p.to_count()) + datas, count = await crud.LoginRecordDal(auth.db).get_datas(**p.dict(), v_return_count=True) return SuccessResponse(datas, count=count) @@ -41,8 +39,7 @@ async def get_record_operation( @app.get("/sms/send/list", summary="获取短信发送列表") async def get_sms_send_list(p: SMSParams = Depends(), auth: Auth = Depends(AllUserAuth())): - datas = await crud.SMSSendRecordDal(auth.db).get_datas(**p.dict()) - count = await crud.SMSSendRecordDal(auth.db).get_count(**p.to_count()) + datas, count = await crud.SMSSendRecordDal(auth.db).get_datas(**p.dict(), v_return_count=True) return SuccessResponse(datas, count=count) diff --git a/kinit-api/apps/vadmin/system/crud.py b/kinit-api/apps/vadmin/system/crud.py index 903c31d..c21b91d 100644 --- a/kinit-api/apps/vadmin/system/crud.py +++ b/kinit-api/apps/vadmin/system/crud.py @@ -6,9 +6,6 @@ # @IDE : PyCharm # @desc : 数据库 增删改查操作 -# sqlalchemy 查询操作:https://segmentfault.com/a/1190000016767008 -# sqlalchemy 关联查询:https://www.jianshu.com/p/dfad7c08c57a -# sqlalchemy 关联查询详细:https://blog.csdn.net/u012324798/article/details/103940527 import json import os from enum import Enum @@ -19,13 +16,15 @@ from motor.motor_asyncio import AsyncIOMotorDatabase from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload -from application.settings import STATIC_ROOT, SUBSCRIBE +from application.settings import STATIC_ROOT, SUBSCRIBE, REDIS_DB_ENABLE +from core.database import redis_getter from core.mongo_manage import MongoManage from utils.file.file_manage import FileManage from . import models, schemas from core.crud import DalBase from core.exception import CustomException from utils import status +from fastapi import Request class DictTypeDal(DalBase): @@ -53,7 +52,7 @@ class DictTypeDal(DalBase): data[obj.dict_type] = [schemas.DictDetailsSimpleOut.model_validate(i).model_dump() for i in obj.details] return data - async def get_select_datas(self): + async def get_select_datas(self) -> list: """获取选择数据,全部数据""" sql = select(self.model) queryset = await self.db.execute(sql) @@ -82,7 +81,7 @@ class SettingsDal(DalBase): result[data.config_key] = data.config_value return result - async def update_datas(self, datas: dict, rd: Redis): + async def update_datas(self, datas: dict, request: Request) -> None: """ 更新系统配置信息 @@ -104,12 +103,13 @@ class SettingsDal(DalBase): sql = update(self.model).where(self.model.config_key == "web_ico").values(config_value=web_ico) await self.db.execute(sql) else: - sql = update(self.model).where(self.model.config_key == key).values(config_value=value) + sql = update(self.model).where(self.model.config_key == str(key)).values(config_value=value) await self.db.execute(sql) - if "wx_server_app_id" in datas: + if "wx_server_app_id" in datas and REDIS_DB_ENABLE: + rd = redis_getter(request) await rd.client().set("wx_server", json.dumps(datas)) - async def get_base_config(self): + async def get_base_config(self) -> dict: """ 获取系统基本信息 """ @@ -127,7 +127,7 @@ class SettingsTabDal(DalBase): def __init__(self, db: AsyncSession): super(SettingsTabDal, self).__init__(db, models.VadminSystemSettingsTab, schemas.SettingsTabSimpleOut) - async def get_classify_tab_values(self, classify: list[str], hidden: bool | None = False): + async def get_classify_tab_values(self, classify: list[str], hidden: bool | None = False) -> dict: """ 获取系统配置分类下的标签信息 """ @@ -143,7 +143,7 @@ class SettingsTabDal(DalBase): ) return self.__generate_values(datas) - async def get_tab_name_values(self, tab_names: list[str], hidden: bool | None = False): + async def get_tab_name_values(self, tab_names: list[str], hidden: bool | None = False) -> dict: """ 获取系统配置标签下的标签信息 """ @@ -160,7 +160,7 @@ class SettingsTabDal(DalBase): return self.__generate_values(datas) @classmethod - def __generate_values(cls, datas: list[models.VadminSystemSettingsTab]): + def __generate_values(cls, datas: list[models.VadminSystemSettingsTab]) -> dict: """ 生成字典值 """ @@ -281,7 +281,7 @@ class TaskDal(MongoManage): v_order: str = None, v_order_field: str = None, **kwargs - ): + ) -> tuple: """ 获取任务信息列表 diff --git a/kinit-api/apps/vadmin/system/models/__init__.py b/kinit-api/apps/vadmin/system/models/__init__.py index 5e9fd9e..c18b7be 100644 --- a/kinit-api/apps/vadmin/system/models/__init__.py +++ b/kinit-api/apps/vadmin/system/models/__init__.py @@ -1,3 +1,2 @@ from .dict import VadminDictType, VadminDictDetails -from .settings import VadminSystemSettings -from .settings_tab import VadminSystemSettingsTab +from .settings import VadminSystemSettings, VadminSystemSettingsTab diff --git a/kinit-api/apps/vadmin/system/models/dict.py b/kinit-api/apps/vadmin/system/models/dict.py index dddae87..060ba47 100644 --- a/kinit-api/apps/vadmin/system/models/dict.py +++ b/kinit-api/apps/vadmin/system/models/dict.py @@ -6,7 +6,7 @@ # @IDE : PyCharm # @desc : 系统字典模型 -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, Mapped, mapped_column from db.db_base import BaseModel from sqlalchemy import Column, String, Boolean, ForeignKey, Integer @@ -15,22 +15,26 @@ class VadminDictType(BaseModel): __tablename__ = "vadmin_system_dict_type" __table_args__ = ({'comment': '字典类型表'}) - dict_name = Column(String(50), index=True, nullable=False, comment="字典名称") - dict_type = Column(String(50), index=True, nullable=False, comment="字典类型") - disabled = Column(Boolean, default=False, comment="字典状态,是否禁用") - remark = Column(String(255), comment="备注") - details = relationship("VadminDictDetails", back_populates="dict_type") + dict_name: Mapped[str] = mapped_column(String(50), index=True, nullable=False, comment="字典名称") + dict_type: Mapped[str] = mapped_column(String(50), index=True, nullable=False, comment="字典类型") + disabled: Mapped[bool] = mapped_column(Boolean, default=False, comment="字典状态,是否禁用") + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + details: Mapped[list["VadminDictDetails"]] = relationship(back_populates="dict_type") class VadminDictDetails(BaseModel): __tablename__ = "vadmin_system_dict_details" __table_args__ = ({'comment': '字典详情表'}) - label = Column(String(50), index=True, nullable=False, comment="字典标签") - value = Column(String(50), index=True, nullable=False, comment="字典键值") - disabled = Column(Boolean, default=False, comment="字典状态,是否禁用") - is_default = Column(Boolean, default=False, comment="是否默认") - order = Column(Integer, comment="字典排序") - dict_type_id = Column(Integer, ForeignKey("vadmin_system_dict_type.id", ondelete='CASCADE'), comment="关联字典类型") - dict_type = relationship("VadminDictType", foreign_keys=dict_type_id, back_populates="details") - remark = Column(String(255), comment="备注") + label: Mapped[str] = mapped_column(String(50), index=True, nullable=False, comment="字典标签") + value: Mapped[str] = mapped_column(String(50), index=True, nullable=False, comment="字典键值") + disabled: Mapped[bool] = mapped_column(Boolean, default=False, comment="字典状态,是否禁用") + is_default: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否默认") + order: Mapped[int] = mapped_column(Integer, comment="字典排序") + dict_type_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("vadmin_system_dict_type.id", ondelete='CASCADE'), + comment="关联字典类型" + ) + dict_type: Mapped[VadminDictType] = relationship(foreign_keys=dict_type_id, back_populates="details") + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") diff --git a/kinit-api/apps/vadmin/system/models/settings.py b/kinit-api/apps/vadmin/system/models/settings.py index 0590f6c..f2f083a 100644 --- a/kinit-api/apps/vadmin/system/models/settings.py +++ b/kinit-api/apps/vadmin/system/models/settings.py @@ -5,20 +5,38 @@ # @File : settings.py # @IDE : PyCharm # @desc : 系统字典模型 -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, Mapped, mapped_column from db.db_base import BaseModel -from sqlalchemy import Column, String, TEXT, Integer, ForeignKey, Boolean +from sqlalchemy import String, Integer, ForeignKey, Boolean, Text + + +class VadminSystemSettingsTab(BaseModel): + __tablename__ = "vadmin_system_settings_tab" + __table_args__ = ({'comment': '系统配置分类表'}) + + title: Mapped[str] = mapped_column(String(255), comment="标题") + classify: Mapped[str] = mapped_column(String(255), index=True, nullable=False, comment="分类键") + tab_label: Mapped[str] = mapped_column(String(255), comment="tab标题") + tab_name: Mapped[str] = mapped_column(String(255), index=True, nullable=False, unique=True, comment="tab标识符") + hidden: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否隐藏") + disabled: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否禁用") + + settings: Mapped[list["VadminSystemSettings"]] = relationship(back_populates="tab") class VadminSystemSettings(BaseModel): __tablename__ = "vadmin_system_settings" __table_args__ = ({'comment': '系统配置表'}) - config_label = Column(String(255), comment="配置表标签") - config_key = Column(String(255), index=True, nullable=False, unique=True, comment="配置表键") - config_value = Column(TEXT, comment="配置表内容") - remark = Column(String(255), comment="备注信息") - disabled = Column(Boolean, default=False, comment="是否禁用") + config_label: Mapped[str] = mapped_column(String(255), comment="配置表标签") + config_key: Mapped[str] = mapped_column(String(255), index=True, nullable=False, unique=True, comment="配置表键") + config_value: Mapped[str | None] = mapped_column(Text, comment="配置表内容") + remark: Mapped[str | None] = mapped_column(String(255), comment="备注信息") + disabled: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否禁用") - tab_id = Column(Integer, ForeignKey("vadmin_system_settings_tab.id", ondelete='CASCADE'), comment="关联tab标签") - tab = relationship("VadminSystemSettingsTab", foreign_keys=tab_id, back_populates="settings") + tab_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("vadmin_system_settings_tab.id", ondelete='CASCADE'), + comment="关联tab标签" + ) + tab: Mapped[VadminSystemSettingsTab] = relationship(foreign_keys=tab_id, back_populates="settings") diff --git a/kinit-api/apps/vadmin/system/models/settings_tab.py b/kinit-api/apps/vadmin/system/models/settings_tab.py deleted file mode 100644 index 1807912..0000000 --- a/kinit-api/apps/vadmin/system/models/settings_tab.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# @version : 1.0 -# @Create Time : 2022/7/7 13:41 -# @File : settings_tab.py -# @IDE : PyCharm -# @desc : 系统配置分类模型 - -from sqlalchemy.orm import relationship -from db.db_base import BaseModel -from sqlalchemy import Column, String, Boolean - - -class VadminSystemSettingsTab(BaseModel): - __tablename__ = "vadmin_system_settings_tab" - __table_args__ = ({'comment': '系统配置分类表'}) - - title = Column(String(255), comment="标题") - classify = Column(String(255), index=True, nullable=False, comment="分类键") - tab_label = Column(String(255), comment="tab标题") - tab_name = Column(String(255), index=True, nullable=False, unique=True, comment="tab标识符") - hidden = Column(Boolean, default=False, comment="是否隐藏") - disabled = Column(Boolean, default=False, comment="是否禁用") - - settings = relationship("VadminSystemSettings", back_populates="tab") diff --git a/kinit-api/apps/vadmin/system/views.py b/kinit-api/apps/vadmin/system/views.py index 3517119..bdf43db 100644 --- a/kinit-api/apps/vadmin/system/views.py +++ b/kinit-api/apps/vadmin/system/views.py @@ -5,9 +5,8 @@ # @IDE : PyCharm # @desc : 主要接口文件 -# UploadFile 库依赖:pip install python-multipart from aioredis import Redis -from fastapi import APIRouter, Depends, Body, UploadFile, Form +from fastapi import APIRouter, Depends, Body, UploadFile, Form, Request from motor.motor_asyncio import AsyncIOMotorDatabase from sqlalchemy.ext.asyncio import AsyncSession from application.settings import ALIYUN_OSS @@ -21,7 +20,7 @@ from core.dependencies import IdList from apps.vadmin.auth.utils.current import AllUserAuth, FullAdminAuth, OpenAuth from apps.vadmin.auth.utils.validation.auth import Auth from .params import DictTypeParams, DictDetailParams, TaskParams -from apps.vadmin.auth import crud as vadminAuthCRUD +from apps.vadmin.auth import crud as vadmin_auth_crud from .params.task import TaskRecordParams app = APIRouter() @@ -32,8 +31,7 @@ app = APIRouter() ########################################################### @app.get("/dict/types", summary="获取字典类型列表") async def get_dict_types(p: DictTypeParams = Depends(), auth: Auth = Depends(AllUserAuth())): - datas = await crud.DictTypeDal(auth.db).get_datas(**p.dict()) - count = await crud.DictTypeDal(auth.db).get_count(**p.to_count()) + datas, count = await crud.DictTypeDal(auth.db).get_datas(**p.dict(), v_return_count=True) return SuccessResponse(datas, count=count) @@ -70,7 +68,7 @@ async def put_dict_types(data_id: int, data: schemas.DictType, auth: Auth = Depe @app.get("/dict/types/{data_id}", summary="获取字典类型详细") async def get_dict_type(data_id: int, auth: Auth = Depends(AllUserAuth())): schema = schemas.DictTypeSimpleOut - return SuccessResponse(await crud.DictTypeDal(auth.db).get_data(data_id, None, v_schema=schema)) + return SuccessResponse(await crud.DictTypeDal(auth.db).get_data(data_id, v_schema=schema)) ########################################################### @@ -85,8 +83,7 @@ async def create_dict_details(data: schemas.DictDetails, auth: Auth = Depends(Al async def get_dict_details(params: DictDetailParams = Depends(), auth: Auth = Depends(AllUserAuth())): if not params.dict_type_id: return ErrorResponse(msg="未获取到字典类型!") - datas = await crud.DictDetailsDal(auth.db).get_datas(**params.dict()) - count = await crud.DictDetailsDal(auth.db).get_count(**params.to_count()) + datas, count = await crud.DictDetailsDal(auth.db).get_datas(**params.dict(), v_return_count=True) return SuccessResponse(datas, count=count) @@ -104,7 +101,7 @@ async def put_dict_details(data_id: int, data: schemas.DictDetails, auth: Auth = @app.get("/dict/details/{data_id}", summary="获取字典元素详情") async def get_dict_detail(data_id: int, auth: Auth = Depends(AllUserAuth())): schema = schemas.DictDetailsSimpleOut - return SuccessResponse(await crud.DictDetailsDal(auth.db).get_data(data_id, None, v_schema=schema)) + return SuccessResponse(await crud.DictDetailsDal(auth.db).get_data(data_id, v_schema=schema)) ########################################################### @@ -140,7 +137,7 @@ async def upload_image_to_local(file: UploadFile, path: str = Form(...)): ########################################################### @app.post("/sms/send", summary="发送短信验证码(阿里云服务)") async def sms_send(telephone: str, rd: Redis = Depends(redis_getter), auth: Auth = Depends(OpenAuth())): - user = await vadminAuthCRUD.UserDal(auth.db).get_data(telephone=telephone, v_return_none=True) + user = await vadmin_auth_crud.UserDal(auth.db).get_data(telephone=telephone, v_return_none=True) if not user: return ErrorResponse("手机号不存在!") sms = CodeSMS(telephone, rd) @@ -162,11 +159,11 @@ async def get_settings_tabs_values(tab_id: int, auth: Auth = Depends(FullAdminAu @app.put("/settings/tabs/values", summary="更新系统配置信息") async def put_settings_tabs_values( + request: Request, datas: dict = Body(...), - auth: Auth = Depends(FullAdminAuth()), - rd: Redis = Depends(redis_getter) + auth: Auth = Depends(FullAdminAuth()) ): - return SuccessResponse(await crud.SettingsDal(auth.db).update_datas(datas, rd)) + return SuccessResponse(await crud.SettingsDal(auth.db).update_datas(datas, request)) @app.get("/settings/base/config", summary="获取系统基础配置", description="每次进入系统中时使用") diff --git a/kinit-api/core/crud.py b/kinit-api/core/crud.py index 4cf5c1d..9b34d7e 100644 --- a/kinit-api/core/crud.py +++ b/kinit-api/core/crud.py @@ -1,33 +1,26 @@ #!/usr/bin/python # -*- coding: utf-8 -*- # @version : 1.0 -# @Create Time : 2021/10/18 22:18 +# @Update Time : 2023/8/21 22:18 # @File : crud.py # @IDE : PyCharm # @desc : 数据库 增删改查操作 -# sqlalchemy 查询操作:https://segmentfault.com/a/1190000016767008 -# sqlalchemy 查询操作(官方文档): https://www.osgeo.cn/sqlalchemy/orm/queryguide.html -# sqlalchemy 增删改操作:https://www.osgeo.cn/sqlalchemy/tutorial/orm_data_manipulation.html#updating-orm-objects -# SQLAlchemy lazy load和eager load: https://www.jianshu.com/p/dfad7c08c57a -# Mysql中内连接,左连接和右连接的区别总结:https://www.cnblogs.com/restartyang/articles/9080993.html -# SQLAlchemy INNER JOIN 内连接 -# selectinload 官方文档: -# https://www.osgeo.cn/sqlalchemy/orm/loading_relationships.html?highlight=selectinload#sqlalchemy.orm.selectinload -# SQLAlchemy LEFT OUTER JOIN 左连接 -# joinedload 官方文档: -# https://www.osgeo.cn/sqlalchemy/orm/loading_relationships.html?highlight=selectinload#sqlalchemy.orm.joinedload +# sqlalchemy 官方文档:https://docs.sqlalchemy.org/en/20/index.html +# sqlalchemy 查询操作(官方文档): https://docs.sqlalchemy.org/en/20/orm/queryguide/select.html +# sqlalchemy 增删改操作:https://docs.sqlalchemy.org/en/20/orm/queryguide/dml.html +# sqlalchemy 1.x 语法迁移到 2.x :https://docs.sqlalchemy.org/en/20/changelog/migration_20.html#migration-20-query-usage import datetime -from typing import Set from fastapi import HTTPException from fastapi.encoders import jsonable_encoder -from sqlalchemy import func, delete, update, or_ -from sqlalchemy.future import select +from sqlalchemy import func, delete, update, BinaryExpression, ScalarResult, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import InstrumentedAttribute +from sqlalchemy.orm.strategy_options import _AbstractLoad from starlette import status from core.exception import CustomException -from sqlalchemy.sql.selectable import Select +from sqlalchemy.sql.selectable import Select as SelectType from typing import Any @@ -35,124 +28,200 @@ class DalBase: # 倒叙 ORDER_FIELD = ["desc", "descending"] - def __init__(self, db: AsyncSession, model: Any, schema: Any, key_models: dict = None): + def __init__(self, db: AsyncSession, model: Any, schema: Any): self.db = db self.model = model self.schema = schema - self.key_models = key_models async def get_data( self, data_id: int = None, - v_options: list = None, - v_join_query: dict = None, - v_or: list[tuple] = None, + v_start_sql: SelectType = None, + v_select_from: list[Any] = None, + v_join: list[list[str | InstrumentedAttribute, BinaryExpression | None]] = None, + v_outerjoin: list[list[str | InstrumentedAttribute, BinaryExpression | None]] = None, + v_options: list[_AbstractLoad] = None, + v_where: list[BinaryExpression] = None, v_order: str = None, v_order_field: str = None, v_return_none: bool = False, v_schema: Any = None, **kwargs - ): + ) -> Any: """ 获取单个数据,默认使用 ID 查询,否则使用关键词查询 :param data_id: 数据 ID - :param v_options: 指示应使用select在预加载中加载给定的属性。 - :param v_join_query: 外键字段查询,内连接 - :param v_or: 或逻辑查询 + :param v_start_sql: 初始 sql + :param v_select_from: 用于指定查询从哪个表开始,通常与 .join() 等方法一起使用。 + :param v_join: 创建内连接(INNER JOIN)操作,返回两个表中满足连接条件的交集。 + :param v_outerjoin: 用于创建外连接(OUTER JOIN)操作,返回两个表中满足连接条件的并集,包括未匹配的行,并用 NULL 值填充。 + :param v_options: 用于为查询添加附加选项,如预加载、延迟加载等。 + :param v_where: 当前表查询条件,原始表达式 :param v_order: 排序,默认正序,为 desc 是倒叙 :param v_order_field: 排序字段 :param v_return_none: 是否返回空 None,否认 抛出异常,默认抛出异常 :param v_schema: 指定使用的序列化对象 :param kwargs: 查询参数 + :return: 默认返回 ORM 对象,如果存在 v_schema 则会返回 v_schema 结果 """ - sql = select(self.model).where(self.model.is_delete == False) + if not isinstance(v_start_sql, SelectType): + v_start_sql = select(self.model).where(self.model.is_delete == False) + if data_id: - sql = sql.where(self.model.id == data_id) - sql = self.add_filter_condition(sql, v_options, v_join_query, v_or, **kwargs) - if v_order_field and (v_order in self.ORDER_FIELD): - sql = sql.order_by(getattr(self.model, v_order_field).desc(), self.model.id.desc()) - elif v_order_field: - sql = sql.order_by(getattr(self.model, v_order_field), self.model.id) - elif v_order and (v_order in self.ORDER_FIELD): - sql = sql.order_by(self.model.create_datetime.desc()) - queryset = await self.db.execute(sql) - data = queryset.scalars().unique().first() + v_start_sql = v_start_sql.where(self.model.id == data_id) + + queryset: ScalarResult = await self.filter_core( + v_start_sql=v_start_sql, + v_select_from=v_select_from, + v_join=v_join, + v_outerjoin=v_outerjoin, + v_options=v_options, + v_where=v_where, + v_order=v_order, + v_order_field=v_order_field, + v_return_sql=False, + **kwargs + ) + + if v_options: + data = queryset.unique().first() + else: + data = queryset.first() + if not data and v_return_none: return None + if data and v_schema: return v_schema.model_validate(data).model_dump() + if data: return data + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到此数据") async def get_datas( self, page: int = 1, limit: int = 10, - v_options: list = None, - v_join_query: dict = None, - v_or: list[tuple] = None, + v_start_sql: SelectType = None, + v_select_from: list[Any] = None, + v_join: list[list[str | InstrumentedAttribute, BinaryExpression | None]] = None, + v_outerjoin: list[list[str | InstrumentedAttribute, BinaryExpression | None]] = None, + v_options: list[_AbstractLoad] = None, + v_where: list[BinaryExpression] = None, v_order: str = None, v_order_field: str = None, + v_return_count: bool = False, + v_return_scalars: bool = False, v_return_objs: bool = False, - v_start_sql: Any = None, v_schema: Any = None, **kwargs - ) -> list: + ) -> list[Any] | ScalarResult | tuple: """ 获取数据列表 + :param page: 页码 :param limit: 当前页数据量 - :param v_options: 指示应使用select在预加载中加载给定的属性。 - :param v_join_query: 外键字段查询 - :param v_or: 或逻辑查询 + :param v_start_sql: 初始 sql + :param v_select_from: 用于指定查询从哪个表开始,通常与 .join() 等方法一起使用。 + :param v_join: 创建内连接(INNER JOIN)操作,返回两个表中满足连接条件的交集。 + :param v_outerjoin: 用于创建外连接(OUTER JOIN)操作,返回两个表中满足连接条件的并集,包括未匹配的行,并用 NULL 值填充。 + :param v_options: 用于为查询添加附加选项,如预加载、延迟加载等。 + :param v_where: 当前表查询条件,原始表达式 :param v_order: 排序,默认正序,为 desc 是倒叙 :param v_order_field: 排序字段 + :param v_return_count: 默认为 False,是否返回 count 过滤后的数据总数,不会影响其他返回结果,会一起返回为一个数组 + :param v_return_scalars: 返回scalars后的结果 :param v_return_objs: 是否返回对象 - :param v_start_sql: 初始 sql :param v_schema: 指定使用的序列化对象 - :param kwargs: 查询参数 + :param kwargs: 查询参数,使用的是自定义表达式 + :return: 返回值优先级:v_return_scalars > v_return_objs > v_schema """ - if not isinstance(v_start_sql, Select): - v_start_sql = select(self.model).where(self.model.is_delete == False) - sql = self.add_filter_condition(v_start_sql, v_options, v_join_query, v_or, **kwargs) - if v_order_field and (v_order in self.ORDER_FIELD): - sql = sql.order_by(getattr(self.model, v_order_field).desc(), self.model.id.desc()) - elif v_order_field: - sql = sql.order_by(getattr(self.model, v_order_field), self.model.id) - elif v_order in self.ORDER_FIELD: - sql = sql.order_by(self.model.id.desc()) + sql: SelectType = await self.filter_core( + v_start_sql=v_start_sql, + v_select_from=v_select_from, + v_join=v_join, + v_outerjoin=v_outerjoin, + v_options=v_options, + v_where=v_where, + v_order=v_order, + v_order_field=v_order_field, + v_return_sql=True, + **kwargs + ) + + count = 0 + if v_return_count: + count_sql = select(func.count()).select_from(sql.alias()) + count_queryset = await self.db.execute(count_sql) + count = count_queryset.one()[0] + if limit != 0: sql = sql.offset((page - 1) * limit).limit(limit) - queryset = await self.db.execute(sql) + + queryset = await self.db.scalars(sql) + + if v_return_scalars: + if v_return_count: + return queryset, count + return queryset + + if v_options: + result = queryset.unique().all() + else: + result = queryset.all() + if v_return_objs: - return queryset.scalars().unique().all() - return [await self.out_dict(i, v_schema=v_schema) for i in queryset.scalars().unique().all()] + if v_return_count: + return list(result), count + return list(result) + + datas = [await self.out_dict(i, v_schema=v_schema) for i in result] + if v_return_count: + return datas, count + return datas async def get_count( self, - v_options: list = None, - v_join_query: dict = None, - v_or: list[tuple] = None, + v_select_from: list[Any] = None, + v_join: list[list[str | InstrumentedAttribute, BinaryExpression | None]] = None, + v_outerjoin: list[list[str | InstrumentedAttribute, BinaryExpression | None]] = None, + v_where: list[BinaryExpression] = None, **kwargs ) -> int: """ 获取数据总数 - :param v_options: 指示应使用select在预加载中加载给定的属性。 - :param v_join_query: 外键字段查询 - :param v_or: 或逻辑查询 + :param v_select_from: 用于指定查询从哪个表开始,通常与 .join() 等方法一起使用。 + :param v_join: 创建内连接(INNER JOIN)操作,返回两个表中满足连接条件的交集。 + :param v_outerjoin: 用于创建外连接(OUTER JOIN)操作,返回两个表中满足连接条件的并集,包括未匹配的行,并用 NULL 值填充。 + :param v_where: 当前表查询条件,原始表达式 :param kwargs: 查询参数 """ - sql = select(func.count(self.model.id).label('total')).where(self.model.is_delete == False) - sql = self.add_filter_condition(sql, v_options, v_join_query, v_or, **kwargs) + v_start_sql = select(func.count(self.model.id)) + sql = await self.filter_core( + v_start_sql=v_start_sql, + v_select_from=v_select_from, + v_join=v_join, + v_outerjoin=v_outerjoin, + v_where=v_where, + v_return_sql=True, + **kwargs + ) queryset = await self.db.execute(sql) - return queryset.one()['total'] + return queryset.one()[0] - async def create_data(self, data, v_options: list = None, v_return_obj: bool = False, v_schema: Any = None): + async def create_data( + self, + data, + v_options: list[_AbstractLoad] = None, + v_return_obj: bool = False, + v_schema: Any = None + ) -> Any: """ - 创建数据 + 创建单个数据 + :param data: 创建数据 :param v_options: 指示应使用select在预加载中加载给定的属性。 :param v_schema: ,指定使用的序列化对象 @@ -165,14 +234,25 @@ class DalBase: await self.flush(obj) return await self.out_dict(obj, v_options, v_return_obj, v_schema) + # async def create_datas(self, datas: list[dict]) -> None: + # """ + # 批量创建数据,暂不启用 + # SQLAlchemy 2.0 批量插入不支持 MySQL 返回对象: + # https://docs.sqlalchemy.org/en/20/orm/queryguide/dml.html#getting-new-objects-with-returning + # + # :param datas: 字典数据列表 + # """ + # await self.db.execute(insert(self.model), datas) + # await self.db.flush() + async def put_data( self, data_id: int, data: Any, - v_options: list = None, + v_options: list[_AbstractLoad] = None, v_return_obj: bool = False, v_schema: Any = None - ): + ) -> Any: """ 更新单个数据 :param data_id: 修改行数据的 ID @@ -188,7 +268,7 @@ class DalBase: await self.flush(obj) return await self.out_dict(obj, None, v_return_obj, v_schema) - async def delete_datas(self, ids: list[int], v_soft: bool = False, **kwargs): + async def delete_datas(self, ids: list[int], v_soft: bool = False, **kwargs) -> None: """ 删除多条数据 :param ids: 数据集 @@ -207,100 +287,152 @@ class DalBase: await self.db.execute(delete(self.model).where(self.model.id.in_(ids))) await self.flush() - def add_filter_condition( - self, - sql: select, - v_options: list = None, - v_join_query: dict = None, - v_or: list[tuple] = None, - **kwargs - ) -> select: + async def flush(self, obj: Any = None) -> Any: """ - 添加过滤条件,以及内连接过滤条件 + 刷新到数据库 + """ + if obj: + self.db.add(obj) + await self.db.flush() + if obj: + await self.db.refresh(obj) + return obj - 当外键模型在查询模型中存在多个外键时,则需要添加onclause属性 - :param sql: + async def out_dict( + self, + obj: Any, + v_options: list[_AbstractLoad] = None, + v_return_obj: bool = False, + v_schema: Any = None + ) -> Any: + """ + 序列化 + :param obj: :param v_options: 指示应使用select在预加载中加载给定的属性。 - :param v_join_query: 外键字段查询,内连接 - :param v_or: 或逻辑 + :param v_return_obj: ,是否返回对象 + :param v_schema: ,指定使用的序列化对象 + :return: + """ + if v_options: + obj = await self.get_data(obj.id, v_options=v_options) + if v_return_obj: + return obj + if v_schema: + return v_schema.model_validate(obj).model_dump() + return self.schema.model_validate(obj).model_dump() + + async def filter_core( + self, + v_start_sql: SelectType = None, + v_select_from: list[Any] = None, + v_join: list[list[str | InstrumentedAttribute, BinaryExpression | None]] = None, + v_outerjoin: list[list[str | InstrumentedAttribute, BinaryExpression | None]] = None, + v_options: list[_AbstractLoad] = None, + v_where: list[BinaryExpression] = None, + v_order: str = None, + v_order_field: str = None, + v_return_sql: bool = False, + **kwargs + ) -> ScalarResult | SelectType: + """ + 数据过滤核心功能 + + :param v_start_sql: 初始 sql + :param v_select_from: 用于指定查询从哪个表开始,通常与 .join() 等方法一起使用。 + :param v_join: 创建内连接(INNER JOIN)操作,返回两个表中满足连接条件的交集。 + :param v_outerjoin: 用于创建外连接(OUTER JOIN)操作,返回两个表中满足连接条件的并集,包括未匹配的行,并用 NULL 值填充。 + :param v_options: 用于为查询添加附加选项,如预加载、延迟加载等。 + :param v_where: 当前表查询条件,原始表达式 + :param v_order: 排序,默认正序,为 desc 是倒叙 + :param v_order_field: 排序字段 + :param v_return_sql: 是否直接返回 sql + :return: 返回过滤后的总数居 或 sql + """ + if not isinstance(v_start_sql, SelectType): + v_start_sql = select(self.model).where(self.model.is_delete == False) + + sql = self.add_relation( + v_start_sql=v_start_sql, + v_select_from=v_select_from, + v_join=v_join, + v_outerjoin=v_outerjoin, + v_options=v_options + ) + + if v_where: + sql = sql.where(*v_where) + + sql = self.add_filter_condition(sql, **kwargs) + + if v_order_field and (v_order in self.ORDER_FIELD): + sql = sql.order_by(getattr(self.model, v_order_field).desc(), self.model.id.desc()) + elif v_order_field: + sql = sql.order_by(getattr(self.model, v_order_field), self.model.id) + elif v_order in self.ORDER_FIELD: + sql = sql.order_by(self.model.id.desc()) + + if v_return_sql: + return sql + + queryset = await self.db.scalars(sql) + + return queryset + + def add_relation( + self, + v_start_sql: SelectType, + v_select_from: list[Any] = None, + v_join: list[list[str | InstrumentedAttribute, BinaryExpression | None]] = None, + v_outerjoin: list[list[str | InstrumentedAttribute, BinaryExpression | None]] = None, + v_options: list[_AbstractLoad] = None, + ) -> SelectType: + """ + :param v_start_sql: 初始 sql + :param v_select_from: 用于指定查询从哪个表开始,通常与 .join() 等方法一起使用。 + :param v_join: 创建内连接(INNER JOIN)操作,返回两个表中满足连接条件的交集。 + :param v_outerjoin: 用于创建外连接(OUTER JOIN)操作,返回两个表中满足连接条件的并集,包括未匹配的行,并用 NULL 值填充。 + :param v_options: 用于为查询添加附加选项,如预加载、延迟加载等。 + """ + if v_select_from: + v_start_sql = v_start_sql.select_from(*v_select_from) + + if v_join: + for relation in v_join: + table = relation[0] + if isinstance(table, str): + table = getattr(self.model, table) + if len(relation) == 2: + v_start_sql = v_start_sql.join(table, relation[1]) + else: + v_start_sql = v_start_sql.join(table) + + if v_outerjoin: + for relation in v_outerjoin: + table = relation[0] + if isinstance(table, str): + table = getattr(self.model, table) + if len(relation) == 2: + v_start_sql = v_start_sql.outerjoin(table, relation[1]) + else: + v_start_sql = v_start_sql.outerjoin(table) + + if v_options: + v_start_sql = v_start_sql.options(*v_options) + + return v_start_sql + + def add_filter_condition(self, sql: SelectType, **kwargs) -> SelectType: + """ + 添加过滤条件 + :param sql: :param kwargs: 关键词参数 """ - v_select_from: Set[str] = set() - v_join: Set[str] = set() - v_join_left: Set[str] = set() - if v_join_query: - for key, value in v_join_query.items(): - foreign_key = self.key_models.get(key) - conditions = self.__dict_filter(foreign_key.get("model"), **value) - if conditions: - sql = sql.where(*conditions) - v_join.add(key) - - if v_or: - sql = self.__or_filter(sql, v_or, v_join_left, v_join) - - sql = self.__generate_join_conditions(sql, v_join, "join", v_select_from) - sql = self.__generate_join_conditions(sql, v_join_left, "outerjoin", v_select_from) - - # 多对多关系查询使用 - for item in v_select_from: - sql = sql.select_from(item) - - conditions = self.__dict_filter(self.model, **kwargs) + conditions = self.__dict_filter(**kwargs) if conditions: sql = sql.where(*conditions) - if v_options: - sql = sql.options(*[load for load in v_options]) return sql - def __generate_join_conditions(self, sql, model_keys: Set[str], join_type: str, v_select_from: []): - """ - 生成 join 条件 - """ - for item in model_keys: - foreign_key = self.key_models.get(item) - join = foreign_key.get("join", None) - model = foreign_key.get("model") - if join: - v_select_from.add(model) - model = join - if join_type == "join": - sql = sql.join(model, onclause=foreign_key.get("onclause")) - elif join_type == "outerjoin": - sql = sql.outerjoin(model, onclause=foreign_key.get("onclause")) - return sql - - def __or_filter(self, sql: select, v_or: list[tuple], v_join_left: Set[str], v_join: Set[str]): - """ - 或逻辑操作 - :param sql: - :param v_or: 或逻辑 - :param v_join_left: 左连接 - :param v_join: 内连接 - """ - or_list = [] - for item in v_or: - if len(item) == 2: - model = self.model - condition = {item[0]: item[1]} - or_list.extend(self.__dict_filter(model, **condition)) - elif len(item) == 4 and item[0] == "fk": - model = self.key_models.get(item[1]).get("model") - condition = {item[2]: item[3]} - conditions = self.__dict_filter(model, **condition) - if conditions: - or_list.extend(conditions) - v_join_left.add(item[1]) - if item[1] in v_join: - v_join.remove(item[1]) - else: - raise CustomException(msg="v_or 获取查询属性失败,语法错误!") - if or_list: - sql = sql.where(or_(i for i in or_list)) - return sql - - @staticmethod - def __dict_filter(model, **kwargs): + def __dict_filter(self, **kwargs) -> list[BinaryExpression]: """ 字典过滤 :param model: @@ -309,7 +441,7 @@ class DalBase: conditions = [] for field, value in kwargs.items(): if value is not None and value != "": - attr = getattr(model, field) + attr = getattr(self.model, field) if isinstance(value, tuple): if len(value) == 1: if value[0] == "None": @@ -341,31 +473,3 @@ class DalBase: else: conditions.append(attr == value) return conditions - - async def flush(self, obj: Any = None): - """ - 刷新到数据库 - """ - if obj: - self.db.add(obj) - await self.db.flush() - if obj: - await self.db.refresh(obj) - return obj - - async def out_dict(self, obj: Any, v_options: list = None, v_return_obj: bool = False, v_schema: Any = None): - """ - 序列化 - :param obj: - :param v_options: 指示应使用select在预加载中加载给定的属性。 - :param v_return_obj: ,是否返回对象 - :param v_schema: ,指定使用的序列化对象 - :return: - """ - if v_options: - obj = await self.get_data(obj.id, v_options=v_options) - if v_return_obj: - return obj - if v_schema: - return v_schema.model_validate(obj).model_dump() - return self.schema.model_validate(obj).model_dump() diff --git a/kinit-api/core/database.py b/kinit-api/core/database.py index 545deb2..92e5b8f 100644 --- a/kinit-api/core/database.py +++ b/kinit-api/core/database.py @@ -1,26 +1,26 @@ # -*- coding: utf-8 -*- # @version : 1.0 -# @Create Time : 2021/10/18 22:19 +# @Update Time : 2023/8/18 9:00 # @File : database.py # @IDE : PyCharm # @desc : SQLAlchemy 部分 """ 导入 SQLAlchemy 部分 -安装: pip install sqlalchemy -中文文档:https://www.osgeo.cn/sqlalchemy/ +安装: pip install sqlalchemy[asyncio] +官方文档:https://docs.sqlalchemy.org/en/20/intro.html#installation """ +from typing import AsyncGenerator from aioredis import Redis -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.ext.declarative import declared_attr, declarative_base -from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker, AsyncAttrs +from sqlalchemy.orm import DeclarativeBase, declared_attr from application.settings import SQLALCHEMY_DATABASE_URL, REDIS_DB_ENABLE, MONGO_DB_ENABLE from fastapi import Request from core.exception import CustomException from motor.motor_asyncio import AsyncIOMotorDatabase -def create_async_engine_session(database_url: str): +def create_async_engine_session(database_url: str) -> async_sessionmaker[AsyncSession]: """ 创建数据库会话 @@ -35,7 +35,7 @@ def create_async_engine_session(database_url: str): :param database_url: 数据库地址 :return: """ - engine = create_async_engine( + async_engine = create_async_engine( database_url, echo=False, pool_pre_ping=True, @@ -44,18 +44,30 @@ def create_async_engine_session(database_url: str): max_overflow=5, connect_args={} ) - return sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=True, class_=AsyncSession) + return async_sessionmaker( + autocommit=False, + autoflush=False, + bind=async_engine, + expire_on_commit=True, + class_=AsyncSession + ) -class Base: - """将表名改为小写""" +class Base(AsyncAttrs, DeclarativeBase): + """ + 创建基本映射类 + 稍后,我们将继承该类,创建每个 ORM 模型 + """ - @declared_attr - def __tablename__(self): - # 如果有自定义表名就取自定义,没有就取小写类名 - table_name = self.__tablename__ + @declared_attr.directive + def __tablename__(cls) -> str: + """ + 将表名改为小写 + 如果有自定义表名就取自定义,没有就取小写类名 + """ + table_name = cls.__tablename__ if not table_name: - model_name = self.__name__ + model_name = cls.__name__ ls = [] for index, char in enumerate(model_name): if char.isupper() and index != 0: @@ -65,29 +77,16 @@ class Base: return table_name -""" -创建基本映射类 -稍后,我们将继承该类,创建每个 ORM 模型 -""" -Model = declarative_base(name='Model', cls=Base) - -""" 附上两个SQLAlchemy教程 - -Python3+SQLAlchemy+Sqlite3实现ORM教程 - https://www.cnblogs.com/jiangxiaobo/p/12350561.html - -SQLAlchemy基础知识 Autoflush和Autocommit - https://www.jianshu.com/p/b219c3dd4d1e -""" - - -async def db_getter(): +async def db_getter() -> AsyncGenerator[AsyncSession, None]: """ 获取主数据库 数据库依赖项,它将在单个请求中使用,然后在请求完成后将其关闭。 + + 函数的返回类型被注解为 AsyncGenerator[int, None],其中 AsyncSession 是生成的值的类型,而 None 表示异步生成器没有终止条件。 """ async with create_async_engine_session(SQLALCHEMY_DATABASE_URL)() as session: + # 创建一个新的事务,半自动 commit async with session.begin(): yield session diff --git a/kinit-api/db/db_base.py b/kinit-api/db/db_base.py index 4ee9fbf..086ecd2 100644 --- a/kinit-api/db/db_base.py +++ b/kinit-api/db/db_base.py @@ -5,21 +5,27 @@ # @IDE : PyCharm # @desc : 数据库公共 ORM 模型 - -from core.database import Model -from sqlalchemy import Column, DateTime, Integer, func, Boolean +from datetime import datetime +from sqlalchemy.orm import Mapped, mapped_column +from core.database import Base +from sqlalchemy import DateTime, Integer, func, Boolean # 使用命令:alembic init alembic 初始化迁移数据库环境 # 这时会生成alembic文件夹 和 alembic.ini文件 -class BaseModel(Model): +class BaseModel(Base): """ 公共 ORM 模型,基表 """ __abstract__ = True - id = Column(Integer, primary_key=True, unique=True, comment='主键ID', index=True, nullable=False) - create_datetime = Column(DateTime, server_default=func.now(), comment='创建时间') - update_datetime = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment='更新时间') - delete_datetime = Column(DateTime, nullable=True, comment='删除时间') - is_delete = Column(Boolean, default=False, comment="是否软删除") + id: Mapped[int] = mapped_column(Integer, primary_key=True, comment='主键ID') + create_datetime: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), comment='创建时间') + update_datetime: Mapped[datetime] = mapped_column( + DateTime, + server_default=func.now(), + onupdate=func.now(), + comment='更新时间' + ) + delete_datetime: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment='删除时间') + is_delete: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否软删除") diff --git a/kinit-api/requirements.txt b/kinit-api/requirements.txt index 7be1bfd710ed5c8fbbefd95d62d632649e5ae774..0ee9177130106b99f5abfe15feba00de8e33870c 100644 GIT binary patch delta 82 zcmZ1{y-s?=Ar@|920aFIAU0w!+I*HJk&)4Gvp$2kU~)F6I;$CwWjuL0=Vx|f b21BsuM6PU3BM=LSjV9malHRPq-Nys~3iT3P delta 82 zcmZ1{y-s?=Ar@{U20aE#AU0w!*?g8Ik&)40vp$Ss)n`29wG6xg<9$aQ86*0419e!2kdN diff --git a/kinit-api/scripts/create_app/main.py b/kinit-api/scripts/create_app/main.py index 85b99eb..1782a45 100644 --- a/kinit-api/scripts/create_app/main.py +++ b/kinit-api/scripts/create_app/main.py @@ -18,7 +18,7 @@ class CreateApp: def __init__(self, path: str): """ - @params path: app 路径,根目录为apps,填写apps后面路径即可,例子:vadmin/auth + :param path: app 路径,根目录为apps,填写apps后面路径即可,例子:vadmin/auth """ self.app_path = os.path.join(self.APPS_ROOT, path) self.path = path @@ -46,7 +46,7 @@ class CreateApp: """ 创建 python 包 - @params path: 绝对路径 + :param path: 绝对路径 """ if self.exist(path): return diff --git a/kinit-api/scripts/initialize/data/init.xlsx b/kinit-api/scripts/initialize/data/init.xlsx index 562dda492bbad28a94813bc6a2df2d9e5687e877..9a138b81566dc16e1a0ec73090ca79333d7d05fd 100644 GIT binary patch delta 38300 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 RL5D+!6Nh!-)>QxM{SQv$pq~H$ delta 35054 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 diff --git a/kinit-api/scripts/initialize/initialize.py b/kinit-api/scripts/initialize/initialize.py index 1686b29..b04506e 100644 --- a/kinit-api/scripts/initialize/initialize.py +++ b/kinit-api/scripts/initialize/initialize.py @@ -7,13 +7,14 @@ # @desc : 简要说明 from enum import Enum +from sqlalchemy import insert from core.database import db_getter from utils.excel.excel_manage import ExcelManage from application.settings import BASE_DIR, VERSION import os from apps.vadmin.auth import models as auth_models from apps.vadmin.system import models as system_models -from sqlalchemy.sql.schema import Table +from apps.vadmin.help import models as help_models import subprocess @@ -76,20 +77,16 @@ class InitializeData: """ 生成数据 - @params table_name: 表名 - @params model: 数据表模型 + :param table_name: 表名 + :param model: 数据表模型 """ async_session = db_getter() db = await async_session.__anext__() - if isinstance(model, Table): - for data in self.datas.get(table_name): - await db.execute(model.insert().values(**data)) - else: - for data in self.datas.get(table_name): - db.add(model(**data)) - print(f"{table_name} 表数据已生成") + datas = self.datas.get(table_name) + await db.execute(insert(model), datas) await db.flush() await db.commit() + print(f"{table_name} 表数据已生成") async def generate_menu(self): """ @@ -113,7 +110,7 @@ class InitializeData: """ 生成用户 """ - await self.__generate_data("vadmin_auth_user_roles", auth_models.vadmin_user_roles) + await self.__generate_data("vadmin_auth_user_roles", auth_models.vadmin_auth_user_roles) async def generate_system_tab(self): """ @@ -139,6 +136,18 @@ class InitializeData: """ await self.__generate_data("vadmin_system_dict_details", system_models.VadminDictDetails) + async def generate_help_issue_category(self): + """ + 生成常见问题类别数据 + """ + await self.__generate_data("vadmin_help_issue_category", help_models.VadminIssueCategory) + + async def generate_help_issue(self): + """ + 生成常见问题详情数据 + """ + await self.__generate_data("vadmin_help_issue", help_models.VadminIssue) + async def run(self, env: Environment = Environment.pro): """ 执行初始化工作 @@ -152,4 +161,6 @@ class InitializeData: await self.generate_dict_type() await self.generate_system_config() await self.generate_dict_details() + await self.generate_help_issue_category() + await self.generate_help_issue() print(f"环境:{env} {VERSION} 数据已初始化完成") diff --git a/kinit-api/utils/excel/excel_manage.py b/kinit-api/utils/excel/excel_manage.py index 4d9974e..749fe99 100644 --- a/kinit-api/utils/excel/excel_manage.py +++ b/kinit-api/utils/excel/excel_manage.py @@ -14,7 +14,8 @@ from openpyxl import load_workbook, Workbook from application.settings import TEMP_DIR, TEMP_URL import hashlib import random -from openpyxl.styles import Alignment +from openpyxl.styles import Alignment, Font, PatternFill, Border, Side +from .excel_schema import AlignmentModel, FontModel, PatternFillModel class ExcelManage: @@ -116,6 +117,9 @@ class ExcelManage: """ if header: self.sheet.append(header) + pattern_fill_style = PatternFillModel(start_color='D9D9D9', end_color='D9D9D9', fill_type='solid') + font_style = FontModel(bold=True) + self.__set_row_style(1, len(header), pattern_fill_style=pattern_fill_style, font_style=font_style) for index, data in enumerate(rows): format_columns = { "date_columns": [] @@ -123,11 +127,12 @@ class ExcelManage: for i in range(0, len(data)): if isinstance(data[i], datetime.datetime): data[i] = data[i].strftime("%Y/%m/%d %H:%M:%S") - format_columns["date_columns"].append(i+1) + format_columns["date_columns"].append(i + 1) self.sheet.append(data) - self.__set_row_style(index+2, len(data)-1) - self.__set_row_format(index+2, format_columns) + self.__set_row_style(index + 2, len(data)) + self.__set_row_format(index + 2, format_columns) self.__auto_width() + self.__set_row_border() def save_excel(self, filename: str = None): """ @@ -148,22 +153,34 @@ class ExcelManage: # 返回访问路由 return f"{TEMP_URL}/{date}/{name}" - def __set_row_style(self, row: int, max_column: int): + def __set_row_style( + self, + row: int, + max_column: int, + alignment_style: AlignmentModel = AlignmentModel(), + font_style: FontModel = FontModel(), + pattern_fill_style: PatternFillModel = PatternFillModel() + ): """ 设置行样式 :param row: 行 :param max_column: 最大列 + :param alignment_style: 单元格内容的对齐设置 + :param font_style: 单元格内容的字体样式设置 + :param pattern_fill_style: 单元格的填充模式设置 """ for index in range(0, max_column): - # 设置单元格对齐方式 - # Alignment(horizontal=水平对齐模式,vertical=垂直对齐模式,text_rotation=旋转角度,wrap_text=是否自动换行) - alignment = Alignment(horizontal='center', vertical='center', text_rotation=0, wrap_text=False) - self.sheet.cell(row=row, column=index+1).alignment = alignment + alignment = Alignment(**alignment_style.model_dump()) + font = Font(**font_style.model_dump()) + pattern_fill = PatternFill(**pattern_fill_style.model_dump()) + self.sheet.cell(row=row, column=index + 1).alignment = alignment + self.sheet.cell(row=row, column=index + 1).font = font + self.sheet.cell(row=row, column=index + 1).fill = pattern_fill def __set_row_format(self, row: int, columns: dict): """ - 设置行样式 + 格式化行数据类型 :param row: 行 :param columns: 列数据 @@ -171,6 +188,22 @@ class ExcelManage: for index in columns.get("date_columns", []): self.sheet.cell(row=row, column=index).number_format = "yyyy/mm/dd h:mm:ss" + def __set_row_border(self): + """ + 设置行边框 + """ + # 创建 Border 对象并设置边框样式 + border = Border( + left=Side(border_style="thin", color="000000"), + right=Side(border_style="thin", color="000000"), + top=Side(border_style="thin", color="000000"), + bottom=Side(border_style="thin", color="000000") + ) + # 设置整个表格的边框 + for row in self.sheet.iter_rows(): + for cell in row: + cell.border = border + def __auto_width(self): """ 设置自适应列宽 diff --git a/kinit-api/utils/excel/excel_schema.py b/kinit-api/utils/excel/excel_schema.py new file mode 100644 index 0000000..e3cbd76 --- /dev/null +++ b/kinit-api/utils/excel/excel_schema.py @@ -0,0 +1,65 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# @version : 1.0 +# @Create Time : 2023/08/24 22:19 +# @File : excel_schema.py +# @IDE : PyCharm +# @desc : + + +from pydantic import BaseModel, Field + + +class AlignmentModel(BaseModel): + horizontal: str = Field('center', description="水平对齐方式。可选值:'left'、'center'、'right'、'justify'、'distributed'") + vertical: str = Field('center', description="垂直对齐方式。可选值:'top'、'center'、'bottom'、'justify'、'distributed'") + textRotation: int = Field(0, description="文本旋转角度(以度为单位)。默认为 0。") + wrapText: bool = Field(None, description="自动换行文本。设置为 True 时,如果文本内容超出单元格宽度,会自动换行显示。") + shrinkToFit: bool = Field( + None, + description="缩小字体以适应单元格。设置为 True 时,如果文本内容超出单元格宽度,会自动缩小字体大小以适应。" + ) + indent: int = Field(0, description="文本缩进级别。默认为 0。") + relativeIndent: int = Field(0, description="相对缩进级别。默认为 0。") + justifyLastLine: bool = Field( + None, + description="对齐换行文本的末尾行。设置为 True 时,如果设置了文本换行,末尾的行也会被对齐。" + ) + readingOrder: int = Field(0, description="阅读顺序。默认为 0。") + + class Config: + title = "对齐设置模型" + description = "用于设置单元格内容的对齐样式。" + + +class FontModel(BaseModel): + name: str = Field(None, description="字体名称") + size: float = Field(None, description="字体大小(磅为单位)") + bold: bool = Field(None, description="是否加粗") + italic: bool = Field(None, description="是否斜体") + underline: str = Field(None, description="下划线样式。可选值:'single'、'double'、'singleAccounting'、'doubleAccounting'") + strikethrough: bool = Field(None, description="是否有删除线") + outline: bool = Field(None, description="是否轮廓字体") + shadow: bool = Field(None, description="是否阴影字体") + condense: bool = Field(None, description="是否压缩字体") + extend: bool = Field(None, description="是否扩展字体") + vertAlign: str = Field(None, description="垂直对齐方式。可选值:'superscript'、'subscript'、'baseline'") + color: dict = Field(None, description="字体颜色") + scheme: str = Field(None, description="字体方案。可选值:'major'、'minor'") + charset: int = Field(None, description="字符集编号") + family: int = Field(None, description="字体族编号") + + class Config: + title = "字体设置模型" + description = "用于设置单元格内容的字体样式" + + +class PatternFillModel(BaseModel): + start_color: str = Field(None, description="起始颜色(RGB 值或颜色名称)") + end_color: str = Field(None, description="结束颜色(RGB 值或颜色名称)") + fill_type: str = Field("solid", description="填充类型('none'、'solid'、'darkDown' 等)") + + class Config: + title = "填充模式设置模型" + description = "单元格的填充模式设置" + diff --git a/kinit-api/utils/send_email.py b/kinit-api/utils/send_email.py index 14d9c49..1e469ed 100644 --- a/kinit-api/utils/send_email.py +++ b/kinit-api/utils/send_email.py @@ -43,7 +43,9 @@ class EmailSender: try: self.server.login(self.email, self.password) except smtplib.SMTPAuthenticationError: - raise CustomException("邮箱服务器认证失败!") + raise CustomException("邮件发送失败,邮箱服务器认证失败!") + except AttributeError: + raise CustomException("邮件发送失败,邮箱服务器认证失败!") async def send_email(self, to_emails: List[str], subject: str, body: str, attachments: List[str] = None): """