diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1d17dae --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.venv diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5c61a62 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 +max_line_length = 100 + +[*.{yml,yaml,json,js,css,html}] +indent_size = 2 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..7a245db --- /dev/null +++ b/.flake8 @@ -0,0 +1,15 @@ +[flake8] +max-line-length = 120 +select = C,E,F,W,B,B9 +ignore = E203, E501, W503, E712, E711 +per-file-ignores = + __init__.py: F401 + app/core/exception.py: F811 + app/cli/plugin/init.py: W605 +exclude = + .git, + __pycache__, + build, + dist, + tests, + .venv diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 9806f8c..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: 提出一个bug -about: 提出bug帮助我们完善项目 ---- - -**描述 bug** - -- 你是如何操作的? -- 发生了什么? -- 你觉得应该出现什么? - -**你使用哪个版本出现该问题?** - -如果使用`master`,请表明是 master 分支,否则给出具体的版本号 - -**如何再现** - -If your bug is deterministic, can you give a minimal reproducing code? -Some bugs are not deterministic. Can you describe with precision in which context it happened? -If this is possible, can you share your code? - -如果你确定存在这个 bug,你能提供我们一个最小的实现代码吗? -一些 bug 是不确定,只会在某些条件下触发,你能详细描述一下具体的情况和提供复现的步骤吗? -当然如果你提供在线的 repo,那就再好不过了。 - -如果你发现了 bug,并修复了它,请用`git rebase`合并成一条标准的`fix: description`提交,然后向我们的 -项目提 PR,我们会在第一时间审核,并感谢您的参与。 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 4f8cfa0..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: 提出新特性 -about: 对项目的发展提出建议 ---- - -CMS 是一个颇为复杂的应用,它需要的东西太多。我们无法涉及到方方面面,因此关于新特性,我们会以讨论的形式来确定这个特性是否去实现,以什么形式实现。 -我们鼓励所有对这个特性感兴趣的人来参与讨论,当然如果你想参与特性的开发那就更好了。 - -如果你实现了一个 feature,并通过了单元测试,请用`git rebase`合并成一条标准的`feat: description`提交,然后向我们的 -项目提 PR,我们会在第一时间审核,并感谢您的参与。 - -**请问这个特性跟什么问题相关? 有哪些应用场景?请详细描述。** -请清晰准确的描述问题的内容,以及真实的场景。 - -**请描述一下你想怎么实现这个特性** -怎么样去实现这个特性?加入核心库?加入工程项目?还是其他方式。 -当然你也可以描述它的具体实现. - -**讨论** -如果这个特性应用场景非常多,或者非常重要,我们会第一时间去处理。但更多的我们希望更多的人参与讨论,来斟酌它的可行性。 diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index b247778..0000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: 提出问题 -about: 关于项目的疑问 ---- - -请详细描述您对本项目的任何问题,我们会在第一时间查阅和解决。 diff --git a/.gitignore b/.gitignore index 8b4c712..618d387 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,8 @@ -# .env 中记录了私密配置 -# *.env - +# OS .DS_Store -.idea -.vscode -*.pytest_cache/ -*.pyc -assets -logs - -# Created by https://www.toptal.com/developers/gitignore/api/python -# Edit at https://www.toptal.com/developers/gitignore?templates=python -### Python ### # Byte-compiled / optimized / DLL files __pycache__/ -*/__pycache__/ *.py[cod] *$py.class @@ -30,6 +17,7 @@ dist/ downloads/ eggs/ .eggs/ +lib/ lib64/ parts/ sdist/ @@ -54,7 +42,9 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ -test.json +.tox/ +.nox/ +.coverage .coverage.* .cache nosetests.xml @@ -63,7 +53,6 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ -pytestdebug.log # Translations *.mo @@ -74,7 +63,6 @@ pytestdebug.log local_settings.py db.sqlite3 db.sqlite3-journal -*.db # Flask stuff: instance/ @@ -85,7 +73,6 @@ instance/ # Sphinx documentation docs/_build/ -doc/_build/ # PyBuilder target/ @@ -118,6 +105,7 @@ celerybeat.pid *.sage.py # Environments +.env .venv env/ venv/ @@ -143,8 +131,22 @@ dmypy.json # Pyre type checker .pyre/ -# pytype static type analyzer -.pytype/ +# IDE +.vscode/ +.idea/ +.vim/ -# End of https://www.toptal.com/developers/gitignore/api/python +# custom +assets +logs + +*.pyc +# poetry lock +poetry.lock +# db +lincmsdev.db +lincmsprod.db +lincms.db +# .env 中记录了私密配置 +# *.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5a390e1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: +- repo: https://github.com/PyCQA/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + exclude: tests +- repo: https://github.com/psf/black + rev: 21.10b0 + hooks: + - id: black +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: fix-byte-order-marker + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c1bc75e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.9 +# 拷贝依赖 +COPY requirements-prod.txt . +# 安装依赖 +# RUN pip install -r requirements-prod.txt -i http://mirrors.aliyun.com/pypi/simple/ --trusted-host=mirrors.aliyun.com >/dev/null 2>&1 +RUN pip install -r requirements-prod.txt >/dev/null 2>&1 +# 拷贝项目 +COPY . /app diff --git a/LICENSE b/LICENSE index 1a07553..68b0003 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,20 @@ +ISC License + +Copyright (c) 2016, Kenneth Reitz + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +=== https://github.com/kennethreitz/records(v0.5.3) === + MIT License Copyright (c) 2019 TaleLin @@ -18,4 +35,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index a639680..6f4654b 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,9 @@

一个简单易用的CMS后端项目 | Lin-CMS-Flask

- - flask version - lin--cms version - LISENCE + + Flask version + LISENCE

@@ -22,7 +21,7 @@

- 简介 | 快速开始 | 下个版本开发计划 + 简介 | 快速起步 

