├── .dockerignore ├── app ├── config │ ├── __init__.py │ ├── development.py │ ├── production.py │ ├── code_message.py │ └── base.py ├── model │ └── __init__.py ├── util │ ├── __init__.py │ ├── common.py │ ├── page.py │ └── captcha.py ├── api │ ├── v1 │ │ ├── model │ │ │ ├── __init__.py │ │ │ └── book.py │ │ ├── validator │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── exception │ │ │ └── __init__.py │ │ ├── schema │ │ │ └── __init__.py │ │ └── book.py │ ├── cms │ │ ├── model │ │ │ ├── __init__.py │ │ │ ├── user_identity.py │ │ │ ├── user_group.py │ │ │ ├── group_permission.py │ │ │ ├── group.py │ │ │ ├── user.py │ │ │ └── permission.py │ │ ├── exception │ │ │ └── __init__.py │ │ ├── file.py │ │ ├── __init__.py │ │ ├── schema │ │ │ ├── log.py │ │ │ ├── __init__.py │ │ │ ├── admin.py │ │ │ └── user.py │ │ ├── log.py │ │ ├── validator │ │ │ └── __init__.py │ │ └── user.py │ └── __init__.py ├── exception │ └── __init__.py ├── validator │ └── __init__.py ├── extension │ ├── file │ │ ├── __init__.py │ │ ├── config.py │ │ ├── file.py │ │ └── local_uploader.py │ └── notify │ │ ├── __init__.py │ │ ├── socketio.py │ │ ├── sse.py │ │ └── notify.py ├── plugin │ ├── poem │ │ ├── requirements.txt │ │ ├── config.py │ │ ├── info.py │ │ ├── app │ │ │ ├── form.py │ │ │ ├── controller.py │ │ │ ├── model.py │ │ │ └── __init__.py │ │ └── README.md │ ├── oss │ │ ├── README.md │ │ ├── requirements.txt │ │ ├── app │ │ │ ├── __init__.py │ │ │ ├── model.py │ │ │ └── controller.py │ │ ├── info.py │ │ └── config.py │ ├── qiniu │ │ ├── README.md │ │ ├── requirements.txt │ │ ├── app │ │ │ ├── __init__.py │ │ │ ├── model.py │ │ │ └── controller.py │ │ ├── config.py │ │ └── info.py │ └── cos │ │ ├── requirements.txt │ │ ├── app │ │ ├── __init__.py │ │ ├── exception.py │ │ ├── schema.py │ │ ├── model.py │ │ └── controller.py │ │ ├── info.py │ │ ├── config.py │ │ └── README.md ├── cli │ ├── db │ │ ├── __init__.py │ │ ├── init.py │ │ └── fake.py │ ├── plugin │ │ ├── __init__.py │ │ ├── generator.py │ │ └── init.py │ └── __init__.py ├── lin │ ├── enums.py │ ├── plugin.py │ ├── __init__.py │ ├── config.py │ ├── redprint.py │ ├── form.py │ ├── encoder.py │ ├── loader.py │ ├── utils.py │ ├── model.py │ ├── exception.py │ ├── jwt.py │ ├── logger.py │ ├── file.py │ ├── lin.py │ ├── syslogger.py │ ├── manager.py │ ├── interface.py │ └── apidoc.py ├── schema │ └── __init__.py └── __init__.py ├── .python-version ├── tests ├── config.py ├── test_user.py ├── test_admin.py ├── __init__.py └── test_book.py ├── .flaskenv ├── docker-deploy.sh ├── Dockerfile ├── .development.env ├── .flake8 ├── .production.env ├── gunicorn.conf.py ├── .editorconfig ├── docker-compose.yml ├── .pre-commit-config.yaml ├── requirements.txt ├── pyproject.toml ├── LICENSE ├── starter.py ├── .gitignore └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv 2 | -------------------------------------------------------------------------------- /app/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /app/api/v1/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/exception/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/validator/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/cms/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/v1/validator/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/extension/file/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/extension/notify/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/plugin/poem/requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/plugin/oss/README.md: -------------------------------------------------------------------------------- 1 | # oss 插件 2 | -------------------------------------------------------------------------------- /app/plugin/qiniu/README.md: -------------------------------------------------------------------------------- 1 | # qiniu 2 | -------------------------------------------------------------------------------- /app/plugin/poem/config.py: -------------------------------------------------------------------------------- 1 | limit = 20 2 | -------------------------------------------------------------------------------- /app/plugin/oss/requirements.txt: -------------------------------------------------------------------------------- 1 | oss2==2.6.1 2 | -------------------------------------------------------------------------------- /app/plugin/qiniu/requirements.txt: -------------------------------------------------------------------------------- 1 | qiniu==7.3.0 2 | -------------------------------------------------------------------------------- /app/plugin/cos/requirements.txt: -------------------------------------------------------------------------------- 1 | cos-python-sdk-v5==1.9.20 2 | -------------------------------------------------------------------------------- /app/plugin/oss/app/__init__.py: -------------------------------------------------------------------------------- 1 | from .controller import api 2 | -------------------------------------------------------------------------------- /tests/config.py: -------------------------------------------------------------------------------- 1 | username = "root" 2 | password = "123456" 3 | -------------------------------------------------------------------------------- /app/cli/db/__init__.py: -------------------------------------------------------------------------------- 1 | from .fake import fake 2 | from .init import init 3 | -------------------------------------------------------------------------------- /app/cli/plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from .generator import generate 2 | from .init import init 3 | -------------------------------------------------------------------------------- /app/plugin/cos/app/__init__.py: -------------------------------------------------------------------------------- 1 | from .controller import cos_api 2 | from .model import COS 3 | -------------------------------------------------------------------------------- /app/plugin/cos/info.py: -------------------------------------------------------------------------------- 1 | __name__ = "cos" 2 | __version__ = "0.0.1" 3 | __author__ = "Bryant He" 4 | -------------------------------------------------------------------------------- /app/plugin/oss/info.py: -------------------------------------------------------------------------------- 1 | __name__ = "oss" 2 | __version__ = "0.0.1" 3 | __author__ = "Lin team" 4 | -------------------------------------------------------------------------------- /app/extension/notify/socketio.py: -------------------------------------------------------------------------------- 1 | from flask_socketio import SocketIO 2 | 3 | socketio = SocketIO() 4 | -------------------------------------------------------------------------------- /app/plugin/poem/info.py: -------------------------------------------------------------------------------- 1 | __name__ = "poem" 2 | __version__ = "0.0.1" 3 | __author__ = "Lin team" 4 | -------------------------------------------------------------------------------- /.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_APP=starter:app 2 | FLASK_RUN_HOST="127.0.0.1" 3 | FLASK_RUN_PORT=5000 4 | # production or development etc. 5 | FLASK_ENV=development 6 | -------------------------------------------------------------------------------- /app/api/cms/model/user_identity.py: -------------------------------------------------------------------------------- 1 | from app.lin import UserIdentity as LinUserIdentity 2 | 3 | 4 | class UserIdentity(LinUserIdentity): 5 | pass 6 | -------------------------------------------------------------------------------- /app/plugin/cos/app/exception.py: -------------------------------------------------------------------------------- 1 | from app.lin import NotFound 2 | 3 | 4 | class ImageNotFound(NotFound): 5 | message = "图片不存在" 6 | _config = False 7 | -------------------------------------------------------------------------------- /app/config/development.py: -------------------------------------------------------------------------------- 1 | from .base import BaseConfig 2 | 3 | 4 | class DevelopmentConfig(BaseConfig): 5 | """ 6 | 开发环境配置 7 | """ 8 | 9 | pass 10 | -------------------------------------------------------------------------------- /app/config/production.py: -------------------------------------------------------------------------------- 1 | from .base import BaseConfig 2 | 3 | 4 | class ProductionConfig(BaseConfig): 5 | """ 6 | 生产环境配置 7 | """ 8 | 9 | pass 10 | -------------------------------------------------------------------------------- /app/plugin/qiniu/app/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from .controller import qiniu_api 7 | -------------------------------------------------------------------------------- /app/api/cms/exception/__init__.py: -------------------------------------------------------------------------------- 1 | from app.lin import Failed 2 | 3 | 4 | class RefreshFailed(Failed): 5 | message = "令牌刷新失败" 6 | message_code = 10052 7 | _config = False 8 | -------------------------------------------------------------------------------- /app/plugin/qiniu/config.py: -------------------------------------------------------------------------------- 1 | access_key = "not complete" 2 | secret_key = "not complete" 3 | bucket_name = "not complete" 4 | token_expire_time = 3600 5 | allowed_extensions = ["jpg", "gif", "png", "bmp"] 6 | -------------------------------------------------------------------------------- /app/plugin/qiniu/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | __name__ = "qiniu" 7 | __version__ = "0.1.0" 8 | __author__ = "Team Lin" 9 | -------------------------------------------------------------------------------- /docker-deploy.sh: -------------------------------------------------------------------------------- 1 | # use production environment settings 2 | echo "FLASK_ENV=production">>.flaskenv 3 | # initialize database 4 | flask db init 5 | # gunicorn server 6 | gunicorn -c gunicorn.conf.py starter:app 7 | -------------------------------------------------------------------------------- /app/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from app.api.v1.book import book_api 4 | 5 | 6 | def create_v1(): 7 | bp_v1 = Blueprint("v1", __name__) 8 | bp_v1.register_blueprint(book_api, url_prefix="/book") 9 | return bp_v1 10 | -------------------------------------------------------------------------------- /app/extension/file/config.py: -------------------------------------------------------------------------------- 1 | # 本地文件上传相关配置模版 2 | FILE = { 3 | "STORE_DIR": "assets", 4 | "SINGLE_LIMIT": 1024 * 1024 * 2, 5 | "TOTAL_LIMIT": 1024 * 1024 * 20, 6 | "NUMS": 10, 7 | "INCLUDE": set([]), 8 | "EXCLUDE": set([]), 9 | } 10 | -------------------------------------------------------------------------------- /app/plugin/oss/config.py: -------------------------------------------------------------------------------- 1 | access_key_id = "not complete" 2 | access_key_secret = "not complete" 3 | endpoint = "http://oss-cn-shenzhen.aliyuncs.com" 4 | bucket_name = "not complete" 5 | 6 | upload_folder = "oss" 7 | allowed_extensions = ["jpg", "gif", "png", "bmp"] 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13 2 | # 拷贝依赖 3 | COPY requirements.txt . 4 | # 安装依赖 5 | # RUN pip install -r requirements-prod.txt >/dev/null 2>&1 6 | RUN pip install -r requirements-prod.txt -i http://mirrors.aliyun.com/pypi/simple/ --trusted-host=mirrors.aliyun.com 7 | # 拷贝项目 8 | COPY . /app 9 | -------------------------------------------------------------------------------- /app/plugin/oss/app/model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | 3 | from app.lin import BaseCrud 4 | 5 | 6 | class OSS(BaseCrud): 7 | __tablename__ = "oss" 8 | 9 | id = Column(Integer, primary_key=True) 10 | url = Column(String(255), nullable=False) 11 | -------------------------------------------------------------------------------- /app/api/v1/exception/__init__.py: -------------------------------------------------------------------------------- 1 | from app.lin import Duplicated, NotFound 2 | 3 | 4 | class BookNotFound(NotFound): 5 | message = "书籍不存在" 6 | _config = False 7 | 8 | 9 | class BookDuplicated(Duplicated): 10 | code = 419 11 | message = "图书已存在" 12 | _config = False 13 | -------------------------------------------------------------------------------- /app/plugin/qiniu/app/model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | 3 | from app.lin import BaseCrud 4 | 5 | 6 | class Qiniu(BaseCrud): 7 | __tablename__ = "qiniu" 8 | 9 | id = Column(Integer, primary_key=True) 10 | url = Column(String(255), nullable=False) 11 | -------------------------------------------------------------------------------- /app/plugin/cos/app/schema.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from app.lin import BaseModel 4 | 5 | 6 | class CosOutSchema(BaseModel): 7 | id: int 8 | file_name: str 9 | file_key: str 10 | url: str 11 | 12 | 13 | class CosOutSchemaList(BaseModel): 14 | __root__: List[CosOutSchema] 15 | -------------------------------------------------------------------------------- /app/lin/enums.py: -------------------------------------------------------------------------------- 1 | """ 2 | enums of Lin 3 | ~~~~~~~~~ 4 | :copyright: © 2020 by the Lin team. 5 | :license: MIT, see LICENSE for more details. 6 | """ 7 | 8 | from enum import Enum 9 | 10 | 11 | # status for user is admin 12 | # 是否为超级管理员的枚举 13 | class GroupLevelEnum(Enum): 14 | ROOT = 1 15 | GUEST = 2 16 | USER = 3 17 | -------------------------------------------------------------------------------- /.development.env: -------------------------------------------------------------------------------- 1 | SQLALCHEMY_DATABASE_URI = 'sqlite:///../lincmsdev.db' 2 | # SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@localhost:3306/lincmsdev' 3 | # SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:123456@localhost:5432/lincmsdev' 4 | SECRET_KEY = '\x88W\xf09\x91\x07\x98\x89\x87\x96\xa0A\xc68\xf9\xecJJU\x17\xc5V\xbe\x8b\xef\xd7\xd8\xd3\xe6\x95*4' 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | select = B,C,E,F,W,B9,T4 4 | ignore = E203, E266, E501, W503, E712, E711 5 | per-file-ignores = 6 | __init__.py: F401 7 | app/core/exception.py: F811 8 | app/cli/plugin/init.py: W605 9 | exclude = 10 | .venv, 11 | .git, 12 | tests/*, 13 | __pycache__, 14 | build, 15 | dist 16 | -------------------------------------------------------------------------------- /.production.env: -------------------------------------------------------------------------------- 1 | SQLALCHEMY_DATABASE_URI = 'sqlite:///../lincmsprod.db' 2 | # SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@localhost:3306/lincmsprod' 3 | # SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:123456@localhost:5432/lincmsprod' 4 | DEVELOPMENT_SECRET_KEY = '\x88W\xf09\x91\x07\x98\x89\x87\x96\xa0A\xc68\xf9\xecJJU\x17\xc5V\xbe\x8b\xef\xd7\xd8\xd3\xe6\x95*4' 5 | -------------------------------------------------------------------------------- /gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | from gevent import monkey 4 | 5 | monkey.patch_all() 6 | 7 | 8 | bind = "0.0.0.0:5000" 9 | worker_class = "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" 10 | daemon = False 11 | workers = multiprocessing.cpu_count() * 2 + 1 12 | debug = False 13 | # pidfile = "/var/run/gunicorn.pid" 14 | # accesslog = "/var/log/gunicorn.log" 15 | -------------------------------------------------------------------------------- /app/util/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | from itertools import groupby 3 | from operator import itemgetter 4 | 5 | 6 | def split_group(dict_list, key): 7 | dict_list.sort(key=itemgetter(key)) 8 | tmps = groupby(dict_list, itemgetter(key)) 9 | result = [] 10 | for key, group in tmps: 11 | result.append({key: list(group)}) 12 | return result 13 | 14 | 15 | basedir = os.getcwd() 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org/#file-format-details 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 4 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | end_of_line = lf 12 | charset = utf-8 13 | max_line_length = 120 14 | 15 | [*.{yml,yaml,json,js,css,html}] 16 | indent_size = 2 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /app/api/cms/model/user_group.py: -------------------------------------------------------------------------------- 1 | from app.lin import UserGroup as LinUserGroup 2 | from app.lin import db 3 | 4 | 5 | class UserGroup(LinUserGroup): 6 | @classmethod 7 | def delete_batch_by_user_id_and_group_ids(cls, user_id, group_ids: list, commit=False): 8 | cls.query.filter_by(user_id=user_id).filter(cls.group_id.in_(group_ids)).delete(synchronize_session=False) 9 | if commit: 10 | db.session.commit() 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | lin-cms-flask: 4 | build: 5 | context: . 6 | dockerfile: ./Dockerfile 7 | container_name: lin_cms_flask 8 | restart: always 9 | hostname: avatar 10 | environment: 11 | - TZ=Asia/Shanghai 12 | ports: 13 | - "5000:5000" 14 | working_dir: /app 15 | tty: true 16 | command: ["sh", "docker-deploy.sh"] 17 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from . import app, fixtureFunc, get_token # type: ignore 7 | 8 | 9 | def test_change_nickname(fixtureFunc): 10 | with app.test_client() as c: 11 | rv = c.put( 12 | "/cms/user", 13 | headers={"Authorization": "Bearer " + get_token()}, 14 | json={"nickname": "tester"}, 15 | ) 16 | assert rv.status_code == 200 17 | -------------------------------------------------------------------------------- /app/api/v1/model/book.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from sqlalchemy import Column, Integer, String 7 | 8 | from app.lin import InfoCrud 9 | 10 | 11 | class Book(InfoCrud): 12 | id = Column(Integer, primary_key=True, autoincrement=True) 13 | title = Column(String(50), nullable=False) 14 | author = Column(String(30), default="未名") 15 | summary = Column(String(1000)) 16 | image = Column(String(100)) 17 | -------------------------------------------------------------------------------- /app/api/cms/model/group_permission.py: -------------------------------------------------------------------------------- 1 | from app.lin import GroupPermission as LinGroupPermission 2 | from app.lin import db 3 | 4 | 5 | class GroupPermission(LinGroupPermission): 6 | @classmethod 7 | def delete_batch_by_group_id_and_permission_ids(cls, group_id, permission_ids: list, commit=False): 8 | cls.query.filter_by(group_id=group_id).filter(cls.permission_id.in_(permission_ids)).delete( 9 | synchronize_session=False 10 | ) 11 | if commit: 12 | db.session.commit() 13 | -------------------------------------------------------------------------------- /app/api/v1/schema/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from app.lin import BaseModel 4 | 5 | 6 | class BookQuerySearchSchema(BaseModel): 7 | q: Optional[str] = str() 8 | 9 | 10 | class BookInSchema(BaseModel): 11 | title: str 12 | author: str 13 | image: str 14 | summary: str 15 | 16 | 17 | class BookOutSchema(BaseModel): 18 | id: int 19 | title: str 20 | author: str 21 | image: str 22 | summary: str 23 | 24 | 25 | class BookSchemaList(BaseModel): 26 | __root__: List[BookOutSchema] 27 | -------------------------------------------------------------------------------- /app/plugin/poem/app/form.py: -------------------------------------------------------------------------------- 1 | from wtforms import IntegerField, StringField 2 | from wtforms.validators import DataRequired, Optional 3 | 4 | from app.lin import Form 5 | 6 | 7 | class PoemListForm(Form): 8 | count = IntegerField() 9 | author = StringField(validators=[Optional()]) 10 | 11 | def validate_count(self, row): 12 | if not row.data: 13 | return True 14 | if int(row.data) > 100 or int(row.data) < 1: 15 | raise ValueError("必须在1~100之间取值") 16 | 17 | 18 | class PoemSearchForm(Form): 19 | q = StringField(validators=[DataRequired(message="必须传入搜索关键字")]) 20 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from . import app, fixtureFunc, get_token 7 | 8 | 9 | def test_permission(fixtureFunc): 10 | with app.test_client() as c: 11 | rv = c.get( 12 | "/cms/admin/permission", 13 | headers={"Authorization": "Bearer " + get_token()}, 14 | ) 15 | assert rv.status_code == 200 16 | 17 | 18 | def test_get_root_users(fixtureFunc): 19 | with app.test_client() as c: 20 | rv = c.get("/cms/admin/users", headers={"Authorization": "Bearer " + get_token()}) 21 | assert rv.status_code == 200 22 | -------------------------------------------------------------------------------- /app/plugin/poem/app/controller.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | 3 | from app.lin import Redprint 4 | from app.plugin.poem.app.form import PoemListForm, PoemSearchForm 5 | 6 | from .model import Poem 7 | 8 | api = Redprint("poem") 9 | 10 | 11 | @api.route("/all") 12 | def get_list(): 13 | form = PoemListForm().validate_for_api() 14 | poems = Poem().get_all(form) 15 | return jsonify(poems) 16 | 17 | 18 | @api.route("/search") 19 | def search(): 20 | form = PoemSearchForm().validate_for_api() 21 | poems = Poem().search(form.q.data) 22 | return jsonify(poems) 23 | 24 | 25 | @api.route("/authors") 26 | def get_authors(): 27 | authors = Poem.get_authors() 28 | return jsonify(authors) 29 | -------------------------------------------------------------------------------- /app/api/cms/file.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from flask import Blueprint, request 7 | 8 | from app.api import AuthorizationBearerSecurity, api 9 | from app.extension.file.local_uploader import LocalUploader 10 | from app.lin import login_required 11 | 12 | file_api = Blueprint("file", __name__) 13 | 14 | 15 | @file_api.route("", methods=["POST"]) 16 | @login_required 17 | @api.validate( 18 | tags=["文件"], 19 | security=[AuthorizationBearerSecurity], 20 | ) 21 | def post_file(): 22 | """ 23 | 上传文件 24 | """ 25 | files = request.files 26 | uploader = LocalUploader(files) 27 | ret = uploader.upload() 28 | return ret 29 | -------------------------------------------------------------------------------- /app/api/cms/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | register api to admin blueprint 3 | ~~~~~~~~~ 4 | :copyright: © 2020 by the Lin team. 5 | :license: MIT, see LICENSE for more details. 6 | """ 7 | 8 | from flask import Blueprint 9 | 10 | 11 | def create_cms(): 12 | cms = Blueprint("cms", __name__) 13 | from app.api.cms.admin import admin_api 14 | from app.api.cms.file import file_api 15 | from app.api.cms.log import log_api 16 | from app.api.cms.user import user_api 17 | 18 | cms.register_blueprint(admin_api, url_prefix="/admin") 19 | cms.register_blueprint(user_api, url_prefix="/user") 20 | cms.register_blueprint(log_api, url_prefix="/log") 21 | cms.register_blueprint(file_api, url_prefix="/file") 22 | return cms 23 | -------------------------------------------------------------------------------- /app/plugin/cos/config.py: -------------------------------------------------------------------------------- 1 | access_key_id = "not complete" 2 | access_key_secret = "not complete" 3 | bucket_name = "not complete" # "bucket-appid" 4 | region = "not complete" 5 | scheme = "https" # 指定使用 http/https 协议来访问 COS,默认为 https,可不填 6 | token = None # 如果使用永久密钥不需要填入token,如果使用临时密钥需要填入,临时密钥生成和使用指引参见https://cloud.tencent.com/document/product/436/14048 7 | proxies = None # 默认不需要,详细见腾讯云 cos 文档 8 | endpoint = None # 默认不需要,详细见腾讯云 cos 文档 9 | domain = None # 默认不需要,详细见腾讯云 cos 文档 10 | 11 | upload_folder = None # 指定 cos 存储桶里的文件夹目录名字,默认为空 12 | allowed_extensions = ["jpg", "gif", "png", "bmp"] # 允许上传的文件类型 13 | expire_time = 60 * 60 # 临时链接过期时间,默认 1 小时 14 | need_save_url = False # 是否要保存 cos 生成的永久链接 15 | need_return_url = False # 是否让接口返回永久链接,默认返回临时链接 16 | -------------------------------------------------------------------------------- /app/plugin/cos/README.md: -------------------------------------------------------------------------------- 1 | # Lin-CMS 的腾讯云 cos 插件 2 | 3 | 基于 Lin—CMS 0.4.8 开发 4 | 5 | [Lin-CMS 项目地址](https://github.com/TaleLin/lin-cms-flask) 6 | 7 | 8 | ## 插件使用方法 9 | 10 | 1. 然后执行初始化脚本,把插件注册到 app 的 config 文件中 11 | ```bash 12 | flask plugin init 13 | ``` 14 | (命令行输入插件名字: cos) 15 | 16 | 2. 再次启动项目即可 17 | 18 | 19 | ## 接口描述 20 | 21 | 目前共实现了 3 个接口 22 | - 上传单张图片 23 | - `POST /plugin/cos/upload_one` 24 | - 上传多张图片 25 | - `POST /plugin/cos/upload_multiple` 26 | - 获取单个cos对象 27 | - `GET /plugin/cos/` 28 | - 返回值 29 | - id 30 | - url (根据配置项,返回 cos 永久链接或者 cos 临时链接) 31 | - file_name (文件的实际名字) 32 | - file_key(cos 的唯一文件 key) 33 | 34 | 35 | ## 配置项 36 | 37 | 详细的插件配置见文件 `cos/config.py` 内的描述 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/schema/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from flask import g 4 | from pydantic import Field 5 | 6 | from app.lin import BaseModel 7 | 8 | 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])$" 9 | 10 | 11 | class BasePageSchema(BaseModel): 12 | page: int 13 | count: int 14 | total: int 15 | total_page: int 16 | items: List[Any] 17 | 18 | 19 | class QueryPageSchema(BaseModel): 20 | count: int = Field(5, gt=0, lt=16, description="0 < count < 16") 21 | page: int = 0 22 | 23 | @staticmethod 24 | def offset_handler(req, resp, req_validation_error, instance): 25 | g.offset = req.context.query.count * req.context.query.page 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: isort 5 | name: isort 6 | stages: [commit] 7 | language: system 8 | entry: poetry run isort 9 | types: [python] 10 | 11 | - id: black 12 | name: black 13 | stages: [commit] 14 | language: system 15 | entry: poetry run black 16 | types: [python] 17 | 18 | - id: flake8 19 | name: flake8 20 | stages: [commit] 21 | language: system 22 | entry: poetry run flake8 23 | types: [python] 24 | exclude: setup.py 25 | 26 | - id: mypy 27 | name: mypy 28 | stages: [commit] 29 | language: system 30 | entry: poetry run mypy 31 | types: [python] 32 | pass_filenames: false 33 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bidict==0.23.1 2 | blinker==1.9.0 3 | click==8.1.8 4 | dnspython==2.7.0 5 | email-validator==2.2.0 6 | flask==3.1.0 7 | flask-cors==5.0.1 8 | flask-jwt-extended==4.7.1 9 | flask-socketio==5.5.1 10 | flask-sqlalchemy==3.1.1 11 | gevent==24.11.1 12 | gevent-websocket==0.10.1 13 | greenlet==3.1.1 14 | gunicorn==23.0.0 15 | h11==0.14.0 16 | idna==3.10 17 | itsdangerous==2.2.0 18 | jinja2==3.1.6 19 | markupsafe==3.0.2 20 | packaging==24.2 21 | pillow==11.1.0 22 | pydantic==1.10.21 23 | pyjwt==2.10.1 24 | python-dotenv==1.0.1 25 | python-engineio==4.11.2 26 | python-socketio==5.12.1 27 | setuptools==76.0.0 28 | simple-websocket==1.1.0 29 | spectree==1.4.5 30 | sqlalchemy==2.0.39 31 | tablib==3.8.0 32 | typing-extensions==4.12.2 33 | werkzeug==3.1.3 34 | wsproto==1.2.0 35 | wtforms==3.2.1 36 | zope-event==5.0 37 | zope-interface==7.2 38 | -------------------------------------------------------------------------------- /app/api/cms/model/group.py: -------------------------------------------------------------------------------- 1 | from app.lin import Group as LinGroup 2 | from app.lin import db, manager 3 | 4 | 5 | class Group(LinGroup): 6 | def _set_fields(self): 7 | self._exclude = ["delete_time", "create_time", "update_time", "is_deleted"] 8 | 9 | @classmethod 10 | def select_by_user_id(cls, user_id) -> list: 11 | """ 12 | 根据用户Id,通过User-Group关联表,获取所属用户组对象列表 13 | """ 14 | query = ( 15 | db.session.query(manager.user_group_model.group_id) 16 | .join( 17 | manager.user_model, 18 | manager.user_model.id == manager.user_group_model.user_id, 19 | ) 20 | .filter(manager.user_model.delete_time == None, manager.user_model.id == user_id) 21 | ) 22 | result = cls.query.filter_by(soft=True).filter(cls.id.in_(query)) 23 | groups = result.all() 24 | return groups 25 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2021 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from typing import Dict 7 | 8 | from spectree import SecurityScheme 9 | 10 | from app.lin import SpecTree 11 | 12 | api = SpecTree( 13 | backend_name="flask", 14 | title="Lin-CMS-Flask", 15 | mode="strict", 16 | version="0.6.0", 17 | # OpenAPI对所有接口描述默认返回一个参数错误, http_status_code为400。 18 | validation_error_status=400, 19 | annotations=True, 20 | security_schemes=[ 21 | SecurityScheme( 22 | name="AuthorizationBearer", 23 | data={ 24 | "type": "http", 25 | "scheme": "bearer", 26 | }, 27 | ), 28 | ], 29 | # swaggerUI中所有接口默认允许传递Headers的AuthorizationToken字段 30 | # 不需要在每个api.validate(security=...)中指定它 31 | # 但所有接口都会显示一把小锁 32 | # SECURITY={"AuthorizationBearer": []}, 33 | ) 34 | 35 | AuthorizationBearerSecurity: Dict = {"AuthorizationBearer": []} 36 | -------------------------------------------------------------------------------- /app/lin/plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | plugin of Lin 3 | ~~~~~~~~~ 4 | 5 | Plugin Class of Lin, which you can access plugins in the manager. 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | 12 | class Plugin(object): 13 | def __init__(self, name=None): 14 | """ 15 | :param name: plugin的名称 16 | """ 17 | # container of plugin's controllers 18 | # 控制器容器 19 | self.controllers = {} 20 | # container of plugin's models 21 | # 模型层容器 22 | self.models = {} 23 | # plugin's services 24 | self.services = {} 25 | 26 | self.name = name 27 | 28 | def add_model(self, name, model): 29 | self.models[name] = model 30 | 31 | def get_model(self, name): 32 | return self.models.get(name) 33 | 34 | def add_controller(self, name, controller): 35 | self.controllers[name] = controller 36 | 37 | def add_service(self, name, service): 38 | self.services[name] = service 39 | 40 | def get_service(self, name): 41 | return self.services.get(name) 42 | -------------------------------------------------------------------------------- /app/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | from flask.cli import AppGroup 3 | 4 | from .db import fake as _db_fake 5 | from .db import init as _db_init 6 | from .plugin import generate as _plugin_generate 7 | from .plugin import init as _plugin_init 8 | 9 | db_cli = AppGroup("db") 10 | plugin_cli = AppGroup("plugin") 11 | 12 | 13 | @db_cli.command("init") 14 | @click.option("--force", is_flag=True, help="Create after drop.") 15 | def db_init(force): 16 | """ 17 | initialize the database. 18 | """ 19 | if force: 20 | click.confirm("此操作将清空数据,是否继续?", abort=True) 21 | _db_init(force) 22 | click.echo("数据库初始化成功") 23 | 24 | 25 | @db_cli.command("fake") 26 | def db_fake(): 27 | """ 28 | fake the db data. 29 | """ 30 | _db_fake() 31 | click.echo("fake数据添加成功") 32 | 33 | 34 | @plugin_cli.command("init", with_appcontext=False) 35 | def plugin_init(): 36 | """ 37 | initialize plugin 38 | """ 39 | _plugin_init() 40 | 41 | 42 | @plugin_cli.command("generate", with_appcontext=False) 43 | def plugin_generate(): 44 | """ 45 | generate plugin 46 | """ 47 | _plugin_generate() 48 | -------------------------------------------------------------------------------- /app/util/page.py: -------------------------------------------------------------------------------- 1 | from flask import current_app, request 2 | 3 | 4 | def get_count_from_query(): 5 | count_default = current_app.config.get("COUNT_DEFAULT") 6 | count = int(request.args.get("count", count_default if count_default else 1)) 7 | return count 8 | 9 | 10 | def get_page_from_query(): 11 | page_default = current_app.config.get("PAGE_DEFAULT") 12 | page = int(request.args.get("page", page_default if page_default else 0)) 13 | return page 14 | 15 | 16 | def paginate(): 17 | from app.lin import ParameterError 18 | 19 | count = int( 20 | request.args.get( 21 | "count", 22 | current_app.config.get("COUNT_DEFAULT") if current_app.config.get("COUNT_DEFAULT") else 5, 23 | ) 24 | ) 25 | start = int( 26 | request.args.get( 27 | "page", 28 | current_app.config.get("PAGE_DEFAULT") if current_app.config.get("PAGE_DEFAULT") else 0, 29 | ) 30 | ) 31 | count = 15 if count >= 15 else count 32 | start = start * count 33 | if start < 0 or count < 0: 34 | raise ParameterError() 35 | return start, count 36 | -------------------------------------------------------------------------------- /app/api/cms/schema/log.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | from typing import List, Optional 4 | 5 | from pydantic import Field, validator 6 | 7 | from app.lin import BaseModel 8 | from app.schema import BasePageSchema, QueryPageSchema, datetime_regex 9 | 10 | 11 | class UsernameListSchema(BaseModel): 12 | items: List[str] 13 | 14 | 15 | class LogQuerySearchSchema(QueryPageSchema): 16 | keyword: Optional[str] = None 17 | name: Optional[str] = None 18 | start: Optional[str] = Field(None, description="YY-MM-DD HH:MM:SS") 19 | end: Optional[str] = Field(None, description="YY-MM-DD HH:MM:SS") 20 | 21 | @validator("start", "end") 22 | def datetime_match(cls, v): 23 | if re.match(datetime_regex, v): 24 | return v 25 | raise ValueError("时间格式有误") 26 | 27 | 28 | class LogSchema(BaseModel): 29 | message: str 30 | user_id: int 31 | username: str 32 | status_code: int 33 | method: str 34 | path: str 35 | permission: str 36 | time: datetime = Field(alias="create_time") 37 | 38 | 39 | class LogPageSchema(BasePageSchema): 40 | items: List[LogSchema] 41 | -------------------------------------------------------------------------------- /app/api/cms/schema/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import EmailStr, Field, validator 4 | 5 | from app.lin import BaseModel, ParameterError 6 | 7 | 8 | class EmailSchema(BaseModel): 9 | email: Optional[str] = Field(description="用户邮箱") 10 | 11 | @validator("email") 12 | def check_email(cls, v, values, **kwargs): 13 | return EmailStr.validate(v) if v else "" 14 | 15 | 16 | class ResetPasswordSchema(BaseModel): 17 | new_password: str = Field(description="新密码", min_length=6, max_length=22) 18 | confirm_password: str = Field(description="确认密码", min_length=6, max_length=22) 19 | 20 | @validator("confirm_password") 21 | def passwords_match(cls, v, values, **kwargs): 22 | if v != values["new_password"]: 23 | raise ParameterError("两次输入的密码不一致,请输入相同的密码") 24 | return v 25 | 26 | 27 | class GroupIdListSchema(BaseModel): 28 | group_ids: List[int] = Field(description="用户组ID列表") 29 | 30 | @validator("group_ids", each_item=True) 31 | def check_group_id(cls, v, values, **kwargs): 32 | if v <= 0: 33 | raise ParameterError("用户组ID必须大于0") 34 | return v 35 | -------------------------------------------------------------------------------- /app/lin/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from .apidoc import BaseModel, DocResponse, SpecTree 7 | from .config import global_config, lin_config 8 | from .db import db 9 | from .enums import GroupLevelEnum 10 | from .exception import ( 11 | Created, 12 | Deleted, 13 | Duplicated, 14 | Failed, 15 | FileExtensionError, 16 | FileTooLarge, 17 | FileTooMany, 18 | Forbidden, 19 | InternalServerError, 20 | MethodNotAllowed, 21 | NotFound, 22 | ParameterError, 23 | RequestLimit, 24 | Success, 25 | TokenExpired, 26 | TokenInvalid, 27 | UnAuthentication, 28 | UnAuthorization, 29 | Updated, 30 | ) 31 | from .file import Uploader 32 | from .form import Form 33 | from .interface import BaseCrud, InfoCrud 34 | from .jwt import admin_required, get_tokens, group_required, login_required 35 | from .lin import Lin 36 | from .logger import Log, Logger 37 | from .manager import manager 38 | from .model import Group, GroupPermission, Permission, User, UserGroup, UserIdentity 39 | from .redprint import Redprint 40 | from .utils import permission_meta 41 | -------------------------------------------------------------------------------- /app/extension/file/file.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Index, Integer, String, func, text 2 | 3 | from app.lin import InfoCrud, db 4 | 5 | 6 | class File(InfoCrud): 7 | __tablename__ = "lin_file" 8 | __table_args__ = (Index("md5_del", "md5", "delete_time", unique=True),) 9 | 10 | id = Column(Integer(), primary_key=True) 11 | path = Column(String(500), nullable=False) 12 | type = Column( 13 | String(10), 14 | nullable=False, 15 | server_default=text("'LOCAL'"), 16 | comment="LOCAL 本地,REMOTE 远程", 17 | ) 18 | name = Column(String(100), nullable=False) 19 | extension = Column(String(50)) 20 | size = Column(Integer()) 21 | md5 = Column(String(40), comment="md5值,防止上传重复文件") 22 | 23 | @classmethod 24 | def select_by_md5(cls, md5): 25 | result = cls.query.filter_by(soft=True, md5=md5) 26 | file = result.first() 27 | return file 28 | 29 | @classmethod 30 | def count_by_md5(cls, md5): 31 | result = db.session.query(func.count(cls.id)).filter(cls.is_deleted == False, cls.md5 == md5) 32 | count = result.scalar() 33 | return count 34 | 35 | @staticmethod 36 | def create_file(**kwargs): 37 | file = File() 38 | for key in kwargs.keys(): 39 | if hasattr(file, key): 40 | setattr(file, key, kwargs[key]) 41 | db.session.add(file) 42 | if kwargs.get("commit") is True: 43 | db.session.commit() 44 | return file 45 | -------------------------------------------------------------------------------- /app/cli/db/init.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from app.lin import GroupLevelEnum, db, manager 7 | 8 | 9 | def init(force=False): 10 | db.create_all() 11 | if force: 12 | db.drop_all() 13 | db.create_all() 14 | elif ( 15 | manager.user_model.get(one=False) 16 | or manager.user_group_model.get(one=False) 17 | or manager.group_model.get(one=False) 18 | ): 19 | exit("表中存在数据,初始化失败") 20 | with db.auto_commit(): 21 | # 创建一个超级管理员分组 22 | root_group = manager.group_model() 23 | root_group.name = "Root" 24 | root_group.info = "超级用户组" 25 | root_group.level = GroupLevelEnum.ROOT.value 26 | db.session.add(root_group) 27 | # 创建一个超级管理员 28 | root = manager.user_model() 29 | root.username = "root" 30 | db.session.add(root) 31 | db.session.flush() 32 | root.password = "123456" 33 | # root用户 and 超级管理员分组 对应关系写入user_group表中 34 | user_group = manager.user_group_model() 35 | user_group.user_id = root.id 36 | user_group.group_id = root_group.id 37 | db.session.add(user_group) 38 | # 添加 默认游客组 39 | guest_group = manager.group_model() 40 | guest_group.name = "Guest" 41 | guest_group.info = "游客组" 42 | guest_group.level = GroupLevelEnum.GUEST.value 43 | db.session.add(guest_group) 44 | # 初始化权限 45 | manager.sync_permissions() 46 | -------------------------------------------------------------------------------- /app/api/cms/model/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import func 2 | 3 | from app.lin import User as LinUser 4 | from app.lin import db, manager 5 | 6 | 7 | class User(LinUser): 8 | def _set_fields(self): 9 | self._exclude = ["delete_time", "create_time", "is_deleted", "update_time"] 10 | 11 | @classmethod 12 | def count_by_username(cls, username) -> int: 13 | result = db.session.query(func.count(cls.id)).filter(cls.username == username, cls.is_deleted == False) 14 | count = result.scalar() 15 | return count 16 | 17 | @classmethod 18 | def count_by_email(cls, email) -> int: 19 | result = db.session.query(func.count(cls.id)).filter(cls.email == email, cls.is_deleted == False) 20 | count = result.scalar() 21 | return count 22 | 23 | @classmethod 24 | def select_page_by_group_id(cls, group_id, root_group_id) -> list: 25 | """通过分组id分页获取用户数据""" 26 | query = db.session.query(manager.user_group_model.user_id).filter( 27 | manager.user_group_model.group_id == group_id, 28 | manager.user_group_model.group_id != root_group_id, 29 | ) 30 | result = cls.query.filter_by(soft=True).filter(cls.id.in_(query)) 31 | users = result.all() 32 | return users 33 | 34 | def reset_password(self, new_password): 35 | self.password = new_password 36 | 37 | def change_password(self, old_password, new_password): 38 | if self.check_password(old_password): 39 | self.password = new_password 40 | return True 41 | return False 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "app" 3 | version = "0.6" 4 | description = "🎀A simple and practical CMS implememted by flask" 5 | requires-python = ">=3.9,<3.14" 6 | authors = [ 7 | {name = "sunlin92", email = "sun.melodies@gmail.com"} 8 | ] 9 | dependencies = [ 10 | "Flask==3.1.0", 11 | "Flask-JWT-Extended==4.7.1", 12 | "Flask-SQLAlchemy==3.1.1", 13 | "WTForms==3.2.1", 14 | "tablib==3.8.0", 15 | "spectree==1.4.5", 16 | "pillow>=11.1.0", 17 | "flask-cors>=5.0.1", 18 | "gunicorn>=23.0.0", 19 | "gevent>=24.1.1", 20 | "flask-socketio>=5.5.1", 21 | "blinker>=1.9.0", 22 | "python-dotenv>=1.0.1", 23 | "gevent-websocket>=0.10.1", 24 | "pydantic[email]>=1.10.21,<2.0.0", 25 | ] 26 | 27 | [project.optional-dependencies] 28 | dev = [ 29 | "black>=25.1.0", 30 | "isort>=6.0.1", 31 | "watchdog>=6.0.0", 32 | "coverage>=7.6.12", 33 | "pytest>=8.3.5", 34 | "pre-commit>=4.1.0", 35 | "pytest-order>=1.3.0", 36 | "flake8>=7.1.2", 37 | "mypy>=1.15.0", 38 | ] 39 | 40 | [build-system] 41 | requires = ["hatchling"] 42 | build-backend = "hatchling.build" 43 | 44 | [[tool.poetry.source]] 45 | name = "tsinghua" 46 | url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" 47 | priority = "primary" 48 | 49 | [tool.isort] 50 | multi_line_output = 3 51 | force_grid_wrap = 0 52 | line_length = 120 53 | use_parentheses = true 54 | include_trailing_comma = true 55 | 56 | [tool.black] 57 | line_length = 120 58 | 59 | [tool.mypy] 60 | files = ["starter.py"] 61 | ignore_missing_imports = true 62 | 63 | [tool.pytest] 64 | testpaths = ["tests"] 65 | -------------------------------------------------------------------------------- /app/api/cms/model/permission.py: -------------------------------------------------------------------------------- 1 | from app.lin import Permission as LinPermission 2 | from app.lin import db, manager 3 | 4 | 5 | class Permission(LinPermission): 6 | @classmethod 7 | def select_by_group_id(cls, group_id) -> list: 8 | """ 9 | 传入用户组Id ,根据 Group-Permission关联表 获取 权限列表 10 | """ 11 | query = db.session.query(manager.group_permission_model.permission_id).filter( 12 | manager.group_permission_model.group_id == group_id 13 | ) 14 | result = cls.query.filter_by(soft=True, mount=True).filter(cls.id.in_(query)) 15 | permissions = result.all() 16 | return permissions 17 | 18 | @classmethod 19 | def select_by_group_ids(cls, group_ids: list) -> list: 20 | """ 21 | 传入用户组Id列表 ,根据 Group-Permission关联表 获取 权限列表 22 | """ 23 | query = db.session.query(manager.group_permission_model.permission_id).filter( 24 | manager.group_permission_model.group_id.in_(group_ids) 25 | ) 26 | result = cls.query.filter_by(soft=True, mount=True).filter(cls.id.in_(query)) 27 | permissions = result.all() 28 | return permissions 29 | 30 | @classmethod 31 | def select_by_group_ids_and_module(cls, group_ids: list, module) -> list: 32 | """ 33 | 传入用户组的 id 列表 和 权限模块名称,根据 Group-Permission关联表 获取 权限列表 34 | """ 35 | query = db.session.query(manager.group_permission_model.permission_id).filter( 36 | manager.group_permission_model.group_id.in_(group_ids) 37 | ) 38 | result = cls.query.filter_by(soft=True, module=module, mount=True).filter(cls.id.in_(query)) 39 | permissions = result.all() 40 | return permissions 41 | -------------------------------------------------------------------------------- /app/cli/db/fake.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from app.api.v1.model.book import Book 7 | from app.lin import db 8 | 9 | 10 | def fake(): 11 | with db.auto_commit(): 12 | # 添加书籍 13 | book1 = Book() 14 | book1.title = "深入理解计算机系统" 15 | book1.author = "Randal E.Bryant" 16 | book1.summary = """ 17 | 从程序员的视角,看计算机系统!\n 18 | 本书适用于那些想要写出更快、更可靠程序的程序员。 19 | 通过掌握程序是如何映射到系统上,以及程序是如何执行的,读者能够更好的理解程序的行为为什么是这样的,以及效率低下是如何造成的。 20 | 粗略来看,计算机系统包括处理器和存储器硬件、编译器、操作系统和网络互连环境。 21 | 而通过程序员的视角,读者可以清晰地明白学习计算机系统的内部工作原理会对他们今后作为计算机科学研究者和工程师的工作有进一步的帮助。 22 | 它还有助于为进一步学习计算机体系结构、操作系统、编译器和网络互连做好准备。\n 23 | 本书的主要论题包括:数据表示、C程序的机器级表示、处理器结构,程序优化、存储器层次结构、链接、异常控制流、虚拟存储器和存储器管理、系统级I/O、网络编程和并发编程。书中所覆盖的内容主要是这些方面是如何影响应用和系统程序员的。 24 | """ 25 | book1.image = "https://img3.doubanio.com/lpic/s1470003.jpg" 26 | db.session.add(book1) 27 | 28 | book2 = Book() 29 | book2.title = "C程序设计语言" 30 | book2.author = "(美)Brian W. Kernighan" 31 | book2.summary = """ 32 | 在计算机发展的历史上,没有哪一种程序设计语言像C语言这样应用广泛。 33 | 本书原著即为C语言的设计者之一Dennis M.Ritchie和著名计算机科学家Brian W.Kernighan合著的一本介绍C语言的权威经典著作。 34 | 我们现在见到的大量论述C语言程序设计的教材和专著均以此书为蓝本。 35 | 原著第1版中介绍的C语言成为后来广泛使用的C语言版本——标准C的基础。 36 | 人们熟知的“hello,World"程序就是由本书首次引入的,现在,这一程序已经成为众多程序设计语言入门的第一课。\n 37 | 原著第2版根据1987年制定的ANSIC标准做了适当的修订.引入了最新的语言形式,并增加了新的示例,通过简洁的描述、典型的示例,作者全面、系统、准确地讲述了C语言的各个特性以及程序设计的基本方法。 38 | 对于计算机从业人员来说,《C程序设计语言》是一本必读的程序设计语 言方面的参考书。 39 | """ 40 | book2.image = "https://img3.doubanio.com/lpic/s1106934.jpg" 41 | db.session.add(book2) 42 | -------------------------------------------------------------------------------- /app/config/code_message.py: -------------------------------------------------------------------------------- 1 | """ 2 | 消息码配置文件 3 | 格式:消息码 -> 消息 4 | """ 5 | 6 | MESSAGE = { 7 | 0: "成功", 8 | 1: "创建成功", 9 | 2: "更新成功", 10 | 3: "删除成功", 11 | 4: "密码修改成功", 12 | 5: "删除用户成功", 13 | 6: "更新用户成功", 14 | 7: "更新分组成功", 15 | 8: "删除分组成功", 16 | 9: "添加权限成功", 17 | 10: "删除权限成功", 18 | 11: "注册成功", 19 | 12: "新建图书成功", 20 | 13: "更新图书成功", 21 | 14: "删除图书成功", 22 | 15: "新建分组成功", 23 | 9999: "服务器未知错误", 24 | 10000: "未携带令牌", 25 | 10001: "权限不足", 26 | 10010: "授权失败", 27 | 10011: "更新密码失败", 28 | 10012: "请传入认证头字段", 29 | 10013: "认证头字段解析失败", 30 | 10020: "资源不存在", 31 | 10021: "用户不存在", 32 | 10022: "未找到相关书籍", 33 | 10023: "分组不存在,无法新建用户", 34 | 10024: "分组不存在", 35 | 10025: "找不到相应的视图处理器", 36 | 10026: "未找到文件", 37 | 10030: "参数错误", 38 | 10031: "用户名或密码错误", 39 | 10032: "请输入正确的密码", 40 | 10040: "令牌失效", 41 | 10041: "access token 损坏", 42 | 10042: "refresh token 损坏", 43 | 10050: "令牌过期", 44 | 10051: "access token 过期", 45 | 10052: "refresh token 过期", 46 | 10060: "字段重复", 47 | 10070: "禁止操作", 48 | 10071: "已经有用户使用了该名称,请重新输入新的用户名", 49 | 10072: "分组名已被使用,请重新填入新的分组名", 50 | 10073: "root分组不可添加用户", 51 | 10074: "root分组不可删除", 52 | 10075: "guest分组不可删除", 53 | 10076: "邮箱已被使用,请重新填入新的邮箱", 54 | 10077: "不可将用户分配给不存在的分组", 55 | 10078: "不可修改root用户的分组", 56 | 10080: "请求方法不允许", 57 | 10100: "刷新令牌获取失败", 58 | 10110: "文件体积过大", 59 | 10120: "文件数量过多", 60 | 10121: "文件太多", 61 | 10130: "文件扩展名不符合规范", 62 | 10140: "请求过于频繁,请稍后重试", 63 | 10150: "丢失参数", 64 | 10160: "类型错误", 65 | 10170: "请求体不可为空", 66 | 10180: "全部文件大小不能超过", 67 | 10190: "读取文件数据失败", 68 | 10200: "失败", 69 | } 70 | -------------------------------------------------------------------------------- /app/plugin/qiniu/app/controller.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from flask import request 7 | from qiniu import Auth 8 | 9 | from app.lin import FileExtensionError, Redprint, lin_config 10 | 11 | from .model import Qiniu 12 | 13 | qiniu_api = Redprint("qiniu") 14 | 15 | 16 | @qiniu_api.route("/uptoken") 17 | def up_token(): 18 | """ 19 | 生成 token 给前端,前端直接上传 20 | """ 21 | access_key = lin_config.get_config("qiniu.access_key") 22 | secret_key = lin_config.get_config("qiniu.secret_key") 23 | # 构建鉴权对象 24 | q = Auth(access_key, secret_key) 25 | # 要上传的空间 26 | bucket_name = lin_config.get_config("qiniu.bucket_name") 27 | # 允许的文件类型 28 | allowed_extensions = lin_config.get_config("qiniu.allowed_extensions") 29 | # 上传后保存的文件名 30 | filename = request.args.get("filename", str()) 31 | if filename.split(".")[-1] in allowed_extensions: 32 | # 生成上传 Token,可以指定过期时间等 33 | # 上传策略示例 34 | # https://developer.qiniu.com/kodo/manual/1206/put-policy 35 | policy = { 36 | # 'callbackUrl':'https://requestb.in/1c7q2d31', 37 | # 'callbackBody':'filename=$(fname)&filesize=$(fsize)' 38 | # 'persistentOps':'imageView2/1/w/200/h/200' 39 | } 40 | token = q.upload_token( 41 | bucket_name, 42 | filename, 43 | lin_config.get_config("qiniu.token_expire_time"), 44 | policy, 45 | ) 46 | return {"token": token} 47 | else: 48 | raise FileExtensionError 49 | 50 | 51 | @qiniu_api.route("/record", methods=["POST"]) 52 | def record(): 53 | Qiniu.create(url=request.get_json().url, commit=True) 54 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import pytest 5 | 6 | from app import create_app 7 | from app.api.cms.model.group import Group 8 | from app.api.cms.model.group_permission import GroupPermission 9 | from app.api.cms.model.permission import Permission 10 | from app.api.cms.model.user import User 11 | from app.api.cms.model.user_group import UserGroup 12 | from app.api.cms.model.user_identity import UserIdentity 13 | 14 | from .config import password, username 15 | 16 | app = create_app( 17 | group_model=Group, 18 | user_model=User, 19 | group_permission_model=GroupPermission, 20 | permission_model=Permission, 21 | identity_model=UserIdentity, 22 | user_group_model=UserGroup, 23 | ) 24 | 25 | 26 | @pytest.fixture() 27 | def fixtureFunc(): 28 | with app.test_client() as c: 29 | rv = c.post( 30 | "/cms/user/login", 31 | headers={"Content-Type": "application/json"}, 32 | json={"username": username, "password": password}, 33 | ) 34 | json_data = rv.get_json() 35 | assert json_data.get("access_token") != None 36 | assert rv.status_code == 200 37 | write_token(json_data) 38 | 39 | 40 | def get_file_path(): 41 | pytest_cache_dir_path = os.getcwd() + os.path.sep + ".pytest_cache" 42 | if not os.path.exists(pytest_cache_dir_path): 43 | os.makedirs(pytest_cache_dir_path) 44 | json_file_path = pytest_cache_dir_path + os.path.sep + "test.json" 45 | return json_file_path 46 | 47 | 48 | def write_token(data): 49 | obj = json.dumps(data) 50 | with open(get_file_path(), "w") as f: 51 | f.write(obj) 52 | 53 | 54 | def get_token(key="access_token"): 55 | with open(get_file_path(), "r") as f: 56 | obj = json.loads(f.read()) 57 | return obj[key] 58 | -------------------------------------------------------------------------------- /app/config/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | import os 7 | from datetime import timedelta 8 | 9 | 10 | class BaseConfig(object): 11 | """ 12 | 基础配置 13 | """ 14 | 15 | # 先读 env 环境变量中的配置 16 | 17 | # 指定加密KEY 18 | SECRET_KEY = os.getenv("SECRET_KEY", "https://github.com/TaleLin/lin-cms-flask") 19 | 20 | # 指定访问api服务的url, 用于文件上传 21 | # SITE_DOMAIN="https://lincms.example.com" 22 | 23 | # 指定数据库 24 | SQLALCHEMY_DATABASE_URI = os.getenv( 25 | "SQLALCHEMY_DATABASE_URI", 26 | "sqlite:////" + os.getcwd() + os.path.sep + "lincms.db", 27 | ) 28 | 29 | # 屏蔽 sql alchemy 的 FSADeprecationWarning 30 | SQLALCHEMY_TRACK_MODIFICATIONS = False 31 | 32 | # flask-sqlalchemy 引擎配置 33 | SQLALCHEMY_ENGINE_OPTIONS = { 34 | # sqlite 不支持pool_size, 其他数据库按需配置 35 | # "pool_size": 10, 36 | # 每次请求前 pre-ping一下数据库, 防止db gone away 37 | "pool_pre_ping": True, 38 | # 小于等于数据库连接主动回收时间 39 | "pool_recycle": 600, 40 | } 41 | 42 | # 令牌配置 43 | JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) 44 | 45 | # 登录验证码 46 | LOGIN_CAPTCHA = False 47 | 48 | # 默认文件上传配置 49 | FILE = { 50 | "STORE_DIR": "assets", 51 | "SINGLE_LIMIT": 1024 * 1024 * 2, 52 | "TOTAL_LIMIT": 1024 * 1024 * 20, 53 | "NUMS": 10, 54 | "INCLUDE": set(["jpg", "png", "jpeg"]), 55 | "EXCLUDE": set([]), 56 | } 57 | 58 | # 运行日志 59 | LOG = { 60 | "LEVEL": "DEBUG", 61 | "DIR": "logs", 62 | "SIZE_LIMIT": 1024 * 1024 * 5, 63 | "REQUEST_LOG": True, 64 | "FILE": True, 65 | } 66 | 67 | # 分页配置 68 | COUNT_DEFAULT = 10 69 | PAGE_DEFAULT = 0 70 | 71 | # 兼容中文 72 | JSON_AS_ASCII = False 73 | -------------------------------------------------------------------------------- /app/lin/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | config class of Lin 3 | ~~~~~~~~~ 4 | 5 | This module implements a config class 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | from collections import defaultdict 12 | from typing import Any, Dict, Optional, Union 13 | 14 | 15 | class Config(defaultdict[str, Any]): 16 | def add_plugin_config(self, plugin_name: str, obj: Dict[str, Any]) -> None: 17 | if type(obj) is dict: 18 | if self.get(plugin_name, None) is None: 19 | self[plugin_name] = {} 20 | for k, v in obj.items(): 21 | self[plugin_name][k] = v 22 | 23 | def add_plugin_config_item(self, plugin_name: str, key: str, value: Any) -> None: 24 | if self.get(plugin_name, None) is None: 25 | self[plugin_name] = {} 26 | self[plugin_name][key] = value 27 | 28 | def get_plugin_config(self, plugin_name: str, default: Optional[Any] = None) -> Any: 29 | return self.get(plugin_name, default) 30 | 31 | def get_plugin_config_item(self, plugin_name: str, key: str, default: Optional[Any] = None) -> Any: 32 | plugin_conf = self.get(plugin_name) 33 | if plugin_conf is None: 34 | return default 35 | return plugin_conf.get(key, default) 36 | 37 | def get_config(self, key: str, default: Optional[Any] = None) -> Any: 38 | """plugin_name.key""" 39 | if "." not in key: 40 | return self.get(key, default) 41 | index = key.rindex(".") 42 | plugin_name = key[:index] 43 | plugin_key = key[index + 1 :] 44 | plugin_conf = self.get(plugin_name) 45 | if plugin_conf is None: 46 | return default 47 | return plugin_conf.get(plugin_key, default) 48 | 49 | 50 | lin_config: Config = Config() 51 | 52 | global_config: Dict[str, Any] = dict() 53 | -------------------------------------------------------------------------------- /app/lin/redprint.py: -------------------------------------------------------------------------------- 1 | """ 2 | Redprint of Lin 3 | ~~~~~~~~~ 4 | Redprint make blueprint more fine-grained 5 | :copyright: © 2020 by the Lin team. 6 | :license: MIT, see LICENSE for more details. 7 | """ 8 | 9 | from typing import Any, Callable, Dict, List, Optional, Tuple 10 | 11 | 12 | class Redprint: 13 | def __init__(self, name: str, with_prefix: bool = True) -> None: 14 | """ 15 | 初始化 Redprint 实例 16 | 17 | :param name: Redprint 的名称 18 | :param with_prefix: 是否使用前缀 19 | """ 20 | self.name: str = name 21 | self.with_prefix: bool = with_prefix 22 | self.mound: List[Tuple[Callable[..., Any], str, Dict[str, Any]]] = [] 23 | 24 | def route(self, rule: str, **options: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 25 | """ 26 | 添加路由规则 27 | 28 | :param rule: 路由规则 29 | :param options: 其他选项 30 | :return: 装饰器函数 31 | """ 32 | 33 | def decorator(f: Callable[..., Any]) -> Callable[..., Any]: 34 | self.mound.append((f, rule, options)) 35 | return f 36 | 37 | return decorator 38 | 39 | def register(self, bp: Any, url_prefix: Optional[str] = None) -> None: 40 | """ 41 | 注册路由到蓝图 42 | 43 | :param bp: 蓝图对象 44 | :param url_prefix: URL 前缀 45 | """ 46 | if url_prefix is None and self.with_prefix: 47 | url_prefix = "/" + self.name 48 | else: 49 | url_prefix = "" + str(url_prefix) + "/" + self.name 50 | for f, rule, options in self.mound: 51 | endpoint = self.name + "+" + options.pop("endpoint", f.__name__) 52 | if rule: 53 | url = url_prefix + rule 54 | bp.add_url_rule(url, endpoint, f, **options) 55 | else: 56 | bp.add_url_rule(url_prefix, endpoint, f, **options) 57 | -------------------------------------------------------------------------------- /app/cli/plugin/generator.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | import os 7 | 8 | banner = """ 9 | \""" 10 | :copyright: © 2020 by the Lin team. 11 | :license: MIT, see LICENSE for more details. 12 | \""" 13 | """ 14 | 15 | controller = """ 16 | from app.lin import Redprint 17 | 18 | {0}_api = Redprint("{0}") 19 | 20 | 21 | @{0}_api.route("/") 22 | def test(): 23 | return "hi, guy!" 24 | """ 25 | 26 | init = """ 27 | from .controller import {0}_api 28 | """ 29 | 30 | info = """ 31 | __name__ = "{0}" 32 | __version__ = "0.1.0" 33 | __author__ = "Team Lin" 34 | """ 35 | 36 | readme = """# {0}""" 37 | 38 | 39 | def create_plugin(name: str): 40 | cmd = os.getcwd() 41 | plugins_path = os.path.join(cmd, "app/plugin") 42 | plugindir = os.path.join(plugins_path, name) 43 | os.mkdir(plugindir) 44 | 45 | open(os.path.join(plugindir, "config.py"), mode="x", encoding="utf-8") 46 | open(os.path.join(plugindir, "requirements.txt"), mode="x", encoding="utf-8") 47 | 48 | with open(os.path.join(plugindir, "info.py"), mode="x", encoding="utf-8") as f: 49 | f.write(banner + info.format(name)) 50 | 51 | with open(os.path.join(plugindir, "README.md"), mode="x", encoding="utf-8") as f: 52 | f.write(readme.format(name)) 53 | 54 | appdir = os.path.join(plugindir, "app") 55 | os.mkdir(appdir) 56 | 57 | with open(os.path.join(appdir, "__init__.py"), mode="x", encoding="utf-8") as f: 58 | f.write(banner + init.format(name)) 59 | 60 | with open(os.path.join(appdir, "controller.py"), mode="x", encoding="utf-8") as f: 61 | f.write(banner + controller.format(name)) 62 | 63 | open(os.path.join(appdir, "model.py"), mode="x", encoding="utf-8") 64 | 65 | 66 | def generate(): 67 | plugin_name = input("请输入要创建的插件名:\n") 68 | create_plugin(plugin_name) 69 | -------------------------------------------------------------------------------- /app/plugin/poem/app/model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, Text, text 2 | 3 | from app.lin import InfoCrud as Base 4 | from app.lin import NotFound, db, lin_config 5 | 6 | 7 | class Poem(Base): 8 | __tablename__ = "lin_poem" 9 | id = Column(Integer, primary_key=True, autoincrement=True) 10 | title = Column(String(50), nullable=False, comment="标题") 11 | author = Column(String(50), default="未名", comment="作者") 12 | dynasty = Column(String(50), default="未知", comment="朝代") 13 | _content = Column("content", Text, nullable=False, comment="内容,以/来分割每一句,以|来分割宋词的上下片") 14 | image = Column(String(255), default="", comment="配图") 15 | 16 | @property 17 | def content(self): 18 | ret = [] 19 | lis = self._content.split("|") 20 | for x in lis: 21 | ret.append(x.split("/")) 22 | return ret 23 | 24 | def get_all(self, form): 25 | query = self.query.filter_by(is_deleted=False) 26 | 27 | if form.author.data: 28 | query = query.filter_by(author=form.author.data) 29 | 30 | limit = form.count.data if form.count.data else lin_config.get_config("poem.limit") 31 | 32 | poems = query.limit(limit).all() 33 | 34 | if not poems: 35 | raise NotFound("没有找到相关诗词") 36 | return poems 37 | 38 | def search(self, q): 39 | poems = self.query.filter(Poem.title.like("%" + q + "%")).all() 40 | if not poems: 41 | raise NotFound("没有找到相关诗词") 42 | return poems 43 | 44 | @classmethod 45 | def get_authors(cls): 46 | authors = ( 47 | db.session.query(cls.author) 48 | .filter_by(soft=False) 49 | .group_by(text("author")) 50 | .having(text("count(author) > 0")) 51 | .all() 52 | ) 53 | ret = [author[0] for author in authors] 54 | return ret 55 | -------------------------------------------------------------------------------- /tests/test_book.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | import pytest 7 | 8 | from . import app, fixtureFunc, get_token 9 | 10 | 11 | @pytest.mark.run(order=1) 12 | def test_create(fixtureFunc): 13 | with app.test_client() as c: 14 | rv = c.post( 15 | "/v1/book", 16 | headers={"Authorization": "Bearer " + get_token()}, 17 | json={ 18 | "title": "create", 19 | "author": "pedro", 20 | "summary": "summary", 21 | "image": "https://img3.doubanio.com/lpic/s1470003.jpg", 22 | }, 23 | ) 24 | assert rv.status_code == 200 or rv.get_json().get("code") == 10030 25 | 26 | 27 | @pytest.mark.run(order=2) 28 | def test_get_books(): 29 | with app.test_client() as c: 30 | rv = c.get("/v1/book", headers={"Authorization": "Bearer "}) 31 | assert rv.status_code == 200 32 | 33 | 34 | @pytest.mark.run(order=3) 35 | def test_update(fixtureFunc): 36 | with app.test_client() as c: 37 | id = c.get("/v1/book", headers={"Authorization": "Bearer "}).get_json()[-1].get("id") 38 | rv = c.put( 39 | "/v1/book/{}".format(id), 40 | headers={"Authorization": "Bearer " + get_token()}, 41 | json={ 42 | "title": "update", 43 | "author": "pedro & erik", 44 | "summary": "summary", 45 | "image": "https://img3.doubanio.com/lpic/s1470003.jpg", 46 | }, 47 | ) 48 | assert rv.status_code == 200 49 | 50 | 51 | @pytest.mark.run(order=4) 52 | def test_delete(): 53 | with app.test_client() as c: 54 | id = c.get("/v1/book", headers={"Authorization": "Bearer "}).get_json()[-1].get("id") 55 | rv = c.delete("/v1/book/{}".format(id), headers={"Authorization": "Bearer " + get_token()}) 56 | assert rv.status_code == 200 57 | -------------------------------------------------------------------------------- /app/lin/form.py: -------------------------------------------------------------------------------- 1 | """ 2 | forms of Lin 3 | ~~~~~~~~~ 4 | 5 | forms check the incoming params and data 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | from typing import Any, List, Optional, Union 12 | 13 | from flask import request 14 | from wtforms import Field 15 | from wtforms import Form as WTForm 16 | from wtforms import IntegerField 17 | from wtforms.validators import StopValidation 18 | 19 | from .exception import ParameterError 20 | 21 | 22 | class Form(WTForm): 23 | def __init__(self) -> None: 24 | data = request.get_json(silent=True) 25 | args = request.args.to_dict() 26 | illegal_params = set(args.keys()) & { 27 | "formdata", 28 | "obj", 29 | "prefix", 30 | "data", 31 | "meta", 32 | } 33 | if illegal_params: 34 | raise ParameterError("非法参数: {}".format(illegal_params)) 35 | super(Form, self).__init__(data=data, **args) 36 | 37 | def validate_for_api(self) -> "Form": 38 | valid = super(Form, self).validate() 39 | if not valid: 40 | raise ParameterError(self.errors) 41 | return self 42 | 43 | 44 | def integer_check(form: Form, field: Field) -> None: 45 | if field.data is None: 46 | raise StopValidation("输入字段不可为空") 47 | try: 48 | field.data = int(field.data) 49 | except ValueError: 50 | raise StopValidation("不是一个有效整数") 51 | 52 | 53 | class LinIntegerField(IntegerField): 54 | """ 55 | 校验一个字段是否为正整数 56 | """ 57 | 58 | def __init__(self, label: Optional[str] = None, validators: Optional[List[Any]] = None, **kwargs: Any) -> None: 59 | if validators is not None and type(validators) == list: 60 | validators.insert(0, integer_check) 61 | else: 62 | validators = [integer_check] 63 | super(LinIntegerField, self).__init__(label, validators, **kwargs) 64 | -------------------------------------------------------------------------------- /app/plugin/poem/README.md: -------------------------------------------------------------------------------- 1 | # 古诗词插件接口文档 2 | 3 | >本接口文档仅供插件开发者阅读,如果你想学习如何开发此插件,请阅读 4 | [插件开发](https://doc.cms.talelin.com/plugins/flask/be_develop.html) 5 | 6 | ## 获取所有诗词接口 7 | URL: 8 | >GET http://localhost:5000/plugin/poem/all 9 | 10 | Parameters: 11 | - count: (可选)获取的数量,最小1,最大100,默认5 12 | - author: (可选)按照作者查找,作者列表从获取所有作者API获取 13 | 14 | Response: 15 | ```json 16 | [ 17 | { 18 | "author": "欧阳修", 19 | "content": [ 20 | [ 21 | "去年元夜时", 22 | "花市灯如昼", 23 | "月上柳梢头", 24 | "人约黄昏后" 25 | ], 26 | [ 27 | "今年元夜时", 28 | "月与灯依旧", 29 | "不见去年人", 30 | "泪湿春衫袖" 31 | ] 32 | ], 33 | "create_time": 1549438754000, 34 | "dynasty": "宋代", 35 | "id": 1, 36 | "image": "", 37 | "title": "生查子·元夕" 38 | }, 39 | { 40 | "author": "苏轼", 41 | "content": [ 42 | [ 43 | "一别都门三改火", 44 | "天涯踏尽红尘", 45 | "依然一笑作春温", 46 | "无波真古井", 47 | "有节是秋筠" 48 | ], 49 | [ 50 | "惆怅孤帆连夜发", 51 | "送行淡月微云", 52 | "尊前不用翠眉颦", 53 | "人生如逆旅", 54 | "我亦是行人" 55 | ] 56 | ], 57 | "create_time": 1549438754000, 58 | "dynasty": "宋代", 59 | "id": 2, 60 | "image": "", 61 | "title": "临江仙·送钱穆父" 62 | } 63 | ] 64 | ``` 65 | 66 | Response_description: 67 | - author: 作者 68 | - content: 内容。是一个二维数组,第一维数组用来区分词的每一阙(如果是古诗,那么第一维数组中只有一个元素),第二维数组用来区分古诗词的每一句。 69 | - create_time: 创建时间 70 | - dynasty: 作者所属朝代 71 | - id: id号码 72 | - image: 配图 73 | - title: 标题 74 | 75 | 76 | ## 获取所有作者接口 77 | URL: 78 | >GET http://localhost:5000/plugin/poem/authors 79 | 80 | Response: 81 | ```json 82 | [ 83 | "元稹", 84 | "晏殊", 85 | "欧阳修", 86 | "纳兰性德", 87 | "苏轼", 88 | "薛涛" 89 | ] 90 | ``` 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2016, Kenneth Reitz 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | === https://github.com/kennethreitz/records(v0.5.3) === 17 | 18 | MIT License 19 | 20 | Copyright (c) 2019 TaleLin 21 | 22 | Permission is hereby granted, free of charge, to any person obtaining a copy 23 | of this software and associated documentation files (the "Software"), to deal 24 | in the Software without restriction, including without limitation the rights 25 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 26 | copies of the Software, and to permit persons to whom the Software is 27 | furnished to do so, subject to the following conditions: 28 | 29 | The above copyright notice and this permission notice shall be included in all 30 | copies or substantial portions of the Software. 31 | 32 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 33 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 34 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 35 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 36 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 37 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 38 | SOFTWARE. 39 | -------------------------------------------------------------------------------- /app/util/captcha.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import io 3 | import random 4 | import string 5 | from typing import Tuple 6 | 7 | from PIL import Image, ImageDraw, ImageFont 8 | 9 | 10 | class CaptchaTool: 11 | """ 12 | 生成图片验证码 13 | """ 14 | 15 | def __init__(self, width=50, height=12): 16 | 17 | self.width = width 18 | self.height = height 19 | # 新图片对象 20 | self.im = Image.new("RGB", (width, height), "white") 21 | # 字体 22 | self.font = ImageFont.load_default() 23 | # draw对象 24 | self.draw = ImageDraw.Draw(self.im) 25 | 26 | def draw_lines(self, num=3): 27 | """ 28 | 划线 29 | """ 30 | for num in range(num): 31 | x1 = random.randint(0, self.width / 2) 32 | y1 = random.randint(0, self.height / 2) 33 | x2 = random.randint(0, self.width) 34 | y2 = random.randint(self.height / 2, self.height) 35 | self.draw.line(((x1, y1), (x2, y2)), fill="black", width=1) 36 | 37 | def get_verify_code(self) -> Tuple[bytes, str]: 38 | """ 39 | 生成验证码图形 40 | """ 41 | # 设置随机4位数字验证码 42 | code = "".join(random.sample(string.digits, 4)) 43 | # 绘制字符串 44 | for item in range(4): 45 | self.draw.text( 46 | (6 + random.randint(-3, 3) + 10 * item, 2 + random.randint(-2, 2)), 47 | text=code[item], 48 | fill=( 49 | random.randint(32, 127), 50 | random.randint(32, 127), 51 | random.randint(32, 127), 52 | ), 53 | font=self.font, 54 | ) 55 | # 划线 56 | # self.draw_lines() 57 | # 重新设置图片大小 58 | self.im = self.im.resize((80, 30)) 59 | # 图片转为base64字符串 60 | buffered = io.BytesIO() 61 | self.im.save(buffered, format="webp") 62 | img = b"data:image/png;base64," + base64.b64encode(buffered.getvalue()) 63 | return img, code 64 | -------------------------------------------------------------------------------- /app/extension/file/local_uploader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import current_app 4 | from werkzeug.utils import secure_filename 5 | 6 | from app.lin import Uploader 7 | 8 | from .file import File 9 | 10 | 11 | class LocalUploader(Uploader): 12 | def upload(self): 13 | ret = [] 14 | self.mkdir_if_not_exists() 15 | site_domain = current_app.config.get( 16 | "SITE_DOMAIN", 17 | "http://{host}:{port}".format( 18 | host=current_app.config.get("FLASK_RUN_HOST", "127.0.0.1"), 19 | port=current_app.config.get("FLASK_RUN_PORT", "5000"), 20 | ), 21 | ) 22 | for single in self._file_storage: 23 | file_md5 = self._generate_md5(single.read()) 24 | single.seek(0) 25 | exists = File.select_by_md5(file_md5) 26 | if exists: 27 | ret.append( 28 | { 29 | "key": single.name, 30 | "id": exists.id, 31 | "path": exists.path, 32 | "url": site_domain + os.path.join(current_app.static_url_path, exists.path), 33 | } 34 | ) 35 | else: 36 | absolute_path, relative_path, real_name = self._get_store_path(single.filename) 37 | secure_filename(single.filename) 38 | single.save(absolute_path) 39 | file = File.create_file( 40 | name=real_name, 41 | path=relative_path, 42 | extension=self._get_ext(single.filename), 43 | size=self._get_size(single), 44 | md5=file_md5, 45 | commit=True, 46 | ) 47 | ret.append( 48 | { 49 | "key": single.name, 50 | "id": file.id, 51 | "path": file.path, 52 | "url": site_domain + os.path.join(current_app.static_url_path, file.path), 53 | } 54 | ) 55 | return ret 56 | -------------------------------------------------------------------------------- /app/extension/notify/sse.py: -------------------------------------------------------------------------------- 1 | """ 2 | sse of Lin 3 | ~~~~~~~~~ 4 | 5 | sse 实现类 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | import json 12 | from collections import deque 13 | 14 | 15 | class Sse(object): 16 | messages = deque() 17 | _retry = None 18 | 19 | def __init__(self, default_retry=2000): 20 | self._buffer = [] 21 | self._default_id = 1 22 | self.set_retry(default_retry) 23 | 24 | def set_retry(self, num): 25 | self._retry = num 26 | self._buffer.append("retry: {0}\n".format(self._retry)) 27 | 28 | def set_event_id(self, event_id=None): 29 | if event_id: 30 | self._default_id = event_id 31 | self._buffer.append("id: {0}\n".format(event_id)) 32 | else: 33 | self._buffer.append("id: {0}\n".format(self._default_id)) 34 | 35 | def reset_event_id(self): 36 | self.set_event_id(1) 37 | 38 | def increase_id(self): 39 | self._default_id += 1 40 | 41 | def add_message(self, event, obj, flush=True): 42 | self.set_event_id() 43 | self._buffer.append("event: {0}\n".format(event)) 44 | line = json.dumps(obj, ensure_ascii=False) 45 | self._buffer.append("data: {0}\n".format(line)) 46 | self._buffer.append("\n") 47 | if flush: 48 | self.flush() 49 | 50 | def flush(self): 51 | self.messages.append(self.join_buffer()) 52 | self._buffer.clear() 53 | self.increase_id() 54 | 55 | def pop(self): 56 | return self.messages.popleft() 57 | 58 | def heartbeat(self, comment=None): 59 | # 发送注释 : this is a test stream\n\n 告诉客户端,服务器还活着 60 | if comment and type(comment) == "str": 61 | self._buffer.append(comment) 62 | else: 63 | self._buffer.append(": sse sever is still alive \n\n") 64 | tmp = self.join_buffer() 65 | self._buffer.clear() 66 | return tmp 67 | 68 | def join_buffer(self): 69 | string = "" 70 | for it in self._buffer: 71 | string += it 72 | return string 73 | 74 | def exit_message(self): 75 | return len(self.messages) > 0 76 | 77 | 78 | sser = Sse() 79 | -------------------------------------------------------------------------------- /app/lin/encoder.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from decimal import Decimal 3 | from enum import Enum 4 | from typing import Iterable 5 | 6 | from flask import json, jsonify 7 | from flask.json.provider import DefaultJSONProvider 8 | from flask.wrappers import Response 9 | 10 | from .apidoc import BaseModel 11 | from .db import Record, RecordCollection 12 | from .exception import APIException 13 | 14 | 15 | class JSONEncoder(DefaultJSONProvider): 16 | ensure_ascii = False 17 | 18 | def default(self, o): 19 | if isinstance(o, BaseModel): 20 | if hasattr(o, "__root__") and o.__root__.__class__.__name__ in ( 21 | "list", 22 | "int", 23 | "set", 24 | "tuple", 25 | ): 26 | return o.__root__ 27 | return o.dict() 28 | if isinstance(o, (int, float, list, set, tuple)): 29 | return json.dumps(o, cls=JSONEncoder) 30 | if isinstance(o, bytes): 31 | return o.decode("utf8") 32 | if isinstance(o, datetime): 33 | return o.strftime("%Y-%m-%dT%H:%M:%SZ") 34 | if isinstance(o, date): 35 | return o.strftime("%Y-%m-%d") 36 | if isinstance(o, Enum): 37 | return o.value 38 | if isinstance(o, (RecordCollection, Record)): 39 | return o.as_dict() 40 | if isinstance(o, Decimal): 41 | return json.dumps(o, use_decimal=True) 42 | if isinstance(o, Iterable): 43 | return list(o) 44 | if isinstance(o, complex): 45 | return f"{o.real}+{o.imag}j" 46 | if hasattr(o, "keys") and hasattr(o, "__getitem__"): 47 | return dict(o) 48 | return JSONEncoder.default(self, o) 49 | 50 | 51 | def auto_response(func): 52 | def make_lin_response(o): 53 | if not isinstance(o, str) and ( 54 | isinstance(o, (RecordCollection, Record, BaseModel, Iterable)) 55 | or (hasattr(o, "keys") and hasattr(o, "__getitem__")) 56 | or isinstance(o, (int, float, list, set, complex, Decimal, Enum)) 57 | ): 58 | o = jsonify(o) 59 | elif isinstance(o, tuple) and not isinstance(o[0], (Response, str)): 60 | oc = list(o) 61 | oc[0] = json.dumps(o[0]) 62 | o = tuple(oc) 63 | 64 | return func(o) 65 | 66 | return make_lin_response 67 | -------------------------------------------------------------------------------- /app/api/cms/schema/admin.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import Field, validator 4 | 5 | from app.lin import BaseModel, ParameterError 6 | from app.schema import BasePageSchema, QueryPageSchema 7 | 8 | from . import EmailSchema, GroupIdListSchema 9 | 10 | 11 | class AdminGroupSchema(BaseModel): 12 | id: int = Field(description="用户组ID") 13 | info: str = Field(description="用户组信息") 14 | name: str = Field(description="用户组名称") 15 | 16 | 17 | class AdminGroupListSchema(BaseModel): 18 | __root__: List[AdminGroupSchema] 19 | 20 | 21 | class AdminUserSchema(EmailSchema): 22 | id: int = Field(description="用户ID") 23 | username: str = Field(description="用户名") 24 | groups: List[AdminGroupSchema] = Field(description="用户组列表") 25 | 26 | 27 | class QueryPageWithGroupIdSchema(QueryPageSchema): 28 | group_id: Optional[int] = Field(description="用户ID") 29 | 30 | 31 | class AdminUserPageSchema(BasePageSchema): 32 | items: List[AdminUserSchema] 33 | 34 | 35 | class UpdateUserInfoSchema(GroupIdListSchema, EmailSchema): 36 | pass 37 | 38 | 39 | class PermissionSchema(BaseModel): 40 | id: int = Field(description="权限ID") 41 | name: str = Field(description="权限名称") 42 | module: str = Field(description="权限所属模块") 43 | mount: bool = Field(description="是否为挂载权限") 44 | 45 | 46 | class AdminGroupPermissionSchema(AdminGroupSchema): 47 | permissions: List[PermissionSchema] 48 | 49 | 50 | class AdminGroupPermissionPageSchema(BasePageSchema): 51 | items: List[AdminGroupPermissionSchema] 52 | 53 | 54 | class GroupBaseSchema(BaseModel): 55 | name: str = Field(description="用户组名称") 56 | info: Optional[str] = Field(description="用户组信息") 57 | 58 | 59 | class CreateGroupSchema(GroupBaseSchema): 60 | permission_ids: List[int] = Field(description="权限ID列表") 61 | 62 | @validator("permission_ids", each_item=True) 63 | def check_permission_id(cls, v, values, **kwargs): 64 | if v <= 0: 65 | raise ParameterError("权限ID必须大于0") 66 | return v 67 | 68 | 69 | class GroupIdWithPermissionIdListSchema(BaseModel): 70 | group_id: int = Field(description="用户组ID") 71 | permission_ids: List[int] = Field(description="权限ID列表") 72 | 73 | @validator("permission_ids", each_item=True) 74 | def check_permission_id(cls, v, values, **kwargs): 75 | if v <= 0: 76 | raise ParameterError("权限ID必须大于0") 77 | return v 78 | -------------------------------------------------------------------------------- /app/api/cms/schema/user.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Optional 3 | 4 | from pydantic import Field, validator 5 | 6 | from app.lin import BaseModel, ParameterError 7 | 8 | from . import EmailSchema, GroupIdListSchema, ResetPasswordSchema 9 | 10 | 11 | class LoginSchema(BaseModel): 12 | username: str = Field(description="用户名") 13 | password: str = Field(description="密码") 14 | captcha: Optional[str] = Field(description="验证码") 15 | 16 | 17 | class LoginTokenSchema(BaseModel): 18 | access_token: str = Field(description="access_token") 19 | refresh_token: str = Field(description="refresh_token") 20 | 21 | 22 | class CaptchaSchema(BaseModel): 23 | image: str = Field("", description="验证码图片base64编码") 24 | tag: str = Field("", description="验证码标记码") 25 | 26 | 27 | class PermissionNameSchema(BaseModel): 28 | name: str = Field(description="权限名称") 29 | 30 | 31 | class PermissionModuleSchema(BaseModel): 32 | module: List[PermissionNameSchema] = Field(description="权限模块") 33 | 34 | 35 | class UserBaseInfoSchema(EmailSchema): 36 | nickname: Optional[str] = Field(description="用户昵称", min_length=2, max_length=10) 37 | avatar: Optional[str] = Field(description="头像url") 38 | 39 | 40 | class UserSchema(UserBaseInfoSchema): 41 | id: int = Field(description="用户id") 42 | username: str = Field(description="用户名") 43 | 44 | 45 | class UserPermissionSchema(UserSchema): 46 | admin: bool = Field(description="是否是管理员") 47 | permissions: List[PermissionModuleSchema] = Field(description="用户权限") 48 | 49 | 50 | class ChangePasswordSchema(ResetPasswordSchema): 51 | old_password: str = Field(description="旧密码") 52 | 53 | 54 | class UserRegisterSchema(GroupIdListSchema, EmailSchema): 55 | username: str = Field(description="用户名", min_length=2, max_length=10) 56 | password: str = Field(description="密码", min_length=6, max_length=22) 57 | confirm_password: str = Field(description="确认密码", min_length=6, max_length=22) 58 | 59 | @validator("confirm_password") 60 | def passwords_match(cls, v, values, **kwargs): 61 | if v != values["password"]: 62 | raise ParameterError("两次输入的密码不一致,请输入相同的密码") 63 | return v 64 | 65 | @validator("username") 66 | def check_username(cls, v, values, **kwargs): 67 | if not re.match(r"^[a-zA-Z0-9_]{2,10}$", v): 68 | raise ParameterError("用户名只能由字母、数字、下划线组成,且长度为2-10位") 69 | return v 70 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | import os 7 | 8 | from dotenv import load_dotenv 9 | from flask import Flask 10 | 11 | from app.util.common import basedir 12 | 13 | 14 | def register_blueprints(app): 15 | from app.api.cms import create_cms 16 | from app.api.v1 import create_v1 17 | 18 | app.register_blueprint(create_v1(), url_prefix="/v1") 19 | app.register_blueprint(create_cms(), url_prefix="/cms") 20 | 21 | 22 | def register_cli(app): 23 | from app.cli import db_cli, plugin_cli 24 | 25 | app.cli.add_command(db_cli) 26 | app.cli.add_command(plugin_cli) 27 | 28 | 29 | def register_api(app): 30 | from app.api import api 31 | 32 | api.register(app) 33 | 34 | 35 | def apply_cors(app): 36 | from flask_cors import CORS 37 | 38 | CORS(app) 39 | 40 | 41 | def init_socketio(app): 42 | from app.extension.notify.socketio import socketio 43 | 44 | socketio.init_app(app, cors_allowed_origins="*") 45 | 46 | 47 | def load_app_config(app): 48 | """ 49 | 根据指定配置环境自动加载对应环境变量和配置类到app config 50 | """ 51 | # 根据传入环境加载对应配置 52 | env = os.environ.get("FLASK_ENV") 53 | # 读取 .env 54 | load_dotenv(os.path.join(basedir, ".{env}.env").format(env=env)) 55 | # 读取配置类 56 | app.config.from_object("app.config.{env}.{Env}Config".format(env=env, Env=env.capitalize())) 57 | 58 | 59 | def set_global_config(**kwargs): 60 | from app.lin import global_config 61 | 62 | # 获取config_*参数对象并挂载到脱离上下文的global config 63 | for k, v in kwargs.items(): 64 | if k.startswith("config_"): 65 | global_config[k[7:]] = v 66 | 67 | 68 | def create_app(register_all=True, **kwargs): 69 | # 全局配置优先生效 70 | set_global_config(**kwargs) 71 | 72 | app = Flask(__name__, static_folder=os.path.join(basedir, "assets")) 73 | # http wsgi server托管启动需指定读取环境配置 74 | # Load .flaskenv file 75 | flaskenv_path = os.path.join(basedir, ".flaskenv") 76 | if os.path.exists(flaskenv_path): 77 | load_dotenv(flaskenv_path) 78 | else: 79 | print(f"Warning: {flaskenv_path} not found") 80 | load_app_config(app) 81 | if register_all: 82 | from app.lin import Lin 83 | 84 | register_blueprints(app) 85 | register_api(app) 86 | apply_cors(app) 87 | init_socketio(app) 88 | Lin(app, **kwargs) 89 | register_cli(app) 90 | return app 91 | -------------------------------------------------------------------------------- /starter.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from app import create_app 7 | from app.api.cms.model.group import Group 8 | from app.api.cms.model.group_permission import GroupPermission 9 | from app.api.cms.model.permission import Permission 10 | from app.api.cms.model.user import User 11 | from app.api.cms.model.user_group import UserGroup 12 | from app.api.cms.model.user_identity import UserIdentity 13 | from app.api.v1.model.book import Book 14 | from app.config.code_message import MESSAGE 15 | 16 | app = create_app( 17 | group_model=Group, 18 | user_model=User, 19 | group_permission_model=GroupPermission, 20 | permission_model=Permission, 21 | identity_model=UserIdentity, 22 | user_group_model=UserGroup, 23 | config_MESSAGE=MESSAGE, 24 | ) 25 | 26 | 27 | @app.route("/hello") 28 | def hello(): 29 | return Book.get(one=True) 30 | 31 | 32 | if app.config.get("ENV") != "production": 33 | 34 | @app.route("/") 35 | def slogan(): 36 | return """ 37 | 76 |
77 |

