├── logs └── readme ├── app ├── common │ ├── __init__.py │ └── flask_apscheduler.py ├── jobs │ ├── __init__.py │ ├── test.py │ └── readme ├── views │ ├── __init__.py │ └── apscheduler.py ├── translations │ ├── __init__.py │ └── README.md ├── middlewares │ └── __init__.py ├── __init__.py ├── models.py └── templates │ └── apscheduler_job_info_list.html ├── temp └── READEME.md ├── docker-entrypoint.sh ├── images ├── 1.png └── 2.png ├── babel.cfg ├── manage.py ├── requirements.txt ├── Dockerfile ├── etc └── gunicorn.py ├── config.py ├── .gitignore └── README.md /logs/readme: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/jobs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /temp/READEME.md: -------------------------------------------------------------------------------- 1 | # 临时文件目录 -------------------------------------------------------------------------------- /app/translations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | gunicorn -c etc/gunicorn.py manage:app -------------------------------------------------------------------------------- /images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mx472756841/apscheduler_manage/HEAD/images/1.png -------------------------------------------------------------------------------- /images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mx472756841/apscheduler_manage/HEAD/images/2.png -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: **.py] 2 | [jinja2: **/templates/**.html] 3 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python3 2 | 3 | from flask_script import Manager 4 | 5 | from app import app 6 | 7 | manager = Manager(app) 8 | 9 | if __name__ == "__main__": 10 | manager.run() 11 | -------------------------------------------------------------------------------- /app/jobs/test.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from flask_apscheduler.scheduler import LOGGER 4 | 5 | 6 | def test_job(): 7 | """ 8 | 测试job 9 | """ 10 | LOGGER.info(f"test_job >>>>> {datetime.datetime.now()}") 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Werkzeug==1.0.1 2 | SQLAlchemy==1.3.23 3 | WTForms==2.3.3 4 | jinja2==2.11.3 5 | pymysql==1.0.2 6 | Flask==1.1.2 7 | Flask-Admin==1.5.7 8 | Flask-SQLAlchemy==2.4.4 9 | Flask-Script==2.0.6 10 | Flask-Babelex==0.9.4 11 | Flask-APScheduler==1.11.0 12 | apscheduler==3.7.0 13 | gunicorn -------------------------------------------------------------------------------- /app/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 3 | 4 | class Middleware(object): 5 | """ 6 | 中间件 7 | """ 8 | 9 | def process_request(self): 10 | """ 11 | 处理请求前处理 12 | :return: 13 | """ 14 | raise NotImplementedError 15 | -------------------------------------------------------------------------------- /app/jobs/readme: -------------------------------------------------------------------------------- 1 | 定义apscheduler可执行的任务 2 | 3 | 通过api动态添加job时,将job统一放在此模块,使用时添加的函数 4 | 请求接口: 5 | http://127.0.0.1:5000/scheduler/jobs 6 | 请求方法: 7 | POST 8 | 请求参数: 9 | { 10 | "id": "test_postman_add", 11 | "name":"管理平台添加", 12 | "func": "app:tasks:test:", 13 | "trigger": "date" 14 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.8-alpine3.12 2 | MAINTAINER "mx472756841" 3 | 4 | # 构建本地代码 5 | COPY / /apscheduler_manage/ 6 | 7 | WORKDIR /apscheduler_manage/ 8 | 9 | # apscheduler对于时区的使用,使用上海时间 10 | RUN echo Asia/Shanghai > /etc/timezone 11 | 12 | RUN pip3 install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple 13 | 14 | COPY docker-entrypoint.sh /usr/local/bin/ 15 | 16 | ENTRYPOINT ["docker-entrypoint.sh"] 17 | 18 | EXPOSE 5000 -------------------------------------------------------------------------------- /etc/gunicorn.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | bind = ":5000" 4 | # 监听队列数量,64-2048 5 | backlog = 512 6 | # 使用gevent模式,还可以使用sync 模式,默认的是sync模式 7 | worker_class = 'sync' 8 | # 进程数 9 | workers = multiprocessing.cpu_count() 10 | # 指定每个进程开启的线程数 11 | threads = multiprocessing.cpu_count() * 4 12 | # 日志级别,这个日志级别指的是错误日志的级别,而访问日志的级别无法设置 13 | loglevel = 'info' 14 | access_log_format = '%(t)s %(p)s %(h)s "%(r)s" %(s)s %(L)s %(b)s %(f)s" "%(a)s"' 15 | # 访问日志文件 16 | accesslog = "logs/gunicorn_access.log" 17 | # accesslog = '-' 18 | # 错误日志文件 19 | errorlog = "logs/gunicorn_error.log" 20 | # errorlog = '-' 21 | # 进程名 22 | proc_name = 'apscheduler_manage' -------------------------------------------------------------------------------- /app/translations/README.md: -------------------------------------------------------------------------------- 1 | # 说明 2 | 3 | ``` 4 | 这里是Flask-Admin==1.5.7版本自带的翻译文件,为方便复用,直接复用中文翻译 5 | 6 | Flask-Admin本地化处理方式是在babel.py中自定义了CustomDomain,创建了一个domain为admin,初始目录为flask_admin的domain,后续在.html以及.py都是使用domain.gettext方式获取翻译内容 7 | 8 | 所以我们的扩展方式时,对于flask-admin中的翻译保留,同时将需要的拿过来,然后对于我们需要翻译的内容再进行处理,最后通过pybabel命令编译到domain为admin的mo文件中 9 | ``` 10 | 11 | # 扩展步骤 12 | 13 | ## 在初始化flask-admin时,指定domain的目录为此目录 14 | 15 | Admin() 16 | 17 | ## 对于我们新增的代码中进行编译内容处理 18 | ### 扩展前置条件 19 | 20 | 安装Flasl-Babelex 21 | 22 | ### 扩展步骤 23 | - 代码中引用gettext,解压出pot文件 24 | 25 | pybabel extract -F babel.cfg -k _gettext -o temp/message.pot app/ 26 | 27 | **-k 指定关键字,对于用这个关键字的东西需要翻译,可能使用的方式不同,会有区别** 28 | 29 | - 填写翻译文件 30 | 31 | 我这里只有中文翻译,所以我只需要一个文件,并且填写中文翻译就可以了 32 | 33 | - 生成/更新po文件 34 | 35 | pybabel init -l zh_Hans_CN -D login -d app/translations -i temp/message.pot 36 | 37 | pybabel update -- 如果是更新文件的话,走这个 38 | 39 | - 编译到mo文件 40 | 41 | pybabel compile -D admin -l zh_Hans_CN -d app/translations -i app/translations/zh_Hans_CN/LC_MESSAGES/login.po 42 | 43 | **用-D admin,是因为flask-admin中用到的翻译customedomain都是admin,就是找的是admin.mo,所以我们要编译到这个里面** 44 | 45 | ## 资料 46 | [flask-babelex参考资料](https://pythonhosted.org/Flask-Babel/#translating-applications) 47 | ``` 48 | 1、新建babel.cfg: 49 | [python: **.py] 50 | [jinja2: **/templates/**.html] 51 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ 52 | 2、生成编译模板 53 | pybabel extract -F babel.cfg -o messages.pot . 54 | 3、翻译 55 | pybabel init -i messages.pot -d app/translations -l zh_Hans_CN -D admin 56 | 4、手动输入中文 57 | messages.mo 58 | 5、编译翻译结果 59 | pybabel compile -d app/translations -l zh_Hans_CN -D admin 60 | 6、更新翻译 61 | pybabel update -i messages.pot -d translations 62 | ``` -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python3 2 | import os 3 | 4 | from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore 5 | 6 | basedir = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | 9 | class Config: 10 | SECRET_KEY = os.environ.get('SECRET_KEY') or 'q5En7wAHpP8jsR^B6xnTP@ZEtWntV^FbV&JcQhU6Dg$ZNjPDEPC%qTZc0Q7llROE' 11 | SQLALCHEMY_COMMIT_ON_TEARDOWN = True 12 | SQLALCHEMY_TRACK_MODIFICATIONS = True 13 | FLASKY_MAIL_SUBJECT_PREFIX = '[Flasky]' 14 | FLASKY_MAIL_SENDER = 'Flasky Admin ' 15 | FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN') 16 | 17 | # apscheduler默认的jobstore 18 | SCHEDULER_JOBSTORES = {} 19 | # flask_apscheduler是否对外提供接口 20 | SCHEDULER_API_ENABLED = True 21 | 22 | @classmethod 23 | def init_app(cls, app): 24 | pass 25 | 26 | 27 | class DevelopmentConfig(Config): 28 | DEBUG = True 29 | SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \ 30 | 'mysql+pymysql://root:apscheduler@127.0.0.1:3306/apscheduler?charset=utf8mb4' 31 | SCHEDULER_JOBSTORES = {"default": SQLAlchemyJobStore(url=SQLALCHEMY_DATABASE_URI)} 32 | 33 | 34 | class TestingConfig(Config): 35 | TESTING = True 36 | SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \ 37 | 'sqlite:///' + os.path.join(basedir, 'data-test.sqlite') 38 | 39 | 40 | class ProductionConfig(Config): 41 | SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \ 42 | 'mysql+pymysql://root:apscheduler@127.0.0.1:3306/apscheduler?charset=utf8mb4' 43 | 44 | 45 | config = { 46 | 'development': DevelopmentConfig, 47 | 'testing': TestingConfig, 48 | 'production': ProductionConfig, 49 | 'default': DevelopmentConfig 50 | } 51 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | 4 | from flask import Flask 5 | from flask_admin import AdminIndexView, Admin 6 | from flask_babelex import Babel 7 | 8 | from app.common.flask_apscheduler import CustomAPScheduler 9 | from config import config 10 | from .models import db 11 | 12 | 13 | def init_view(): 14 | """ 15 | 动态导入本地view 16 | :param app: 17 | :return: 18 | """ 19 | #: 基础view处理 20 | curr_dir = os.path.dirname(os.path.abspath(__file__)) 21 | files = os.walk(os.sep.join([curr_dir, "views"])) 22 | #: 获取views中多有定义的views信息 23 | root, paths, fs = files.send(None) 24 | fs.remove("__init__.py") 25 | for file_name in fs: 26 | module = importlib.import_module("app.views" + ".{}".format(file_name.split(".")[0])) 27 | views = getattr(module, 'views', []) 28 | admin.add_views(*views) 29 | 30 | 31 | def init_middlewares(): 32 | """ 33 | 处理中间件 34 | :return: 35 | """ 36 | middlewares = [] 37 | curr_dir = os.path.dirname(os.path.abspath(__file__)) 38 | files = os.walk(os.sep.join([curr_dir, "middlewares"])) 39 | root, paths, fs = files.send(None) 40 | fs.remove("__init__.py") 41 | for file_name in fs: 42 | module = importlib.import_module("app.middlewares" + ".{}".format(file_name.split(".")[0])) 43 | tmp_middlewares = getattr(module, 'middlewares', []) 44 | middlewares.extend(tmp_middlewares) 45 | 46 | return middlewares 47 | 48 | 49 | admin = Admin(name="APScheduler管理平台", index_view=AdminIndexView(), template_mode="bootstrap3") 50 | #: 国际化 51 | babel = Babel(default_locale='zh_Hans_CN') 52 | 53 | config_name = os.getenv('FLASK_CONFIG') or 'default' 54 | app = Flask(__name__) 55 | app.config.from_object(config[config_name]) 56 | config[config_name].init_app(app) 57 | # 初始化Sqlarchemy 58 | db.app = app 59 | db.init_app(app) 60 | # 初始化Admin 61 | app.config['FLASK_ADMIN_SWATCH'] = 'cerulean' 62 | init_view() 63 | admin.init_app(app) 64 | # 初始化国际化 65 | babel.init_app(app) 66 | # 初始化 flask_apscheduler,将scheduler嵌入到flask管理,本地在flask_apscheduler插件中增加add_listener监听所有的job生命周期 67 | flask_apscheduler = CustomAPScheduler(db.session, app=app) 68 | # 启动apscheduler 69 | flask_apscheduler.start() 70 | # 注册蓝图 71 | # 增加中间件处理 72 | for middleware in init_middlewares(): 73 | app.before_request(middleware.process_request) 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .idea/ -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | db = SQLAlchemy() 4 | 5 | class ApschedulerJobInfo(db.Model): 6 | """ 7 | apscheduler job 定义表 8 | """ 9 | JOB_STATUS_MAPPING = { 10 | 0: "待执行", 11 | 1: "执行完成", 12 | 2: "执行异常", 13 | 3: "未执行结束", 14 | 4: "系统异常", 15 | 5: "已删除", 16 | 6: "批量删除" 17 | } 18 | 19 | # 应对历史的job名字映射 函数映射文字 20 | COMMON_JOB_NAME_MAPPING = { 21 | "end_before_2hours_notice": "拍卖结束前通知用户", 22 | "end_auction": "拍卖结束", 23 | "end_type1_auction": "直播拍卖结束", 24 | "auction_type1_start_product": "直播拍卖开始", 25 | "auction_preview_notify": "拍卖预展通知", 26 | "live_auction_start_notify": "拍卖开拍前提醒", 27 | "notify_ws": "拍卖开始", 28 | "heart_beat": "心跳任务", 29 | } 30 | 31 | __tablename__ = "apscheduler_job_info" 32 | id = db.Column(db.Integer, primary_key=True, comment="id 主键,用于防止JObID多次使用的情况") 33 | job_id = db.Column(db.String(200), nullable=False, comment="JOBID") 34 | job_name = db.Column(db.String(200), comment="JOB名字") 35 | job_trigger = db.Column(db.String(30), comment="触发类型") 36 | job_func = db.Column(db.String(200), comment="执行的函数信息") 37 | job_next_run_time = db.Column(db.String(30), comment="JOB下次执行时间") 38 | job_status = db.Column(db.Integer, nullable=False, comment="JOB 状态 0:待执行 1:执行完成 2:执行异常 3:未执行结束 4:系统异常 5:已删除 6:批量删除") 39 | job_traceback = db.Column(db.TEXT, comment="执行报错时的错误信息") 40 | create_time = db.Column(db.TIMESTAMP(True), nullable=False, comment="创建时间") 41 | update_time = db.Column(db.TIMESTAMP(True), nullable=False, comment="更新时间") 42 | 43 | def __repr__(self): 44 | return self.job_id 45 | 46 | 47 | class ApschedulerJobEventInfo(db.Model): 48 | """ 49 | apscheduler job 事件表 50 | """ 51 | EVENT_MAPPING = { 52 | 0: "添加JOB", 53 | 1: "修改JOB", 54 | 2: "提交JOB", 55 | 3: "执行JOB", 56 | 4: "删除JOB", 57 | 5: "执行JOB异常", 58 | 6: "执行JOB过期", 59 | 7: "全量删除JOB", 60 | 8: "JOB超过最大实例数" 61 | } 62 | 63 | __tablename__ = "apscheduler_job_event_info" 64 | id = db.Column(db.Integer, primary_key=True, comment="id 主键,用于防止JObID多次使用的情况") 65 | job_info_id = db.Column(db.Integer, db.ForeignKey('apscheduler_job_info.id'), comment="JOB_INFO_ID") 66 | job_info = db.relationship("ApschedulerJobInfo", backref='events') 67 | event = db.Column(db.Integer, 68 | comment="JOB事件 0:添加JOB 1:修改JOB 2:提交JOB 3:执行JOB 4:删除JOB 5:执行JOB异常 6:执行JOB过期 7:全量删除JOB 8:JOB超过最大实例数") 69 | create_time = db.Column(db.TIMESTAMP(True), nullable=False, comment="创建时间") 70 | 71 | def __repr__(self): 72 | return "<>".format(self.job_info.job_id, self.EVENT_MAPPING.get(self.event, self.event)) 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 背景 2 | APScheduler是一个非常好用的调度平台,不过目前所有Scheduler的JOB信息都无法通过可视化的方式展示,只能通过后台日志来查看调度信息,管理上非常不便。 3 | 4 | 但APScheduler扩展及预留非常多,其预留的event功能可以来实现job的生命周期跟踪。 5 | 6 | 另外APScheduler的Scheduler也可以实现动态的增删查job等操作,可以提前定义一些job,在web上快速方便的添加一些调度任务等,目前Flask-APScheduler库已经将apscheduler中的方法抽象成api接口,开启后直接可以使用 7 | 8 | # 目标 9 | - [x] 跟踪所有job状态及生命周期 10 | - [x] web页动态增删job调度 11 | 12 | # 部署 13 | ## 环境 14 | 15 | - python3.8+ 16 | ``` 17 | ubuntu 上要先安装python3.8 18 | ``` 19 | [可参考资料](https://www.jb51.net/article/182392.htm) 20 | 21 | - mysql 22 | 23 | ## virtualenv部署 24 | 25 | ``` 26 | 配套安装python3.8需要的虚拟环境创建工具 27 | sudo apt install python3.8-venv 28 | python3.8 -m venv <准备创建虚拟环境的路径> 29 | ``` 30 | 31 | 1. python3.8 -m venv venv 32 | 2. . venv/bin/activate 33 | 3. pip install -r requirements.txt 34 | 4. gunicorn -c etc/gunicorn.py manage:app [记得要配置数据库相关信息] 35 | 36 | ## docker部署 37 | 这里没有提供docker镜像,可直接使用Dockerfile从本地生成镜像即可 38 | - 生成镜像 39 | 40 | ```shell 41 | # 在当前目录执行以下命令 42 | docker build -t apscheduler:latest . 43 | ``` 44 | - 启动服务 45 | 46 | 生成镜像之后启动镜像即可 47 | ```shell 48 | docker run -p 10050:5000 -v "/etc/localtime:/etc/localtime" \ 49 | -e "DEV_DATABASE_URL=mysql+pymysql://root:realpw@127.0.0.1:3306/testing?charset=utf8mb4" \ 50 | -it -d --name apscheduler apscheduler 51 | ``` 52 | 53 | # 使用 54 | - 动态添加JOB执行 55 | 56 | 通过config中的参数SCHEDULER_API_ENABLED已经开启flask_apscheduler的api,所以可以直接使用,下面是具体的api信息 57 | ```python 58 | def _load_api(self): 59 | """ 60 | Add the routes for the scheduler API. 61 | """ 62 | self._add_url_route('get_scheduler_info', '', api.get_scheduler_info, 'GET') 63 | self._add_url_route('add_job', '/jobs', api.add_job, 'POST') 64 | self._add_url_route('get_job', '/jobs/', api.get_job, 'GET') 65 | self._add_url_route('get_jobs', '/jobs', api.get_jobs, 'GET') 66 | self._add_url_route('delete_job', '/jobs/', api.delete_job, 'DELETE') 67 | self._add_url_route('update_job', '/jobs/', api.update_job, 'PATCH') 68 | self._add_url_route('pause_job', '/jobs//pause', api.pause_job, 'POST') 69 | self._add_url_route('resume_job', '/jobs//resume', api.resume_job, 'POST') 70 | self._add_url_route('run_job', '/jobs//run', api.run_job, 'POST') 71 | ``` 72 | 直接动态调用接口添加, 具体的参数需要到apscheduler的源码进行查看,就是通过apscheduler add_job的那些参数 73 | ``` 74 | 请求添加接口:http://127.0.0.1:5000/scheduler/jobs 75 | 请求方法:POST 76 | 请求header: 77 | { 78 | "Content-Type": "application/json" 79 | } 80 | 请求body: 81 | { 82 | "id": "test_add_job", 83 | "name":"管理平台添加job测试", 84 | "func": "app:jobs.test.test_job", # 这里就是模块:函数,本地定义的方法保证可以import 85 | "trigger": "date" # 触发器为指定时间,这里时间没有指定,就是立马执行 86 | } 87 | 返回结果: 88 | { 89 | "id": "test_add_job", 90 | "name": "管理平台添加job测试", 91 | "func": "app:jobs.test.test_job", 92 | "args": [], 93 | "kwargs": {}, 94 | "trigger": "date", 95 | "run_date": "2021-03-05T15:17:10.107210+08:00", 96 | "misfire_grace_time": 1, 97 | "max_instances": 1, 98 | "next_run_time": "2021-03-05T15:17:10.107210+08:00" 99 | } 100 | ``` 101 | 102 | - 监控JOB状态 103 | 104 | ![](images/1.png) 105 | 106 | - 监控JOB执行事件 107 | 108 | ![](images/2.png) -------------------------------------------------------------------------------- /app/views/apscheduler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 3 | """ 4 | @author: mengx@funsun.cn 5 | @file: common.py.py 6 | @time: 2019/4/16 9:59 7 | """ 8 | import logging 9 | 10 | from flask_admin.contrib.sqla import ModelView 11 | from flask_admin.contrib.sqla.filters import FilterLike, FilterEqual 12 | 13 | from app.models import db, ApschedulerJobInfo, ApschedulerJobEventInfo 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class ApschedulerJobInfoView(ModelView): 19 | """ apscheduler 任务管理 """ 20 | extra_css = [] 21 | 22 | extra_js = [] 23 | 24 | list_template = 'apscheduler_job_info_list.html' 25 | 26 | # 创建窗口,不使用modal 27 | create_modal = False 28 | can_create = False 29 | # 编辑窗口,不使用modal 30 | edit_modal = False 31 | can_edit = False 32 | # 是否可以删除 33 | can_delete = False 34 | page_size = 20 35 | can_set_page_size = True 36 | # 是否可以看详情 37 | can_view_details = True 38 | 39 | # 配置列表中要展示的字段 40 | column_list = [ 41 | "id", "job_id", "job_name", "job_trigger", "job_next_run_time", "job_status", 42 | "events", "create_time", "update_time" 43 | ] 44 | 45 | # 配置各字段在页面中的中文显示 46 | column_labels = { 47 | "id": "id", 48 | "events": "执行事件", 49 | "job_id": "任务ID", 50 | "job_name": "任务描述", 51 | "job_trigger": "触发器", 52 | "job_func": "执行函数", 53 | "job_next_run_time": "执行时间", 54 | "job_status": "状态", 55 | "create_time": "创建时间", 56 | "update_time": "更新时间", 57 | "job_traceback": "执行异常信息" 58 | } 59 | # 初始化排序 60 | column_default_sort = [('id', True)] 61 | 62 | # 页面筛选 63 | column_filters = [ 64 | FilterLike(column=ApschedulerJobInfo.job_name, name='任务描述'), 65 | FilterEqual(column=ApschedulerJobInfo.job_id, name='任务ID'), 66 | ] 67 | 68 | # 页面中可排序的字段 69 | column_sortable_list = ["id", "update_time"] 70 | 71 | column_formatters = dict( 72 | job_status=lambda v, c, m, p: m.JOB_STATUS_MAPPING.get(m.job_status, "错误数据"), 73 | job_name=lambda v, c, m, p: m.COMMON_JOB_NAME_MAPPING.get(m.job_name, m.job_name), 74 | events=lambda v, c, m, p: len(m.events), 75 | ) 76 | 77 | # 详细中的展示 78 | column_details_list = ["id", "job_id", "job_name", "job_trigger", "job_func", "job_traceback"] 79 | 80 | 81 | class ApschedulerJobEventInfoView(ModelView): 82 | """ apscheduler 任务管理 """ 83 | extra_css = [ 84 | ] 85 | 86 | extra_js = [] 87 | 88 | # 创建窗口,不使用modal 89 | create_modal = False 90 | can_create = False 91 | # 编辑窗口,不使用modal 92 | edit_modal = False 93 | can_edit = False 94 | # 是否可以删除 95 | can_delete = False 96 | page_size = 20 97 | can_set_page_size = True 98 | 99 | # 配置列表中要展示的字段 100 | column_list = ["id", "job_info.job_id", "event", "create_time"] 101 | 102 | # 配置各字段在页面中的中文显示 103 | column_labels = { 104 | "id": "ID", 105 | "event": "执行事件", 106 | "job_info.job_id": "任务ID", 107 | "job_info.job_name": "任务描述", 108 | "create_time": "执行时间" 109 | } 110 | # 初始化排序 111 | column_default_sort = [('id', True)] 112 | 113 | # 页面筛选 114 | column_filters = [ 115 | FilterEqual(column=ApschedulerJobInfo.id, name='任务本地ID'), 116 | FilterEqual(column=ApschedulerJobInfo.job_id, name='任务ID'), 117 | ] 118 | 119 | # 页面中可排序的字段 120 | column_sortable_list = ["id"] 121 | 122 | column_formatters = dict( 123 | event=lambda v, c, m, p: m.EVENT_MAPPING.get(m.event, m.event), 124 | ) 125 | 126 | 127 | views = [ 128 | ApschedulerJobInfoView(ApschedulerJobInfo, db.session, name="任务管理", category="apscheduler管理", 129 | endpoint="apscheduler_job_manage"), 130 | ApschedulerJobEventInfoView(ApschedulerJobEventInfo, db.session, name="任务执行明细", category="apscheduler管理", 131 | endpoint="apscheduler_job_event_manage") 132 | ] 133 | -------------------------------------------------------------------------------- /app/common/flask_apscheduler.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_MISSED, EVENT_JOB_MAX_INSTANCES, EVENT_ALL_JOBS_REMOVED, \ 4 | EVENT_JOB_ADDED, EVENT_JOB_REMOVED, EVENT_JOB_MODIFIED, EVENT_JOB_EXECUTED, EVENT_JOB_SUBMITTED 5 | from flask_apscheduler.scheduler import APScheduler, LOGGER 6 | 7 | from app.models import ApschedulerJobInfo, ApschedulerJobEventInfo 8 | 9 | 10 | class CustomAPScheduler(APScheduler): 11 | # scheduler事件映射本地状态 12 | STATUS_MAPPING = { 13 | EVENT_JOB_ADDED: 0, 14 | EVENT_JOB_MODIFIED: 1, 15 | EVENT_JOB_SUBMITTED: 2, 16 | EVENT_JOB_EXECUTED: 3, 17 | EVENT_JOB_REMOVED: 4, 18 | EVENT_JOB_ERROR: 5, 19 | EVENT_JOB_MISSED: 6, 20 | EVENT_ALL_JOBS_REMOVED: 7, 21 | EVENT_JOB_MAX_INSTANCES: 8 22 | } 23 | 24 | def __init__(self, session, scheduler=None, app=None): 25 | super(CustomAPScheduler, self).__init__(scheduler, app) 26 | self.session = session 27 | 28 | def listener_all_job(self, event): 29 | """ 30 | 监控job的生命周期,可视化监控,并且可增加后续的没有触发任务等监控 31 | 添加到线程做处理 32 | :param event: 33 | :return: 34 | """ 35 | job_id = None 36 | args = [] 37 | if event.code != EVENT_ALL_JOBS_REMOVED: 38 | job_id = event.job_id 39 | if job_id: 40 | jobstore_alias = event.jobstore 41 | job = self.scheduler.get_job(job_id, jobstore_alias) 42 | if job: 43 | name = job.name 44 | func = str(job.func_ref) 45 | trigger = job.trigger if isinstance(job.trigger, str) else str(job.trigger).split("[")[0] 46 | next_run_time = str(job.next_run_time).split(".")[0] 47 | else: 48 | name = None 49 | func = None 50 | trigger = None 51 | next_run_time = None 52 | args = [name, func, trigger, next_run_time] 53 | traceback = event.traceback if hasattr(event, 'traceback') else "", 54 | args.append(traceback) 55 | t = threading.Thread(target=self.handle_listener_all_job, args=[event.code, job_id, *args]) 56 | t.start() 57 | t.join() 58 | 59 | def handle_listener_all_job(self, event_type, *args): 60 | """ 61 | 实际处理IO操作 62 | 如何处理一个job_id重复使用的问题,采用本地id自增,如果真有job_id重复的情况,则认为指定的是最后一个job_id对应的任务 63 | """ 64 | try: 65 | if event_type == EVENT_JOB_ADDED: 66 | # 添加任务定义表 67 | job = ApschedulerJobInfo() 68 | job.job_id = args[0] 69 | job.job_name = args[1] 70 | job.job_func = args[2] 71 | job.job_trigger = args[3] 72 | job.job_next_run_time = args[4] 73 | job.job_status = 0 74 | self.session.add(job) 75 | self.session.flush() 76 | # 增加任务事件表 77 | job_event = ApschedulerJobEventInfo() 78 | job_event.job_info_id = job.id 79 | job_event.event = self.STATUS_MAPPING[event_type] 80 | self.session.add(job_event) 81 | self.session.commit() 82 | elif event_type == EVENT_JOB_MODIFIED: 83 | # 修改job[取数据库表中job_id最后一个进行修改] 84 | job = ApschedulerJobInfo.query.order_by(ApschedulerJobInfo.id.desc()).filter( 85 | ApschedulerJobInfo.job_id == args[0]).first() 86 | if job: 87 | # 更新JOB表 88 | job.job_name = args[1] 89 | job.job_func = args[2] 90 | job.job_trigger = args[3] 91 | job.job_next_run_time = args[4] 92 | job.job_status = 0 93 | 94 | # 增加任务事件表 95 | job_event = ApschedulerJobEventInfo() 96 | job_event.job_info_id = job.id 97 | job_event.event = self.STATUS_MAPPING[event_type] 98 | self.session.add(job_event) 99 | self.session.commit() 100 | else: 101 | LOGGER.warning("指定的job本地不存在{}".format(args)) 102 | elif event_type == EVENT_JOB_SUBMITTED: 103 | # 提交job执行 104 | job = ApschedulerJobInfo.query.order_by(ApschedulerJobInfo.id.desc()).filter( 105 | ApschedulerJobInfo.job_id == args[0]).first() 106 | if job: 107 | # 增加任务事件表 108 | job_event = ApschedulerJobEventInfo() 109 | job_event.job_info_id = job.id 110 | job_event.event = self.STATUS_MAPPING[event_type] 111 | self.session.add(job_event) 112 | self.session.commit() 113 | else: 114 | LOGGER.warning("指定的job本地不存在{}".format(args)) 115 | elif event_type == EVENT_JOB_EXECUTED: 116 | # 执行job 117 | job = ApschedulerJobInfo.query.order_by(ApschedulerJobInfo.id.desc()).filter( 118 | ApschedulerJobInfo.job_id == args[0]).first() 119 | if job: 120 | # 更新JOB表 121 | job.job_status = 1 122 | 123 | # 增加任务事件表 124 | job_event = ApschedulerJobEventInfo() 125 | job_event.job_info_id = job.id 126 | job_event.event = self.STATUS_MAPPING[event_type] 127 | self.session.add(job_event) 128 | self.session.commit() 129 | else: 130 | LOGGER.warning("指定的job本地不存在{}".format(args)) 131 | elif event_type == EVENT_JOB_REMOVED: 132 | # 删除job 133 | job = ApschedulerJobInfo.query.order_by(ApschedulerJobInfo.id.desc()).filter( 134 | ApschedulerJobInfo.job_id == args[0]).first() 135 | if job: 136 | # 更新JOB表 137 | job.job_status = 5 138 | 139 | # 增加任务事件表 140 | job_event = ApschedulerJobEventInfo() 141 | job_event.job_info_id = job.id 142 | job_event.event = self.STATUS_MAPPING[event_type] 143 | self.session.add(job_event) 144 | self.session.commit() 145 | else: 146 | LOGGER.warning("指定的job本地不存在{}".format(args)) 147 | elif event_type == EVENT_JOB_ERROR: 148 | # 执行job出错 149 | job = ApschedulerJobInfo.query.order_by(ApschedulerJobInfo.id.desc()).filter( 150 | ApschedulerJobInfo.job_id == args[0]).first() 151 | if job: 152 | # 更新JOB表 153 | job.job_status = 2 154 | job.job_traceback = args[5] 155 | # 增加任务事件表 156 | job_event = ApschedulerJobEventInfo() 157 | job_event.job_info_id = job.id 158 | job_event.event = self.STATUS_MAPPING[event_type] 159 | self.session.add(job_event) 160 | self.session.commit() 161 | else: 162 | LOGGER.warning("指定的job本地不存在{}".format(args)) 163 | elif event_type == EVENT_JOB_MISSED: 164 | # job执行错过 165 | job = ApschedulerJobInfo.query.order_by(ApschedulerJobInfo.id.desc()).filter( 166 | ApschedulerJobInfo.job_id == args[0]).first() 167 | if job: 168 | # 更新JOB表 169 | job.job_status = 3 170 | job.job_traceback = args[5] 171 | # 增加任务事件表 172 | job_event = ApschedulerJobEventInfo() 173 | job_event.job_info_id = job.id 174 | job_event.event = self.STATUS_MAPPING[event_type] 175 | self.session.add(job_event) 176 | self.session.commit() 177 | else: 178 | LOGGER.warning("指定的job本地不存在{}".format(args)) 179 | elif event_type == EVENT_ALL_JOBS_REMOVED: 180 | # 删除所有job 181 | all_jobs = ApschedulerJobInfo.query.filter(ApschedulerJobInfo.job_status == 0).all() 182 | for job in all_jobs: 183 | job.job_status = 6 184 | # 增加任务事件表 185 | job_event = ApschedulerJobEventInfo() 186 | job_event.job_info_id = job.id 187 | job_event.event = self.STATUS_MAPPING[event_type] 188 | self.session.add(job_event) 189 | self.session.commit() 190 | elif event_type == EVENT_JOB_MAX_INSTANCES: 191 | # job超过最大实例 192 | job = ApschedulerJobInfo.query.order_by(ApschedulerJobInfo.id.desc()).filter( 193 | ApschedulerJobInfo.job_id == args[0]).first() 194 | if job: 195 | # 更新JOB表 196 | job.job_status = 4 197 | job.job_traceback = args[5] 198 | # 增加任务事件表 199 | job_event = ApschedulerJobEventInfo() 200 | job_event.job_info_id = job.id 201 | job_event.event = self.STATUS_MAPPING[event_type] 202 | self.session.add(job_event) 203 | self.session.commit() 204 | else: 205 | LOGGER.warning("指定的job本地不存在{}".format(args)) 206 | except: 207 | LOGGER.exception("执行任务异常") 208 | 209 | def init_app(self, app): 210 | super(CustomAPScheduler, self).init_app(app) 211 | 212 | # 增加监听函数,监听所有job的生命周期 213 | self.add_listener(self.listener_all_job, 214 | EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_JOB_MAX_INSTANCES | EVENT_ALL_JOBS_REMOVED | EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED | EVENT_JOB_SUBMITTED) 215 | -------------------------------------------------------------------------------- /app/templates/apscheduler_job_info_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | {% import 'admin/lib.html' as lib with context %} 3 | {% import 'admin/static.html' as admin_static with context %} 4 | {% import 'admin/model/layout.html' as model_layout with context %} 5 | {% import 'admin/actions.html' as actionlib with context %} 6 | {% import 'admin/model/row_actions.html' as row_actions with context %} 7 | 8 | {% block head %} 9 | {{ super() }} 10 | {{ lib.form_css() }} 11 | {% endblock %} 12 | 13 | {% block body %} 14 | {% block model_menu_bar %} 15 | 63 | {% endblock %} 64 | 65 | {% if filters %} 66 | {{ model_layout.filter_form() }} 67 |
68 | {% endif %} 69 | 70 | {% block model_list_table %} 71 |
72 | 73 | 74 | 75 | {% block list_header scoped %} 76 | {% if actions %} 77 | 81 | {% endif %} 82 | {% block list_row_actions_header %} 83 | {% if admin_view.column_display_actions %} 84 | 85 | {% endif %} 86 | {% endblock %} 87 | {% for c, name in list_columns %} 88 | {% set column = loop.index0 %} 89 | {% set set_style = "" %} 90 | {% if c == 'content' %} 91 | {% set set_style = "width: 30%" %} 92 | {% endif %} 93 | 119 | {% endfor %} 120 | {% endblock %} 121 | 122 | 123 | {% for row in data %} 124 | 125 | {% block list_row scoped %} 126 | {% if actions %} 127 | 132 | {% endif %} 133 | {% block list_row_actions_column scoped %} 134 | {% if admin_view.column_display_actions %} 135 | 142 | {%- endif -%} 143 | {% endblock %} 144 | 145 | {% for c, name in list_columns %} 146 | 163 | {% endfor %} 164 | {% endblock %} 165 | 166 | {% else %} 167 | 168 | 175 | 176 | {% endfor %} 177 |
78 | 80 |   94 | {% if admin_view.is_sortable(c) %} 95 | {% if sort_column == column %} 96 | 98 | {{ name }} 99 | {% if sort_desc %} 100 | 101 | {% else %} 102 | 103 | {% endif %} 104 | 105 | {% else %} 106 | {{ name }} 108 | {% endif %} 109 | {% else %} 110 | {{ name }} 111 | {% endif %} 112 | {% if admin_view.column_descriptions.get(c) %} 113 | 117 | {% endif %} 118 |
128 | 131 | 136 | {% block list_row_actions scoped %} 137 | {% for action in list_row_actions %} 138 | {{ action.render_ctx(get_pk_value(row), row) }} 139 | {% endfor %} 140 | {% endblock %} 141 | 147 | {% if admin_view.is_editable(c) %} 148 | {% set form = list_forms[get_pk_value(row)] %} 149 | {% if form.csrf_token %} 150 | {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=form.csrf_token._value()) }} 151 | {% else %} 152 | {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c)) }} 153 | {% endif %} 154 | {% else %} 155 | {% if c == 'events' %} 156 | {{ get_value(row, c) }} 158 | {% else %} 159 | {{ get_value(row, c) }} 160 | {% endif %} 161 | {% endif %} 162 |
169 | {% block empty_list_message %} 170 |
171 | {{ admin_view.get_empty_list_message() }} 172 |
173 | {% endblock %} 174 |
178 |
179 | {% block list_pager %} 180 | {% if num_pages is not none %} 181 | {{ lib.pager(page, num_pages, pager_url) }} 182 | {% else %} 183 | {{ lib.simple_pager(page, data|length == page_size, pager_url) }} 184 | {% endif %} 185 | {% endblock %} 186 | {% endblock %} 187 | 188 | {% block actions %} 189 | {{ actionlib.form(actions, get_url('.action_view')) }} 190 | {% endblock %} 191 | 192 | {%- if admin_view.edit_modal or admin_view.create_modal or admin_view.details_modal -%} 193 | {{ lib.add_modal_window() }} 194 | {%- endif -%} 195 | {% endblock %} 196 | 197 | {% block tail %} 198 | {{ super() }} 199 | 200 | {% if filter_groups %} 201 | 202 | 203 | {% endif %} 204 | 205 | {{ lib.form_js() }} 206 | 207 | 208 | 216 | {{ actionlib.script(_gettext('Please select at least one record.'), 217 | actions, 218 | actions_confirmation) }} 219 | {% endblock %} 220 | --------------------------------------------------------------------------------