## 简介 @@ -33,16 +32,6 @@ Lin-CMS 是林间有风团队经过大量项目实践所提炼出的一套**内 本项目是 Lin CMS 后端的 Flask 实现,需要前端?请访问[前端仓库](https://github.com/TaleLin/lin-cms-vue)。 -### 当前最新版本 - -lin-cms-flask(当前示例工程):0.3.2 - -lin-cms(核心库) :0.3.1 - -### 文档地址 - -[https://doc.cms.talelin.com/start/flask/](https://doc.cms.talelin.com/start/flask/) - ### 线上 demo [http://face.cms.talelin.com/](http://face.cms.talelin.com/) @@ -61,11 +50,11 @@ QQ 群号:643205479 ### Lin CMS 的特点 -Lin CMS 的构筑思想是有其自身特点的。下面我们阐述一些 Lin 的主要特点。 +Lin CMS 的构筑思想是有其自身特点的。 #### Lin CMS 是一个前后端分离的 CMS 解决方案 -这意味着,Lin 既提供后台的支撑,也有一套对应的前端系统,当然双端分离的好处不仅仅在于此,我们会在后续提供`Java`版本的 Lin。如果您心仪 Lin,却又因为技术栈的原因无法即可使用,没关系,我们会在后续提供更多的语言版本。为什么 Lin 要选择前后端分离的单页面架构呢? +这意味着,Lin CMS 既提供后台的支撑,也有一套对应的前端系统,当然双端分离的好处不仅仅在于此。如果您心仪 Lin,却又因为技术栈的原因无法即可使用,没关系,我们也提供了更多的语言版本和框架的后端实现。为什么 Lin 要选择前后端分离的单页面架构呢? 首先,传统的网站开发更多的是采用服务端渲染的方式,需用使用一种模板语言在服务端完成页面渲染:比如 JinJa2、Jade 等。 服务端渲染的好处在于可以比较好的支持 SEO,但作为内部使用的 CMS 管理系统,SEO 并不重要。 @@ -92,111 +81,9 @@ Lin CMS 除了内置常见的功能外,还提供了一套开发规范与工具 #### 前端组件库支持 -Lin 还将提供一套类似于 Vue Element 的前端组件库,以方便前端开发者快速开发。相比于 Vue Element 或 iView 等成熟的组件库,Lin 所提供的组件库将针对 Lin CMS 的整体设计风格、交互体验等作出大量的优化,使用 Lin 的组件库将更容易开发出体验更好的 CMS 系统。当然,Lin 本身不限制开发者选用任何的组件库,您完全可以根据自己的喜好/习惯/熟悉度,去选择任意的一个基于 Vue 的组件库,比如前面提到的 Vue Element 和 iView 等。您甚至可以混搭使用。当然,前提是这些组件库是基于 Vue 的。 +Lin CMS 还将提供一套类似于 Vue Element 的前端组件库,以方便前端开发者快速开发。相比于 Vue Element 或 iView 等成熟的组件库,Lin 所提供的组件库将针对 Lin CMS 的整体设计风格、交互体验等作出大量的优化,使用 Lin CMS 的组件库将更容易开发出体验更好的 CMS 系统。当然,Lin CMS 本身不限制开发者选用任何的组件库,您完全可以根据自己的喜好/习惯/熟悉度,去选择任意的一个基于 Vue 的组件库,比如前面提到的 Vue Element 和 iView 等。您甚至可以混搭使用。当然,前提是这些组件库是基于 Vue 的。 #### 完善的文档 -我们将提供详尽的文档来帮助开发者使用 Lin - -### 所需基础 - -由于 Lin 采用的是前后端分离的架构,所以您至少需要熟悉 Python 和 Vue。 - -Lin 的服务端框架是基于 Python Flask 的,所以如果您比较熟悉 Flask 的开发模式,那将可以更好的使用 Lin。但如果您并不熟悉 Flask,我们认为也没有太大的关系,因为 Lin 本身已经提供了一套完整的开发机制,您只需要在 Lin 的框架下用 Python 来编写自己的业务代码即可。照葫芦画瓢应该就是这种感觉。 - -但前端不同,前端还是需要开发者比较熟悉 Vue 的。但我想以 Vue 在国内的普及程度,绝大多数的开发者是没有问题的。这也正是我们选择 Vue 作为前端框架的原因。如果您喜欢 React Or Angular,那么加入我们,为 Lin 开发一个对应的版本吧。 - -## 快速开始 - -### Server 端必备环境 - -- 安装`Python`环境(version: 3.6+) - -### 获取工程项目 - -打开您的命令行工具(terminal),在其中键入: - -```bash -git clone https://github.com/TaleLin/lin-cms-flask.git starter -``` - -> **Tips:** -> -> 我们以 `starter` 作为工程名,当然您也可以以任意您喜爱的名字作为工程名。 -> -> 如果您想以某个版本,如`0.0.1`版,作为起始项目,那么请在 github 上的版本页下载相应的版本即可。 - -### 安装依赖包 - -进入项目目录,调用环境中的 pip 来安装依赖包: - -```bash -pip install -r requirements-${env}.txt -``` - -### 数据库配置 - -#### 默认使用 Sqlite3 - -Lin 默认启用 Sqlite3 数据库,打开项目根目录下的.env 文件(我们提供了开发环境的`.development.env`和生产环境的`.production.env`),配置其`SQLALCHEMY_DATABASE_URI` - -> Tips: 下面我们用{env}指代配置对应的环境 - -```conf -# 数据库配置示例 - SQLALCHEMY_DATABASE_URI='sqlite:///relative/path/to/file.db' - - or - - SQLALCHEMY_DATABASE_URI='sqlite:////absolute/path/to/file.db' -``` - -这将在项目的最外层目录生成名为`lincms${env}.db`的 Sqlite3 数据库文件。 - -#### 使用 MySQL - -**Tips:** 默认的依赖中不包含 Python 的 Mysql 库,如有需要,请自行在您的运行环境中安装它(如`pymysql`或`cymysql`等)。 - -Lin 需要您自己在 MySQL 中新建一个数据库,名字由您自己决定(例如`lincms`)。 - -创建数据库后,打开项目根目录下的`.${env}.env`文件,配置对应的`SQLALCHEMY_DATABASE_URI`。 - -如下所示: - -```conf -# 数据库配置示例: '数据库+驱动库://用户名:密码@主机:端口/数据库名' -SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@localhost:3306/lincms' -``` - -> 您所使用的数据库账号必须具有创建数据表的权限,否则 Lin 将无法为您自动创建数据表 - -### 初始化 - -如果您是第一次使用 **`Lin-CMS`**,需要初始化数据库。 - -请先进入项目根目录,然后执行`flask db init`,用来添加超级管理员 root(默认密码 123456), 以及新建其他必要的分组 - -> **Tips:** -> 如果您需要一些业务样例数据,可以执行脚本`flask db fake`添加它 - -### 运行 - -一切就绪后,再次从命令行中执行 - -```bash -flask run -``` - -如果一切顺利,您将在命令行中看到项目成功运行的信息。如果您没有修改代码,Lin 将默认在本地启动一个端口号为 5000 的端口用来监听请求。此时,我们访问`http://localhost:5000`,将看到一组字符: - -“心上无垢,林间有风" - -点击“心上无垢”,将跳转到`Redoc`页面;点击“林间有风”,跳转到`Swagger`页面。 - -这证明您已经成功的将服务运行起来了,Congratulations! - -## 后续开发计划 - -- [x] 重构插件机制 -- [x] 新增七牛上传插件 -- [ ] 扩展行为日志 +我们将提供尽可能完善的[文档](https://doc.cms.talelin.com) +来帮助开发者使用 Lin CMS diff --git a/app/__init__.py b/app/__init__.py index 77f93a6..ce60bae 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,9 +3,13 @@ :license: MIT, see LICENSE for more details. """ +import os + from dotenv import load_dotenv from flask import Flask +from app.util.common import basedir + def register_blueprints(app): from app.api.cms import create_cms @@ -23,7 +27,7 @@ def register_cli(app): def register_api(app): - from lin.apidoc import api + from app.api import api api.register(app) @@ -34,6 +38,12 @@ def apply_cors(app): CORS(app) +def init_socketio(app): + from app.extension.notify.socketio import socketio + + socketio.init_app(app, cors_allowed_origins="*") + + def load_app_config(app): """ 根据指定配置环境自动加载对应环境变量和配置类到app config @@ -41,7 +51,7 @@ def load_app_config(app): # 根据传入环境加载对应配置 env = app.config.get("ENV") # 读取 .env - load_dotenv(".{env}.env".format(env=env)) + load_dotenv(os.path.join(basedir, ".{env}.env").format(env=env)) # 读取配置类 app.config.from_object( "app.config.{env}.{Env}Config".format(env=env, Env=env.capitalize()) @@ -49,7 +59,7 @@ def load_app_config(app): def set_global_config(**kwargs): - from lin.config import global_config + from lin import global_config # 获取config_*参数对象并挂载到脱离上下文的global config for k, v in kwargs.items(): @@ -58,17 +68,19 @@ def set_global_config(**kwargs): def create_app(register_all=True, **kwargs): + # 全局配置优先生效 + set_global_config(**kwargs) # http wsgi server托管启动需指定读取环境配置 - load_dotenv(".flaskenv") - app = Flask(__name__, static_folder="../assets") + load_dotenv(os.path.join(basedir, ".flaskenv")) + app = Flask(__name__, static_folder=os.path.join(basedir, "assets")) load_app_config(app) if register_all: from lin import Lin - set_global_config(**kwargs) register_blueprints(app) register_api(app) apply_cors(app) + init_socketio(app) Lin(app, **kwargs) register_cli(app) return app diff --git a/app/api/__init__.py b/app/api/__init__.py index 53b4b45..bf7ecef 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1,4 +1,32 @@ """ - :copyright: © 2020 by the Lin team. + :copyright: © 2021 by the Lin team. :license: MIT, see LICENSE for more details. """ + +from lin import SpecTree +from spectree import SecurityScheme + +api = SpecTree( + backend_name="flask", + title="Lin-CMS-Flask", + mode="strict", + version="0.4.0", + # OpenAPI对所有接口描述默认返回一个参数错误, http_status_code为400。 + validation_error_status=400, + annotations=True, + security_schemes=[ + SecurityScheme( + name="AuthorizationBearer", + data={ + "type": "http", + "scheme": "bearer", + }, + ), + ], + # swaggerUI中所有接口默认允许传递Headers的AuthorizationToken字段 + # 不需要在每个api.validate(security=...)中指定它 + # 但所有接口都会显示一把小锁 + # SECURITY={"AuthorizationBearer": []}, +) + +AuthorizationBearerSecurity = {"AuthorizationBearer": []} diff --git a/app/api/cms/__init__.py b/app/api/cms/__init__.py index 02eef9e..b36ae49 100644 --- a/app/api/cms/__init__.py +++ b/app/api/cms/__init__.py @@ -10,13 +10,13 @@ def create_cms(): cms = Blueprint("cms", __name__) - from .admin import admin_api - from .file import file_api - from .log import log_api - from .user import user_api + from app.api.cms.admin import admin_api + from app.api.cms.file import file_api + from app.api.cms.log import log_api + from app.api.cms.user import user_api - admin_api.register(cms) - user_api.register(cms) - log_api.register(cms) - file_api.register(cms) + cms.register_blueprint(admin_api, url_prefix="/admin") + cms.register_blueprint(user_api, url_prefix="/user") + cms.register_blueprint(log_api, url_prefix="/log") + cms.register_blueprint(file_api, url_prefix="/file") return cms diff --git a/app/api/cms/admin.py b/app/api/cms/admin.py index d2cd225..24df14e 100644 --- a/app/api/cms/admin.py +++ b/app/api/cms/admin.py @@ -6,43 +6,66 @@ """ import math -from flask import request -from lin import find_user, get_ep_infos, manager, permission_meta -from lin.db import db -from lin.enums import GroupLevelEnum -from lin.exception import Forbidden, NotFound, ParameterError, Success -from lin.jwt import admin_required -from lin.logger import Logger -from lin.redprint import Redprint +from flask import Blueprint, g +from lin import ( + DocResponse, + Forbidden, + GroupLevelEnum, + Logger, + NotFound, + ParameterError, + Success, + admin_required, + db, + manager, + permission_meta, +) from sqlalchemy import func -from app.util.page import get_page_from_query, paginate -from app.validator.form import ( - DispatchAuth, - DispatchAuths, - NewGroup, - RemoveAuths, - ResetPasswordForm, - UpdateGroup, - UpdateUserInfoForm, +from app.api import AuthorizationBearerSecurity, api +from app.api.cms.schema import ResetPasswordSchema +from app.api.cms.schema.admin import ( + AdminGroupListSchema, + AdminGroupPermissionSchema, + AdminUserPageSchema, + CreateGroupSchema, + GroupBaseSchema, + GroupIdWithPermissionIdListSchema, + GroupQuerySearchSchema, + UpdateUserInfoSchema, ) +from app.util.page import get_page_from_query -admin_api = Redprint("admin") +admin_api = Blueprint("admin", __name__) @admin_api.route("/permission") @permission_meta(name="查询所有可分配的权限", module="管理员", mount=False) @admin_required +@api.validate( + tags=["管理员"], + security=[AuthorizationBearerSecurity], +) def permissions(): - return get_ep_infos() + """ + 查询所有可分配的权限 + """ + return manager.get_ep_infos() @admin_api.route("/users") @permission_meta(name="查询所有用户", module="管理员", mount=False) @admin_required -def get_admin_users(): - start, count = paginate() - group_id = request.args.get("group_id") +@api.validate( + tags=["管理员"], + security=[AuthorizationBearerSecurity], + resp=DocResponse(r=AdminUserPageSchema), + before=GroupQuerySearchSchema.offset_handler, +) +def get_admin_users(query: GroupQuerySearchSchema): + """ + 查询所有用户 + """ # 根据筛选条件和分页,获取 用户id 与 用户组id 的一对多 数据 # 过滤root 分组 query_root_group_id = db.session.query(manager.group_model.id).filter( @@ -52,14 +75,14 @@ def get_admin_users(): manager.user_group_model.user_id.label("user_id"), func.group_concat(manager.user_group_model.group_id).label("group_ids"), ).filter(~manager.user_group_model.group_id.in_(query_root_group_id)) - if group_id: + if g.group_id: _user_groups_list = _user_groups_list.filter( - manager.user_group_model.group_id == group_id + manager.user_group_model.group_id == g.group_id ) user_groups_list = ( _user_groups_list.group_by(manager.user_group_model.user_id) - .offset(start) - .limit(count) + .offset(g.offset) + .limit(g.count) .all() ) @@ -68,8 +91,8 @@ def get_admin_users(): _total = db.session.query( func.count(func.distinct(manager.user_group_model.user_id)) ).filter(~manager.user_group_model.group_id.in_(query_root_group_id)) - if group_id: - _total = _total.filter(manager.user_group_model.group_id == group_id) + if g.group_id: + _total = _total.filter(manager.user_group_model.group_id == g.group_id) total = _total.scalar() # 获取本次需要返回的用户的数据 @@ -82,7 +105,7 @@ def get_admin_users(): user_dict[user.id] = user # 使用用户组来过滤,则不需要补全用户组信息 - if not group_id: + if not g.group_id: # 拿到本次请求返回用户的所有 用户组 id List group_ids = [ int(i) @@ -104,29 +127,32 @@ def get_admin_users(): user_dict[user_id].groups = groups items.append(user_dict[user_id]) - total_page = math.ceil(total / count) + total_page = math.ceil(total / g.count) page = get_page_from_query() - return { - "count": count, - "items": users, - "page": page, - "total": total, - "total_page": total_page, - } + return AdminUserPageSchema( + count=g.count, total=total, total_page=total_page, page=page, items=users + ) @admin_api.route("/user//password", methods=["PUT"]) @permission_meta(name="修改用户密码", module="管理员", mount=False) @admin_required -def change_user_password(uid): - form = ResetPasswordForm().validate_for_api() +@api.validate( + tags=["管理员"], + resp=DocResponse(NotFound("用户不存在"), Success("密码修改成功")), + security=[AuthorizationBearerSecurity], +) +def change_user_password(uid: int, json: ResetPasswordSchema): + """ + 修改用户密码 + """ - user = find_user(id=uid) - if user is None: + user = manager.find_user(id=uid) + if not user: raise NotFound("用户不存在") with db.auto_commit(): - user.reset_password(form.new_password.data) + user.reset_password(g.new_password) return Success("密码修改成功") @@ -135,7 +161,15 @@ def change_user_password(uid): @permission_meta(name="删除用户", module="管理员", mount=False) @Logger(template="管理员删除了一个用户") @admin_required +@api.validate( + tags=["管理员"], + security=[AuthorizationBearerSecurity], + resp=DocResponse(NotFound("用户不存在"), Success("删除成功"), Forbidden("用户不能删除")), +) def delete_user(uid): + """ + 删除用户 + """ user = manager.user_model.get(id=uid) if user is None: raise NotFound("用户不存在") @@ -154,19 +188,29 @@ def delete_user(uid): @admin_api.route("/user/", methods=["PUT"]) @permission_meta(name="管理员更新用户信息", module="管理员", mount=False) @admin_required -def update_user(uid): - form = UpdateUserInfoForm().validate_for_api() - +@api.validate( + tags=["管理员"], + security=[AuthorizationBearerSecurity], + resp=DocResponse( + NotFound("用户不存在"), + Success("更新成功"), + ParameterError("邮箱已被注册,请重新输入邮箱"), + ), +) +def update_user(uid: int, json: UpdateUserInfoSchema): + """ + 更新用户信息 + """ user = manager.user_model.get(id=uid) if user is None: raise NotFound("用户不存在") - if user.email != form.email.data: - exists = manager.user_model.get(email=form.email.data) + if user.email != g.email: + exists = manager.user_model.get(email=g.email) if exists: raise ParameterError("邮箱已被注册,请重新输入邮箱") with db.auto_commit(): - user.email = form.email.data - group_ids = form.group_ids.data + user.email = g.email + group_ids = g.group_ids # 清空原来的所有关联关系 manager.user_group_model.query.filter_by(user_id=user.id).delete( synchronize_session=False @@ -174,7 +218,7 @@ def update_user(uid): # 根据传入分组ids 新增关联记录 user_group_list = list() # 如果没传分组数据,则将其设定为 guest 分组 - if len(group_ids) == 0: + if not group_ids: group_ids = [manager.group_model.get(level=GroupLevelEnum.GUEST.value).id] for group_id in group_ids: user_group = manager.user_group_model() @@ -185,54 +229,20 @@ def update_user(uid): return Success("操作成功") -@admin_api.route("/group") -@permission_meta(name="查询所有分组及其权限", module="管理员", mount=False) -@admin_required -def get_admin_groups(): - start, count = paginate() - groups = ( - manager.group_model.query.filter( - manager.group_model.level != GroupLevelEnum.ROOT.value - ) - .offset(start) - .limit(count) - .all() - ) - if groups is None: - raise NotFound("不存在任何分组") - - for group in groups: - permissions = manager.permission_model.select_by_group_id(group.id) - setattr(group, "permissions", permissions) - group._fields.append("permissions") - - # root分组隐藏不显示 - total = ( - db.session.query(func.count(manager.group_model.id)) - .filter( - manager.group_model.level != GroupLevelEnum.ROOT.value, - manager.group_model.delete_time == None, - ) - .scalar() - ) - total_page = math.ceil(total / count) - page = get_page_from_query() - - return { - "count": count, - "items": groups, - "page": page, - "total": total, - "total_page": total_page, - } - - @admin_api.route("/group/all") @permission_meta(name="查询所有分组", module="管理员", mount=False) @admin_required +@api.validate( + tags=["管理员"], + security=[AuthorizationBearerSecurity], + resp=DocResponse(NotFound("不存在任何分组"), r=AdminGroupListSchema), +) def get_all_group(): + """ + 获取所有分组 + """ groups = manager.group_model.query.filter( - manager.group_model.delete_time == None, + manager.group_model.is_deleted == False, manager.group_model.level != GroupLevelEnum.ROOT.value, ).all() if groups is None: @@ -243,7 +253,15 @@ def get_all_group(): @admin_api.route("/group/") @permission_meta(name="查询一个分组及其权限", module="管理员", mount=False) @admin_required +@api.validate( + tags=["管理员"], + security=[AuthorizationBearerSecurity], + resp=DocResponse(NotFound("分组不存在"), r=AdminGroupPermissionSchema), +) def get_group(gid): + """ + 获取一个分组及其权限 + """ group = manager.group_model.get(id=gid, one=True, soft=False) if group is None: raise NotFound("分组不存在") @@ -257,19 +275,29 @@ def get_group(gid): @permission_meta(name="新建分组", module="管理员", mount=False) @Logger(template="管理员新建了一个分组") # 记录日志 @admin_required -def create_group(): - form = NewGroup().validate_for_api() - exists = manager.group_model.get(name=form.name.data) +@api.validate( + tags=["管理员"], + security=[AuthorizationBearerSecurity], + resp=DocResponse( + ParameterError("用户组已存在, 不可创建同名分组"), + Success("新建用户组成功"), + ), +) +def create_group(json: CreateGroupSchema): + """ + 新建分组 + """ + exists = manager.group_model.get(name=g.name) if exists: raise Forbidden("分组已存在,不可创建同名分组") with db.auto_commit(): group = manager.group_model.create( - name=form.name.data, - info=form.info.data, + name=g.name, + info=g.info, ) db.session.flush() group_permission_list = list() - for permission_id in form.permission_ids.data: + for permission_id in g.permission_ids: gp = manager.group_permission_model() gp.group_id = group.id gp.permission_id = permission_id @@ -281,20 +309,38 @@ def create_group(): @admin_api.route("/group/", methods=["PUT"]) @permission_meta(name="更新一个分组", module="管理员", mount=False) @admin_required -def update_group(gid): - form = UpdateGroup().validate_for_api() +@api.validate( + tags=["管理员"], + security=[AuthorizationBearerSecurity], + resp=DocResponse( + ParameterError("分组不存在,更新失败"), + Success("更新分组成功"), + ), +) +def update_group(gid, json: GroupBaseSchema): + """ + 更新一个分组基本信息 + """ exists = manager.group_model.get(id=gid) if not exists: raise NotFound("分组不存在,更新失败") - exists.update(name=form.name.data, info=form.info.data, commit=True) - return Success("更新分组成功") + exists.update(name=g.name, info=g.info, commit=True) + return Success("更新成功") @admin_api.route("/group/", methods=["DELETE"]) @permission_meta(name="删除一个分组", module="管理员", mount=False) @Logger(template="管理员删除一个分组") # 记录日志 @admin_required +@api.validate( + tags=["管理员"], + security=[AuthorizationBearerSecurity], + resp=DocResponse(NotFound("分组不存在,删除失败"), Forbidden("分组不可删除"), Success("删除分组成功")), +) def delete_group(gid): + """ + 删除一个分组 + """ exist = manager.group_model.get(id=gid) if not exist: raise NotFound("分组不存在,删除失败") @@ -314,35 +360,28 @@ def delete_group(gid): return Success("删除分组成功") -@admin_api.route("/permission/dispatch", methods=["POST"]) -@permission_meta(name="分配单个权限", module="管理员", mount=False) -@admin_required -def dispatch_auth(): - form = DispatchAuth().validate_for_api() - one = manager.group_permission_model.get( - group_id=form.group_id.data, permission_id=form.permission_id.data - ) - if one: - raise Forbidden("已有权限,不可重复添加") - manager.group_permission_model.create( - group_id=form.group_id.data, permission_id=form.permission_id.data, commit=True - ) - return Success("添加权限成功") - - @admin_api.route("/permission/dispatch/batch", methods=["POST"]) @permission_meta(name="分配多个权限", module="管理员", mount=False) @admin_required -def dispatch_auths(): - form = DispatchAuths().validate_for_api() +@api.validate( + tags=["管理员"], + security=[AuthorizationBearerSecurity], + resp=DocResponse( + Success("添加权限成功"), + ), +) +def dispatch_auths(json: GroupIdWithPermissionIdListSchema): + """ + 分配多个权限 + """ with db.auto_commit(): - for permission_id in form.permission_ids.data: + for permission_id in g.permission_ids: one = manager.group_permission_model.get( - group_id=form.group_id.data, permission_id=permission_id + group_id=g.group_id, permission_id=permission_id ) if not one: manager.group_permission_model.create( - group_id=form.group_id.data, + group_id=g.group_id, permission_id=permission_id, ) return Success("添加权限成功") @@ -351,13 +390,20 @@ def dispatch_auths(): @admin_api.route("/permission/remove", methods=["POST"]) @permission_meta(name="删除多个权限", module="管理员", mount=False) @admin_required -def remove_auths(): - form = RemoveAuths().validate_for_api() +@api.validate( + tags=["管理员"], + resp=DocResponse(Success("删除权限成功")), + security=[AuthorizationBearerSecurity], +) +def remove_auths(json: GroupIdWithPermissionIdListSchema): + """ + 删除多个权限 + """ with db.auto_commit(): db.session.query(manager.group_permission_model).filter( - manager.group_permission_model.permission_id.in_(form.permission_ids.data), - manager.group_permission_model.group_id == form.group_id.data, + manager.group_permission_model.permission_id.in_(g.permission_ids), + manager.group_permission_model.group_id == g.group_id, ).delete(synchronize_session=False) return Success("删除权限成功") diff --git a/app/api/cms/exception/__init__.py b/app/api/cms/exception/__init__.py new file mode 100644 index 0000000..8a16dae --- /dev/null +++ b/app/api/cms/exception/__init__.py @@ -0,0 +1,7 @@ +from lin import Failed + + +class RefreshFailed(Failed): + message = "令牌刷新失败" + message_code = 10052 + _config = False diff --git a/app/api/cms/file.py b/app/api/cms/file.py index 97b4adf..821a982 100644 --- a/app/api/cms/file.py +++ b/app/api/cms/file.py @@ -2,18 +2,25 @@ :copyright: © 2020 by the Lin team. :license: MIT, see LICENSE for more details. """ -from flask import request -from lin.jwt import login_required -from lin.redprint import Redprint +from flask import Blueprint, request +from lin import login_required +from app.api import AuthorizationBearerSecurity, api from app.extension.file.local_uploader import LocalUploader -file_api = Redprint("file") +file_api = Blueprint("file", __name__) @file_api.route("", methods=["POST"]) @login_required +@api.validate( + tags=["文件"], + security=[AuthorizationBearerSecurity], +) def post_file(): + """ + 上传文件 + """ files = request.files uploader = LocalUploader(files) ret = uploader.upload() diff --git a/app/api/cms/log.py b/app/api/cms/log.py index 8220660..9899670 100644 --- a/app/api/cms/log.py +++ b/app/api/cms/log.py @@ -1,35 +1,29 @@ import math -from flask import g -from lin import permission_meta -from lin.apidoc import DocResponse, api -from lin.db import db -from lin.jwt import group_required -from lin.logger import Log -from lin.redprint import Redprint +from flask import Blueprint, g +from lin import DocResponse, Log, db, group_required, permission_meta from sqlalchemy import text -from app.validator.schema import ( - AuthorizationSchema, +from app.api import AuthorizationBearerSecurity, api +from app.api.cms.schema.log import ( LogPageSchema, LogQuerySearchSchema, UsernameListSchema, ) -log_api = Redprint("log") +log_api = Blueprint("log", __name__) @log_api.route("") @permission_meta(name="查询日志", module="日志") @group_required @api.validate( - headers=AuthorizationSchema, - query=LogQuerySearchSchema, resp=DocResponse(r=LogPageSchema), before=LogQuerySearchSchema.offset_handler, + security=[AuthorizationBearerSecurity], tags=["日志"], ) -def get_logs(): +def get_logs(query: LogQuerySearchSchema): """ 日志浏览查询(人员,时间, 关键字),分页展示 """ @@ -44,7 +38,7 @@ def get_logs(): page=g.page, count=g.count, total=total, - items=get_items_with_time_field(items), + items=items, total_page=total_page, ) @@ -53,13 +47,12 @@ def get_logs(): @permission_meta(name="搜索日志", module="日志") @group_required @api.validate( - headers=AuthorizationSchema, - query=LogQuerySearchSchema, resp=DocResponse(r=LogPageSchema), + security=[AuthorizationBearerSecurity], before=LogQuerySearchSchema.offset_handler, tags=["日志"], ) -def search_logs(): +def search_logs(query: LogQuerySearchSchema): """ 日志搜索(人员,时间, 关键字),分页展示 """ @@ -82,7 +75,7 @@ def search_logs(): page=g.page, count=g.count, total=total, - items=get_items_with_time_field(items), + items=items, total_page=total_page, ) @@ -91,8 +84,8 @@ def search_logs(): @permission_meta(name="查询日志记录的用户", module="日志") @group_required @api.validate( - headers=AuthorizationSchema, resp=DocResponse(r=UsernameListSchema), + security=[AuthorizationBearerSecurity], tags=["日志"], ) def get_users_for_log(): @@ -107,13 +100,3 @@ def get_users_for_log(): .all() ) return UsernameListSchema(items=[u.username for u in usernames]) - - -# TODO:临时time字段, 等待lin 核心库中调整后移除 -def get_items_with_time_field(items): - new_items = list() - for item in items: - item = dict(item) - item["time"] = item["create_time"] - new_items.append(item) - return new_items diff --git a/app/model/v1/__init__.py b/app/api/cms/model/__init__.py similarity index 100% rename from app/model/v1/__init__.py rename to app/api/cms/model/__init__.py diff --git a/app/model/lin/group.py b/app/api/cms/model/group.py similarity index 88% rename from app/model/lin/group.py rename to app/api/cms/model/group.py index 402d81a..54df89a 100644 --- a/app/model/lin/group.py +++ b/app/api/cms/model/group.py @@ -1,26 +1,26 @@ -from lin.model import Group as LinGroup -from lin.model import db, manager - - -class Group(LinGroup): - def _set_fields(self): - self._exclude = ["delete_time", "create_time", "update_time"] - - @classmethod - def select_by_user_id(cls, user_id) -> list: - """ - 根据用户Id,通过User-Group关联表,获取所属用户组对象列表 - """ - query = ( - db.session.query(manager.user_group_model.group_id) - .join( - manager.user_model, - manager.user_model.id == manager.user_group_model.user_id, - ) - .filter( - manager.user_model.delete_time == None, manager.user_model.id == user_id - ) - ) - result = cls.query.filter_by(soft=True).filter(cls.id.in_(query)) - groups = result.all() - return groups +from lin import Group as LinGroup +from lin import db, manager + + +class Group(LinGroup): + def _set_fields(self): + self._exclude = ["delete_time", "create_time", "update_time", "is_deleted"] + + @classmethod + def select_by_user_id(cls, user_id) -> list: + """ + 根据用户Id,通过User-Group关联表,获取所属用户组对象列表 + """ + query = ( + db.session.query(manager.user_group_model.group_id) + .join( + manager.user_model, + manager.user_model.id == manager.user_group_model.user_id, + ) + .filter( + manager.user_model.delete_time == None, manager.user_model.id == user_id + ) + ) + result = cls.query.filter_by(soft=True).filter(cls.id.in_(query)) + groups = result.all() + return groups diff --git a/app/model/lin/group_permission.py b/app/api/cms/model/group_permission.py similarity index 77% rename from app/model/lin/group_permission.py rename to app/api/cms/model/group_permission.py index 58126ec..4e16df6 100644 --- a/app/model/lin/group_permission.py +++ b/app/api/cms/model/group_permission.py @@ -1,14 +1,14 @@ -from lin.model import GroupPermission as LinGroupPermission -from lin.model import db, manager - - -class GroupPermission(LinGroupPermission): - @classmethod - def delete_batch_by_group_id_and_permission_ids( - cls, group_id, permission_ids: list, commit=False - ): - cls.query.filter_by(group_id=group_id).filter( - cls.permission_id.in_(permission_ids) - ).delete(synchronize_session=False) - if commit: - db.session.commit() +from lin import GroupPermission as LinGroupPermission +from lin import db + + +class GroupPermission(LinGroupPermission): + @classmethod + def delete_batch_by_group_id_and_permission_ids( + cls, group_id, permission_ids: list, commit=False + ): + cls.query.filter_by(group_id=group_id).filter( + cls.permission_id.in_(permission_ids) + ).delete(synchronize_session=False) + if commit: + db.session.commit() diff --git a/app/model/lin/permission.py b/app/api/cms/model/permission.py similarity index 92% rename from app/model/lin/permission.py rename to app/api/cms/model/permission.py index 92dd75b..38b2a3c 100644 --- a/app/model/lin/permission.py +++ b/app/api/cms/model/permission.py @@ -1,42 +1,42 @@ -from lin.model import Permission as LinPermission -from lin.model import db, manager - - -class Permission(LinPermission): - @classmethod - def select_by_group_id(cls, group_id) -> list: - """ - 传入用户组Id ,根据 Group-Permission关联表 获取 权限列表 - """ - query = db.session.query(manager.group_permission_model.permission_id).filter( - manager.group_permission_model.group_id == group_id - ) - result = cls.query.filter_by(soft=True, mount=True).filter(cls.id.in_(query)) - permissions = result.all() - return permissions - - @classmethod - def select_by_group_ids(cls, group_ids: list) -> list: - """ - 传入用户组Id列表 ,根据 Group-Permission关联表 获取 权限列表 - """ - query = db.session.query(manager.group_permission_model.permission_id).filter( - manager.group_permission_model.group_id.in_(group_ids) - ) - result = cls.query.filter_by(soft=True, mount=True).filter(cls.id.in_(query)) - permissions = result.all() - return permissions - - @classmethod - def select_by_group_ids_and_module(cls, group_ids: list, module) -> list: - """ - 传入用户组的 id 列表 和 权限模块名称,根据 Group-Permission关联表 获取 权限列表 - """ - query = db.session.query(manager.group_permission_model.permission_id).filter( - manager.group_permission_model.group_id.in_(group_ids) - ) - result = cls.query.filter_by(soft=True, module=module, mount=True).filter( - cls.id.in_(query) - ) - permissions = result.all() - return permissions +from lin import Permission as LinPermission +from lin import db, manager + + +class Permission(LinPermission): + @classmethod + def select_by_group_id(cls, group_id) -> list: + """ + 传入用户组Id ,根据 Group-Permission关联表 获取 权限列表 + """ + query = db.session.query(manager.group_permission_model.permission_id).filter( + manager.group_permission_model.group_id == group_id + ) + result = cls.query.filter_by(soft=True, mount=True).filter(cls.id.in_(query)) + permissions = result.all() + return permissions + + @classmethod + def select_by_group_ids(cls, group_ids: list) -> list: + """ + 传入用户组Id列表 ,根据 Group-Permission关联表 获取 权限列表 + """ + query = db.session.query(manager.group_permission_model.permission_id).filter( + manager.group_permission_model.group_id.in_(group_ids) + ) + result = cls.query.filter_by(soft=True, mount=True).filter(cls.id.in_(query)) + permissions = result.all() + return permissions + + @classmethod + def select_by_group_ids_and_module(cls, group_ids: list, module) -> list: + """ + 传入用户组的 id 列表 和 权限模块名称,根据 Group-Permission关联表 获取 权限列表 + """ + query = db.session.query(manager.group_permission_model.permission_id).filter( + manager.group_permission_model.group_id.in_(group_ids) + ) + result = cls.query.filter_by(soft=True, module=module, mount=True).filter( + cls.id.in_(query) + ) + permissions = result.all() + return permissions diff --git a/app/model/lin/user.py b/app/api/cms/model/user.py similarity index 79% rename from app/model/lin/user.py rename to app/api/cms/model/user.py index b401247..f462195 100644 --- a/app/model/lin/user.py +++ b/app/api/cms/model/user.py @@ -1,43 +1,44 @@ -from lin.model import User as LinUser -from lin.model import db, func, manager - - -class User(LinUser): - def _set_fields(self): - self._exclude = ["delete_time", "create_time", "update_time"] - - @classmethod - def count_by_username(cls, username) -> int: - result = db.session.query(func.count(cls.id)).filter( - cls.username == username, cls.delete_time == None - ) - count = result.scalar() - return count - - @classmethod - def count_by_email(cls, email) -> int: - result = db.session.query(func.count(cls.id)).filter( - cls.email == email, cls.delete_time == None - ) - count = result.scalar() - return count - - @classmethod - def select_page_by_group_id(cls, group_id, root_group_id) -> list: - """通过分组id分页获取用户数据""" - query = db.session.query(manager.user_group_model.user_id).filter( - manager.user_group_model.group_id == group_id, - manager.user_group_model.group_id != root_group_id, - ) - result = cls.query.filter_by(soft=True).filter(cls.id.in_(query)) - users = result.all() - return users - - def reset_password(self, new_password): - self.password = new_password - - def change_password(self, old_password, new_password): - if self.check_password(old_password): - self.password = new_password - return True - return False +from lin import User as LinUser +from lin import db, manager +from sqlalchemy import func + + +class User(LinUser): + def _set_fields(self): + self._exclude = ["delete_time", "create_time", "is_deleted", "update_time"] + + @classmethod + def count_by_username(cls, username) -> int: + result = db.session.query(func.count(cls.id)).filter( + cls.username == username, cls.is_deleted == False + ) + count = result.scalar() + return count + + @classmethod + def count_by_email(cls, email) -> int: + result = db.session.query(func.count(cls.id)).filter( + cls.email == email, cls.is_deleted == False + ) + count = result.scalar() + return count + + @classmethod + def select_page_by_group_id(cls, group_id, root_group_id) -> list: + """通过分组id分页获取用户数据""" + query = db.session.query(manager.user_group_model.user_id).filter( + manager.user_group_model.group_id == group_id, + manager.user_group_model.group_id != root_group_id, + ) + result = cls.query.filter_by(soft=True).filter(cls.id.in_(query)) + users = result.all() + return users + + def reset_password(self, new_password): + self.password = new_password + + def change_password(self, old_password, new_password): + if self.check_password(old_password): + self.password = new_password + return True + return False diff --git a/app/model/lin/user_group.py b/app/api/cms/model/user_group.py similarity index 78% rename from app/model/lin/user_group.py rename to app/api/cms/model/user_group.py index 8d28c9d..1710fdb 100644 --- a/app/model/lin/user_group.py +++ b/app/api/cms/model/user_group.py @@ -1,14 +1,14 @@ -from lin.model import UserGroup as LinUserGroup -from lin.model import db, manager - - -class UserGroup(LinUserGroup): - @classmethod - def delete_batch_by_user_id_and_group_ids( - cls, user_id, group_ids: list, commit=False - ): - cls.query.filter_by(user_id=user_id).filter(cls.group_id.in_(group_ids)).delete( - synchronize_session=False - ) - if commit: - db.session.commit() +from lin import UserGroup as LinUserGroup +from lin import db + + +class UserGroup(LinUserGroup): + @classmethod + def delete_batch_by_user_id_and_group_ids( + cls, user_id, group_ids: list, commit=False + ): + cls.query.filter_by(user_id=user_id).filter(cls.group_id.in_(group_ids)).delete( + synchronize_session=False + ) + if commit: + db.session.commit() diff --git a/app/api/cms/model/user_identity.py b/app/api/cms/model/user_identity.py new file mode 100644 index 0000000..7f3fa29 --- /dev/null +++ b/app/api/cms/model/user_identity.py @@ -0,0 +1,5 @@ +from lin import UserIdentity as LinUserIdentity + + +class UserIdentity(LinUserIdentity): + pass diff --git a/app/api/cms/schema/__init__.py b/app/api/cms/schema/__init__.py new file mode 100644 index 0000000..459c476 --- /dev/null +++ b/app/api/cms/schema/__init__.py @@ -0,0 +1,33 @@ +from typing import List, Optional + +from lin import BaseModel, ParameterError +from pydantic import EmailStr, Field, validator + + +class EmailSchema(BaseModel): + email: Optional[str] = Field(description="用户邮箱") + + @validator("email") + def check_email(cls, v, values, **kwargs): + return EmailStr.validate(v) if v else "" + + +class ResetPasswordSchema(BaseModel): + new_password: str = Field(description="新密码", min_length=6, max_length=22) + confirm_password: str = Field(description="确认密码", min_length=6, max_length=22) + + @validator("confirm_password") + def passwords_match(cls, v, values, **kwargs): + if v != values["new_password"]: + raise ParameterError("两次输入的密码不一致,请输入相同的密码") + return v + + +class GroupIdListSchema(BaseModel): + group_ids: List[int] = Field(description="用户组ID列表") + + @validator("group_ids", each_item=True) + def check_group_id(cls, v, values, **kwargs): + if v <= 0: + raise ParameterError("用户组ID必须大于0") + return v diff --git a/app/api/cms/schema/admin.py b/app/api/cms/schema/admin.py new file mode 100644 index 0000000..3951495 --- /dev/null +++ b/app/api/cms/schema/admin.py @@ -0,0 +1,77 @@ +from typing import List, Optional + +from lin import BaseModel, ParameterError +from pydantic import Field, validator + +from app.schema import BasePageSchema, QueryPageSchema + +from . import EmailSchema, GroupIdListSchema + + +class AdminGroupSchema(BaseModel): + id: int = Field(description="用户组ID") + info: str = Field(description="用户组信息") + name: str = Field(description="用户组名称") + + +class AdminGroupListSchema(BaseModel): + __root__: List[AdminGroupSchema] + + +class AdminUserSchema(EmailSchema): + id: int = Field(description="用户ID") + username: str = Field(description="用户名") + groups: List[AdminGroupSchema] = Field(description="用户组列表") + + +class AdminUserPageSchema(BasePageSchema): + items: List[AdminUserSchema] + + +class GroupQuerySearchSchema(QueryPageSchema): + group_id: Optional[int] = Field(gt=0, description="用户组ID") + + +class UpdateUserInfoSchema(GroupIdListSchema, EmailSchema): + pass + + +class PermissionSchema(BaseModel): + id: int = Field(description="权限ID") + name: str = Field(description="权限名称") + module: str = Field(description="权限所属模块") + mount: bool = Field(description="是否为挂载权限") + + +class AdminGroupPermissionSchema(AdminGroupSchema): + permissions: List[PermissionSchema] + + +class AdminGroupPermissionPageSchema(BasePageSchema): + items: List[AdminGroupPermissionSchema] + + +class GroupBaseSchema(BaseModel): + name: str = Field(description="用户组名称") + info: Optional[str] = Field(description="用户组信息") + + +class CreateGroupSchema(GroupBaseSchema): + permission_ids: List[int] = Field(description="权限ID列表") + + @validator("permission_ids", each_item=True) + def check_permission_id(cls, v, values, **kwargs): + if v <= 0: + raise ParameterError("权限ID必须大于0") + return v + + +class GroupIdWithPermissionIdListSchema(BaseModel): + group_id: int = Field(description="用户组ID") + permission_ids: List[int] = Field(description="权限ID列表") + + @validator("permission_ids", each_item=True) + def check_permission_id(cls, v, values, **kwargs): + if v <= 0: + raise ParameterError("权限ID必须大于0") + return v diff --git a/app/api/cms/schema/log.py b/app/api/cms/schema/log.py new file mode 100644 index 0000000..bd2a092 --- /dev/null +++ b/app/api/cms/schema/log.py @@ -0,0 +1,40 @@ +import re +from datetime import datetime +from typing import List, Optional + +from lin import BaseModel +from pydantic import Field, validator + +from app.schema import BasePageSchema, QueryPageSchema, datetime_regex + + +class UsernameListSchema(BaseModel): + items: List[str] + + +class LogQuerySearchSchema(QueryPageSchema): + keyword: Optional[str] = None + name: Optional[str] = None + start: Optional[str] = Field(None, description="YY-MM-DD HH:MM:SS") + end: Optional[str] = Field(None, description="YY-MM-DD HH:MM:SS") + + @validator("start", "end") + def datetime_match(cls, v): + if re.match(datetime_regex, v): + return v + raise ValueError("时间格式有误") + + +class LogSchema(BaseModel): + message: str + user_id: int + username: str + status_code: int + method: str + path: str + permission: str + time: datetime = Field(alias="create_time") + + +class LogPageSchema(BasePageSchema): + items: List[LogSchema] diff --git a/app/api/cms/schema/user.py b/app/api/cms/schema/user.py new file mode 100644 index 0000000..da6b514 --- /dev/null +++ b/app/api/cms/schema/user.py @@ -0,0 +1,68 @@ +import re +from typing import List, Optional + +from lin import BaseModel, ParameterError +from pydantic import AnyHttpUrl, Field, validator + +from . import EmailSchema, GroupIdListSchema, ResetPasswordSchema + + +class LoginSchema(BaseModel): + username: str = Field(description="用户名") + password: str = Field(description="密码") + captcha: Optional[str] = Field(description="验证码") + + +class LoginTokenSchema(BaseModel): + access_token: str = Field(description="access_token") + refresh_token: str = Field(description="refresh_token") + + +class CaptchaSchema(BaseModel): + image: str = Field("", description="验证码图片base64编码") + tag: str = Field("", description="验证码标记码") + + +class PermissionNameSchema(BaseModel): + name: str = Field(description="权限名称") + + +class PermissionModuleSchema(BaseModel): + module: List[PermissionNameSchema] = Field(description="权限模块") + + +class UserBaseInfoSchema(EmailSchema): + nickname: Optional[str] = Field(description="用户昵称", min_length=2, max_length=10) + avatar: Optional[AnyHttpUrl] = Field(description="头像url") + + +class UserSchema(UserBaseInfoSchema): + id: int = Field(description="用户id") + username: str = Field(description="用户名") + + +class UserPermissionSchema(UserSchema): + admin: bool = Field(description="是否是管理员") + permissions: List[PermissionModuleSchema] = Field(description="用户权限") + + +class ChangePasswordSchema(ResetPasswordSchema): + old_password: str = Field(description="旧密码") + + +class UserRegisterSchema(GroupIdListSchema, EmailSchema): + username: str = Field(description="用户名", min_length=2, max_length=10) + password: str = Field(description="密码", min_length=6, max_length=22) + confirm_password: str = Field(description="确认密码", min_length=6, max_length=22) + + @validator("confirm_password") + def passwords_match(cls, v, values, **kwargs): + if v != values["password"]: + raise ParameterError("两次输入的密码不一致,请输入相同的密码") + return v + + @validator("username") + def check_username(cls, v, values, **kwargs): + if not re.match(r"^[a-zA-Z0-9_]{2,10}$", v): + raise ParameterError("用户名只能由字母、数字、下划线组成,且长度为2-10位") + return v diff --git a/app/api/cms/user.py b/app/api/cms/user.py index 5c92760..c2f8374 100644 --- a/app/api/cms/user.py +++ b/app/api/cms/user.py @@ -4,62 +4,107 @@ :copyright: © 2020 by the Lin team. :license: MIT, see LICENSE for more details. """ -from flask import current_app, request +from flask import Blueprint, current_app, g, request from flask_jwt_extended import ( create_access_token, create_refresh_token, get_current_user, get_jwt_identity, - verify_jwt_refresh_token_in_request, + verify_jwt_in_request, ) from itsdangerous import JSONWebSignatureSerializer as JWSSerializer -from lin import manager, permission_meta -from lin.db import db -from lin.exception import Duplicated, Failed, NotFound, ParameterError, Success -from lin.jwt import admin_required, get_tokens, login_required -from lin.logger import Log, Logger -from lin.redprint import Redprint - -from app.exception.api import RefreshFailed +from lin import ( + DocResponse, + Duplicated, + Failed, + Log, + Logger, + NotFound, + ParameterError, + Success, + admin_required, + db, + get_tokens, + login_required, + manager, + permission_meta, +) + +from app.api import AuthorizationBearerSecurity, api +from app.api.cms.exception import RefreshFailed +from app.api.cms.schema.user import ( + CaptchaSchema, + ChangePasswordSchema, + LoginSchema, + LoginTokenSchema, + UserBaseInfoSchema, + UserPermissionSchema, + UserRegisterSchema, + UserSchema, +) from app.util.captcha import CaptchaTool from app.util.common import split_group -from app.validator.form import ( - ChangePasswordForm, - LoginForm, - RegisterForm, - UpdateInfoForm, -) -user_api = Redprint("user") +user_api = Blueprint("user", __name__) @user_api.route("/register", methods=["POST"]) @permission_meta(name="注册", module="用户", mount=False) @Logger(template="管理员新建了一个用户") # 记录日志 @admin_required -def register(): - form = RegisterForm().validate_for_api() - if manager.user_model.count_by_username(form.username.data) > 0: - raise Duplicated("用户名重复,请重新输入") - if form.email.data and form.email.data.strip() != "": - if manager.user_model.count_by_email(form.email.data) > 0: - raise Duplicated("注册邮箱重复,请重新输入") - _register_user(form) - return Success("用户创建成功") +@api.validate( + tags=["用户"], + security=[AuthorizationBearerSecurity], + resp=DocResponse(Success("用户创建成功"), Duplicated("字段重复,请重新输入")), +) +def register(json: UserRegisterSchema): + """ + 注册新用户 + """ + if manager.user_model.count_by_username(g.username) > 0: + raise Duplicated("用户名重复,请重新输入") # type: ignore + if g.email and g.email.strip() != "": + if manager.user_model.count_by_email(g.email) > 0: + raise Duplicated("注册邮箱重复,请重新输入") # type: ignore + # create a user + with db.auto_commit(): + user = manager.user_model() + user.username = g.username + if g.email and g.email.strip() != "": + user.email = g.email + db.session.add(user) + db.session.flush() + user.password = g.password + group_ids = g.group_ids + # 如果没传分组数据,则将其设定为 guest 分组 + if len(group_ids) == 0: + from lin import GroupLevelEnum + + group_ids = [GroupLevelEnum.GUEST.value] + for group_id in group_ids: + user_group = manager.user_group_model() + user_group.user_id = user.id + user_group.group_id = group_id + db.session.add(user_group) + + return Success("用户创建成功") # type: ignore @user_api.route("/login", methods=["POST"]) -def login(): - form = LoginForm().validate_for_api() +@api.validate(resp=DocResponse(Failed("验证码校验失败"), r=LoginTokenSchema), tags=["用户"]) +def login(json: LoginSchema): + """ + 用户登录 + """ # 校对验证码 if current_app.config.get("LOGIN_CAPTCHA"): tag = request.headers.get("tag") secret_key = current_app.config.get("SECRET_KEY") serializer = JWSSerializer(secret_key) - if form.captcha.data != serializer.loads(tag): - raise Failed("验证码校验失败") + if g.captcha != serializer.loads(tag): + raise Failed("验证码校验失败") # type: ignore - user = manager.user_model.verify(form.username.data, form.password.data) + user = manager.user_model.verify(g.username, g.password) # 用户未登录,此处不能用装饰器记录日志 Log.create_log( message=f"{user.username}登录成功获取了令牌", @@ -72,41 +117,52 @@ def login(): commit=True, ) access_token, refresh_token = get_tokens(user) - return {"access_token": access_token, "refresh_token": refresh_token} + return LoginTokenSchema(access_token=access_token, refresh_token=refresh_token) @user_api.route("", methods=["PUT"]) @permission_meta(name="用户更新信息", module="用户", mount=False) @login_required -def update(): - form = UpdateInfoForm().validate_for_api() +@api.validate( + tags=["用户"], + security=[AuthorizationBearerSecurity], + resp=DocResponse(Success("用户信息更新成功"), ParameterError("邮箱已被注册,请重新输入邮箱")), +) +def update(json: UserBaseInfoSchema): + """ + 更新用户信息 + """ user = get_current_user() - email = form.email.data - nickname = form.nickname.data - avatar = form.avatar.data - if email and user.email != email: - exists = manager.user_model.get(email=form.email.data) + if g.email and user.email != g.email: + exists = manager.user_model.get(email=g.email) if exists: raise ParameterError("邮箱已被注册,请重新输入邮箱") with db.auto_commit(): - if email: - user.email = form.email.data - if nickname: - user.nickname = form.nickname.data - if avatar: - user._avatar = form.avatar.data - return Success("操作成功") + if g.email: + user.email = g.email + if g.nickname: + user.nickname = g.nickname + if g.avatar: + user._avatar = g.avatar + return Success("用户信息更新成功") @user_api.route("/change_password", methods=["PUT"]) @permission_meta(name="修改密码", module="用户", mount=False) @Logger(template="{user.username}修改了自己的密码") # 记录日志 @login_required -def change_password(): - form = ChangePasswordForm().validate_for_api() +@api.validate( + tags=["用户"], + security=[AuthorizationBearerSecurity], + resp=DocResponse(Success("密码修改成功"), Failed("密码修改失败")), +) +def change_password(json: ChangePasswordSchema): + """ + 修改密码 + """ user = get_current_user() - ok = user.change_password(form.old_password.data, form.new_password.data) + ok = user.change_password(g.old_password, g.new_password) if ok: db.session.commit() return Success("密码修改成功") @@ -117,24 +173,39 @@ def change_password(): @user_api.route("/information") @permission_meta(name="查询自己信息", module="用户", mount=False) @login_required +@api.validate( + tags=["用户"], + security=[AuthorizationBearerSecurity], + resp=DocResponse(r=UserSchema), +) def get_information(): + """ + 获取用户信息 + """ current_user = get_current_user() return current_user @user_api.route("/refresh") @permission_meta(name="刷新令牌", module="用户", mount=False) +@api.validate( + resp=DocResponse(RefreshFailed, NotFound("refresh_token未被识别"), r=LoginTokenSchema), + tags=["用户"], +) def refresh(): + """ + 刷新令牌 + """ try: - verify_jwt_refresh_token_in_request() + verify_jwt_in_request(refresh=True) except Exception: - return RefreshFailed() + return RefreshFailed identity = get_jwt_identity() if identity: access_token = create_access_token(identity=identity) refresh_token = create_refresh_token(identity=identity) - return {"access_token": access_token, "refresh_token": refresh_token} + return LoginTokenSchema(access_token=access_token, refresh_token=refresh_token) return NotFound("refresh_token未被识别") @@ -142,7 +213,15 @@ def refresh(): @user_api.route("/permissions") @permission_meta(name="查询自己拥有的权限", module="用户", mount=False) @login_required +@api.validate( + tags=["用户"], + security=[AuthorizationBearerSecurity], + resp=DocResponse(r=UserPermissionSchema), +) def get_allowed_apis(): + """ + 获取用户拥有的权限 + """ user = get_current_user() groups = manager.group_model.select_by_user_id(user.id) group_ids = [group.id for group in groups] @@ -159,36 +238,19 @@ def get_allowed_apis(): return user -def _register_user(form: RegisterForm): - with db.auto_commit(): - user = manager.user_model() - user.username = form.username.data - if form.email.data and form.email.data.strip() != "": - user.email = form.email.data - db.session.add(user) - db.session.flush() - user.password = form.password.data - group_ids = form.group_ids.data - # 如果没传分组数据,则将其设定为 id 2 的 guest 分组 - if len(group_ids) == 0: - group_ids = [2] - for group_id in group_ids: - user_group = manager.user_group_model() - user_group.user_id = user.id - user_group.group_id = group_id - db.session.add(user_group) - - @user_api.route("/captcha", methods=["GET", "POST"]) +@api.validate( + resp=DocResponse(r=CaptchaSchema), + tags=["用户"], +) def get_captcha(): """ 获取图形验证码 """ if not current_app.config.get("LOGIN_CAPTCHA"): - return {"tag": "", "image": ""} + return CaptchaSchema() # type: ignore image, code = CaptchaTool().get_verify_code() secret_key = current_app.config.get("SECRET_KEY") serializer = JWSSerializer(secret_key) - tag = str(serializer.dumps(code), encoding="utf-8") - image = str(image, encoding="utf-8") + tag = serializer.dumps(code) return {"tag": tag, "image": image} diff --git a/app/validator/form.py b/app/api/cms/validator/__init__.py similarity index 98% rename from app/validator/form.py rename to app/api/cms/validator/__init__.py index 937715e..8b6466b 100644 --- a/app/validator/form.py +++ b/app/api/cms/validator/__init__.py @@ -5,9 +5,7 @@ import re import time -from lin import manager -from lin.exception import ParameterError -from lin.form import Form +from lin import Form, ParameterError, manager from wtforms import DateTimeField, FieldList, IntegerField, PasswordField, StringField from wtforms.validators import DataRequired, EqualTo, NumberRange, Regexp, length diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index 3630fed..a4ba257 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -1,14 +1,9 @@ -""" - :copyright: © 2020 by the Lin team. - :license: MIT, see LICENSE for more details. -""" - from flask import Blueprint -from app.api.v1 import book +from app.api.v1.book import book_api def create_v1(): bp_v1 = Blueprint("v1", __name__) - book.book_api.register(bp_v1) + bp_v1.register_blueprint(book_api, url_prefix="/book") return bp_v1 diff --git a/app/api/v1/book.py b/app/api/v1/book.py index 601e462..0878f20 100644 --- a/app/api/v1/book.py +++ b/app/api/v1/book.py @@ -5,24 +5,20 @@ :license: MIT, see LICENSE for more details. """ -from flask import g, request -from lin import permission_meta -from lin.apidoc import DocResponse, api -from lin.exception import Success -from lin.jwt import group_required, login_required -from lin.redprint import Redprint +from flask import Blueprint, g +from lin import DocResponse, Success, group_required, login_required, permission_meta -from app.exception.api import BookNotFound -from app.model.v1.book import Book -from app.validator.schema import ( - AuthorizationSchema, +from app.api import AuthorizationBearerSecurity, api +from app.api.v1.exception import BookNotFound +from app.api.v1.model.book import Book +from app.api.v1.schema import ( BookInSchema, BookOutSchema, BookQuerySearchSchema, BookSchemaList, ) -book_api = Redprint("book") +book_api = Blueprint("book", __name__) @book_api.route("/") @@ -54,54 +50,49 @@ def get_books(): @book_api.route("/search") @api.validate( - query=BookQuerySearchSchema, resp=DocResponse(r=BookSchemaList), tags=["图书"], ) -def search(): +def search(query: BookQuerySearchSchema): """ 关键字搜索图书 """ return Book.query.filter( - Book.title.like("%" + g.q + "%"), Book.delete_time == None + Book.title.like("%" + g.q + "%"), Book.is_deleted == False ).all() @book_api.route("", methods=["POST"]) @login_required @api.validate( - headers=AuthorizationSchema, - json=BookInSchema, resp=DocResponse(Success(12)), + security=[AuthorizationBearerSecurity], tags=["图书"], ) -def create_book(): +def create_book(json: BookInSchema): """ 创建图书 """ - book_schema = request.context.json - Book.create(**book_schema.dict(), commit=True) + Book.create(**json.dict(), commit=True) return Success(12) @book_api.route("/", methods=["PUT"]) @login_required @api.validate( - headers=AuthorizationSchema, - json=BookInSchema, resp=DocResponse(Success(13)), + security=[AuthorizationBearerSecurity], tags=["图书"], ) -def update_book(id): +def update_book(id, json: BookInSchema): """ 更新图书信息 """ - book_schema = request.context.json book = Book.get(id=id) if book: book.update( id=id, - **book_schema.dict(), + **json.dict(), commit=True, ) return Success(13) @@ -112,8 +103,8 @@ def update_book(id): @permission_meta(name="删除图书", module="图书") @group_required @api.validate( - headers=AuthorizationSchema, resp=DocResponse(BookNotFound, Success(14)), + security=[AuthorizationBearerSecurity], tags=["图书"], ) def delete_book(id): diff --git a/app/exception/api.py b/app/api/v1/exception/__init__.py similarity index 52% rename from app/exception/api.py rename to app/api/v1/exception/__init__.py index 846fe18..dd769b0 100644 --- a/app/exception/api.py +++ b/app/api/v1/exception/__init__.py @@ -1,4 +1,4 @@ -from lin.exception import Duplicated, Failed, NotFound +from lin import Duplicated, NotFound class BookNotFound(NotFound): @@ -10,9 +10,3 @@ class BookDuplicated(Duplicated): code = 419 message = "图书已存在" _config = False - - -class RefreshFailed(Failed): - message = "令牌刷新失败" - message_code = 10052 - _config = False diff --git a/app/api/v1/model/__init__.py b/app/api/v1/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/model/v1/book.py b/app/api/v1/model/book.py similarity index 81% rename from app/model/v1/book.py rename to app/api/v1/model/book.py index a1953a5..e4abe14 100644 --- a/app/model/v1/book.py +++ b/app/api/v1/model/book.py @@ -3,11 +3,9 @@ :license: MIT, see LICENSE for more details. """ -from lin.interface import InfoCrud as Base +from lin import InfoCrud as Base from sqlalchemy import Column, Integer, String -from app.exception.api import BookNotFound - class Book(Base): id = Column(Integer, primary_key=True, autoincrement=True) diff --git a/app/api/v1/schema/__init__.py b/app/api/v1/schema/__init__.py new file mode 100644 index 0000000..71bb147 --- /dev/null +++ b/app/api/v1/schema/__init__.py @@ -0,0 +1,26 @@ +from typing import List, Optional + +from lin import BaseModel + + +class BookQuerySearchSchema(BaseModel): + q: Optional[str] = str() + + +class BookInSchema(BaseModel): + title: str + author: str + image: str + summary: str + + +class BookOutSchema(BaseModel): + id: int + title: str + author: str + image: str + summary: str + + +class BookSchemaList(BaseModel): + __root__: List[BookOutSchema] diff --git a/app/api/v1/validator/__init__.py b/app/api/v1/validator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/cli/db/fake.py b/app/cli/db/fake.py index 0d78372..f63581b 100644 --- a/app/cli/db/fake.py +++ b/app/cli/db/fake.py @@ -3,9 +3,9 @@ :license: MIT, see LICENSE for more details. """ -from lin.db import db +from lin import db -from app.model.v1.book import Book +from app.api.v1.model.book import Book def fake(): diff --git a/app/cli/db/init.py b/app/cli/db/init.py index 96bbf63..01adc78 100644 --- a/app/cli/db/init.py +++ b/app/cli/db/init.py @@ -2,9 +2,7 @@ :copyright: © 2020 by the Lin team. :license: MIT, see LICENSE for more details. """ -from lin import manager -from lin.db import db -from lin.enums import GroupLevelEnum +from lin import GroupLevelEnum, db, manager def init(force=False): diff --git a/app/cli/plugin/generator.py b/app/cli/plugin/generator.py index 61d3d01..0769a32 100644 --- a/app/cli/plugin/generator.py +++ b/app/cli/plugin/generator.py @@ -12,7 +12,7 @@ """ controller = """ -from lin.redprint import Redprint +from lin import Redprint {0}_api = Redprint("{0}") diff --git a/app/cli/plugin/init.py b/app/cli/plugin/init.py index 49f56f9..27b728e 100644 --- a/app/cli/plugin/init.py +++ b/app/cli/plugin/init.py @@ -130,7 +130,7 @@ def __update_setting(self, new_setting): setting_path = self.app.config.root_path + "/config/base.py" with open(setting_path, "r", encoding="UTF-8") as f: content = f.read() - pattern = "PLUGIN_PATH = \{([\s\S]*)\}+.*?" + pattern = "PLUGIN_PATH = \{([\s\S]*)\}+.*?" # type: ignore if len(re.findall(pattern, content)) == 0: content += """ PLUGIN_PATH = {} diff --git a/app/config/base.py b/app/config/base.py index f980a06..7049db3 100644 --- a/app/config/base.py +++ b/app/config/base.py @@ -32,8 +32,9 @@ class BaseConfig(object): # 令牌配置 JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) - # 启用验证码登录 - LOGIN_CAPTCHA = True + # 登录验证码 + LOGIN_CAPTCHA = False + # 默认文件上传配置 FILE = { "STORE_DIR": "assets", diff --git a/app/config/http_status_desc.py b/app/config/http_status_desc.py deleted file mode 100644 index 5fb6cf3..0000000 --- a/app/config/http_status_desc.py +++ /dev/null @@ -1,64 +0,0 @@ -DESC = { - # Information 1xx - 100: "Continue", - 101: "Switching Protocols", - # Successful 2xx - 200: "OK", - 201: "Created", - 202: "Accepted", - 203: "Non-Authoritative Information", - 204: "No Content", - 205: "Reset Content", - 206: "Partial Content", - # Redirection 3xx - 300: "Multiple Choices", - 301: "Moved Permanently", - 302: "Found", - 303: "See Other", - 304: "Not Modified", - 305: "Use Proxy", - 306: "(Unused)", - 307: "Temporary Redirect", - 308: "Permanent Redirect", - # Client Error 4xx - 400: "Bad Request", - 401: "Unauthorized", - 402: "Payment Required", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 406: "Not Acceptable", - 407: "Proxy Authentication Required", - 408: "Request Timeout", - 409: "Conflict", - 410: "Gone", - 411: "Length Required", - 412: "Precondition Failed", - 413: "Request Entity Too Large", - 414: "Request-URI Too Long", - 415: "Unsupported Media Type", - 416: "Requested Range Not Satisfiable", - 417: "Expectation Failed", - 418: "I'm a teapot", - 421: "Misdirected Request", - 422: "Unprocessable Entity", - 423: "Locked", - 424: "Failed Dependency", - 425: "Too Early", - 426: "Upgrade Required", - 428: "Precondition Required", - 429: "Too Many Requests", - 431: "Request Header Fields Too Large", - 451: "Unavailable For Legal Reasons", - # Server Error 5xx - 500: "Internal Server Error", - 501: "Not Implemented", - 502: "Bad Gateway", - 503: "Service Unavailable", - 504: "Gateway Timeout", - 505: "HTTP Version Not Supported", - 506: "Variant Also negotiates", - 507: "Insufficient Sotrage", - 508: "Loop Detected", - 511: "Network Authentication Required", -} diff --git a/app/extension/file/file.py b/app/extension/file/file.py index 009f7c6..b4808f5 100644 --- a/app/extension/file/file.py +++ b/app/extension/file/file.py @@ -1,46 +1,45 @@ -from lin.db import db -from lin.interface import InfoCrud -from sqlalchemy import Column, Index, Integer, String, func, text - - -class File(InfoCrud): - __tablename__ = "lin_file" - __table_args__ = (Index("md5_del", "md5", "delete_time", unique=True),) - - id = Column(Integer(), primary_key=True) - path = Column(String(500), nullable=False) - type = Column( - String(10), - nullable=False, - server_default=text("'LOCAL'"), - comment="LOCAL 本地,REMOTE 远程", - ) - name = Column(String(100), nullable=False) - extension = Column(String(50)) - size = Column(Integer()) - md5 = Column(String(40), comment="md5值,防止上传重复文件") - - @classmethod - def select_by_md5(cls, md5): - result = cls.query.filter_by(soft=True, md5=md5) - file = result.first() - return file - - @classmethod - def count_by_md5(cls, md5): - result = db.session.query(func.count(cls.id)).filter( - cls.delete_time == None, cls.md5 == md5 - ) - count = result.scalar() - return count - - @staticmethod - def create_file(**kwargs): - file = File() - for key in kwargs.keys(): - if hasattr(file, key): - setattr(file, key, kwargs[key]) - db.session.add(file) - if kwargs.get("commit") is True: - db.session.commit() - return file +from lin import InfoCrud, db +from sqlalchemy import Column, Index, Integer, String, func, text + + +class File(InfoCrud): + __tablename__ = "lin_file" + __table_args__ = (Index("md5_del", "md5", "delete_time", unique=True),) + + id = Column(Integer(), primary_key=True) + path = Column(String(500), nullable=False) + type = Column( + String(10), + nullable=False, + server_default=text("'LOCAL'"), + comment="LOCAL 本地,REMOTE 远程", + ) + name = Column(String(100), nullable=False) + extension = Column(String(50)) + size = Column(Integer()) + md5 = Column(String(40), comment="md5值,防止上传重复文件") + + @classmethod + def select_by_md5(cls, md5): + result = cls.query.filter_by(soft=True, md5=md5) + file = result.first() + return file + + @classmethod + def count_by_md5(cls, md5): + result = db.session.query(func.count(cls.id)).filter( + cls.is_deleted == False, cls.md5 == md5 + ) + count = result.scalar() + return count + + @staticmethod + def create_file(**kwargs): + file = File() + for key in kwargs.keys(): + if hasattr(file, key): + setattr(file, key, kwargs[key]) + db.session.add(file) + if kwargs.get("commit") is True: + db.session.commit() + return file diff --git a/app/extension/file/local_uploader.py b/app/extension/file/local_uploader.py index 6f7cdd3..9a361b4 100644 --- a/app/extension/file/local_uploader.py +++ b/app/extension/file/local_uploader.py @@ -1,7 +1,7 @@ import os from flask import current_app -from lin.file import Uploader +from lin import Uploader from werkzeug.utils import secure_filename from .file import File diff --git a/app/extension/notify/socketio.py b/app/extension/notify/socketio.py new file mode 100644 index 0000000..61813d5 --- /dev/null +++ b/app/extension/notify/socketio.py @@ -0,0 +1,3 @@ +from flask_socketio import SocketIO + +socketio = SocketIO() diff --git a/app/model/__init__.py b/app/model/__init__.py index 53b4b45..e69de29 100644 --- a/app/model/__init__.py +++ b/app/model/__init__.py @@ -1,4 +0,0 @@ -""" - :copyright: © 2020 by the Lin team. - :license: MIT, see LICENSE for more details. -""" diff --git a/app/model/lin/__init__.py b/app/model/lin/__init__.py deleted file mode 100644 index 8691bda..0000000 --- a/app/model/lin/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .group import Group -from .group_permission import GroupPermission -from .permission import Permission -from .user import User -from .user_group import UserGroup -from .user_identity import UserIdentity diff --git a/app/model/lin/user_identity.py b/app/model/lin/user_identity.py deleted file mode 100644 index 647e96e..0000000 --- a/app/model/lin/user_identity.py +++ /dev/null @@ -1,5 +0,0 @@ -from lin.model import UserIdentity as LinUserIdentity - - -class UserIdentity(LinUserIdentity): - pass diff --git a/app/plugin/oss/README.md b/app/plugin/oss/README.md index d3fb79a..5bc1e86 100644 --- a/app/plugin/oss/README.md +++ b/app/plugin/oss/README.md @@ -1 +1 @@ -# oss 插件 \ No newline at end of file +# oss 插件 diff --git a/app/plugin/oss/app/controller.py b/app/plugin/oss/app/controller.py index 7b66260..7cbcccc 100644 --- a/app/plugin/oss/app/controller.py +++ b/app/plugin/oss/app/controller.py @@ -2,11 +2,15 @@ import oss2 from flask import jsonify, request -from lin.config import lin_config -from lin.db import db -from lin.exception import Failed, ParameterError, Success -from lin.redprint import Redprint -from lin.utils import get_random_str +from lin import ( + Failed, + ParameterError, + Redprint, + Success, + db, + get_random_str, + lin_config, +) from .model import OSS diff --git a/app/plugin/oss/app/model.py b/app/plugin/oss/app/model.py index bd47dd9..60a146b 100644 --- a/app/plugin/oss/app/model.py +++ b/app/plugin/oss/app/model.py @@ -1,4 +1,4 @@ -from lin.interface import BaseCrud +from lin import BaseCrud from sqlalchemy import Column, Integer, String diff --git a/app/plugin/oss/requirements.txt b/app/plugin/oss/requirements.txt index 279cc13..1c424d6 100644 --- a/app/plugin/oss/requirements.txt +++ b/app/plugin/oss/requirements.txt @@ -1 +1 @@ -oss2==2.6.1 \ No newline at end of file +oss2==2.6.1 diff --git a/app/plugin/poem/app/__init__.py b/app/plugin/poem/app/__init__.py index e92813d..78d6d77 100644 --- a/app/plugin/poem/app/__init__.py +++ b/app/plugin/poem/app/__init__.py @@ -7,7 +7,7 @@ def initial_data(): - from lin.db import db + from lin import db from app import create_app diff --git a/app/plugin/poem/app/controller.py b/app/plugin/poem/app/controller.py index f47782a..6186ebc 100644 --- a/app/plugin/poem/app/controller.py +++ b/app/plugin/poem/app/controller.py @@ -1,5 +1,5 @@ from flask import jsonify -from lin.redprint import Redprint +from lin import Redprint from app.plugin.poem.app.form import PoemListForm, PoemSearchForm diff --git a/app/plugin/poem/app/form.py b/app/plugin/poem/app/form.py index ec3d697..d7262ae 100644 --- a/app/plugin/poem/app/form.py +++ b/app/plugin/poem/app/form.py @@ -1,6 +1,6 @@ -from lin.form import Form +from lin import Form from wtforms import IntegerField, StringField -from wtforms.validators import DataRequired, NumberRange, Optional +from wtforms.validators import DataRequired, Optional class PoemListForm(Form): diff --git a/app/plugin/poem/app/model.py b/app/plugin/poem/app/model.py index 7c90ae2..79cd5de 100644 --- a/app/plugin/poem/app/model.py +++ b/app/plugin/poem/app/model.py @@ -1,7 +1,5 @@ -from lin.config import lin_config -from lin.db import db -from lin.exception import NotFound -from lin.interface import InfoCrud as Base +from lin import InfoCrud as Base +from lin import NotFound, db, lin_config from sqlalchemy import Column, Integer, String, Text, text @@ -25,7 +23,7 @@ def content(self): return ret def get_all(self, form): - query = self.query.filter_by(delete_time=None) + query = self.query.filter_by(is_deleted=False) if form.author.data: query = query.filter_by(author=form.author.data) diff --git a/app/plugin/qiniu/README.md b/app/plugin/qiniu/README.md index f6bc916..100994e 100644 --- a/app/plugin/qiniu/README.md +++ b/app/plugin/qiniu/README.md @@ -1 +1 @@ -# qiniu \ No newline at end of file +# qiniu diff --git a/app/plugin/qiniu/app/controller.py b/app/plugin/qiniu/app/controller.py index 32ec8a6..5b24530 100644 --- a/app/plugin/qiniu/app/controller.py +++ b/app/plugin/qiniu/app/controller.py @@ -4,9 +4,7 @@ """ from flask import request -from lin.config import lin_config -from lin.exception import FileExtensionError -from lin.redprint import Redprint +from lin import FileExtensionError, Redprint, lin_config from qiniu import Auth from .model import Qiniu diff --git a/app/plugin/qiniu/app/model.py b/app/plugin/qiniu/app/model.py index 4e4bcac..dcfa49e 100644 --- a/app/plugin/qiniu/app/model.py +++ b/app/plugin/qiniu/app/model.py @@ -1,4 +1,4 @@ -from lin.interface import BaseCrud +from lin import BaseCrud from sqlalchemy import Column, Integer, String diff --git a/app/plugin/qiniu/requirements.txt b/app/plugin/qiniu/requirements.txt index 36a9aac..9a5fb0b 100644 --- a/app/plugin/qiniu/requirements.txt +++ b/app/plugin/qiniu/requirements.txt @@ -1 +1 @@ -qiniu==7.3.0 \ No newline at end of file +qiniu==7.3.0 diff --git a/app/schema/__init__.py b/app/schema/__init__.py new file mode 100644 index 0000000..aeaf34c --- /dev/null +++ b/app/schema/__init__.py @@ -0,0 +1,24 @@ +from typing import Any, List + +from flask import g +from lin import BaseModel +from pydantic import Field + +datetime_regex = "^((([1-9][0-9][0-9][0-9]-(0[13578]|1[02])-(0[1-9]|[12][0-9]|3[01]))|(20[0-3][0-9]-(0[2469]|11)-(0[1-9]|[12][0-9]|30))) (20|21|22|23|[0-1][0-9]):[0-5][0-9]:[0-5][0-9])$" + + +class BasePageSchema(BaseModel): + page: int + count: int + total: int + total_page: int + items: List[Any] + + +class QueryPageSchema(BaseModel): + count: int = Field(5, gt=0, lt=16, description="0 < count < 16") + page: int = 0 + + @staticmethod + def offset_handler(req, resp, req_validation_error, instance): + g.offset = req.context.query.count * req.context.query.page diff --git a/app/util/captcha.py b/app/util/captcha.py index ab452d3..9d6b6ea 100644 --- a/app/util/captcha.py +++ b/app/util/captcha.py @@ -2,6 +2,7 @@ import io import random import string +from typing import Tuple from PIL import Image, ImageDraw, ImageFont @@ -33,7 +34,7 @@ def draw_lines(self, num=3): y2 = random.randint(self.height / 2, self.height) self.draw.line(((x1, y1), (x2, y2)), fill="black", width=1) - def get_verify_code(self): + def get_verify_code(self) -> Tuple[bytes, str]: """ 生成验证码图形 """ @@ -54,9 +55,9 @@ def get_verify_code(self): # 划线 # self.draw_lines() # 重新设置图片大小 - self.im = self.im.resize((100, 24)) + self.im = self.im.resize((80, 30)) # 图片转为base64字符串 buffered = io.BytesIO() - self.im.save(buffered, format="JPEG") - img_str = b"data:image/png;base64," + base64.b64encode(buffered.getvalue()) - return img_str, code + self.im.save(buffered, format="webp") + img = b"data:image/png;base64," + base64.b64encode(buffered.getvalue()) + return img, code diff --git a/app/util/common.py b/app/util/common.py index 97eb714..f24e78f 100644 --- a/app/util/common.py +++ b/app/util/common.py @@ -1,3 +1,4 @@ +import os from itertools import groupby from operator import itemgetter @@ -9,3 +10,6 @@ def split_group(dict_list, key): for key, group in tmps: result.append({key: list(group)}) return result + + +basedir = os.getcwd() diff --git a/app/util/page.py b/app/util/page.py index 157a4ab..30d4fc0 100644 --- a/app/util/page.py +++ b/app/util/page.py @@ -14,7 +14,7 @@ def get_page_from_query(): def paginate(): - from lin.exception import ParameterError + from lin import ParameterError count = int( request.args.get( diff --git a/app/validator/schema.py b/app/validator/schema.py deleted file mode 100644 index 605ff19..0000000 --- a/app/validator/schema.py +++ /dev/null @@ -1,89 +0,0 @@ -import re -from datetime import datetime -from enum import Enum -from typing import Any, List, Optional - -from flask import g -from lin.apidoc import BaseModel -from pydantic import Field, validator - -datetime_regex = "^((([1-9][0-9][0-9][0-9]-(0[13578]|1[02])-(0[1-9]|[12][0-9]|3[01]))|(20[0-3][0-9]-(0[2469]|11)-(0[1-9]|[12][0-9]|30))) (20|21|22|23|[0-1][0-9]):[0-5][0-9]:[0-5][0-9])$" - - -class AuthorizationSchema(BaseModel): - Authorization: str - - -class BookQuerySearchSchema(BaseModel): - q: Optional[str] = str() - - -class UsernameListSchema(BaseModel): - items: List[str] - - -class LogQuerySearchSchema(BaseModel): - keyword: Optional[str] = None - name: Optional[str] = None - start: Optional[str] = Field(None, description="YY-MM-DD HH:MM:SS") - end: Optional[str] = Field(None, description="YY-MM-DD HH:MM:SS") - count: int = Field(5, gt=0, lt=16, description="0 < count < 16") - page: int = 0 - - @validator("start", "end") - def datetime_match(cls, v, values, **kwargs): - if re.match(datetime_regex, v): - return v - raise ValueError("时间格式有误") - - @staticmethod - def offset_handler(req, resp, req_validation_error, instance): - g.offset = req.context.query.count * req.context.query.page - - -class LogSchema(BaseModel): - id: int - message: str - user_id: int - username: str - status_code: int - method: str - path: str - permission: str - time: datetime - - -class BasePageSchema(BaseModel): - page: int - count: int - total: int - total_page: int - items: List[Any] - - -class LogPageSchema(BasePageSchema): - items: List[LogSchema] - - -class BookInSchema(BaseModel): - title: str - author: str - image: str - summary: str - - -class BookOutSchema(BaseModel): - id: int - title: str - author: str - image: str - summary: str - - -class BookSchemaList(BaseModel): - __root__: List[BookOutSchema] - - -class Language(str, Enum): - en = "en-US" - zh = "zh-CN" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4e89e01 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3" +services: + lin-cms-flask: + build: + context: . + dockerfile: ./Dockerfile + container_name: lin_cms_flask + restart: always + hostname: avatar + environment: + - TZ=Asia/Shanghai + ports: + - "5000:5000" + working_dir: /app + tty: true + command: ["sh", "docker-deploy.sh"] diff --git a/docker-deploy.sh b/docker-deploy.sh new file mode 100644 index 0000000..855b0e1 --- /dev/null +++ b/docker-deploy.sh @@ -0,0 +1,6 @@ +# use production environment settings +echo "FLASK_ENV=production" >> .flaskenv +# initialize database +flask db init +# gunicorn server +gunicorn -c gunicorn.conf.py starter:app diff --git a/gunicorn.conf.py b/gunicorn.conf.py index 28ecda0..0bc1201 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -1,12 +1,12 @@ -import os +import multiprocessing from gevent import monkey monkey.patch_all() -import multiprocessing + bind = "0.0.0.0:5000" -worker_class = "gevent" +worker_class = "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" daemon = False workers = multiprocessing.cpu_count() * 2 + 1 debug = False diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6261372 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[tool.poetry] +name = "lin-cms-flask" +version = "0.4" +description = "🎀A simple and practical CMS implememted by flask" +authors = ["sunlin92 "] + +[[tool.poetry.source]] +name = "tsinghua" +url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" +default = true + +[tool.poetry.dependencies] +python = "^3.8" +Lin-CMS = "^0.4.5" +pillow = "^9.0.0" +flask-cors = "^3.0.10" +gunicorn = "^20.1.0" +gevent = "^21.8.0" +flask-socketio = "^5.1.1" +blinker = "^1.4" +python-dotenv = "^0.19.2" +gevent-websocket = "^0.10.1" +pydantic = {extras = ["email"], version = "^1.9.0"} + +[tool.poetry.dev-dependencies] +flask-sqlacodegen = "^1.1.8" +black = "^21.10b0" +isort = "^5.10.1" +watchdog = "^2.1.6" +coverage = "^6.1.2" +pytest = "^6.2.5" +pre-commit = "^2.16.0" +pytest-ordering = "^0.6" +flake8 = "^4.0.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements-dev.txt b/requirements-dev.txt index b4bcfee..9db4d7c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,19 @@ -Lin-CMS==0.3.1 -Flask-Cors==3.0.9 -python-dotenv==0.15.0 +python==3.8 +Lin-CMS==0.4.5 +pillow==9.0.0 +flask-cors==3.0.10 +gunicorn==20.1.0 +gevent==21.8.0 +flask-socketio==5.1.1 +blinker==1.4 +python-dotenv==0.19.2 +gevent-websocket==0.10.1 +flask-sqlacodegen==1.1.8 +black==21.10b0 +isort==5.10.1 +watchdog==2.1.6 +coverage==6.1.2 +pytest==6.2.5 +pre-commit==2.16.0 pytest-ordering==0.6 -Pillow==8.4.0 +flake8==4.0.1 diff --git a/requirements-prod.txt b/requirements-prod.txt index 5dd8d5c..caf3e97 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -1,6 +1,9 @@ -Lin-CMS==0.3.1 -Flask-Cors==3.0.9 -python-dotenv==0.15.0 -gevent==20.9.0 -gunicorn==20.0.4 -Pillow==8.4.0 +Lin-CMS==0.4.5 +pillow==9.0.0 +flask-cors==3.0.10 +gunicorn==20.1.0 +gevent==21.8.0 +flask-socketio==5.1.1 +blinker==1.4 +python-dotenv==0.19.2 +gevent-websocket==0.10.1 diff --git a/starter.py b/starter.py index 97bfaa4..51caf89 100644 --- a/starter.py +++ b/starter.py @@ -4,16 +4,13 @@ """ from app import create_app +from app.api.cms.model.group import Group +from app.api.cms.model.group_permission import GroupPermission +from app.api.cms.model.permission import Permission +from app.api.cms.model.user import User +from app.api.cms.model.user_group import UserGroup +from app.api.cms.model.user_identity import UserIdentity from app.config.code_message import MESSAGE -from app.config.http_status_desc import DESC -from app.model.lin import ( - Group, - GroupPermission, - Permission, - User, - UserGroup, - UserIdentity, -) app = create_app( group_model=Group, @@ -23,7 +20,6 @@ identity_model=UserIdentity, user_group_model=UserGroup, config_MESSAGE=MESSAGE, - config_DESC=DESC, ) @@ -37,21 +33,21 @@ def slogan(): padding: 0; margin: 0; } - + div { padding: 4px 48px; } - + a { color: black; cursor: pointer; text-decoration: none } - + a:hover { text-decoration: None; } - + body { background: #fff; font-family: @@ -59,13 +55,13 @@ def slogan(): color: #333; font-size: 18px; } - + h1 { font-size: 100px; font-weight: normal; margin-bottom: 12px; } - + p { line-height: 1.6em; font-size: 42px diff --git a/tests/__init__.py b/tests/__init__.py index b5ef925..9416c21 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,14 +4,12 @@ import pytest from app import create_app -from app.model.lin import ( - Group, - GroupPermission, - Permission, - User, - UserGroup, - UserIdentity, -) +from app.api.cms.model.group import Group +from app.api.cms.model.group_permission import GroupPermission +from app.api.cms.model.permission import Permission +from app.api.cms.model.user import User +from app.api.cms.model.user_group import UserGroup +from app.api.cms.model.user_identity import UserIdentity from .config import password, username diff --git a/tests/test_book.py b/tests/test_book.py index aafc022..3d4b14d 100644 --- a/tests/test_book.py +++ b/tests/test_book.py @@ -20,7 +20,7 @@ def test_create(fixtureFunc): "image": "https://img3.doubanio.com/lpic/s1470003.jpg", }, ) - assert rv.status_code == 201 or rv.get_json().get("code") == 10030 + assert rv.status_code == 200 or rv.get_json().get("code") == 10030 @pytest.mark.run(order=2) @@ -48,7 +48,7 @@ def test_update(fixtureFunc): "image": "https://img3.doubanio.com/lpic/s1470003.jpg", }, ) - assert rv.status_code == 201 + assert rv.status_code == 200 @pytest.mark.run(order=4) @@ -62,4 +62,4 @@ def test_delete(): rv = c.delete( "/v1/book/{}".format(id), headers={"Authorization": "Bearer " + get_token()} ) - assert rv.status_code == 201 + assert rv.status_code == 200 diff --git a/tests/test_user.py b/tests/test_user.py index 0691681..7e35f91 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -4,7 +4,7 @@ """ -from . import app, fixtureFunc, get_token +from . import app, fixtureFunc, get_token # type: ignore def test_change_nickname(fixtureFunc): @@ -14,4 +14,4 @@ def test_change_nickname(fixtureFunc): headers={"Authorization": "Bearer " + get_token()}, json={"nickname": "tester"}, ) - assert rv.status_code == 201 + assert rv.status_code == 200