78 | Lin 79 |
80 | 81 | 心上无垢林间有风。 82 | 83 |

84 |
85 | """ 86 | 87 | 88 | if __name__ == "__main__": 89 | app.logger.warning( 90 | """ 91 | ---------------------------- 92 | | app.run() => flask run | 93 | ---------------------------- 94 | """ 95 | ) 96 | app.run() 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS 2 | .DS_Store 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # IDE 133 | .vscode/ 134 | .idea/ 135 | .vim/ 136 | 137 | # custom 138 | assets 139 | logs 140 | 141 | *.pyc 142 | # poetry lock 143 | poetry.lock 144 | # db 145 | lincmsdev.db 146 | lincmsprod.db 147 | lincms.db 148 | 149 | # .env 中记录了私密配置 150 | # *.env 151 | -------------------------------------------------------------------------------- /app/lin/loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | loader of Lin 3 | ~~~~~~~~~ 4 | 5 | This module implements a plugin loader of Lin. 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | from importlib import import_module 12 | 13 | from .config import lin_config 14 | from .db import db 15 | from .plugin import Plugin 16 | from .redprint import Redprint 17 | 18 | 19 | class Loader(object): 20 | plugin_path: dict = None 21 | 22 | def __init__(self, plugin_path): 23 | self.plugins = {} 24 | assert type(plugin_path) is dict, "plugin_path must be a dict" 25 | self.plugin_path = plugin_path 26 | self.load_plugins_config() 27 | self.load_plugins() 28 | 29 | def load_plugins(self): 30 | for name, conf in self.plugin_path.items(): 31 | enable = conf.get("enable", None) 32 | if enable: 33 | path = conf.get("path") 34 | # load plugin 35 | path and self._load_plugin(f"{path}.app.__init__", name) 36 | 37 | def load_plugins_config(self): 38 | for name, conf in self.plugin_path.items(): 39 | path = conf.get("path", None) 40 | # load config 41 | self._load_config(f"{path}.config", name, conf) 42 | 43 | def _load_plugin(self, path, name): 44 | mod = import_module(path) 45 | plugin = Plugin(name=name) 46 | dic = mod.__dict__ 47 | for key in dic.keys(): 48 | if not key.startswith("_") and key != "initial_data": 49 | attr = dic[key] 50 | if isinstance(attr, Redprint): 51 | plugin.add_controller(attr.name, attr) 52 | elif issubclass(attr, db.Model): 53 | plugin.add_model(attr.__name__, attr) 54 | # 暂时废弃加载service,用处不大 55 | # elif issubclass(attr, ServiceInterface): 56 | # plugin.add_service(attr.__name__, attr) 57 | self.plugins[plugin.name] = plugin 58 | 59 | def _check_version(self, path, version, name): 60 | info_mod = import_module(path) 61 | info_dic = info_mod.__dict__ 62 | assert info_dic["__version__"] == version, "the plugin " + name + " needs to be updated" 63 | 64 | def _load_config(self, config_path, name, conf): 65 | default_conf = {} 66 | try: 67 | if config_path: 68 | mod = import_module(config_path) 69 | dic = mod.__dict__ 70 | for key in dic.keys(): 71 | if not key.startswith("_"): 72 | default_conf[key] = dic[key] 73 | except ModuleNotFoundError: 74 | pass 75 | default_conf.update(**conf) 76 | lin_config.add_plugin_config(plugin_name=name, obj=default_conf) 77 | -------------------------------------------------------------------------------- /app/api/cms/log.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from flask import Blueprint, g 4 | from sqlalchemy import text 5 | 6 | from app.api import AuthorizationBearerSecurity, api 7 | from app.api.cms.schema.log import LogPageSchema, LogQuerySearchSchema, UsernameListSchema 8 | from app.lin import DocResponse, Log, db, group_required, permission_meta 9 | 10 | log_api = Blueprint("log", __name__) 11 | 12 | 13 | @log_api.route("") 14 | @permission_meta(name="查询日志", module="日志") 15 | @group_required 16 | @api.validate( 17 | resp=DocResponse(r=LogPageSchema), 18 | before=LogQuerySearchSchema.offset_handler, 19 | security=[AuthorizationBearerSecurity], 20 | tags=["日志"], 21 | ) 22 | def get_logs(query: LogQuerySearchSchema): 23 | """ 24 | 日志浏览查询(人员,时间, 关键字),分页展示 25 | """ 26 | logs = Log.query.filter() 27 | total = logs.count() 28 | items = logs.order_by(text("create_time desc")).offset(g.offset).limit(g.count).all() 29 | total_page = math.ceil(total / g.count) 30 | 31 | return LogPageSchema( 32 | page=g.page, 33 | count=g.count, 34 | total=total, 35 | items=items, 36 | total_page=total_page, 37 | ) 38 | 39 | 40 | @log_api.route("/search") 41 | @permission_meta(name="搜索日志", module="日志") 42 | @group_required 43 | @api.validate( 44 | resp=DocResponse(r=LogPageSchema), 45 | security=[AuthorizationBearerSecurity], 46 | before=LogQuerySearchSchema.offset_handler, 47 | tags=["日志"], 48 | ) 49 | def search_logs(query: LogQuerySearchSchema): 50 | """ 51 | 日志搜索(人员,时间, 关键字),分页展示 52 | """ 53 | if g.keyword: 54 | logs = Log.query.filter(Log.message.like(f"%{g.keyword}%")) 55 | else: 56 | logs = Log.query.filter() 57 | if g.name: 58 | logs = logs.filter(Log.username == g.name) 59 | if g.start and g.end: 60 | logs = logs.filter(Log.create_time.between(g.start, g.end)) 61 | 62 | total = logs.count() 63 | items = logs.order_by(text("create_time desc")).offset(g.offset).limit(g.count).all() 64 | total_page = math.ceil(total / g.count) 65 | 66 | return LogPageSchema( 67 | page=g.page, 68 | count=g.count, 69 | total=total, 70 | items=items, 71 | total_page=total_page, 72 | ) 73 | 74 | 75 | @log_api.route("/users") 76 | @permission_meta(name="查询日志记录的用户", module="日志") 77 | @group_required 78 | @api.validate( 79 | resp=DocResponse(r=UsernameListSchema), 80 | security=[AuthorizationBearerSecurity], 81 | tags=["日志"], 82 | ) 83 | def get_users_for_log(): 84 | """ 85 | 获取所有记录行为日志的用户名 86 | """ 87 | usernames = ( 88 | db.session.query(Log.username) 89 | .filter_by(soft=False) 90 | .group_by(text("username")) 91 | .having(text("count(username) > 0")) 92 | .all() 93 | ) 94 | return UsernameListSchema(items=[u.username for u in usernames]) 95 | -------------------------------------------------------------------------------- /app/plugin/cos/app/model.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | from sqlalchemy import Column, Integer, String, text 4 | 5 | from app.lin import BaseCrud, lin_config 6 | 7 | 8 | class COS(BaseCrud): 9 | __tablename__ = "cos" 10 | 11 | id = Column(Integer, primary_key=True) 12 | file_name = Column(String(255), nullable=False) 13 | file_key = Column(String(255), nullable=False) 14 | file_md5 = Column(String(40), nullable=False) 15 | file_size = Column(Integer()) 16 | url = Column(String(255), nullable=True, comment="存放文件永久访问链接") 17 | type = Column( 18 | String(10), 19 | nullable=False, 20 | server_default=text("'REMOTE'"), 21 | comment="LOCAL 本地,REMOTE 远程", 22 | ) 23 | status = Column( 24 | String(10), 25 | nullable=False, 26 | server_default=text("'REQUEST'"), 27 | comment="REQUEST 请求上传,UPLOADED 已上传,ERROR 上传错误", 28 | ) 29 | 30 | @staticmethod 31 | def generate_key(filename: str): 32 | dir_name = lin_config.get_config("cos.upload_folder") 33 | file_key = COS._generate_uuid() + COS._get_ext(filename) 34 | return dir_name + "/" + file_key if dir_name else file_key 35 | 36 | @staticmethod 37 | def generate_md5(data: bytes): 38 | md5_obj = hashlib.md5() 39 | md5_obj.update(data) 40 | ret = md5_obj.hexdigest() 41 | return ret 42 | 43 | @staticmethod 44 | def get_size(client, bucket, file_key) -> str: 45 | """ 46 | 得到文件大小(字节) 47 | :param client: cos 实例 48 | :param bucket: 存储桶 49 | :param file_key: 文件名 50 | :return: 文件的字节数 51 | """ 52 | return client.head_object(Bucket=bucket, Key=file_key).get("Content-Length", None) 53 | 54 | @staticmethod 55 | def get_url(client, bucket, file_key) -> str: 56 | """ 57 | 得到文件永久访问链接 58 | :param client: cos 实例 59 | :param bucket: 存储桶 60 | :param file_key: 文件名 61 | :return: 文件的永久访问链接 62 | """ 63 | return client.get_object_url(Bucket=bucket, Key=file_key) 64 | 65 | @staticmethod 66 | def get_presigned_url(client, bucket, file_key) -> str: 67 | """ 68 | 得到文件临时访问链接 69 | :param client: cos 实例 70 | :param bucket: 存储桶 71 | :param file_key: 文件名 72 | :return: 文件的临时访问链接 73 | """ 74 | return client.get_presigned_url( 75 | Method="GET", Bucket=bucket, Key=file_key, Expired=lin_config.get_config("cos.expire_time") 76 | ) 77 | 78 | @staticmethod 79 | def _generate_uuid(): 80 | import uuid 81 | 82 | return str(uuid.uuid1()) 83 | 84 | @staticmethod 85 | def _get_ext(filename: str): 86 | """ 87 | 得到文件的扩展名 88 | :param filename: 原始文件名 89 | :return: string 文件的扩展名 90 | """ 91 | return "." + filename.lower().split(".")[-1] 92 | -------------------------------------------------------------------------------- /app/api/v1/book.py: -------------------------------------------------------------------------------- 1 | """ 2 | a standard CRUD template of book 3 | 通过 图书 来实现一套标准的 CRUD 功能,供学习 4 | :copyright: © 2020 by the Lin team. 5 | :license: MIT, see LICENSE for more details. 6 | """ 7 | 8 | from flask import Blueprint, g 9 | 10 | from app.api import AuthorizationBearerSecurity, api 11 | from app.api.v1.exception import BookNotFound 12 | from app.api.v1.model.book import Book 13 | from app.api.v1.schema import BookInSchema, BookOutSchema, BookQuerySearchSchema, BookSchemaList 14 | from app.lin import DocResponse, Success, group_required, login_required, permission_meta 15 | 16 | book_api = Blueprint("book", __name__) 17 | 18 | 19 | @book_api.route("/") 20 | @api.validate( 21 | resp=DocResponse(BookNotFound, r=BookOutSchema), 22 | tags=["图书"], 23 | ) 24 | def get_book(id): 25 | """ 26 | 获取id指定图书的信息 27 | """ 28 | book = Book.get(id=id) 29 | if book: 30 | return book 31 | raise BookNotFound 32 | 33 | 34 | @book_api.route("") 35 | @api.validate( 36 | resp=DocResponse(r=BookSchemaList), 37 | tags=["图书"], 38 | ) 39 | def get_books(): 40 | """ 41 | 获取图书列表 42 | """ 43 | return Book.get(one=False) 44 | 45 | 46 | @book_api.route("/search") 47 | @api.validate( 48 | resp=DocResponse(r=BookSchemaList), 49 | tags=["图书"], 50 | ) 51 | def search(query: BookQuerySearchSchema): 52 | """ 53 | 关键字搜索图书 54 | """ 55 | return Book.query.filter(Book.title.like("%" + g.q + "%"), Book.is_deleted == False).all() 56 | 57 | 58 | @book_api.route("", methods=["POST"]) 59 | @login_required 60 | @api.validate( 61 | resp=DocResponse(Success(12)), 62 | security=[AuthorizationBearerSecurity], 63 | tags=["图书"], 64 | ) 65 | def create_book(json: BookInSchema): 66 | """ 67 | 创建图书 68 | """ 69 | Book.create(**json.dict(), commit=True) 70 | raise Success(12) 71 | 72 | 73 | @book_api.route("/", methods=["PUT"]) 74 | @login_required 75 | @api.validate( 76 | resp=DocResponse(Success(13)), 77 | security=[AuthorizationBearerSecurity], 78 | tags=["图书"], 79 | ) 80 | def update_book(id, json: BookInSchema): 81 | """ 82 | 更新图书信息 83 | """ 84 | book = Book.get(id=id) 85 | if book: 86 | book.update( 87 | id=id, 88 | **json.dict(), 89 | commit=True, 90 | ) 91 | raise Success(13) 92 | raise BookNotFound 93 | 94 | 95 | @book_api.route("/", methods=["DELETE"]) 96 | @permission_meta(name="删除图书", module="图书") 97 | @group_required 98 | @api.validate( 99 | resp=DocResponse(BookNotFound, Success(14)), 100 | security=[AuthorizationBearerSecurity], 101 | tags=["图书"], 102 | ) 103 | def delete_book(id): 104 | """ 105 | 传入id删除对应图书 106 | """ 107 | book = Book.get(id=id) 108 | if book: 109 | # 删除图书,软删除 110 | book.delete(commit=True) 111 | raise Success(14) 112 | raise BookNotFound 113 | -------------------------------------------------------------------------------- /app/extension/notify/notify.py: -------------------------------------------------------------------------------- 1 | """ 2 | notify of Lin 3 | ~~~~~~~~~ 4 | 5 | notify 扩展,消息推送 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | import re 12 | from datetime import datetime 13 | from functools import wraps 14 | 15 | from flask import Response, request 16 | from flask_jwt_extended import get_current_user 17 | 18 | from .sse import sser 19 | 20 | REG_XP = r"[{](.*?)[}]" 21 | OBJECTS = ["user", "response", "request"] 22 | SUCCESS_STATUS = [200, 201] 23 | MESSAGE_EVENTS = set() 24 | 25 | 26 | class Notify(object): 27 | def __init__(self, template=None, event=None, **kwargs): 28 | """ 29 | Notify a message or create a log 30 | :param template: message template 31 | {user.username}查看自己是否为激活状态 ,状态码为{response.status_code} -> pedro查看自己是否为激活状态 ,状态码为200 32 | :param write: write to db or not 33 | :param push: push to front_end or not 34 | """ 35 | if event: 36 | self.event = event 37 | elif self.event is None: 38 | raise Exception("event must not be None!") 39 | if template: 40 | self.template: str = template 41 | elif self.template is None: 42 | raise Exception("template must not be None!") 43 | # 加入所有types中 44 | MESSAGE_EVENTS.add(event) 45 | self.message = "" 46 | self.response = None 47 | self.user = None 48 | self.extra = kwargs 49 | 50 | def __call__(self, func): 51 | @wraps(func) 52 | def wrap(*args, **kwargs): 53 | response: Response = func(*args, **kwargs) 54 | self.response = response 55 | self.user = get_current_user() 56 | self.message = self._parse_template() 57 | self.push_message() 58 | return response 59 | 60 | return wrap 61 | 62 | def push_message(self): 63 | # status = '操作成功' if self.response.status_code in SUCCESS_STATUS else '操作失败' 64 | sser.add_message( 65 | self.event, 66 | { 67 | "message": self.message, 68 | "time": int(datetime.now().timestamp()), 69 | **self.extra, 70 | }, 71 | ) 72 | 73 | # 解析自定义消息的模板 74 | def _parse_template(self): 75 | message = self.template 76 | total = re.findall(REG_XP, message) 77 | for it in total: 78 | assert "." in it, "%s中必须包含 . ,且为一个" % it 79 | i = it.rindex(".") 80 | obj = it[:i] 81 | assert obj in OBJECTS, "%s只能为user,response,request中的一个" % obj 82 | prop = it[i + 1 :] 83 | if obj == "user": 84 | item = getattr(self.user, prop, "") 85 | elif obj == "response": 86 | item = getattr(self.response, prop, "") 87 | else: 88 | item = getattr(request, prop, "") 89 | message = message.replace("{%s}" % it, str(item)) 90 | return message 91 | 92 | def _check_can_push(self): 93 | # 超级管理员不可push,暂时测试可push 94 | if self.user.is_admin: 95 | return False 96 | return True 97 | -------------------------------------------------------------------------------- /app/plugin/poem/app/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | from .controller import api 7 | from .model import Poem 8 | 9 | 10 | def initial_data(): 11 | from app import create_app 12 | from app.lin import db 13 | 14 | app = create_app() 15 | with app.app_context(): 16 | data = Poem.query.limit(1).all() 17 | if data: 18 | return 19 | with db.auto_commit(): 20 | # 添加诗歌 21 | img_url = "http://yanlan.oss-cn-shenzhen.aliyuncs.com/gqmgbmu06yO2zHD.png" 22 | poem1 = Poem() 23 | poem1.title = "生查子·元夕" 24 | poem1.author = "欧阳修" 25 | poem1.dynasty = "宋代" 26 | poem1._content = ( 27 | """去年元夜时/花市灯如昼/月上柳梢头/人约黄昏后|今年元夜时/月与灯依旧/不见去年人/泪湿春衫袖""" 28 | ) 29 | poem1.image = img_url 30 | db.session.add(poem1) 31 | 32 | poem2 = Poem() 33 | poem2.title = "临江仙·送钱穆父" 34 | poem2.author = "苏轼" 35 | poem2.dynasty = "宋代" 36 | poem2._content = """一别都门三改火/天涯踏尽红尘/依然一笑作春温/无波真古井/有节是秋筠|惆怅孤帆连夜发/送行淡月微云/尊前不用翠眉颦/人生如逆旅/我亦是行人""" 37 | poem2.image = img_url 38 | db.session.add(poem2) 39 | 40 | poem3 = Poem() 41 | poem3.title = "春望词四首" 42 | poem3.author = "薛涛" 43 | poem3.dynasty = "唐代" 44 | poem3._content = """花开不同赏/花落不同悲/欲问相思处/花开花落时/揽草结同心/将以遗知音/春愁正断绝/春鸟复哀吟/风花日将老/佳期犹渺渺/不结同心人/空结同心草/那堪花满枝/翻作两相思/玉箸垂朝镜/春风知不知""" 45 | poem3.image = img_url 46 | db.session.add(poem3) 47 | 48 | poem4 = Poem() 49 | poem4.title = "长相思" 50 | poem4.author = "纳兰性德" 51 | poem4.dynasty = "清代" 52 | poem4._content = """山一程/水一程/身向榆关那畔行/夜深千帐灯|风一更/雪一更/聒碎乡心梦不成/故园无此声""" 53 | poem4.image = img_url 54 | db.session.add(poem4) 55 | 56 | poem5 = Poem() 57 | poem5.title = "离思五首·其四" 58 | poem5.author = "元稹" 59 | poem5.dynasty = "唐代" 60 | poem5._content = """曾经沧海难为水/除却巫山不是云/取次花丛懒回顾/半缘修道半缘君""" 61 | poem5.image = img_url 62 | db.session.add(poem5) 63 | 64 | poem6 = Poem() 65 | poem6.title = "浣溪沙" 66 | poem6.author = "晏殊" 67 | poem6.dynasty = "宋代" 68 | poem6._content = ( 69 | """一曲新词酒一杯/去年天气旧亭台/夕阳西下几时回|无可奈何花落去/似曾相识燕归来/小园香径独徘徊""" 70 | ) 71 | poem6.image = img_url 72 | db.session.add(poem6) 73 | 74 | poem7 = Poem() 75 | poem7.title = "浣溪沙" 76 | poem7.author = "纳兰性德" 77 | poem7.dynasty = "清代" 78 | poem7._content = ( 79 | """残雪凝辉冷画屏/落梅横笛已三更/更无人处月胧明|我是人间惆怅客/知君何事泪纵横/断肠声里忆平生""" 80 | ) 81 | poem7.image = img_url 82 | db.session.add(poem7) 83 | 84 | poem8 = Poem() 85 | poem8.title = "蝶恋花·春景" 86 | poem8.author = "苏轼" 87 | poem8.dynasty = "宋代" 88 | poem8._content = """花褪残红青杏小/燕子飞时/绿水人家绕/枝上柳绵吹又少/天涯何处无芳草|墙里秋千墙外道/墙外行人/墙里佳人笑/笑渐不闻声渐悄/多情却被无情恼""" 89 | poem8.image = img_url 90 | db.session.add(poem8) 91 | 92 | return app 93 | -------------------------------------------------------------------------------- /app/plugin/oss/app/controller.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import oss2 4 | from flask import jsonify, request 5 | 6 | from app.lin import Failed, ParameterError, Redprint, Success, db, get_random_str, lin_config 7 | 8 | from .model import OSS 9 | 10 | api = Redprint("oss") 11 | 12 | 13 | @api.route("/upload_to_local", methods=["POST"]) 14 | def upload(): 15 | image = request.files.get("image", None) 16 | if not image: 17 | raise ParameterError("没有找到图片") 18 | if image and allowed_file(image.filename): 19 | path = os.path.join(lin_config.get_config("oss.upload_folder"), image.filename) 20 | image.save(path) 21 | else: 22 | raise ParameterError("图片类型不允许或图片key不合法") 23 | raise Success() 24 | 25 | 26 | @api.route("/upload_to_ali", methods=["POST"]) 27 | def upload_to_ali(): 28 | image = request.files.get("image", None) 29 | if not image: 30 | raise ParameterError("没有找到图片") 31 | if image and allowed_file(image.filename): 32 | url = upload_image_bytes(image.filename, image) 33 | if url: 34 | res = {"url": url} 35 | with db.auto_commit(): 36 | exist = OSS.get(url=url) 37 | if not exist: 38 | data = {"url": url} 39 | one = OSS.create(**data) 40 | db.session.flush() 41 | res["id"] = one.id 42 | else: 43 | res["id"] = exist.id 44 | return jsonify(res) 45 | return Failed("上传图片失败,请检查图片路径") 46 | 47 | 48 | @api.route("/upload_multiple", methods=["POST"]) 49 | def upload_multiple_to_ali(): 50 | imgs = [] 51 | for item in request.files: 52 | img = request.files.get(item, None) 53 | if not img: 54 | raise ParameterError("没接收到图片,请检查图片路径") 55 | if img and allowed_file(img.filename): 56 | url = upload_image_bytes(img.filename, img) 57 | if url: 58 | # 每上传成功一次图片需记录到数据库 59 | with db.auto_commit(): 60 | exist = OSS.get(url=url) 61 | if not exist: 62 | data = {"url": url} 63 | res = OSS.create(**data) 64 | db.session.flush() 65 | imgs.append({"key": item, "url": url, "id": res.id}) 66 | else: 67 | imgs.append({"key": item, "url": url, "id": exist.id}) 68 | return jsonify(imgs) 69 | 70 | 71 | def allowed_file(filename): 72 | return "." in filename and filename.rsplit(".", 1)[1] in lin_config.get_config("oss.allowed_extensions", []) 73 | 74 | 75 | def upload_image_bytes(name: str, data: bytes): 76 | access_key_id = lin_config.get_config("oss.access_key_id") 77 | access_key_secret = lin_config.get_config("oss.access_key_secret") 78 | auth = oss2.Auth(access_key_id, access_key_secret) 79 | bucket = oss2.Bucket( 80 | auth, 81 | lin_config.get_config("oss.endpoint"), 82 | lin_config.get_config("oss.bucket_name"), 83 | ) 84 | suffix = name.split(".")[-1] 85 | rand_name = get_random_str(15) + "." + suffix 86 | res = bucket.put_object(rand_name, data) 87 | if res.resp.status == 200: 88 | return res.resp.response.url 89 | return None 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | 5 | 6 |
7 | Lin-CMS-Flask 8 |

9 | 10 |

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

11 | 12 |

13 | 14 | Flask version 15 | 16 | Python version 17 | 18 | LISENCE 19 |

20 | 21 |
22 | Lin-CMS 是林间有风团队经过大量项目实践所提炼出的一套内容管理系统框架
23 | Lin-CMS 可以有效的帮助开发者提高 CMS 的开发效率。 24 |
25 | 26 |

27 | 简介 | 快速起步  28 |

29 | 30 | ## 简介 31 | 32 | ### 什么是 Lin CMS? 33 | 34 | Lin-CMS 是林间有风团队经过大量项目实践所提炼出的一套**内容管理系统框架**。Lin-CMS 可以有效的帮助开发者提高 CMS 的开发效率。 35 | 36 | 本项目是 Lin CMS 后端的 Flask 实现,需要前端?请访问[前端仓库](https://github.com/TaleLin/lin-cms-vue)。 37 | 38 | ### 线上 demo 39 | 40 | [http://face.cms.talelin.com/](http://face.cms.talelin.com/) 41 | 42 | ### QQ 交流群 43 | 44 | QQ 群号:643205479 45 | 46 | 47 | 48 | ### 微信公众号 49 | 50 | 微信搜索:林间有风 51 | 52 | 53 | 54 | ### Lin CMS 的特点 55 | 56 | Lin CMS 的构筑思想是有其自身特点的。 57 | 58 | #### Lin CMS 是一个前后端分离的 CMS 解决方案 59 | 60 | 这意味着,Lin CMS 既提供后台的支撑,也有一套对应的前端系统,当然双端分离的好处不仅仅在于此。如果您心仪 Lin,却又因为技术栈的原因无法即可使用,没关系,我们也提供了更多的语言版本和框架的后端实现。为什么 Lin 要选择前后端分离的单页面架构呢? 61 | 62 | 首先,传统的网站开发更多的是采用服务端渲染的方式,需用使用一种模板语言在服务端完成页面渲染:比如 JinJa2、Jade 等。 63 | 服务端渲染的好处在于可以比较好的支持 SEO,但作为内部使用的 CMS 管理系统,SEO 并不重要。 64 | 65 | 但一个不可忽视的事实是,服务器渲染的页面到底是由前端开发者来完成,还是由服务器开发者来完成?其实都不太合适。现在已经没有多少前端开发者是了解这些服务端模板语言的,而服务器开发者本身是不太擅长开发页面的。那还是分开吧,前端用最熟悉的 Vue 写 JS 和 CSS,而服务器只关注自己的 API 即可。 66 | 67 | 其次,单页面应用程序的体验本身就要好于传统网站。 68 | 69 | #### 框架本身已内置了 CMS 常用的功能 70 | 71 | Lin 已经内置了 CMS 中最为常见的需求:用户管理、权限管理、日志系统等。开发者只需要集中精力开发自己的 CMS 业务即可 72 | 73 | #### Lin CMS 本身也是一套开发规范 74 | 75 | Lin CMS 除了内置常见的功能外,还提供了一套开发规范与工具类。换句话说,开发者无需再纠结如何验证参数?如何操作数据库?如何做全局的异常处理?API 的结构如何?前端结构应该如何组织?这些问题 Lin CMS 已经给出了解决方案。当然,如果您不喜欢 Lin 给出的架构,那么自己去实现自己的 CMS 架构也是可以的。但通常情况下,您确实无需再做出架构上的改动,Lin 可以满足绝大多数中小型的 CMS 需求。 76 | 77 | 举例来说,每个 API 都需要校验客户端传递的参数。但校验的方法有很多种,不同的开发者会有不同的构筑方案。但 Lin 提供了一套验证机制,开发者无需再纠结如何校验参数,只需模仿 Lin 的校验方案去写自己的业务即可。 78 | 79 | 还是基于这样的一个原则:Lin CMS 只需要开发者关注自己的业务开发,它已经内置了很多机制帮助开发者快速开发自己的业务。 80 | 81 | #### 基于插件的扩展 82 | 83 | 任何优秀的框架都需要考虑到扩展。而 Lin 的扩展支持是通过插件的思想来设计的。当您需要新增一个功能时,您既可以直接在 Lin 的目录下编写代码,也可以将功能以插件的形式封装。比如,您开发了一个文章管理功能,您可以选择以插件的形式来发布,这样其他开发者通过安装您的插件就可以使用这个功能了。毫无疑问,以插件的形式封装功能将最大化代码的可复用性。您甚至可以把自己开发的插件发布,以提供给其他开发者使用。这种机制相当的棒。 84 | 85 | #### 前端组件库支持 86 | 87 | Lin CMS 还将提供一套类似于 Vue Element 的前端组件库,以方便前端开发者快速开发。相比于 Vue Element 或 iView 等成熟的组件库,Lin 所提供的组件库将针对 Lin CMS 的整体设计风格、交互体验等作出大量的优化,使用 Lin CMS 的组件库将更容易开发出体验更好的 CMS 系统。当然,Lin CMS 本身不限制开发者选用任何的组件库,您完全可以根据自己的喜好/习惯/熟悉度,去选择任意的一个基于 Vue 的组件库,比如前面提到的 Vue Element 和 iView 等。您甚至可以混搭使用。当然,前提是这些组件库是基于 Vue 的。 88 | 89 | #### 完善的文档 90 | 91 | 我们将提供尽可能完善的[文档](https://doc.cms.talelin.com) 92 | 来帮助开发者使用 Lin CMS 93 | -------------------------------------------------------------------------------- /app/lin/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | utils of Lin 3 | ~~~~~~~~~ 4 | 5 | util functions make Lin more easy. 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | import errno 12 | import importlib.util 13 | import os 14 | import random 15 | import re 16 | import time 17 | import types 18 | from collections import namedtuple 19 | from importlib import import_module 20 | from typing import Any, Callable, Dict, Union 21 | 22 | 23 | def get_timestamp(fmt: str = "%Y-%m-%d %H:%M:%S") -> str: 24 | """ 25 | 获取当前时间戳,并按指定格式返回。 26 | 27 | :param fmt: 时间戳的格式。 28 | :return: 格式化后的时间戳。 29 | """ 30 | return time.strftime(fmt, time.localtime(time.time())) 31 | 32 | 33 | def get_pyfile(path: str, module_name: str, silent: bool = False) -> Union[Dict[str, Any], bool]: 34 | """ 35 | 获取 Python 文件的所有属性。 36 | 37 | :param path: Python 文件的路径。 38 | :param module_name: 模块名称。 39 | :param silent: 是否静默处理错误。 40 | :return: Python 文件的所有属性,或在静默模式下返回 False。 41 | """ 42 | d = types.ModuleType(module_name) 43 | d.__file__ = path 44 | try: 45 | with open(path, mode="rb") as config_file: 46 | exec(compile(config_file.read(), path, "exec"), d.__dict__) 47 | except IOError as e: 48 | if silent and e.errno in (errno.ENOENT, errno.EISDIR, errno.ENOTDIR): 49 | return False 50 | e.strerror = "无法加载配置文件 (%s)" % e.strerror 51 | raise 52 | return d.__dict__ 53 | 54 | 55 | def load_object(path: str) -> Any: 56 | """ 57 | 从模块中获取属性。 58 | 59 | :param path: 模块路径。 60 | :return: 模块中的对象。 61 | :raises ValueError: 如果路径不是完整路径。 62 | :raises NameError: 如果模块中未定义该对象。 63 | """ 64 | try: 65 | dot = path.rindex(".") 66 | except ValueError: 67 | raise ValueError("加载对象 '%s' 出错:不是完整路径" % path) 68 | 69 | module, name = path[:dot], path[dot + 1 :] 70 | mod = import_module(module) 71 | 72 | try: 73 | obj = getattr(mod, name) 74 | except AttributeError: 75 | raise NameError("模块 '%s' 中未定义名为 '%s' 的对象" % (module, name)) 76 | 77 | return obj 78 | 79 | 80 | def import_module_abs(name: str, path: str) -> None: 81 | """ 82 | 使用绝对路径导入模块。 83 | 84 | :param name: 模块名称。 85 | :param path: 模块的绝对路径。 86 | """ 87 | spec = importlib.util.spec_from_file_location(name, path) 88 | if spec is not None and spec.loader is not None: 89 | foo = importlib.util.module_from_spec(spec) 90 | spec.loader.exec_module(foo) 91 | 92 | 93 | def get_pwd() -> str: 94 | """ 95 | 获取当前工作目录的绝对路径。 96 | 97 | :return: 当前工作目录的绝对路径。 98 | """ 99 | return os.path.abspath(os.getcwd()) 100 | 101 | 102 | def camel2line(camel: str) -> str: 103 | """ 104 | 将驼峰命名字符串转换为下划线命名字符串。 105 | 106 | :param camel: 驼峰命名字符串。 107 | :return: 下划线命名字符串。 108 | """ 109 | p = re.compile(r"([a-z]|\d)([A-Z])") 110 | line = re.sub(p, r"\1_\2", camel).lower() 111 | return line 112 | 113 | 114 | def get_random_str(length: int) -> str: 115 | """ 116 | 生成指定长度的随机字符串。 117 | 118 | :param length: 随机字符串的长度。 119 | :return: 随机字符串。 120 | """ 121 | seed = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 122 | sa = [random.choice(seed) for _ in range(length)] 123 | return "".join(sa) 124 | 125 | 126 | Meta = namedtuple("Meta", ["name", "module", "mount"]) 127 | 128 | permission_meta_infos: Dict[str, Meta] = {} 129 | 130 | 131 | def permission_meta(name: str, module: str = "common", mount: bool = True): 132 | """ 133 | 记录路由函数的信息。 134 | 135 | :param name: 权限名称。 136 | :param module: 函数所属模块。 137 | :param mount: 是否将函数挂载到权限。 138 | :return: 包装函数。 139 | """ 140 | 141 | def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: 142 | func_name = func.__name__ + str(func.__hash__()) 143 | existed_meta = permission_meta_infos.get(func_name, None) 144 | existed = existed_meta is not None and existed_meta.module == module 145 | if existed: 146 | raise Exception("函数名在同一模块中不能重复") 147 | else: 148 | permission_meta_infos.setdefault(func_name, Meta(name, module, mount)) 149 | 150 | return func 151 | 152 | return wrapper 153 | -------------------------------------------------------------------------------- /app/plugin/cos/app/controller.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from werkzeug.local import LocalProxy 3 | 4 | from app.api import AuthorizationBearerSecurity, api 5 | from app.lin import DocResponse, Failed, ParameterError, Redprint, db, lin_config, login_required 6 | 7 | from .exception import ImageNotFound 8 | from .model import COS 9 | from .schema import CosOutSchema, CosOutSchemaList 10 | 11 | client = LocalProxy(lambda: get_cos_client()) 12 | 13 | cos_api = Redprint("cos") 14 | 15 | 16 | @cos_api.route("/") 17 | @login_required 18 | @api.validate( 19 | resp=DocResponse(ImageNotFound, r=CosOutSchema), 20 | tags=["cos"], 21 | security=[AuthorizationBearerSecurity], 22 | ) 23 | def get_cos_image(_id): 24 | """ 25 | 获取指定 id 的 cos 26 | """ 27 | cos = COS.get(id=_id) 28 | if cos: 29 | bucket = lin_config.get_config("cos.bucket_name") 30 | if lin_config.get_config("cos.need_return_url"): 31 | # 返回永久链接 32 | url = cos.url if cos.url else COS.get_url(client, bucket, cos.file_key) 33 | else: 34 | # 返回临时链接 35 | url = COS.get_presigned_url(client, bucket, cos.file_key) 36 | return {"id": cos.id, "url": url, "file_name": cos.file_name, "file_key": cos.file_key} 37 | raise ImageNotFound 38 | 39 | 40 | @cos_api.route("/upload_one", methods=["POST"]) 41 | @login_required 42 | @api.validate( 43 | resp=DocResponse(r=CosOutSchema), 44 | tags=["cos"], 45 | security=[AuthorizationBearerSecurity], 46 | ) 47 | def upload_one(): 48 | image = request.files.get("image", None) 49 | if not image: 50 | raise ParameterError("没有找到图片") 51 | if image and allowed_file(image.filename): 52 | return upload_image_and_create_cos(image.filename, image.read()) 53 | return Failed("上传图片失败,请检查图片路径") 54 | 55 | 56 | @cos_api.route("/upload_multiple", methods=["POST"]) 57 | @login_required 58 | @api.validate( 59 | resp=DocResponse(r=CosOutSchemaList), 60 | tags=["cos"], 61 | security=[AuthorizationBearerSecurity], 62 | ) 63 | def upload_multiple(): 64 | images = [] 65 | for item in request.files: 66 | image = request.files.get(item, None) 67 | if not image: 68 | raise ParameterError("没接收到图片,请检查图片路径") 69 | if image and allowed_file(image.filename): 70 | images.append(upload_image_and_create_cos(image.filename, image.read())) 71 | return images 72 | 73 | 74 | def upload_image_and_create_cos(name: str, data: bytes) -> dict: 75 | bucket = lin_config.get_config("cos.bucket_name") 76 | file_md5 = COS.generate_md5(data) 77 | exist = COS.get(file_name=name, file_md5=file_md5) 78 | if exist: 79 | file_url = COS.get_presigned_url(client, bucket, exist.file_key) 80 | res = {"id": exist.id, "url": file_url, "file_name": exist.file_name, "file_key": exist.file_key} 81 | return res 82 | 83 | file_key = COS.generate_key(name) 84 | client.put_object(Bucket=bucket, Body=data, Key=file_key, StorageClass="STANDARD") 85 | res = {"file_name": name, "file_key": file_key} 86 | url = COS.get_url(client, bucket, file_key) 87 | if lin_config.get_config("cos.need_return_url"): 88 | # 返回永久链接 89 | res["url"] = url 90 | else: 91 | # 返回临时链接 92 | res["url"] = COS.get_presigned_url(client, bucket, file_key) 93 | file_size = COS.get_size(client, bucket, file_key) 94 | with db.auto_commit(): 95 | cos_data = { 96 | "file_name": name, 97 | "file_key": file_key, 98 | "file_md5": file_md5, 99 | "file_size": file_size, 100 | "status": "UPLOADED", 101 | "commit": True, 102 | } 103 | if lin_config.get_config("cos.need_save_url"): 104 | cos_data["url"] = url 105 | one = COS.create(**cos_data) 106 | res["id"] = one.id 107 | return res 108 | 109 | 110 | def get_cos_client(): 111 | from qcloud_cos import CosConfig, CosS3Client 112 | 113 | token, proxies, endpoint, domain = None, None, None, None 114 | secret_id = lin_config.get_config("cos.access_key_id") 115 | secret_key = lin_config.get_config("cos.access_key_secret") 116 | region = lin_config.get_config("cos.region") 117 | scheme = lin_config.get_config("cos.scheme") 118 | if lin_config.get_config("cos.token"): 119 | token = lin_config.get_config("cos.token") 120 | if lin_config.get_config("cos.proxies"): 121 | proxies = lin_config.get_config("cos.proxies") 122 | if lin_config.get_config("cos.endpoint"): 123 | endpoint = lin_config.get_config("cos.endpoint") 124 | if lin_config.get_config("cos.domain"): 125 | domain = lin_config.get_config("cos.domain") 126 | 127 | config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key, Scheme=scheme) 128 | if token: 129 | config._token = token 130 | if proxies: 131 | config._proxies = proxies 132 | if endpoint: 133 | config._endpoint = endpoint 134 | if domain: 135 | config._domain = domain 136 | return CosS3Client(config) 137 | 138 | 139 | def allowed_file(filename): 140 | return "." in filename and (filename.rsplit(".", 1)[1]).lower() in lin_config.get_config( 141 | "cos.allowed_extensions", [] 142 | ) 143 | -------------------------------------------------------------------------------- /app/lin/model.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | from flask import current_app 5 | from sqlalchemy import func 6 | from werkzeug.security import check_password_hash, generate_password_hash 7 | 8 | from . import manager 9 | from .db import db 10 | from .enums import GroupLevelEnum 11 | from .exception import NotFound, ParameterError, UnAuthentication 12 | from .interface import ( 13 | GroupInterface, 14 | GroupPermissionInterface, 15 | PermissionInterface, 16 | UserGroupInterface, 17 | UserIdentityInterface, 18 | UserInterface, 19 | ) 20 | 21 | 22 | class Group(GroupInterface): 23 | @classmethod 24 | def count_by_id(cls, id: int) -> int: 25 | result = db.session.query(func.count(cls.id)).filter(cls.id == id, cls.is_deleted == False) 26 | count = result.scalar() 27 | return count 28 | 29 | 30 | class GroupPermission(GroupPermissionInterface): 31 | pass 32 | 33 | 34 | class Permission(PermissionInterface): 35 | def __hash__(self) -> int: 36 | return hash(self.name + self.module) 37 | 38 | def __eq__(self, other: object) -> bool: 39 | if not isinstance(other, Permission): 40 | return NotImplemented 41 | if self.name == other.name and self.module == other.module: 42 | # 如果出现了复用同名权限,则要保证mount=True的权限生效 43 | self.mount = self.mount or other.mount 44 | return True 45 | else: 46 | return False 47 | 48 | 49 | class User(UserInterface): 50 | @property 51 | def avatar(self) -> Optional[str]: 52 | site_domain = current_app.config.get( 53 | "SITE_DOMAIN", 54 | "http://{host}:{port}".format( 55 | host=current_app.config.get("FLASK_RUN_HOST", "127.0.0.1"), 56 | port=current_app.config.get("FLASK_RUN_PORT", "5000"), 57 | ), 58 | ) 59 | 60 | if self._avatar is not None: 61 | return site_domain + os.path.join(current_app.static_url_path or "", self._avatar) 62 | return None 63 | 64 | @classmethod 65 | def count_by_id(cls, uid: int) -> int: 66 | result = db.session.query(func.count(cls.id)).filter(cls.id == uid, cls.is_deleted == False) 67 | count = result.scalar() 68 | return count 69 | 70 | @staticmethod 71 | def count_by_id_and_group_name(user_id: int, group_name: str) -> int: 72 | stmt = ( 73 | db.session.query(manager.group_model.id.label("group_id")).filter_by(soft=True, name=group_name).subquery() 74 | ) 75 | result = db.session.query(func.count(manager.user_group_model.id)).filter( 76 | manager.user_group_model.user_id == user_id, 77 | manager.user_group_model.group_id == stmt.c.group_id, 78 | ) 79 | count = result.scalar() 80 | return count 81 | 82 | @property 83 | def is_admin(self) -> bool: 84 | return manager.user_group_model.get(user_id=self.id).group_id == GroupLevelEnum.ROOT.value 85 | 86 | @property 87 | def is_active(self) -> bool: 88 | return True 89 | 90 | @property 91 | def password(self) -> str: 92 | user_identity = manager.identity_model.get(user_id=self.id) 93 | if user_identity: 94 | return user_identity.credential 95 | return "" # 如果没有认证记录,返回空字符串 96 | 97 | @password.setter 98 | def password(self, raw: str) -> None: 99 | # 验证密码输入 100 | if not raw or len(raw.strip()) == 0: 101 | raise ParameterError("密码不能为空") 102 | if len(raw) < 6: 103 | raise ParameterError("密码长度不能少于6位") 104 | 105 | try: 106 | # 查找用户的身份认证记录 107 | user_identity = manager.identity_model.get(user_id=self.id) 108 | 109 | if user_identity: 110 | # 更新现有的认证记录 111 | user_identity.credential = generate_password_hash(raw) 112 | user_identity.update(synchronize_session=False) 113 | 114 | else: 115 | # 创建新的认证记录 116 | user_identity = manager.identity_model() 117 | user_identity.user_id = self.id 118 | user_identity.identity_type = "USERNAME_PASSWORD" # 默认类型,可根据需要调整 119 | user_identity.identifier = self.username # 使用用户的实际用户名 120 | user_identity.credential = generate_password_hash(raw) 121 | db.session.add(user_identity) 122 | db.session.commit() 123 | except Exception as e: 124 | db.session.rollback() 125 | raise ParameterError(f"密码设置失败: {str(e)}") 126 | 127 | def check_password(self, raw: str) -> bool: 128 | return check_password_hash(self.password, raw) 129 | 130 | @classmethod 131 | def verify(cls, username: str, password: str) -> "User": 132 | user = cls.query.filter_by(username=username).first() 133 | if user is None or user.is_deleted: 134 | raise NotFound("用户不存在") 135 | if not user.check_password(password): 136 | raise ParameterError("密码错误,请输入正确密码") 137 | if not user.is_active: 138 | raise UnAuthentication("您目前处于未激活状态,请联系超级管理员") 139 | return user 140 | 141 | 142 | class UserGroup(UserGroupInterface): 143 | pass 144 | 145 | 146 | class UserIdentity(UserIdentityInterface): 147 | pass 148 | -------------------------------------------------------------------------------- /app/lin/exception.py: -------------------------------------------------------------------------------- 1 | """ 2 | exceptions of Lin 3 | ~~~~~~~~~ 4 | :copyright: © 2020 by the Lin team. 5 | :license: MIT, see LICENSE for more details. 6 | """ 7 | 8 | from flask import json, request 9 | from werkzeug.exceptions import HTTPException 10 | 11 | from .config import global_config 12 | 13 | 14 | class APIException(HTTPException): 15 | code = 500 16 | message = "抱歉,服务器未知错误" 17 | message_code = 9999 18 | headers = {"Content-Type": "application/json"} 19 | _config = True 20 | 21 | def __init__(self, *args): 22 | # 1. 没有参数 23 | if len(args) == 0: 24 | self.message = ( 25 | global_config.get("MESSAGE", dict()).get(self.message_code, self.message) 26 | if self._config 27 | else self.message 28 | ) 29 | # 2.1 一个参数,为数字 30 | elif len(args) == 1: 31 | if isinstance(args[0], int): 32 | self.message_code = args[0] 33 | self.message = ( 34 | global_config.get("MESSAGE", dict()).get(self.message_code, self.message) 35 | if self._config 36 | else self.message 37 | ) 38 | # 2.2. 一个参数,为字符串 or 字典 39 | elif isinstance(args[0], (str, dict)): 40 | self.message = args[0] 41 | # 3. 两个参数, 一个整数,一个字符串 or 字典 42 | elif len(args) == 2: 43 | if isinstance(args[0], int) and isinstance(args[1], (str, dict)): 44 | self.message_code = args[0] 45 | self.message = args[1] 46 | elif isinstance(args[1], int) and isinstance(args[0], (str, dict)): 47 | self.message_code = args[1] 48 | self.message = args[0] 49 | # 最终都要调用父类方法 50 | super().__init__(self.message, None) 51 | 52 | def set_code(self, code: int): 53 | self.code = code 54 | return self 55 | 56 | def set_message_code(self, message_code: int): 57 | self.message_code = message_code 58 | return self 59 | 60 | def add_headers(self, headers: dict): 61 | headers_merged = headers.copy() 62 | headers_merged.update(self.headers) 63 | self.headers = headers_merged 64 | return self 65 | 66 | def get_body(self, environ=None, scope=None): 67 | body = dict( 68 | message=self.message, 69 | code=self.message_code, 70 | request=request.method + " " + self.get_url_no_param(), 71 | ) 72 | text = json.dumps(body) 73 | return text 74 | 75 | @staticmethod 76 | def get_url_no_param(): 77 | full_path = str(request.full_path) 78 | main_path = full_path.split("?") 79 | return main_path[0] 80 | 81 | def get_headers(self, environ=None, scope=None): 82 | return [(k, v) for k, v in self.headers.items()] 83 | 84 | 85 | class Success(APIException): 86 | code = 200 87 | message = "OK" 88 | message_code = 0 89 | 90 | 91 | class Created(APIException): 92 | code = 201 93 | message = "Created" 94 | message_code = 1 95 | 96 | 97 | class Updated(APIException): 98 | code = 200 99 | message = "Updated" 100 | message_code = 2 101 | 102 | 103 | class Deleted(APIException): 104 | code = 200 105 | message = "Deleted" 106 | message_code = 3 107 | 108 | 109 | class Failed(APIException): 110 | code = 400 111 | message = "Failed" 112 | message_code = 10200 113 | 114 | 115 | class UnAuthorization(APIException): 116 | code = 401 117 | message = "Authorization Failed" 118 | message_code = 10000 119 | 120 | 121 | class UnAuthentication(APIException): 122 | code = 401 123 | message = "Authentication Failed" 124 | message_code = 10010 125 | 126 | 127 | class NotFound(APIException): 128 | code = 404 129 | message = "Not Found" 130 | message_code = 10021 131 | 132 | 133 | class ParameterError(APIException): 134 | code = 400 135 | message = "Parameters Error" 136 | message_code = 10030 137 | 138 | 139 | class TokenInvalid(APIException): 140 | code = 401 141 | message = "Token Invalid" 142 | message_code = 10040 143 | 144 | 145 | class TokenExpired(APIException): 146 | code = 422 147 | message = "Token Expired" 148 | message_code = 10052 149 | 150 | 151 | class InternalServerError(APIException): 152 | code = 500 153 | message = "Internal Server Error" 154 | message_code = 9999 155 | 156 | 157 | class Duplicated(APIException): 158 | code = 400 159 | message = "Duplicated" 160 | message_code = 10060 161 | 162 | 163 | class Forbidden(APIException): 164 | code = 401 165 | message = "Forbidden" 166 | message_code = 10070 167 | 168 | 169 | class FileTooLarge(APIException): 170 | code = 413 171 | message = "File Too Large" 172 | message_code = 10110 173 | 174 | 175 | class FileTooMany(APIException): 176 | code = 413 177 | message = "File Too Many" 178 | message_code = 10120 179 | 180 | 181 | class FileExtensionError(APIException): 182 | code = 401 183 | message = "FIle Extension Not Allowed" 184 | message_code = 10130 185 | 186 | 187 | class MethodNotAllowed(APIException): 188 | code = 401 189 | message = "Method Not Allowed" 190 | message_code = 10080 191 | 192 | 193 | class RequestLimit(APIException): 194 | code = 401 195 | message = "Too Many Requests" 196 | message_code = 10140 197 | -------------------------------------------------------------------------------- /app/lin/jwt.py: -------------------------------------------------------------------------------- 1 | """ 2 | jwt(Json Web Token) of Lin 3 | ~~~~~~~~~ 4 | 5 | jwt implement for Lin. 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | from functools import wraps 12 | from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, Union 13 | 14 | from flask import request 15 | from flask_jwt_extended import JWTManager, create_access_token, create_refresh_token, get_current_user 16 | from flask_jwt_extended.view_decorators import jwt_required 17 | 18 | from .exception import NotFound, TokenExpired, TokenInvalid, UnAuthentication 19 | from .manager import manager 20 | 21 | __all__ = ["login_required", "admin_required", "group_required"] 22 | 23 | SCOPE = "lin" 24 | jwt = JWTManager() 25 | identity: Dict[str, Union[int, str]] = dict(uid=0, scope=SCOPE) 26 | 27 | 28 | F = TypeVar("F", bound=Callable[..., Any]) 29 | 30 | 31 | def admin_required(fn: F) -> F: 32 | @wraps(fn) 33 | @jwt_required() 34 | def wrapper(*args: Any, **kwargs: Any) -> Any: 35 | current_user = get_current_user() 36 | if not current_user.is_admin: 37 | raise UnAuthentication("只有超级管理员可操作") # type: ignore 38 | return fn(*args, **kwargs) 39 | 40 | return wrapper 41 | 42 | 43 | def group_required(fn: F) -> F: 44 | @wraps(fn) 45 | @jwt_required() 46 | def wrapper(*args: Any, **kwargs: Any) -> Any: 47 | current_user = get_current_user() 48 | # check current user is active or not 49 | # 判断当前用户是否为激活状态 50 | _check_is_active(current_user) 51 | 52 | # not admin 53 | if not current_user.is_admin: 54 | 55 | group_ids = manager.find_group_ids_by_user_id(current_user.id) 56 | if group_ids is None: 57 | raise UnAuthentication("您还不属于任何分组,请联系超级管理员获得权限") # type: ignore 58 | 59 | if not manager.is_user_allowed(group_ids): 60 | raise UnAuthentication("权限不够,请联系超级管理员获得权限") # type: ignore 61 | else: 62 | return fn(*args, **kwargs) 63 | else: 64 | return fn(*args, **kwargs) 65 | 66 | return wrapper 67 | 68 | 69 | def login_required(fn: F) -> F: 70 | @wraps(fn) 71 | @jwt_required() 72 | def wrapper(*args: Any, **kwargs: Any) -> Any: 73 | _check_is_active(current_user=get_current_user()) 74 | return fn(*args, **kwargs) 75 | 76 | return wrapper 77 | 78 | 79 | @jwt.user_lookup_loader 80 | def user_loader_callback(jwt_header: Any, jwt_payload: Dict[str, Any]) -> Any: 81 | import json 82 | 83 | # 从JWT payload中获取identity并解析 84 | identity_str = jwt_payload.get("sub") 85 | if not identity_str: 86 | raise UnAuthentication() # type: ignore 87 | 88 | try: 89 | identity = json.loads(identity_str) 90 | except (json.JSONDecodeError, TypeError): 91 | raise UnAuthentication() # type: ignore 92 | 93 | if identity["scope"] != SCOPE: 94 | raise UnAuthentication() # type: ignore 95 | if identity.get("remote_addr") and identity["remote_addr"] != request.remote_addr: 96 | raise UnAuthentication() # type: ignore 97 | # token is granted , user must be exit 98 | # 如果token已经被颁发,则该用户一定存在 99 | user = manager.find_user(id=identity["uid"]) 100 | if user is None: 101 | raise NotFound("用户不存在") # type: ignore 102 | return user 103 | 104 | 105 | @jwt.user_identity_loader 106 | def user_identity_callback(identity: Dict[str, Union[int, str]]) -> str: 107 | """将identity字典转换为字符串作为JWT的subject""" 108 | import json 109 | 110 | return json.dumps(identity) 111 | 112 | 113 | @jwt.expired_token_loader 114 | def expired_loader_callback(t: Any, identity: Dict[str, Any]) -> TokenExpired: 115 | return TokenExpired(10051) # type: ignore 116 | 117 | 118 | @jwt.invalid_token_loader 119 | def invalid_loader_callback(e: str) -> TokenInvalid: 120 | return TokenInvalid(10041) # type: ignore 121 | 122 | 123 | @jwt.unauthorized_loader 124 | def unauthorized_loader_callback(e: str) -> UnAuthentication: 125 | return UnAuthentication("认证失败,请检查请求头或者重新登录") # type: ignore 126 | 127 | 128 | @jwt.additional_claims_loader 129 | def add_claims_to_access_token(identity: Dict[str, Union[int, str]]) -> Dict[str, Any]: 130 | return { 131 | "uid": identity["uid"], 132 | "scope": identity["scope"], 133 | "remote_addr": identity["remote_addr"] if "remote_addr" in identity.keys() else None, 134 | } 135 | 136 | 137 | def verify_access_token() -> None: 138 | __verify_token("access") 139 | 140 | 141 | def verify_refresh_token() -> None: 142 | __verify_token("refresh") 143 | 144 | 145 | def __verify_token(request_type: str) -> None: 146 | 1 / 0 147 | from flask import request 148 | from flask_jwt_extended.config import config 149 | from flask_jwt_extended.utils import verify_token_claims 150 | from flask_jwt_extended.view_decorators import _decode_jwt_from_cookies as decode 151 | 152 | try: 153 | from flask import _app_ctx_stack as ctx_stack 154 | except ImportError: 155 | from flask import _request_ctx_stack as ctx_stack 156 | 157 | if request.method not in config.exempt_methods: 158 | jwt_data = decode(request_type=request_type) 159 | ctx_stack.top.jwt = jwt_data 160 | verify_token_claims(jwt_data) 161 | 162 | 163 | def _check_is_active(current_user: Any) -> None: 164 | if not current_user.is_active: 165 | raise UnAuthentication("您目前处于未激活状态,请联系超级管理员") 166 | 167 | 168 | def get_tokens(user: Any, verify_remote_addr: bool = False) -> Tuple[str, str]: 169 | identity["uid"] = user.id 170 | if verify_remote_addr: 171 | identity["remote_addr"] = request.remote_addr 172 | access_token = create_access_token(identity) 173 | refresh_token = create_refresh_token(identity) 174 | return access_token, refresh_token 175 | -------------------------------------------------------------------------------- /app/lin/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logger of Lin 3 | ~~~~~~~~~ 4 | 5 | logger模块,用户行为日志记录器 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | import re 12 | from functools import wraps 13 | from typing import Any, Callable, Dict, List, Optional, TypeVar, Union, cast 14 | 15 | from flask import Response, request 16 | from flask_jwt_extended import get_current_user 17 | from sqlalchemy import Column, Integer, String, func 18 | 19 | from .db import db 20 | from .interface import InfoCrud 21 | from .manager import manager 22 | 23 | F = TypeVar("F", bound=Callable[..., Any]) 24 | 25 | 26 | class Log(InfoCrud): 27 | __tablename__ = "lin_log" 28 | 29 | id = Column(Integer(), primary_key=True) 30 | message = Column(String(450), comment="日志信息") 31 | user_id = Column(Integer(), nullable=False, comment="用户id") 32 | username = Column(String(24), comment="用户当时的昵称") 33 | status_code = Column(Integer(), comment="请求的http返回码") 34 | method = Column(String(20), comment="请求方法") 35 | path = Column(String(50), comment="请求路径") 36 | permission = Column(String(100), comment="访问哪个权限") 37 | 38 | @property 39 | def time(self) -> int: 40 | return int(round(self.create_time.timestamp() * 1000)) 41 | 42 | @classmethod 43 | def select_by_conditions(cls, **kwargs: Any) -> List["Log"]: 44 | """ 45 | 根据条件筛选日志,条件的可以是所有表内字段,以及start, end 时间段,keyword模糊匹配message字段 46 | """ 47 | conditions: Dict[str, Any] = dict() 48 | # 过滤 传入参数 49 | avaliable_keys = [c for c in vars(Log).keys() if not c.startswith("_")] + [ 50 | "start", 51 | "end", 52 | "keyword", 53 | ] 54 | for key, value in kwargs.items(): 55 | if key in avaliable_keys: 56 | conditions[key] = value 57 | query = cls.query.filter_by(soft=True) 58 | # 搜索特殊字段 59 | if conditions.get("start"): 60 | query = query.filter(cls.create_time > conditions.get("start")) 61 | del conditions["start"] 62 | if conditions.get("end"): 63 | query = query.filter(cls.create_time < conditions.get("end")) 64 | del conditions["end"] 65 | if conditions.get("keyword"): 66 | query = query.filter(cls.message.like("%{keyword}%".format(keyword=conditions.get("keyword")))) 67 | del conditions["keyword"] 68 | # 搜索表内字段 69 | query = query.filter_by(**conditions).group_by(cls.create_time).order_by(cls.create_time.desc()) 70 | logs = query.all() 71 | return logs 72 | 73 | @classmethod 74 | def get_usernames(cls) -> List[str]: 75 | result = ( 76 | db.session.query(cls.username) 77 | .filter(cls.is_deleted == False) 78 | .group_by(cls.username) 79 | .having(func.count(cls.username) > 0) 80 | ) 81 | # [(‘张三',),('李四',),...] -> ['张三','李四',...] 82 | usernames = [x[0] for x in result.all()] 83 | return usernames 84 | 85 | @staticmethod 86 | def create_log(**kwargs: Any) -> "Log": 87 | log = Log() 88 | for key in kwargs.keys(): 89 | if hasattr(log, key): 90 | setattr(log, key, kwargs[key]) 91 | db.session.add(log) 92 | if kwargs.get("commit") is True: 93 | db.session.commit() 94 | return log 95 | 96 | 97 | REG_XP = r"[{](.*?)[}]" 98 | OBJECTS = ["user", "response", "request"] 99 | 100 | 101 | class Logger(object): 102 | """ 103 | 用户行为日志记录器 104 | """ 105 | 106 | # message template 107 | template: Optional[str] = None 108 | 109 | def __init__(self, template: Optional[str] = None): 110 | if template: 111 | self.template: str = template 112 | elif self.template is None: 113 | raise Exception("template must not be None!") 114 | self.message: str = "" 115 | self.response: Optional[Response] = None 116 | self.user: Optional[Any] = None 117 | 118 | def __call__(self, func: F) -> F: 119 | @wraps(func) 120 | def wrap(*args: Any, **kwargs: Any) -> Any: 121 | response: Response = func(*args, **kwargs) 122 | self.response = response 123 | self.user = get_current_user() 124 | if not self.user: 125 | raise Exception("Logger must be used in the login state") 126 | self.message = self._parse_template() 127 | self.write_log() 128 | return response 129 | 130 | return cast(F, wrap) 131 | 132 | def write_log(self) -> None: 133 | info = manager.find_info_by_ep(request.endpoint) 134 | permission = info.name if info is not None else "" 135 | status_code = getattr(self.response, "status_code", None) 136 | if status_code is None: 137 | status_code = getattr(self.response, "code", None) 138 | if status_code is None: 139 | status_code = 0 140 | Log.create_log( 141 | message=self.message, 142 | user_id=self.user.id, 143 | username=self.user.username, 144 | status_code=status_code, 145 | method=request.method, 146 | path=request.path, 147 | permission=permission, 148 | commit=True, 149 | ) 150 | 151 | # 解析自定义模板 152 | def _parse_template(self) -> str: 153 | message = self.template 154 | total = re.findall(REG_XP, message) 155 | for it in total: 156 | assert "." in it, "%s中必须包含 . ,且为一个" % it 157 | i = it.rindex(".") 158 | obj = it[:i] 159 | assert obj in OBJECTS, "%s只能为user,response,request中的一个" % obj 160 | prop = it[i + 1 :] 161 | if obj == "user": 162 | item = getattr(self.user, prop, "") 163 | elif obj == "response": 164 | item = getattr(self.response, prop, "") 165 | else: 166 | item = getattr(request, prop, "") 167 | message = message.replace("{%s}" % it, str(item)) 168 | return message 169 | -------------------------------------------------------------------------------- /app/lin/file.py: -------------------------------------------------------------------------------- 1 | """uploader of Lin 2 | ~~~~~~~~~ 3 | 4 | uploader 模块,使用策略模式实现的上传文件接口 5 | 6 | :copyright: © 2020 by the Lin team. 7 | :license: MIT, see LICENSE for more details. 8 | """ 9 | 10 | import hashlib 11 | import os 12 | from typing import Any, Dict, List, Optional, Tuple, Union 13 | 14 | from flask import current_app 15 | from werkzeug.datastructures import FileStorage 16 | 17 | from .exception import FileExtensionError, FileTooLarge, FileTooMany, ParameterError 18 | 19 | 20 | class Uploader(object): 21 | def __init__(self, files: Union[List[FileStorage], FileStorage], config: Dict[str, Any] = {}): 22 | #: the list of allowed files 23 | #: 被允许的文件类型列表 24 | self._include: List[str] = [] 25 | #: the list of not allowed files 26 | #: 不被允许的文件类型列表 27 | self._exclude: List[str] = [] 28 | #: the max bytes of single file 29 | #: 单个文件的最大字节数 30 | self._single_limit: int = 0 31 | #: the max bytes of multiple files 32 | #: 多个文件的最大字节数 33 | self._total_limit: int = 0 34 | #: the max nums of files 35 | #: 文件上传的最大数量 36 | self._nums: int = 0 37 | #: the directory of file storage 38 | #: 文件存贮目录 39 | self._store_dir: str = "" 40 | #: the FileStorage Object 41 | #: 文件存贮对象 42 | self._file_storage: List[FileStorage] = self.__parse_files(files) 43 | self.__load_config(config) 44 | self.__verify() 45 | 46 | def upload(self, **kwargs) -> Dict[str, Any]: 47 | """ 48 | 文件上传抽象方法,一定要被子类所实现 49 | """ 50 | raise NotImplementedError() 51 | 52 | @staticmethod 53 | def _generate_uuid() -> str: 54 | import uuid 55 | 56 | return str(uuid.uuid1()) 57 | 58 | @staticmethod 59 | def _get_ext(filename: str) -> str: 60 | """ 61 | 得到文件的扩展名 62 | :param filename: 原始文件名 63 | :return: string 文件的扩展名 64 | """ 65 | return "." + filename.lower().split(".")[-1] 66 | 67 | @staticmethod 68 | def _generate_md5(data: bytes) -> str: 69 | md5_obj = hashlib.md5() 70 | md5_obj.update(data) 71 | ret = md5_obj.hexdigest() 72 | return ret 73 | 74 | @staticmethod 75 | def _get_size(file_obj: FileStorage) -> int: 76 | """ 77 | 得到文件大小(字节) 78 | :param file_obj: 文件对象 79 | :return: 文件的字节数 80 | """ 81 | file_obj.seek(0, os.SEEK_END) 82 | size = file_obj.tell() 83 | file_obj.seek(0) # 将文件指针重置 84 | return size 85 | 86 | @staticmethod 87 | def _generate_name(filename: str) -> str: 88 | return Uploader._generate_uuid() + Uploader._get_ext(filename) 89 | 90 | def __load_config(self, custom_config: Dict[str, Any]) -> None: 91 | """ 92 | 加载文件配置,如果用户不传 config 参数,则加载默认配置 93 | :param custom_config: 用户自定义配置参数 94 | :return: None 95 | """ 96 | default_config = current_app.config.get("FILE") 97 | self._include = custom_config["INCLUDE"] if "INCLUDE" in custom_config else default_config["INCLUDE"] 98 | self._exclude = custom_config["EXCLUDE"] if "EXCLUDE" in custom_config else default_config["EXCLUDE"] 99 | self._single_limit = ( 100 | custom_config["SINGLE_LIMIT"] if "SINGLE_LIMIT" in custom_config else default_config["SINGLE_LIMIT"] 101 | ) 102 | self._total_limit = ( 103 | custom_config["TOTAL_LIMIT"] if "TOTAL_LIMIT" in custom_config else default_config["TOTAL_LIMIT"] 104 | ) 105 | self._nums = custom_config["NUMS"] if "NUMS" in custom_config else default_config["NUMS"] 106 | self._store_dir = custom_config["STORE_DIR"] if "STORE_DIR" in custom_config else default_config["STORE_DIR"] 107 | 108 | @staticmethod 109 | def __parse_files(files: Union[List[FileStorage], FileStorage]) -> List[FileStorage]: 110 | ret: List[FileStorage] = [] 111 | for key, value in files.items(): 112 | ret += files.getlist(key) 113 | return ret 114 | 115 | def __verify(self) -> None: 116 | """ 117 | 验证文件是否合法 118 | """ 119 | if not self._file_storage: 120 | raise ParameterError("未找到符合条件的文件资源") 121 | self.__allowed_file() 122 | self.__allowed_file_size() 123 | 124 | def _get_store_path(self, filename: str) -> Tuple[str, str, str]: 125 | uuid_filename = self._generate_name(filename) 126 | format_day = self.__get_format_day() 127 | store_dir = self._store_dir 128 | return ( 129 | os.path.join(store_dir, uuid_filename), 130 | format_day + os.path.sep + uuid_filename, 131 | uuid_filename, 132 | ) 133 | 134 | def mkdir_if_not_exists(self) -> None: 135 | if not os.path.isabs(self._store_dir): 136 | self._store_dir = os.path.abspath(self._store_dir) 137 | # mkdir by YYYY/MM/DD 138 | self._store_dir += os.path.sep + self.__get_format_day() 139 | if not os.path.exists(self._store_dir): 140 | os.makedirs(self._store_dir) 141 | 142 | @staticmethod 143 | def __get_format_day() -> str: 144 | import time 145 | 146 | return str(time.strftime("%Y/%m/%d")) 147 | 148 | def __allowed_file(self) -> bool: 149 | """ 150 | 验证扩展名是否合法 151 | """ 152 | if (self._include and self._exclude) or self._include: 153 | for single in self._file_storage: 154 | if "." not in single.filename or single.filename.lower().rsplit(".", 1)[1] not in self._include: 155 | raise FileExtensionError() 156 | return True 157 | elif self._exclude and not self._include: 158 | for single in self._file_storage: 159 | if "." not in single.filename or single.filename.lower().rsplit(".", 1)[1] in self._exclude: 160 | raise FileExtensionError() 161 | return True 162 | return False 163 | 164 | def __allowed_file_size(self) -> None: 165 | """ 166 | 验证文件大小是否合法 167 | """ 168 | file_count = len(self._file_storage) 169 | if file_count > 1: 170 | if file_count > self._nums: 171 | raise FileTooMany() 172 | total_size = 0 173 | for single in self._file_storage: 174 | if self._get_size(single) > self._single_limit: 175 | raise FileTooLarge(single.filename + "大小不能超过" + str(self._single_limit) + "字节") 176 | total_size += self._get_size(single) 177 | if total_size > self._total_limit: 178 | raise FileTooLarge() 179 | else: 180 | file_size = self._get_size(self._file_storage[0]) 181 | if file_size > self._single_limit: 182 | raise FileTooLarge() 183 | -------------------------------------------------------------------------------- /app/lin/lin.py: -------------------------------------------------------------------------------- 1 | """core module of Lin. 2 | ~~~~~~~~~ 3 | :copyright: © 2020 by the Lin team. 4 | :license: MIT, see LICENSE for more details. 5 | """ 6 | 7 | from typing import Any, Callable, Dict, List, Optional, Type, Union 8 | 9 | from flask import Blueprint, Flask 10 | from sqlalchemy.exc import DatabaseError 11 | 12 | from .apidoc import schema_response 13 | from .db import db 14 | from .encoder import JSONEncoder, auto_response 15 | from .exception import APIException, HTTPException, InternalServerError 16 | from .jwt import jwt 17 | from .manager import Manager 18 | from .syslogger import SysLogger 19 | from .utils import permission_meta_infos 20 | 21 | 22 | class Lin(object): 23 | def __init__( 24 | self, 25 | app: Optional[Flask] = None, # flask app , default None 26 | group_model: Optional[Any] = None, # group model, default None 27 | user_model: Optional[Any] = None, # user model, default None 28 | identity_model: Optional[Any] = None, # user identity model,default None 29 | permission_model: Optional[Any] = None, # permission model, default None 30 | group_permission_model: Optional[Any] = None, # group permission 多对多关联模型 31 | user_group_model: Optional[Any] = None, # user group 多对多关联模型 32 | jsonencoder: Optional[Type[JSONEncoder]] = None, # 序列化器 33 | sync_permissions: bool = True, # create db table if not exist and sync permissions, default True 34 | mount: bool = True, # 是否挂载默认的蓝图, default True 35 | handle: bool = True, # 是否使用全局异常处理, default True 36 | syslogger: bool = True, # 是否使用自定义系统运行日志,default True 37 | **kwargs: Any, # 保留配置项 38 | ): 39 | self.app: Optional[Flask] = app 40 | if app is not None: 41 | self.init_app( 42 | app, 43 | group_model, 44 | user_model, 45 | identity_model, 46 | permission_model, 47 | group_permission_model, 48 | user_group_model, 49 | jsonencoder, 50 | sync_permissions, 51 | mount, 52 | handle, 53 | syslogger, 54 | ) 55 | 56 | def init_app( 57 | self, 58 | app: Flask, 59 | group_model: Optional[Any] = None, 60 | user_model: Optional[Any] = None, 61 | identity_model: Optional[Any] = None, 62 | permission_model: Optional[Any] = None, 63 | group_permission_model: Optional[Any] = None, 64 | user_group_model: Optional[Any] = None, 65 | jsonencoder: Optional[Type[JSONEncoder]] = None, 66 | sync_permissions: bool = True, 67 | mount: bool = True, 68 | handle: bool = True, 69 | syslogger: bool = True, 70 | ) -> None: 71 | # load default lin db model if None 72 | if not group_model: 73 | from .model import Group 74 | 75 | group_model = Group 76 | if not user_model: 77 | from .model import User 78 | 79 | self.user_model = User 80 | if not permission_model: 81 | from .model import Permission 82 | 83 | permission_model = Permission 84 | if not group_permission_model: 85 | from .model import GroupPermission 86 | 87 | group_permission_model = GroupPermission 88 | if not user_group_model: 89 | from .model import UserGroup 90 | 91 | user_group_model = UserGroup 92 | if not identity_model: 93 | from .model import UserIdentity 94 | 95 | identity_model = UserIdentity 96 | # 默认蓝图的前缀 97 | app.config.setdefault("BP_URL_PREFIX", "/plugin") 98 | # 文件上传配置未指定时的默认值 99 | app.config.setdefault( 100 | "FILE", 101 | { 102 | "STORE_DIR": "assets", 103 | "SINGLE_LIMIT": 1024 * 1024 * 2, 104 | "TOTAL_LIMIT": 1024 * 1024 * 20, 105 | "NUMS": 10, 106 | "INCLUDE": set(["jpg", "png", "jpeg"]), 107 | "EXCLUDE": set([]), 108 | }, 109 | ) 110 | self.jsonencoder = jsonencoder 111 | self.enable_auto_jsonify(app) 112 | self.app = app 113 | # 初始化 manager 114 | self.manager: Manager = Manager( 115 | app.config.get("PLUGIN_PATH", dict()), 116 | group_model=group_model, 117 | user_model=user_model, 118 | identity_model=identity_model, 119 | permission_model=permission_model, 120 | group_permission_model=group_permission_model, 121 | user_group_model=user_group_model, 122 | ) 123 | self.app.extensions["manager"] = self.manager 124 | db.init_app(app) 125 | jwt.init_app(app) 126 | mount and self.mount(app) 127 | sync_permissions and self.sync_permissions(app) 128 | handle and self.handle_error(app) 129 | syslogger and SysLogger(app) 130 | 131 | def sync_permissions(self, app: Flask) -> None: 132 | # 挂载后才能获取代码中的权限 133 | # 多进程/线程下可能同时写入相同数据,由权限表联合唯一约束限制 134 | try: 135 | with app.app_context(): 136 | self.manager.sync_permissions() 137 | except DatabaseError: 138 | pass 139 | 140 | def mount(self, app: Flask) -> None: 141 | # 加载默认插件路由 142 | bp = Blueprint("plugin", __name__) 143 | # 加载插件的路由 144 | for plugin in self.manager.plugins.values(): 145 | if len(plugin.controllers.values()) > 1: 146 | for controller in plugin.controllers.values(): 147 | controller.register(bp, url_prefix="/" + plugin.name) 148 | else: 149 | for controller in plugin.controllers.values(): 150 | controller.register(bp) 151 | app.register_blueprint(bp, url_prefix=app.config.get("BP_URL_PREFIX")) 152 | for ep, func in app.view_functions.items(): 153 | info = permission_meta_infos.get(func.__name__ + str(func.__hash__()), None) 154 | if info: 155 | self.manager.ep_meta.setdefault(ep, info) 156 | 157 | def handle_error(self, app: Flask) -> None: 158 | @app.errorhandler(Exception) 159 | def handler(e: Exception) -> Any: 160 | if isinstance(e, APIException): 161 | return e 162 | if isinstance(e, HTTPException): 163 | code = e.code 164 | message = e.description 165 | message_code = 20000 166 | return APIException(message_code, message).set_code(code) 167 | else: 168 | if not app.config["DEBUG"]: 169 | import traceback 170 | 171 | app.logger.error(traceback.format_exc()) 172 | return InternalServerError() 173 | else: 174 | raise e 175 | 176 | def enable_auto_jsonify(self, app: Flask) -> None: 177 | # app.json_encoder = self.jsonencoder or JSONEncoder 178 | app.json = self.jsonencoder(app) if self.jsonencoder else JSONEncoder(app) 179 | app.make_response = auto_response(app.make_response) 180 | schema_response(app) 181 | -------------------------------------------------------------------------------- /app/api/cms/validator/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | import re 7 | import time 8 | 9 | from wtforms import DateTimeField, FieldList, IntegerField, PasswordField, StringField 10 | from wtforms.validators import DataRequired, EqualTo, NumberRange, Regexp, length 11 | 12 | from app.lin import Form, ParameterError, manager 13 | 14 | # 注册校验 15 | 16 | 17 | class EmailForm(Form): 18 | email = StringField("电子邮件") 19 | 20 | def validate_email(self, value): 21 | if value.data: 22 | if not re.match( 23 | r"^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z0-9]{2,6}$", 24 | value.data, 25 | ): 26 | raise ParameterError("电子邮箱不符合规范,请输入正确的邮箱") 27 | 28 | 29 | class RegisterForm(EmailForm): 30 | password = PasswordField( 31 | "新密码", 32 | validators=[ 33 | DataRequired(message="新密码不可为空"), 34 | Regexp(r"^[A-Za-z0-9_*&$#@]{6,22}$", message="密码长度必须在6~22位之间,包含字符、数字和 _ "), 35 | EqualTo("confirm_password", message="两次输入的密码不一致,请输入相同的密码"), 36 | ], 37 | ) 38 | confirm_password = PasswordField("确认新密码", validators=[DataRequired(message="请确认密码")]) 39 | username = StringField( 40 | validators=[ 41 | DataRequired(message="用户名不可为空"), 42 | length(min=2, max=10, message="用户名长度必须在2~10之间"), 43 | ] 44 | ) 45 | group_ids = FieldList( 46 | IntegerField( 47 | "分组id", 48 | validators=[ 49 | DataRequired(message="请输入分组id"), 50 | NumberRange(message="分组id必须大于0", min=1), 51 | ], 52 | ) 53 | ) 54 | 55 | def validate_group_ids(self, value): 56 | for group_id in value.data: 57 | if not manager.group_model.count_by_id(group_id): 58 | raise ParameterError("分组不存在") 59 | 60 | 61 | # 登录校验 62 | 63 | 64 | class LoginForm(Form): 65 | username = StringField(validators=[DataRequired()]) 66 | password = PasswordField("密码", validators=[DataRequired(message="密码不可为空")]) 67 | captcha = StringField() 68 | 69 | 70 | # 重置密码校验 71 | class ResetPasswordForm(Form): 72 | new_password = PasswordField( 73 | "新密码", 74 | validators=[ 75 | DataRequired(message="新密码不可为空"), 76 | Regexp(r"^[A-Za-z0-9_*&$#@]{6,22}$", message="密码长度必须在6~22位之间,包含字符、数字和 _ "), 77 | EqualTo("confirm_password", message="两次输入的密码不一致,请输入相同的密码"), 78 | ], 79 | ) 80 | confirm_password = PasswordField("确认新密码", validators=[DataRequired(message="请确认密码")]) 81 | 82 | 83 | # 更改密码校验 84 | class ChangePasswordForm(ResetPasswordForm): 85 | old_password = PasswordField("原密码", validators=[DataRequired(message="不可为空")]) 86 | 87 | 88 | # 管理员创建分组 89 | class NewGroup(Form): 90 | # 分组name 91 | name = StringField(validators=[DataRequired(message="请输入分组名称")]) 92 | # 非必须 93 | info = StringField() 94 | # 必填,分组的权限 95 | permission_ids = FieldList( 96 | IntegerField( 97 | "权限id", 98 | validators=[ 99 | DataRequired(message="请输入权限id"), 100 | NumberRange(message="权限id必须大于0", min=1), 101 | ], 102 | ) 103 | ) 104 | 105 | def validate_permission_id(self, value): 106 | exists = manager.permission_model.get(id=value.data) 107 | if not exists: 108 | raise ParameterError("权限不存在") 109 | 110 | 111 | # 管理员更新分组 112 | class UpdateGroup(Form): 113 | # 分组name 114 | name = StringField(validators=[DataRequired(message="请输入分组名称")]) 115 | # 非必须 116 | info = StringField() 117 | 118 | 119 | class DispatchAuths(Form): 120 | # 为用户分配的权限 121 | group_id = IntegerField( 122 | "分组id", 123 | validators=[ 124 | DataRequired(message="请输入分组id"), 125 | NumberRange(message="分组id必须大于0", min=1), 126 | ], 127 | ) 128 | 129 | permission_ids = FieldList(IntegerField(validators=[DataRequired(message="请输入permission_ids字段")])) 130 | 131 | 132 | class DispatchAuth(Form): 133 | # 为用户分配的权限 134 | group_id = IntegerField( 135 | "分组id", 136 | validators=[ 137 | DataRequired(message="请输入分组id"), 138 | NumberRange(message="分组id必须大于0", min=1), 139 | ], 140 | ) 141 | permission_id = IntegerField(validators=[DataRequired(message="请输入permission_id字段")]) 142 | 143 | 144 | # 批量删除权限 145 | class RemoveAuths(Form): 146 | group_id = IntegerField( 147 | "分组id", 148 | validators=[ 149 | DataRequired(message="请输入分组id"), 150 | NumberRange(message="分组id必须大于0", min=1), 151 | ], 152 | ) 153 | permission_ids = FieldList(IntegerField(validators=[DataRequired(message="请输入permission_ids字段")])) 154 | 155 | 156 | # 日志查找范围校验 157 | class LogFindForm(Form): 158 | # name可选,若无则表示全部 159 | name = StringField() 160 | # 2018-11-01 09:39:35 161 | start = DateTimeField(validators=[]) 162 | end = DateTimeField(validators=[]) 163 | 164 | def validate_start(self, value): 165 | if value.data: 166 | try: 167 | _ = time.strptime(value.data, "%Y-%m-%d %H:%M:%S") 168 | except ParameterError as e: 169 | raise e 170 | 171 | def validate_end(self, value): 172 | if value.data: 173 | try: 174 | _ = time.strptime(value.data, "%Y-%m-%d %H:%M:%S") 175 | except ParameterError as e: 176 | raise e 177 | 178 | 179 | class EventsForm(Form): 180 | group_id = IntegerField( 181 | "分组id", 182 | validators=[ 183 | DataRequired(message="请输入分组id"), 184 | NumberRange(message="分组id必须大于0", min=1), 185 | ], 186 | ) 187 | events = FieldList(StringField(validators=[DataRequired(message="请输入events字段")])) 188 | 189 | 190 | # 更新用户邮箱和昵称 191 | class UpdateInfoForm(EmailForm): 192 | nickname = StringField() 193 | avatar = StringField() 194 | 195 | def validate_nickname(self, value): 196 | if value.data: 197 | length = len(value.data) 198 | if length < 2 or length > 10: 199 | raise ParameterError("昵称长度必须在2~10之间") 200 | 201 | 202 | # 更新用户信息 203 | class UpdateUserInfoForm(EmailForm): 204 | group_ids = FieldList( 205 | IntegerField( 206 | "分组id", 207 | validators=[ 208 | DataRequired(message="请输入分组id"), 209 | NumberRange(message="分组id必须大于0", min=1), 210 | ], 211 | ) 212 | ) 213 | 214 | def validate_group_ids(self, value): 215 | for group_id in value.data: 216 | if not manager.group_model.count_by_id(group_id): 217 | raise ParameterError("分组不存在") 218 | 219 | 220 | class BookSearchForm(Form): 221 | q = StringField(validators=[DataRequired(message="必须传入搜索关键字")]) # 前端的请求参数中必须携带`q` 222 | 223 | 224 | class CreateOrUpdateBookForm(Form): 225 | title = StringField(validators=[DataRequired(message="必须传入图书名")]) 226 | author = StringField(validators=[DataRequired(message="必须传入图书作者")]) 227 | summary = StringField(validators=[DataRequired(message="必须传入图书综述")]) 228 | image = StringField(validators=[DataRequired(message="必须传入图书插图")]) 229 | -------------------------------------------------------------------------------- /app/cli/plugin/init.py: -------------------------------------------------------------------------------- 1 | """ 2 | :copyright: © 2020 by the Lin team. 3 | :license: MIT, see LICENSE for more details. 4 | """ 5 | 6 | import os 7 | import re 8 | import subprocess 9 | from importlib import import_module 10 | 11 | from app import create_app 12 | 13 | """ 14 | 插件初始化流程: 15 | 1、输入要初始化的插件名称。(多个用空格隔开,*表示初始化所有) 16 | 2、python依赖的安装 17 | 2、将插件的配置写入到项目app/config/base.py中 18 | 3、将model中的模型插入到数据库中 19 | 4、如果有需要,将初始数据插入到数据表中 20 | """ 21 | 22 | app = create_app(register_all=False) 23 | 24 | 25 | class PluginInit: 26 | # 插件位置默认前缀 27 | plugin_path = "app.plugin" 28 | 29 | def __init__(self, name): 30 | self.app = create_app(register_all=False) 31 | self.name = name.strip() 32 | # 插件相关的路径信息,包含plugin_path(插件路径),plugin_config_path(插件的配置文件路径),plugin_info_path(插件的基本信息路径) 33 | self.path_info = dict() 34 | # 根据name生成path,写入到path_info属性中 35 | self.generate_path() 36 | # 安装依赖 37 | self.auto_install_rely() 38 | # 将插件的配置自动写入setting 39 | self.auto_write_setting() 40 | # 创建数据表,并且向表中插入模型中的一些初始数据 41 | self.create_data() 42 | 43 | def generate_path(self): 44 | if self.name == "*": 45 | names = self.__get_all_plugins() 46 | else: 47 | names = self.name.split(" ") 48 | for name in names: 49 | if self.name == "": 50 | exit("插件名称不能为空,请重试") 51 | self.path_info[name] = { 52 | "plugin_path": self.plugin_path + "." + name, 53 | "plugin_config_path": self.plugin_path + "." + name + ".config", 54 | "plugin_info_path": self.plugin_path + "." + name + ".info", 55 | } 56 | 57 | def auto_install_rely(self): 58 | for name in self.path_info: 59 | print("正在初始化插件" + name + "...") 60 | filename = "requirements.txt" 61 | file_path = self.app.config.root_path + "/plugin/" + name + "/" + filename 62 | success_msg = "安装" + name + "插件的依赖成功" 63 | fail_msg = name + "插件的依赖安装失败,请[手动安装依赖]: https://doc.cms.talelin.com/" 64 | if os.path.exists(file_path): 65 | if (os.path.getsize(file_path)) == 0: 66 | continue 67 | print("正在安装" + name + "插件的依赖,请耐心等待...") 68 | 69 | ret = self.__execute_cmd(cmd="pip install -r " + file_path) 70 | 71 | if ret: 72 | print(success_msg) 73 | else: 74 | exit(fail_msg) 75 | 76 | def auto_write_setting(self): 77 | print("正在自动写入配置文件...") 78 | setting_text = dict() 79 | for name, val in self.path_info.items(): 80 | try: 81 | info_mod = import_module(self.path_info[name]["plugin_info_path"]) 82 | except ModuleNotFoundError as e: 83 | raise Exception(str(e) + "\n未找到插件" + name + ",请检查您输入的插件名是否正确") 84 | 85 | res = self._generate_setting(name, info_mod) 86 | setting_text[name] = res 87 | 88 | # 正则匹配setting.py中的配置文件,将配置文件替换成新的setting_doc 89 | self.__update_setting(new_setting=setting_text) 90 | 91 | def create_data(self): 92 | print("正在创建基础数据...") 93 | for name, val in self.path_info.items(): 94 | # 调用插件__init__模块中的initial_data方法,创建初始的数据 95 | try: 96 | plugin_module = import_module(self.path_info[name]["plugin_path"] + ".app.__init__") 97 | dir_info = dir(plugin_module) 98 | except ModuleNotFoundError as e: 99 | raise Exception( 100 | str(e) + "\n未找到插件" + name + ",请检查您输入的插件名是否正确或插件中是否有未安装的依赖包" 101 | ) 102 | if "initial_data" in dir_info: 103 | plugin_module.initial_data() 104 | print("插件初始化成功") 105 | 106 | def _generate_setting(self, name, info_mod): 107 | info_mod_dic = info_mod.__dict__ 108 | ret = { 109 | "path": self.path_info[name]["plugin_path"], 110 | "enable": True, 111 | # info_mod_dic.__version__ 112 | "version": info_mod_dic.pop("__version__", "0.0.1"), 113 | } 114 | # 向setting_doc中写入插件的配置项 115 | cfg_mod = import_module(self.path_info[name]["plugin_config_path"]) 116 | dic = cfg_mod.__dict__ 117 | for key in dic.keys(): 118 | if not key.startswith("__"): 119 | ret[key] = dic[key] 120 | return ret 121 | 122 | def __update_setting(self, new_setting): 123 | # 得到现存的插件配置 124 | old_setting = self.app.config.get("PLUGIN_PATH", dict()) 125 | final_setting = self.__cal_setting(new_setting, old_setting) 126 | 127 | sub_str = "PLUGIN_PATH = " + self.__format_setting(final_setting) 128 | 129 | setting_path = self.app.config.root_path + "/config/base.py" 130 | with open(setting_path, "r", encoding="UTF-8") as f: 131 | content = f.read() 132 | pattern = "PLUGIN_PATH = \{([\s\S]*)\}+.*?" # type: ignore 133 | if len(re.findall(pattern, content)) == 0: 134 | content += """ 135 | PLUGIN_PATH = {} 136 | """ 137 | result = re.sub(pattern, sub_str, content) 138 | 139 | with open(setting_path, "w+", encoding="UTF-8") as f: 140 | f.write(result) 141 | 142 | def __get_all_plugins(self): 143 | # 返回所有插件的目录名称 144 | ret = [] 145 | path = self.app.config.root_path + "/plugin" 146 | for file in os.listdir(path=path): 147 | file_path = os.path.join(path, file) 148 | if os.path.isdir(file_path): 149 | ret.append(file) 150 | return ret 151 | 152 | @classmethod 153 | def __execute_cmd(cls, cmd): 154 | code = subprocess.check_call(cmd, shell=True, stdout=subprocess.PIPE) 155 | if code == 0: 156 | return True 157 | elif code == 1: 158 | return False 159 | 160 | @classmethod 161 | def __format_setting(cls, setting): 162 | # 格式化setting字符串 163 | setting_str = str(setting) 164 | ret = setting_str.replace("},", "},\n ").replace("{", "{\n ", 1) 165 | replace_reg = re.compile(r"\}$") 166 | ret = replace_reg.sub("\n}", ret) 167 | return ret 168 | 169 | @staticmethod 170 | def __cal_setting(new_setting, old_setting): 171 | # 将新旧的setting合并,返回一个字典 172 | # 1、对比old和new,并且将这两个配置合并 173 | # 2、如果新的存在,旧的不存在,就追加新的; 174 | # 3、如果旧的存在,新的不存在,就保留旧的; 175 | # 4、如果新旧都存在,那么在版本号相同的情况下,保留旧的配置项,否则新的配置覆盖旧的配置。 176 | 177 | final_setting = dict() 178 | all_keys = new_setting.keys() | old_setting.keys() # 得到新旧配置的并集 179 | 180 | for key in all_keys: 181 | if key not in old_setting.keys(): 182 | # 不存在,追加新的 183 | final_setting[key] = new_setting[key] 184 | else: 185 | # 存在,对比版本号,看看是否需要更新 TODO 优化条件判断 186 | if key not in new_setting: 187 | # 新的不存在 188 | final_setting[key] = old_setting[key] 189 | else: 190 | # 新的存在 191 | if new_setting[key]["version"] == old_setting[key]["version"]: 192 | # 版本号相同,使用旧的配置 193 | final_setting[key] = old_setting[key] 194 | else: 195 | # 版本号不同,更新配置为新的 196 | final_setting[key] = new_setting[key] 197 | 198 | return final_setting 199 | 200 | 201 | def init(): 202 | plugin_name = input("请输入要初始化的插件名,如果多个插件请使用空格分隔插件名,输入*表示初始化所有插件:\n") 203 | PluginInit(plugin_name) 204 | -------------------------------------------------------------------------------- /app/lin/syslogger.py: -------------------------------------------------------------------------------- 1 | """ 2 | syslogger of Lin 3 | ~~~~~~~~~ 4 | 5 | logger 模块,记录系统日志 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | import datetime 12 | import logging 13 | import os 14 | import time 15 | from logging.handlers import BaseRotatingHandler 16 | from typing import Any, Optional 17 | 18 | from flask import Flask, g, json, request 19 | 20 | __all__ = ["SysLogger"] 21 | 22 | 23 | class SysLogger: 24 | """ 25 | 运行日志 26 | """ 27 | 28 | def __init__(self, app: Flask, fmt: Optional[str] = None, handler: Optional[logging.Handler] = None) -> None: 29 | """ 30 | 初始化 SysLogger 实例 31 | 32 | :param app: Flask 应用实例 33 | :param fmt: 日志格式 34 | :param handler: 日志处理器 35 | """ 36 | self._app = app 37 | self._fmt = fmt 38 | self._handler = handler 39 | self._logger: Optional[logging.Logger] = None 40 | self._log_config = self._app.config.get("LOG") 41 | self.init_logger() 42 | self.set_logger() 43 | self.display_request() 44 | 45 | def register_before_request(self) -> None: 46 | """ 47 | 注册请求前的处理函数,记录请求开始时间 48 | """ 49 | 50 | @self._app.before_request 51 | def request_cost_time() -> None: 52 | g.request_start_time = time.time() 53 | g.request_time = lambda: "%.5f" % (time.time() - g.request_start_time) 54 | 55 | def register_after_request(self) -> None: 56 | """ 57 | 注册请求后的处理函数,记录请求日志 58 | 59 | :param resp: 响应对象 60 | :return: 响应对象 61 | """ 62 | 63 | @self._app.after_request 64 | def log_response(resp: Any) -> Any: 65 | log_config = self._app.config.get("LOG") 66 | if not log_config["REQUEST_LOG"]: 67 | return resp 68 | message = "[%s] -> [%s] from:%s costs:%.3f ms" % ( 69 | request.method, 70 | request.path, 71 | request.remote_addr, 72 | float(g.request_time()) * 1000, 73 | ) 74 | if log_config["LEVEL"] == "INFO": 75 | self._app.logger.info(message) 76 | elif log_config["LEVEL"] == "DEBUG": 77 | req_body = "{}" 78 | try: 79 | req_body = request.get_json() if request.get_json() else {} 80 | except Exception: 81 | pass 82 | message += " data:{\n\tparam: %s, \n\tbody: %s\n} " % ( 83 | json.dumps(request.args, ensure_ascii=False), 84 | req_body, 85 | ) 86 | self._app.logger.debug(message) 87 | return resp 88 | 89 | def display_request(self) -> None: 90 | """ 91 | 终端回显系统日志 92 | """ 93 | self.register_before_request() 94 | self.register_after_request() 95 | 96 | def init_logger(self) -> None: 97 | """ 98 | 初始化日志记录器 99 | """ 100 | if self._log_config["FILE"] and not self._app.debug: 101 | fmt = logging.Formatter( 102 | "%(asctime)s %(levelname)s %(process)d --- [%(threadName)s]" " - %(message)s" 103 | if not self._fmt 104 | else self._fmt 105 | ) 106 | logging.basicConfig(level=logging.DEBUG) 107 | self._handler = LinRotatingFileHandler( 108 | log_dir=self._log_config["DIR"], 109 | max_bytes=self._log_config["SIZE_LIMIT"], 110 | encoding="UTF-8", 111 | ) 112 | self._handler.setFormatter(fmt) 113 | self._handler.setLevel(level=logging.DEBUG) 114 | self._app.logger.addHandler(self._handler) 115 | else: 116 | return 117 | 118 | def set_logger(self) -> None: 119 | """ 120 | 设置日志记录器 121 | """ 122 | self._logger = logging.getLogger(__name__) 123 | 124 | def get_logger(self) -> Optional[logging.Logger]: 125 | """ 126 | 获取日志记录器 127 | 128 | :return: 日志记录器 129 | """ 130 | return self._logger 131 | 132 | 133 | class LinRotatingFileHandler(BaseRotatingHandler): 134 | def __init__( 135 | self, 136 | log_dir: str = "logs", 137 | mode: str = "a", 138 | max_bytes: int = 0, 139 | encoding: Optional[str] = None, 140 | delay: bool = False, 141 | ) -> None: 142 | """ 143 | 初始化 LinRotatingFileHandler 实例 144 | 145 | :param log_dir: 日志目录 146 | :param mode: 文件打开模式 147 | :param max_bytes: 最大文件大小 148 | :param encoding: 文件编码 149 | :param delay: 是否延迟文件打开 150 | """ 151 | if max_bytes > 0: 152 | mode = "a" 153 | self._log_dir = log_dir 154 | self._suffix = ".log" 155 | self._year_month = datetime.datetime.now().strftime("%Y-%m") 156 | self.store_dir = os.path.join(self._log_dir, self._year_month) 157 | self._create_new_stream_if_not_exists(self.store_dir, open_stream=False) 158 | self.filename = datetime.datetime.now().strftime("%Y-%m-%d") 159 | filename = os.path.join(self.store_dir, self.filename) + self._suffix 160 | BaseRotatingHandler.__init__(self, filename, mode, encoding, delay) 161 | self.max_bytes = max_bytes 162 | 163 | def doRollover(self) -> None: 164 | """ 165 | 执行日志滚动 166 | """ 167 | year_month = datetime.datetime.now().strftime("%Y-%m") 168 | filename = datetime.datetime.now().strftime("%Y-%m-%d") 169 | 170 | if self.stream: 171 | self.stream.close() 172 | self.stream = None 173 | 174 | if self.filename != filename or self._year_month != year_month: 175 | self.baseFilename = self.baseFilename.replace( 176 | os.path.join(self._year_month, self.filename), 177 | os.path.join(year_month, filename), 178 | ) 179 | self.filename = filename 180 | self._year_month = year_month 181 | else: 182 | dfn = self.rotation_filename( 183 | self.baseFilename.replace( 184 | self._suffix, 185 | "-" + datetime.datetime.now().strftime("%H-%M-%S") + self._suffix, 186 | ) 187 | ) 188 | if os.path.exists(dfn): 189 | os.remove(dfn) 190 | self.rotate(self.baseFilename, dfn) 191 | if not self.delay: 192 | self.stream = self._open() 193 | 194 | def shouldRollover(self, record: logging.LogRecord) -> int: 195 | """ 196 | 判断是否需要滚动日志 197 | 198 | :param record: 日志记录 199 | :return: 是否需要滚动日志 200 | """ 201 | year_month = datetime.datetime.now().strftime("%Y-%m") 202 | filename = datetime.datetime.now().strftime("%Y-%m-%d") 203 | self._create_new_stream_if_not_exists(os.path.join(self._log_dir, year_month)) 204 | if self.stream is None: 205 | self.stream = self._open() 206 | if self._year_month != year_month or self.filename != filename: 207 | return 1 208 | if self.max_bytes > 0: 209 | msg = "%s\n" % self.format(record) 210 | self.stream.seek(0, 2) 211 | if self.stream.tell() + len(msg) >= self.max_bytes: 212 | return 1 213 | return 0 214 | 215 | def _create_new_stream_if_not_exists(self, store_dir: str, open_stream: bool = True) -> None: 216 | """ 217 | 创建新的日志流(如果不存在) 218 | 219 | :param store_dir: 存储目录 220 | :param open_stream: 是否打开流 221 | """ 222 | if not os.path.exists(store_dir): 223 | os.makedirs(store_dir) 224 | if open_stream: 225 | self.stream = self._open() 226 | -------------------------------------------------------------------------------- /app/api/cms/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | user apis 3 | ~~~~~~~~~ 4 | :copyright: © 2020 by the Lin team. 5 | :license: MIT, see LICENSE for more details. 6 | """ 7 | 8 | import jwt 9 | from flask import Blueprint, current_app, g, request 10 | from flask_jwt_extended import ( 11 | create_access_token, 12 | create_refresh_token, 13 | get_current_user, 14 | get_jwt_identity, 15 | verify_jwt_in_request, 16 | ) 17 | 18 | from app.api import AuthorizationBearerSecurity, api 19 | from app.api.cms.exception import RefreshFailed 20 | from app.api.cms.schema.user import ( 21 | CaptchaSchema, 22 | ChangePasswordSchema, 23 | LoginSchema, 24 | LoginTokenSchema, 25 | UserBaseInfoSchema, 26 | UserRegisterSchema, 27 | UserSchema, 28 | ) 29 | from app.lin import ( 30 | DocResponse, 31 | Duplicated, 32 | Failed, 33 | Log, 34 | Logger, 35 | NotFound, 36 | ParameterError, 37 | Success, 38 | admin_required, 39 | db, 40 | get_tokens, 41 | login_required, 42 | manager, 43 | permission_meta, 44 | ) 45 | from app.util.captcha import CaptchaTool 46 | from app.util.common import split_group 47 | 48 | user_api = Blueprint("user", __name__) 49 | 50 | 51 | @user_api.route("/register", methods=["POST"]) 52 | @permission_meta(name="注册", module="用户", mount=False) 53 | @Logger(template="管理员新建了一个用户") # 记录日志 54 | @admin_required 55 | @api.validate( 56 | tags=["用户"], 57 | security=[AuthorizationBearerSecurity], 58 | resp=DocResponse(Success("用户创建成功"), Duplicated("字段重复,请重新输入")), 59 | ) 60 | def register(json: UserRegisterSchema): 61 | """ 62 | 注册新用户 63 | """ 64 | if manager.user_model.count_by_username(g.username) > 0: 65 | raise Duplicated("用户名重复,请重新输入") # type: ignore 66 | if g.email and g.email.strip() != "": 67 | if manager.user_model.count_by_email(g.email) > 0: 68 | raise Duplicated("注册邮箱重复,请重新输入") # type: ignore 69 | # create a user 70 | with db.auto_commit(): 71 | user = manager.user_model() 72 | user.username = g.username 73 | if g.email and g.email.strip() != "": 74 | user.email = g.email 75 | db.session.add(user) 76 | db.session.flush() 77 | user.password = g.password 78 | group_ids = g.group_ids 79 | # 如果没传分组数据,则将其设定为 guest 分组 80 | if len(group_ids) == 0: 81 | from app.lin import GroupLevelEnum 82 | 83 | group_ids = [GroupLevelEnum.GUEST.value] 84 | for group_id in group_ids: 85 | user_group = manager.user_group_model() 86 | user_group.user_id = user.id 87 | user_group.group_id = group_id 88 | db.session.add(user_group) 89 | 90 | raise Success("用户创建成功") # type: ignore 91 | 92 | 93 | @user_api.route("/login", methods=["POST"]) 94 | @api.validate(resp=DocResponse(Failed("验证码校验失败"), r=LoginTokenSchema), tags=["用户"]) 95 | def login(json: LoginSchema): 96 | """ 97 | 用户登录 98 | """ 99 | # 校对验证码 100 | if current_app.config.get("LOGIN_CAPTCHA"): 101 | tag = request.headers.get("tag", "") 102 | secret_key = current_app.config.get("SECRET_KEY", "") 103 | if g.captcha != jwt.decode(tag, secret_key, algorithms=["HS256"]).get("code"): 104 | raise Failed("验证码校验失败") # type: ignore 105 | 106 | user = manager.user_model.verify(g.username, g.password) 107 | # 用户未登录,此处不能用装饰器记录日志 108 | Log.create_log( 109 | message=f"{user.username}登录成功获取了令牌", 110 | user_id=user.id, 111 | username=user.username, 112 | status_code=200, 113 | method="post", 114 | path="/cms/user/login", 115 | permission="", 116 | commit=True, 117 | ) 118 | access_token, refresh_token = get_tokens(user) 119 | return LoginTokenSchema(access_token=access_token, refresh_token=refresh_token) 120 | 121 | 122 | @user_api.route("", methods=["PUT"]) 123 | @permission_meta(name="用户更新信息", module="用户", mount=False) 124 | @login_required 125 | @api.validate( 126 | tags=["用户"], 127 | security=[AuthorizationBearerSecurity], 128 | resp=DocResponse(Success("用户信息更新成功"), ParameterError("邮箱已被注册,请重新输入邮箱")), 129 | ) 130 | def update(json: UserBaseInfoSchema): 131 | """ 132 | 更新用户信息 133 | """ 134 | user = get_current_user() 135 | 136 | if g.email and user.email != g.email: 137 | exists = manager.user_model.get(email=g.email) 138 | if exists: 139 | raise ParameterError("邮箱已被注册,请重新输入邮箱") 140 | with db.auto_commit(): 141 | if g.email: 142 | user.email = g.email 143 | if g.nickname: 144 | user.nickname = g.nickname 145 | if g.avatar: 146 | user._avatar = g.avatar 147 | raise Success("用户信息更新成功") 148 | 149 | 150 | @user_api.route("/change_password", methods=["PUT"]) 151 | @permission_meta(name="修改密码", module="用户", mount=False) 152 | @Logger(template="{user.username}修改了自己的密码") # 记录日志 153 | @login_required 154 | @api.validate( 155 | tags=["用户"], 156 | security=[AuthorizationBearerSecurity], 157 | resp=DocResponse(Success("密码修改成功"), Failed("密码修改失败")), 158 | ) 159 | def change_password(json: ChangePasswordSchema): 160 | """ 161 | 修改密码 162 | """ 163 | user = get_current_user() 164 | ok = user.change_password(g.old_password, g.new_password) 165 | if ok: 166 | db.session.commit() 167 | raise Success("密码修改成功") 168 | else: 169 | return Failed("修改密码失败") 170 | 171 | 172 | @user_api.route("/information") 173 | @permission_meta(name="查询自己信息", module="用户", mount=False) 174 | @login_required 175 | @api.validate( 176 | tags=["用户"], 177 | security=[AuthorizationBearerSecurity], 178 | resp=DocResponse(r=UserSchema), 179 | ) 180 | def get_information(): 181 | """ 182 | 获取用户信息 183 | """ 184 | current_user = get_current_user() 185 | return current_user 186 | 187 | 188 | @user_api.route("/refresh") 189 | @permission_meta(name="刷新令牌", module="用户", mount=False) 190 | @api.validate( 191 | resp=DocResponse(RefreshFailed, NotFound("refresh_token未被识别"), r=LoginTokenSchema), 192 | tags=["用户"], 193 | ) 194 | def refresh(): 195 | """ 196 | 刷新令牌 197 | """ 198 | try: 199 | verify_jwt_in_request(refresh=True) 200 | except Exception: 201 | raise RefreshFailed 202 | 203 | identity = get_jwt_identity() 204 | if identity: 205 | access_token = create_access_token(identity=identity) 206 | refresh_token = create_refresh_token(identity=identity) 207 | return LoginTokenSchema(access_token=access_token, refresh_token=refresh_token) 208 | 209 | return NotFound("refresh_token未被识别") 210 | 211 | 212 | @user_api.route("/permissions") 213 | @permission_meta(name="查询自己拥有的权限", module="用户", mount=False) 214 | @login_required 215 | @api.validate( 216 | tags=["用户"], 217 | security=[AuthorizationBearerSecurity], 218 | # resp=DocResponse(r=UserPermissionSchema), # 当前数据接口无法使用OpenAPI表示 219 | ) 220 | def get_allowed_apis(): 221 | """ 222 | 获取用户拥有的权限 223 | """ 224 | user = get_current_user() 225 | groups = manager.group_model.select_by_user_id(user.id) 226 | group_ids = [group.id for group in groups] 227 | _permissions = manager.permission_model.select_by_group_ids(group_ids) 228 | permission_list = [{"permission": permission.name, "module": permission.module} for permission in _permissions] 229 | res = split_group(permission_list, "module") 230 | setattr(user, "permissions", res) 231 | setattr(user, "admin", user.is_admin) 232 | user._fields.extend(["admin", "permissions"]) 233 | 234 | return user 235 | 236 | 237 | @user_api.route("/captcha", methods=["GET", "POST"]) 238 | @api.validate( 239 | resp=DocResponse(r=CaptchaSchema), 240 | tags=["用户"], 241 | ) 242 | def get_captcha(): 243 | """ 244 | 获取图形验证码 245 | """ 246 | if not current_app.config.get("LOGIN_CAPTCHA"): 247 | return CaptchaSchema() # type: ignore 248 | image, code = CaptchaTool().get_verify_code() 249 | secret_key = current_app.config.get("SECRET_KEY") 250 | tag = jwt.encode({"code": code}, secret_key, algorithm="HS256") 251 | return {"tag": tag, "image": image} 252 | -------------------------------------------------------------------------------- /app/lin/manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | mananger module of Lin. 3 | ~~~~~~~~~ 4 | 5 | manager model 6 | 7 | :copyright: © 2020 by the Lin team. 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | from typing import Any, Dict, List, Optional, Type, Union 12 | 13 | from flask import current_app 14 | from werkzeug.local import LocalProxy 15 | 16 | from .db import db 17 | 18 | __all__ = ["Manager", "manager"] 19 | 20 | 21 | class Manager(object): 22 | """manager for lin""" 23 | 24 | # 路由函数的meta信息的容器 25 | ep_meta: Dict[str, Any] = {} 26 | 27 | def __init__( 28 | self, 29 | plugin_path: Dict[str, Any], 30 | group_model: Type[Any], 31 | user_model: Type[Any], 32 | identity_model: Type[Any], 33 | permission_model: Type[Any], 34 | group_permission_model: Type[Any], 35 | user_group_model: Type[Any], 36 | ) -> None: 37 | self.group_model = group_model 38 | self.user_model = user_model 39 | self.permission_model = permission_model 40 | self.group_permission_model = group_permission_model 41 | self.user_group_model = user_group_model 42 | self.identity_model = identity_model 43 | from .loader import Loader 44 | 45 | self.loader: Loader = Loader(plugin_path) 46 | 47 | def find_user(self, **kwargs: Any) -> Optional[Any]: 48 | return self.user_model.query.filter_by(**kwargs).first() 49 | 50 | def verify_user(self, username: str, password: str) -> Any: 51 | return self.user_model.verify(username, password) 52 | 53 | def find_group(self, **kwargs: Any) -> Optional[Any]: 54 | return self.group_model.query.filter_by(**kwargs).first() 55 | 56 | def get_ep_infos(self) -> Dict[str, List[Any]]: 57 | """返回权限管理中的所有视图函数的信息,包含它所属module""" 58 | info_list = self.permission_model.query.filter_by(mount=True).all() 59 | infos: Dict[str, List[Any]] = {} 60 | for permission in info_list: 61 | module = infos.get(permission.module, None) 62 | if module: 63 | module.append(permission) 64 | else: 65 | infos.setdefault(permission.module, [permission]) 66 | 67 | return infos 68 | 69 | def find_info_by_ep(self, ep: str) -> Optional[Any]: 70 | """通过请求的endpoint寻找路由函数的meta信息""" 71 | info = self.ep_meta.get(ep) 72 | return info if info is not None and info.mount else None 73 | 74 | def find_group_ids_by_user_id(self, user_id: int) -> List[int]: 75 | """ 76 | 根据用户ID,通过User-Group关联表,获取所属用户组的Id列表 77 | """ 78 | query = ( 79 | db.session.query(self.user_group_model.group_id) 80 | .join( 81 | self.user_model, 82 | self.user_model.id == self.user_group_model.user_id, 83 | ) 84 | .filter(self.user_model.is_deleted == False, self.user_model.id == user_id) 85 | ) 86 | result = ( 87 | db.session.query(self.group_model.id) 88 | .filter(self.group_model.is_deleted == False) 89 | .filter(self.group_model.id.in_(query)) 90 | ) 91 | # [(1,),(2,),...] => [1,2,...] 92 | group_ids = [x[0] for x in result.all()] 93 | return group_ids 94 | 95 | def is_user_allowed(self, group_ids: List[int]) -> bool: 96 | """查看当前user有无权限访问该路由函数""" 97 | from flask import request 98 | 99 | from .db import db 100 | 101 | ep = request.endpoint 102 | if ep is None: 103 | return False 104 | # 根据 endpoint 查找 permission, 一定存在 105 | meta = self.ep_meta.get(ep) 106 | if meta is None: 107 | return False 108 | # 判断 用户组拥有的权限是否包含endpoint标记的权限 109 | # 传入用户组的 id 列表 和 权限模块名称 权限名称,根据 Group-Permission Model 判断对应权限是否存在 110 | query = db.session.query(self.group_permission_model.permission_id).filter( 111 | self.group_permission_model.group_id.in_(group_ids) 112 | ) 113 | result = self.permission_model.query.filter_by( 114 | soft=True, module=meta.module, name=meta.name, mount=True 115 | ).filter(self.permission_model.id.in_(query)) 116 | permission = result.first() 117 | return True if permission else False 118 | 119 | def find_permission_module(self, name: str) -> Optional[Any]: 120 | """通过权限寻找meta信息""" 121 | for _, meta in self.ep_meta.items(): 122 | if meta.name == name: 123 | return meta 124 | return None 125 | 126 | @property 127 | def plugins(self) -> Dict[str, Any]: 128 | return self.loader.plugins 129 | 130 | def get_plugin(self, name: str) -> Optional[Any]: 131 | return self.loader.plugins.get(name) 132 | 133 | def get_model(self, name: str) -> Optional[Any]: 134 | # attention!!! if models have the same name,will return the first one 135 | # 注意!!! 如果容器内有相同的model,则默认返回第一个 136 | for plugin in self.plugins.values(): 137 | model = plugin.models.get(name) 138 | if model is not None: 139 | return model 140 | return None 141 | 142 | def get_service(self, name: str) -> Optional[Any]: 143 | # attention!!! if services have the same name,will return the first one 144 | # 注意!!! 如果容器内有相同的service,则默认返回第一个 145 | for plugin in self.plugins.values(): 146 | service = plugin.services.get(name) 147 | if service is not None: 148 | return service 149 | return None 150 | 151 | def sync_permissions(self) -> None: 152 | with db.auto_commit(): 153 | db.create_all() 154 | permissions = self.permission_model.get(one=False) 155 | # 新增的权限记录 156 | new_added_permissions: set[Any] = set() 157 | deleted_ids = [permission.id for permission in permissions] 158 | # mount-> unmount 159 | unmounted_ids: List[int] = list() 160 | # unmount-> mount 的记录 161 | mounted_ids: List[int] = list() 162 | # 用代码中记录的权限比对数据库中的权限 163 | for _, meta in self.ep_meta.items(): 164 | name, module, mount = meta 165 | # db_existed 判定 代码中的权限是否存在于权限表记录中 166 | db_existed = False 167 | for permission in permissions: 168 | if permission.name == name and permission.module == module: 169 | # 此条记录存在,从待删除列表中移除,不会被删除 170 | if permission.id in deleted_ids: 171 | deleted_ids.remove(permission.id) 172 | # 此条记录存在,不需要添加到权限表 173 | db_existed = True 174 | # 判定mount的变动情况,将记录id添加到对应的列表中 175 | if permission.mount != mount: 176 | if mount: 177 | mounted_ids.append(permission.id) 178 | else: 179 | unmounted_ids.append(permission.id) 180 | break 181 | # 遍历结束,代码中的记录不存在于已有的权限表中,则将其添加到新增权限记录列表 182 | if not db_existed: 183 | permission = self.permission_model() 184 | permission.name = name 185 | permission.module = module 186 | permission.mount = mount 187 | new_added_permissions.add(permission) 188 | _sync_permissions(self, new_added_permissions, unmounted_ids, mounted_ids, deleted_ids) 189 | 190 | 191 | def _sync_permissions( 192 | manager: Manager, 193 | new_added_permissions: set[Any], 194 | unmounted_ids: List[int], 195 | mounted_ids: List[int], 196 | deleted_ids: List[int], 197 | ) -> None: 198 | if new_added_permissions: 199 | db.session.add_all(new_added_permissions) 200 | if unmounted_ids: 201 | manager.permission_model.query.filter(manager.permission_model.id.in_(unmounted_ids)).update( 202 | {"mount": False}, synchronize_session=False 203 | ) 204 | if mounted_ids: 205 | manager.permission_model.query.filter(manager.permission_model.id.in_(mounted_ids)).update( 206 | {"mount": True}, synchronize_session=False 207 | ) 208 | if deleted_ids: 209 | manager.permission_model.query.filter(manager.permission_model.id.in_(deleted_ids)).delete( 210 | synchronize_session=False 211 | ) 212 | # 分组-权限关联表中的数据也要清理 213 | manager.group_permission_model.query.filter( 214 | manager.group_permission_model.permission_id.in_(deleted_ids) 215 | ).delete(synchronize_session=False) 216 | 217 | 218 | def get_manager() -> Manager: 219 | _manager = current_app.extensions["manager"] 220 | if _manager: 221 | return _manager 222 | else: 223 | app = current_app._get_current_object() 224 | with app.app_context(): 225 | return app.extensions["manager"] 226 | 227 | 228 | # a proxy for manager instance 229 | # attention, only used when context in stack 230 | 231 | # 获得manager实例 232 | # 注意,仅仅在flask的上下文栈中才可获得 233 | manager: Manager = LocalProxy(lambda: get_manager()) 234 | -------------------------------------------------------------------------------- /app/lin/interface.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Some model interfaces of Lin 4 | ~~~~~~~~~ 5 | 6 | interface means you must implement the necessary methods and inherit properties. 7 | 8 | :copyright: © 2020 by the Lin team. 9 | :license: MIT, see LICENSE for more details. 10 | """ 11 | from datetime import datetime 12 | from typing import Any, Dict, List, Optional, TypeVar, Union 13 | 14 | try: 15 | from typing import Self # Python 3.11+ 16 | except ImportError: 17 | try: 18 | from typing_extensions import Self # 兼容性包 19 | except ImportError: 20 | # 后备方案:使用TypeVar 21 | Self = TypeVar("Self") # type: ignore 22 | 23 | from sqlalchemy import Boolean, Column, DateTime, Index, Integer, SmallInteger, String, func, text 24 | 25 | from .db import MixinJSONSerializer, db 26 | from .enums import GroupLevelEnum 27 | from .utils import camel2line 28 | 29 | 30 | # 基础的crud model 31 | class BaseCrud(db.Model, MixinJSONSerializer): 32 | __abstract__ = True 33 | 34 | def __init__(self) -> None: 35 | name: str = self.__class__.__name__ 36 | if not hasattr(self, "__tablename__"): 37 | self.__tablename__ = camel2line(name) 38 | 39 | def _set_fields(self) -> None: 40 | self._exclude = [] 41 | 42 | def set_attrs(self, attrs_dict: Dict[str, Any]) -> None: 43 | for key, value in attrs_dict.items(): 44 | if hasattr(self, key) and key != "id": 45 | setattr(self, key, value) 46 | 47 | # 硬删除 48 | def delete(self, commit: bool = False) -> None: 49 | db.session.delete(self) 50 | if commit: 51 | db.session.commit() 52 | 53 | # 查 54 | @classmethod 55 | def get( 56 | cls, start: Optional[int] = None, count: Optional[int] = None, one: bool = True, **kwargs: Any 57 | ) -> Union[Optional[Self], List[Self]]: 58 | if one: 59 | return cls.query.filter().filter_by(**kwargs).first() 60 | return cls.query.filter().filter_by(**kwargs).offset(start).limit(count).all() 61 | 62 | # 增 63 | @classmethod 64 | def create(cls, **kwargs: Any) -> Self: 65 | one = cls() 66 | for key in kwargs.keys(): 67 | if hasattr(one, key): 68 | setattr(one, key, kwargs[key]) 69 | db.session.add(one) 70 | if kwargs.get("commit") is True: 71 | db.session.commit() 72 | return one 73 | 74 | def update(self, commit: bool = False, **kwargs: Any) -> Self: 75 | for key in kwargs.keys(): 76 | if hasattr(self, key): 77 | setattr(self, key, kwargs[key]) 78 | db.session.add(self) 79 | if kwargs.get("commit") is True: 80 | db.session.commit() 81 | return self 82 | 83 | 84 | # 提供软删除,及创建时间,更新时间信息的crud model 85 | 86 | 87 | class InfoCrud(db.Model, MixinJSONSerializer): 88 | __abstract__ = True 89 | create_time = Column(DateTime(timezone=True), server_default=func.now()) 90 | update_time = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) 91 | delete_time = Column(DateTime(timezone=True)) 92 | is_deleted = Column(Boolean, nullable=False, default=False) 93 | 94 | def _set_fields(self) -> None: 95 | self._exclude = ["delete_time", "is_deleted"] 96 | 97 | def set_attrs(self, attrs_dict: Dict[str, Any]) -> None: 98 | for key, value in attrs_dict.items(): 99 | if hasattr(self, key) and key != "id": 100 | setattr(self, key, value) 101 | 102 | # 软删除 103 | def delete(self, commit: bool = False) -> None: 104 | self.delete_time = datetime.now() 105 | self.is_deleted = True 106 | db.session.add(self) 107 | # 提交会话 108 | if commit: 109 | db.session.commit() 110 | 111 | # 硬删除 112 | def hard_delete(self, commit: bool = False) -> None: 113 | db.session.delete(self) 114 | if commit: 115 | db.session.commit() 116 | 117 | # 查 118 | @classmethod 119 | def get( 120 | cls, start: Optional[int] = None, count: Optional[int] = None, one: bool = True, **kwargs: Any 121 | ) -> Union[Optional[Self], List[Self]]: 122 | # 应用软删除,必须带有delete_time 123 | if kwargs.get("is_deleted") is None: 124 | kwargs["is_deleted"] = False 125 | if one: 126 | return cls.query.filter().filter_by(**kwargs).first() 127 | return cls.query.filter().filter_by(**kwargs).offset(start).limit(count).all() 128 | 129 | # 增 130 | @classmethod 131 | def create(cls, **kwargs: Any) -> Self: 132 | one = cls() 133 | for key in kwargs.keys(): 134 | # if key == 'from': 135 | # setattr(one, '_from', kwargs[key]) 136 | # if key == 'parts': 137 | # setattr(one, '_parts', kwargs[key]) 138 | if hasattr(one, key): 139 | setattr(one, key, kwargs[key]) 140 | db.session.add(one) 141 | if kwargs.get("commit") is True: 142 | db.session.commit() 143 | return one 144 | 145 | def update(self, **kwargs: Any) -> Self: 146 | for key in kwargs.keys(): 147 | # if key == 'from': 148 | # setattr(self, '_from', kwargs[key]) 149 | if hasattr(self, key): 150 | setattr(self, key, kwargs[key]) 151 | db.session.add(self) 152 | if kwargs.get("commit") is True: 153 | db.session.commit() 154 | return self 155 | 156 | 157 | class GroupInterface(InfoCrud): 158 | __tablename__ = "lin_group" 159 | __table_args__ = (Index("name_del", "name", "is_deleted", unique=True),) 160 | 161 | id = Column(Integer(), primary_key=True) 162 | name = Column(String(60), nullable=False, comment="分组名称,例如:搬砖者") 163 | info = Column(String(255), comment="分组信息:例如:搬砖的人") 164 | level = Column( 165 | SmallInteger(), 166 | nullable=False, 167 | server_default=text(str(GroupLevelEnum.USER.value)), 168 | comment="分组级别 1:ROOT 2:GUEST 3:USER (ROOT、GUEST Level 对应分组均唯一存在)", 169 | ) 170 | 171 | @classmethod 172 | def count_by_id(cls, id: int) -> int: 173 | raise NotImplementedError() 174 | 175 | 176 | class GroupPermissionInterface(BaseCrud): 177 | __tablename__ = "lin_group_permission" 178 | __table_args__ = (Index("group_id_permission_id", "group_id", "permission_id"),) 179 | 180 | id = Column(Integer(), primary_key=True) 181 | group_id = Column(Integer(), nullable=False, comment="分组id") 182 | permission_id = Column(Integer(), nullable=False, comment="权限id") 183 | 184 | 185 | class PermissionInterface(InfoCrud): 186 | __tablename__ = "lin_permission" 187 | __table_args__ = (Index("name_module", "name", "module", unique=True),) 188 | 189 | id = Column(Integer(), primary_key=True) 190 | name = Column(String(60), nullable=False, comment="权限名称,例如:访问首页") 191 | module = Column(String(50), nullable=False, comment="权限所属模块,例如:人员管理") 192 | mount = Column(Boolean, nullable=False, comment="是否为挂载权限") 193 | 194 | def __hash__(self) -> int: 195 | raise NotImplementedError() 196 | 197 | def __eq__(self, other: object) -> bool: 198 | if not isinstance(other, PermissionInterface): 199 | return NotImplemented 200 | return True # 具体的比较逻辑由子类实现 201 | 202 | 203 | class UserInterface(InfoCrud): 204 | __tablename__ = "lin_user" 205 | __table_args__ = ( 206 | Index("username_del", "username", "is_deleted", unique=True), 207 | Index("email_del", "email", "is_deleted", unique=True), 208 | ) 209 | 210 | id = Column(Integer(), primary_key=True) 211 | username = Column(String(24), nullable=False, comment="用户名,唯一") 212 | nickname = Column(String(24), comment="用户昵称") 213 | _avatar = Column("avatar", String(500), comment="头像url") 214 | email = Column(String(100), comment="邮箱") 215 | 216 | @property 217 | def avatar(self) -> Optional[str]: 218 | raise NotImplementedError() 219 | 220 | @classmethod 221 | def count_by_id(cls, uid: int) -> int: 222 | raise NotImplementedError() 223 | 224 | @staticmethod 225 | def count_by_id_and_group_name(user_id: int, group_name: str) -> int: 226 | raise NotImplementedError() 227 | 228 | @property 229 | def is_admin(self) -> bool: 230 | raise NotImplementedError() 231 | 232 | @property 233 | def is_active(self) -> bool: 234 | raise NotImplementedError() 235 | 236 | @property 237 | def password(self) -> str: 238 | raise NotImplementedError() 239 | 240 | @password.setter 241 | def password(self, raw: str) -> None: 242 | raise NotImplementedError() 243 | 244 | def check_password(self, raw: str) -> bool: 245 | raise NotImplementedError() 246 | 247 | @classmethod 248 | def verify(cls, username: str, password: str) -> "UserInterface": 249 | raise NotImplementedError() 250 | 251 | 252 | class UserGroupInterface(BaseCrud): 253 | __tablename__ = "lin_user_group" 254 | __table_args__ = (Index("user_id_group_id", "user_id", "group_id"),) 255 | 256 | id = Column(Integer(), primary_key=True) 257 | user_id = Column(Integer(), nullable=False, comment="用户id") 258 | group_id = Column(Integer(), nullable=False, comment="分组id") 259 | 260 | 261 | class UserIdentityInterface(InfoCrud): 262 | __tablename__ = "lin_user_identity" 263 | 264 | id = Column(Integer(), primary_key=True) 265 | user_id = Column(Integer(), nullable=False, comment="用户id") 266 | identity_type = Column(String(100), nullable=False, comment="认证类型") 267 | identifier = Column(String(100), comment="标识") 268 | credential = Column(String(255), comment="凭证") 269 | 270 | 271 | class LinViewModel: 272 | def __init__(self, attrs: List[str], **kwargs: Any) -> None: 273 | self._attrs = attrs 274 | 275 | def keys(self) -> List[str]: 276 | return self._attrs 277 | 278 | def __getitem__(self, key: str) -> Any: 279 | return getattr(self, key) 280 | -------------------------------------------------------------------------------- /app/lin/apidoc.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from functools import wraps 3 | from typing import * 4 | 5 | from flask import Flask, current_app, g, json, jsonify, make_response 6 | from pydantic import BaseModel as _BaseModel 7 | from pydantic import validate_model 8 | from pydantic.main import object_setattr 9 | from spectree import SpecTree as _SpecTree 10 | from spectree._types import ModelType 11 | from spectree.response import DEFAULT_CODE_DESC, Response 12 | 13 | from .db import Record, RecordCollection 14 | from .exception import ParameterError 15 | from .utils import camel2line 16 | 17 | 18 | class ValidationError(_BaseModel): 19 | message: str = "parameter validation error message" 20 | code: int = 10030 21 | 22 | 23 | class BaseModel(_BaseModel): 24 | class Config: 25 | allow_population_by_field_name = True 26 | 27 | # Uses something other than `self` the first arg to allow "self" as a settable attribute 28 | def __init__(__pydantic_self__, **data: t.Any) -> None: # type: ignore 29 | values, fields_set, validation_error = validate_model(__pydantic_self__.__class__, data) 30 | if validation_error: 31 | raise ParameterError(" and ".join([f'{i["loc"][0]} {i["msg"]}' for i in validation_error.errors()])) 32 | try: 33 | object_setattr(__pydantic_self__, "__dict__", values) 34 | except TypeError as e: 35 | raise TypeError( 36 | "Model values must be a dict; you may not have returned a dictionary from a root validator" 37 | ) from e 38 | object_setattr(__pydantic_self__, "__fields_set__", fields_set) 39 | __pydantic_self__._init_private_attributes() 40 | 41 | """ 42 | Workaround for serializing properties with pydantic until 43 | https://github.com/samuelcolvin/pydantic/issues/935 44 | is solved 45 | """ 46 | 47 | @classmethod 48 | def get_properties(cls): 49 | return [ 50 | prop 51 | for prop in dir(cls) 52 | if isinstance(getattr(cls, prop), property) and prop not in ("__values__", "fields") 53 | ] 54 | 55 | def dict( 56 | self, 57 | *, 58 | include=None, 59 | exclude=None, 60 | by_alias: bool = False, 61 | skip_defaults: bool = None, 62 | exclude_unset: bool = False, 63 | exclude_defaults: bool = False, 64 | exclude_none: bool = False, 65 | ): 66 | attribs = super().dict( 67 | include=include, 68 | exclude=exclude, 69 | by_alias=by_alias, 70 | skip_defaults=skip_defaults, 71 | exclude_unset=exclude_unset, 72 | exclude_defaults=exclude_defaults, 73 | exclude_none=exclude_none, 74 | ) 75 | props = self.get_properties() 76 | # Include and exclude properties 77 | if include: 78 | props = [prop for prop in props if prop in include] 79 | if exclude: 80 | props = [prop for prop in props if prop not in exclude] 81 | 82 | # Update the attribute dict with the properties 83 | if props: 84 | attribs.update({prop: getattr(self, prop) for prop in props}) 85 | 86 | return attribs 87 | 88 | 89 | class SpecTree(_SpecTree): 90 | def validate( # noqa: PLR0913 [too-many-arguments] 91 | self, 92 | query: Optional[ModelType] = None, 93 | json: Optional[ModelType] = None, 94 | form: Optional[ModelType] = None, 95 | headers: Optional[ModelType] = None, 96 | cookies: Optional[ModelType] = None, 97 | resp: Optional[Response] = None, 98 | tags: Sequence = (), 99 | security: Any = None, 100 | deprecated: bool = False, 101 | before: Optional[Callable] = None, 102 | after: Optional[Callable] = None, 103 | validation_error_status: int = 0, 104 | path_parameter_descriptions: Optional[Mapping[str, str]] = None, 105 | skip_validation: bool = False, 106 | operation_id: Optional[str] = None, 107 | ) -> Callable: 108 | """ 109 | - validate query, json, headers in request 110 | - validate response body and status code 111 | - add tags to this API route 112 | 113 | :param operation_id: 114 | :param skip_validation: 115 | :param query: query in uri like `?name=value` 116 | :param json: JSON format request body 117 | :param headers: if you have specific headers 118 | :param cookies: if you have cookies for this route 119 | :param resp: DocResponse object 120 | :param tags: a tuple of strings or :class:`spectree.models.Tag` 121 | :param security: dict with security config for current route and method 122 | :param deprecated: bool if endpoint is marked as deprecated 123 | :param before: :meth:`spectree.utils.default_before_handler` for 124 | specific endpoint 125 | :param after: :meth:`spectree.utils.default_after_handler` for 126 | specific endpoint 127 | :param validation_error_status: The response status code to use for the 128 | specific endpoint, in the event of a validation error. If not specified, 129 | the global `validation_error_status` is used instead, defined 130 | in :meth:`spectree.spec.SpecTree`. 131 | :param path_parameter_descriptions: A dictionary of path parameter names and 132 | their description. 133 | """ 134 | if not validation_error_status: 135 | validation_error_status = self.validation_error_status 136 | 137 | resp_schema = resp.r if resp else None 138 | 139 | def lin_before(req, resp, req_validation_error, instance): 140 | g._resp_schema = resp_schema 141 | if before: 142 | before(req, resp, req_validation_error, instance) 143 | schemas = ["headers", "cookies", "query", "json"] 144 | for schema in schemas: 145 | params = getattr(req.context, schema) 146 | if params: 147 | for k, v in params: 148 | # 检测参数命名是否存在冲突,冲突则抛出要求重新命名的ParameterError 149 | if hasattr(g, k) or hasattr(g, camel2line(k)): 150 | raise ParameterError( 151 | {k: f"This parameter in { schema.capitalize() } needs to be renamed"} 152 | ) # type: ignore 153 | # 将参数设置到g中,以便后续使用 154 | setattr(g, k, v) 155 | # 将参数设置到g中,同时将参数名转换为下划线格式 156 | setattr(g, camel2line(k), v) 157 | 158 | def lin_after(req, resp, resp_validation_error, instance): 159 | # global after handler here 160 | if after: 161 | after(req, resp, resp_validation_error, instance) 162 | elif self.after: 163 | self.after(req, resp, resp_validation_error, instance) 164 | 165 | def decorate_validation(func): 166 | @wraps(func) 167 | def validation(*args, **kwargs): 168 | return self.backend.validate( 169 | func, 170 | query, 171 | json, 172 | form, 173 | headers, 174 | cookies, 175 | resp, 176 | lin_before, 177 | lin_after, 178 | validation_error_status, 179 | skip_validation, 180 | *args, 181 | **kwargs, 182 | ) 183 | 184 | has_annotations = False 185 | if self.config.annotations: 186 | nonlocal query, json, form, headers, cookies 187 | annotations = get_type_hints(func) 188 | query = annotations.get("query", query) 189 | json = annotations.get("json", json) 190 | form = annotations.get("form", form) 191 | headers = annotations.get("headers", headers) 192 | cookies = annotations.get("cookies", cookies) 193 | if annotations: 194 | has_annotations = True 195 | 196 | # register 197 | for name, model in zip( 198 | ("query", "json", "form", "headers", "cookies"), 199 | (query, json, form, headers, cookies), 200 | ): 201 | if model is not None: 202 | model_key = self._add_model(model=model) 203 | setattr(validation, name, model_key) 204 | 205 | if resp: 206 | # Make sure that the endpoint specific status code and data model for 207 | # validation errors shows up in the response spec. 208 | # resp.add_model( 209 | # validation_error_status, self.validation_error_model, replace=False 210 | # ) 211 | if has_annotations: 212 | resp.add_model(validation_error_status, ValidationError, replace=False) 213 | for model in resp.models: 214 | self._add_model(model=model) 215 | validation.resp = resp 216 | 217 | if tags: 218 | validation.tags = tags 219 | 220 | validation.security = security 221 | validation.deprecated = deprecated 222 | validation.path_parameter_descriptions = path_parameter_descriptions 223 | validation.operation_id = operation_id 224 | # register decorator 225 | validation._decorator = self 226 | return validation 227 | 228 | return decorate_validation 229 | 230 | 231 | def schema_response(app: Flask): 232 | """ 233 | 根据apidoc中指定的r schema,重新生成对应类型的响应 234 | """ 235 | 236 | @app.after_request 237 | def make_schema_response(response): 238 | res = response 239 | if hasattr(g, "_resp_schema") and g._resp_schema and response.status_code == 200: 240 | data, _code, _headers = response.get_json() 241 | res = make_response(jsonify(g._resp_schema.parse_obj(data))) 242 | return res 243 | 244 | 245 | class DocResponse(Response): 246 | """ 247 | response object 248 | 249 | :param args: subclass/object of APIException or obj/dict with code message_code message or None 250 | """ 251 | 252 | def __init__( 253 | self, 254 | *args: Any, 255 | r=None, 256 | ) -> None: 257 | # 初始化 self 258 | self.codes = [] # 重写功能后此属性无用,只是防止报错 259 | self.code_models: Dict[str, ModelType] = {} 260 | self.code_descriptions: Dict[str, Optional[str]] = {} 261 | self.code_list_item_types: Dict[str, ModelType] = {} 262 | 263 | # 将 args 转换后存入code_models 264 | for arg in args: 265 | assert "HTTP_" + str(arg.code) in DEFAULT_CODE_DESC, "invalid HTTP status code" 266 | name = arg.__class__.__name__ 267 | schema_name = "{class_name}_{message_code}_{hashmsg}Schema".format( 268 | class_name=name, 269 | message_code=arg.message_code, 270 | hashmsg=hash(arg.message), 271 | ) 272 | # 通过 name, schema_name, arg(包含code:int, 和message:str 两个属性) 生成一个新的BaseModel子类, 并存入code_models 273 | self.code_models["HTTP_" + str(arg.code)] = type( 274 | schema_name, 275 | (BaseModel,), 276 | {"__annotations__": {"code": int, "message": str}, "code": arg.message_code, "message": arg.message}, 277 | ) 278 | # 将 r 转换后存入code_models 279 | if r: 280 | http_status = "HTTP_200" 281 | if r.__class__.__name__ == "ModelMetaclass": 282 | self.code_models[http_status] = r 283 | elif isinstance(r, dict): 284 | response_str = json.dumps(r, cls=current_app.json_encoder) 285 | r = type("Dict-{}Schema".format(hash(response_str)), (BaseModel,), r) 286 | self.code_models[http_status] = r 287 | elif isinstance(r, (RecordCollection, Record)) or (hasattr(r, "keys") and hasattr(r, "__getitem__")): 288 | r_str = json.dumps(r, cls=current_app.json_encoder) 289 | r = json.loads(r_str) 290 | r = type("Json{}Schema".format(hash(r_str)), (BaseModel,), r) 291 | self.code_models[http_status] = r 292 | self.r = r 293 | --------------------------------------------------------------------------------