init
This commit is contained in:
parent
6310ef2d32
commit
4edb0fa93a
4
.env
Normal file
4
.env
Normal file
@ -0,0 +1,4 @@
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=change-this-secret
|
||||
DATABASE_URL=mysql+pymysql://api_schedule:22CKSd22NFekncDc@dify.pinnovatecloud.com:3306/api_schedule
|
||||
APP_TIMEZONE=UTC
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
app/__pycache__/*.pyc
|
||||
*.pyc
|
||||
108
app/__init__.py
Normal file
108
app/__init__.py
Normal file
@ -0,0 +1,108 @@
|
||||
import logging
|
||||
import os
|
||||
from flask import Flask, redirect, url_for
|
||||
from flask_migrate import Migrate
|
||||
|
||||
from app.config import DevelopmentConfig, ProductionConfig
|
||||
from app.extensions import db, login_manager, scheduler
|
||||
from app.services.scheduler import SchedulerService
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""
|
||||
Application factory creating the Flask app, loading config, registering blueprints,
|
||||
initializing extensions, and booting the scheduler.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
config_name = os.getenv("FLASK_ENV", "development").lower()
|
||||
if config_name == "production":
|
||||
app_config = ProductionConfig()
|
||||
else:
|
||||
app_config = DevelopmentConfig()
|
||||
app.config.from_object(app_config)
|
||||
|
||||
configure_logging(app)
|
||||
register_extensions(app)
|
||||
register_blueprints(app)
|
||||
register_template_filters(app)
|
||||
enable_scheduler = app.config.get("ENABLE_SCHEDULER", True) and os.getenv("FLASK_SKIP_SCHEDULER") != "1"
|
||||
if enable_scheduler:
|
||||
init_scheduler(app)
|
||||
else:
|
||||
app.logger.info("Scheduler not started (ENABLE_SCHEDULER=%s, FLASK_SKIP_SCHEDULER=%s)",
|
||||
app.config.get("ENABLE_SCHEDULER", True), os.getenv("FLASK_SKIP_SCHEDULER"))
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return redirect(url_for("apis.list_apis"))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def configure_logging(app: Flask) -> None:
|
||||
log_level = logging.DEBUG if app.debug else logging.INFO
|
||||
logging.basicConfig(level=log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
|
||||
|
||||
def register_extensions(app: Flask) -> None:
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
Migrate(app, db)
|
||||
scheduler.init_app(app)
|
||||
|
||||
|
||||
def register_blueprints(app: Flask) -> None:
|
||||
from app.views.auth import auth_bp
|
||||
from app.views.apis import apis_bp
|
||||
from app.views.logs import logs_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(apis_bp)
|
||||
app.register_blueprint(logs_bp)
|
||||
|
||||
|
||||
def register_template_filters(app: Flask) -> None:
|
||||
@app.template_filter("cron_human")
|
||||
def cron_human(expr: str) -> str:
|
||||
"""
|
||||
将常见的 5 字段 cron 表达式转换为简单中文描述,不能完全覆盖所有情况。
|
||||
"""
|
||||
parts = expr.strip().split()
|
||||
if len(parts) != 5:
|
||||
return expr
|
||||
minute, hour, day, month, dow = parts
|
||||
|
||||
# 每 N 分钟
|
||||
if minute.startswith("*/") and hour == "*" and day == "*" and month == "*" and dow == "*":
|
||||
return f"每 {minute[2:]} 分钟"
|
||||
|
||||
# 整点或固定时间
|
||||
if minute.isdigit() and hour.isdigit() and day == "*" and month == "*" and dow in ("*", "?"):
|
||||
return f"每天 {hour.zfill(2)}:{minute.zfill(2)}"
|
||||
|
||||
# 每 N 小时的整点
|
||||
if minute == "0" and hour.startswith("*/") and day == "*" and month == "*" and dow in ("*", "?"):
|
||||
return f"每 {hour[2:]} 小时整点"
|
||||
|
||||
# 每月某日
|
||||
if minute.isdigit() and hour.isdigit() and day.isdigit() and month == "*" and dow in ("*", "?"):
|
||||
return f"每月 {day} 日 {hour.zfill(2)}:{minute.zfill(2)}"
|
||||
|
||||
# 每周某天
|
||||
weekday_map = {"0": "周日", "1": "周一", "2": "周二", "3": "周三", "4": "周四", "5": "周五", "6": "周六", "7": "周日"}
|
||||
if minute.isdigit() and hour.isdigit() and day in ("*", "?") and month == "*" and dow not in ("*", "?"):
|
||||
label = weekday_map.get(dow, f"周{dow}")
|
||||
return f"每{label} {hour.zfill(2)}:{minute.zfill(2)}"
|
||||
|
||||
return expr
|
||||
|
||||
|
||||
def init_scheduler(app: Flask) -> None:
|
||||
"""
|
||||
Start APScheduler and load enabled jobs from database.
|
||||
"""
|
||||
scheduler.start()
|
||||
with app.app_context():
|
||||
service = SchedulerService(scheduler)
|
||||
service.load_enabled_jobs()
|
||||
30
app/config.py
Normal file
30
app/config.py
Normal file
@ -0,0 +1,30 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class BaseConfig:
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-me")
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"mysql+pymysql://user:password@localhost:3306/api_scheduler",
|
||||
)
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
# 防止长时间空闲导致连接断开
|
||||
"pool_pre_ping": True,
|
||||
"pool_recycle": 300,
|
||||
"pool_timeout": 30,
|
||||
}
|
||||
REMEMBER_COOKIE_DURATION = timedelta(days=7)
|
||||
SCHEDULER_API_ENABLED = False
|
||||
# 默认采用中国标准时间,可通过环境变量 APP_TIMEZONE 覆盖
|
||||
SCHEDULER_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai")
|
||||
ENABLE_SCHEDULER = True
|
||||
|
||||
|
||||
class DevelopmentConfig(BaseConfig):
|
||||
DEBUG = True
|
||||
|
||||
|
||||
class ProductionConfig(BaseConfig):
|
||||
DEBUG = False
|
||||
11
app/extensions.py
Normal file
11
app/extensions.py
Normal file
@ -0,0 +1,11 @@
|
||||
from flask_apscheduler import APScheduler
|
||||
from flask_login import LoginManager
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = "auth.login"
|
||||
login_manager.login_message = "请先登录后再访问该页面"
|
||||
|
||||
# 通过 Flask-APScheduler 集成 APScheduler,方便在应用上下文中管理后台任务
|
||||
scheduler = APScheduler()
|
||||
65
app/forms.py
Normal file
65
app/forms.py
Normal file
@ -0,0 +1,65 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
BooleanField,
|
||||
IntegerField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
StringField,
|
||||
SubmitField,
|
||||
TextAreaField,
|
||||
)
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional as OptionalValidator
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField("用户名", validators=[DataRequired(), Length(max=64)])
|
||||
password = PasswordField("密码", validators=[DataRequired()])
|
||||
submit = SubmitField("登录")
|
||||
|
||||
|
||||
class ApiConfigForm(FlaskForm):
|
||||
name = StringField("名称", validators=[DataRequired(), Length(max=128)])
|
||||
description = TextAreaField("描述", validators=[OptionalValidator()])
|
||||
url = StringField("URL 地址", validators=[DataRequired(), Length(max=512)])
|
||||
http_method = SelectField(
|
||||
"HTTP 方法",
|
||||
choices=[("GET", "GET"), ("POST", "POST"), ("PUT", "PUT"), ("DELETE", "DELETE")],
|
||||
validators=[DataRequired()],
|
||||
)
|
||||
headers = TextAreaField("请求头(JSON)", validators=[OptionalValidator()])
|
||||
query_params = TextAreaField("查询参数(JSON)", validators=[OptionalValidator()])
|
||||
body = TextAreaField("请求体(JSON 或文本)", validators=[OptionalValidator()])
|
||||
schedule_type = SelectField(
|
||||
"调度类型",
|
||||
choices=[("cron", "cron"), ("interval", "interval"), ("daily", "daily")],
|
||||
validators=[DataRequired()],
|
||||
)
|
||||
schedule_expression = StringField("调度表达式", validators=[DataRequired(), Length(max=128)])
|
||||
timeout_seconds = IntegerField("超时时间(秒)", validators=[DataRequired(), NumberRange(min=1, max=600)])
|
||||
retry_times = IntegerField("重试次数", validators=[DataRequired(), NumberRange(min=0, max=10)])
|
||||
retry_interval_seconds = IntegerField(
|
||||
"重试间隔(秒)", validators=[DataRequired(), NumberRange(min=1, max=300)]
|
||||
)
|
||||
enabled = BooleanField("启用", default=True)
|
||||
submit = SubmitField("保存")
|
||||
|
||||
|
||||
class LogFilterForm(FlaskForm):
|
||||
api_id = SelectField("API", coerce=int, validators=[OptionalValidator()])
|
||||
success = SelectField(
|
||||
"成功状态", choices=[("", "全部"), ("1", "成功"), ("0", "失败")], validators=[OptionalValidator()]
|
||||
)
|
||||
start_date = StringField("开始日期(YYYY-MM-DD)", validators=[OptionalValidator()])
|
||||
end_date = StringField("结束日期(YYYY-MM-DD)", validators=[OptionalValidator()])
|
||||
submit = SubmitField("筛选")
|
||||
|
||||
def parse_date(self, value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
82
app/models.py
Normal file
82
app/models.py
Normal file
@ -0,0 +1,82 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from app.extensions import db, login_manager
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class User(UserMixin, TimestampMixin, db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
email = db.Column(db.String(128))
|
||||
role = db.Column(db.String(32))
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool: # type: ignore[override]
|
||||
return True
|
||||
|
||||
def get_id(self) -> str: # type: ignore[override]
|
||||
return str(self.id)
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id: str) -> Any:
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
|
||||
class ApiConfig(TimestampMixin, db.Model):
|
||||
__tablename__ = "api_configs"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
url = db.Column(db.String(512), nullable=False)
|
||||
http_method = db.Column(db.String(10), nullable=False, default="GET")
|
||||
headers = db.Column(db.Text)
|
||||
query_params = db.Column(db.Text)
|
||||
body = db.Column(db.Text)
|
||||
enabled = db.Column(db.Boolean, default=True)
|
||||
schedule_type = db.Column(db.String(32), nullable=False, default="cron")
|
||||
schedule_expression = db.Column(db.String(128), nullable=False, default="0 10 * * *")
|
||||
timeout_seconds = db.Column(db.Integer, default=30)
|
||||
retry_times = db.Column(db.Integer, default=0)
|
||||
retry_interval_seconds = db.Column(db.Integer, default=3)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey("users.id"))
|
||||
|
||||
creator = db.relationship("User", backref="api_configs")
|
||||
|
||||
def job_id(self) -> str:
|
||||
return f"api_job_{self.id}"
|
||||
|
||||
|
||||
class ApiCallLog(TimestampMixin, db.Model):
|
||||
__tablename__ = "api_call_logs"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
api_id = db.Column(db.Integer, db.ForeignKey("api_configs.id"), nullable=False)
|
||||
request_time = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
response_time = db.Column(db.DateTime)
|
||||
success = db.Column(db.Boolean, default=False)
|
||||
http_status_code = db.Column(db.Integer)
|
||||
error_message = db.Column(db.Text)
|
||||
response_body = db.Column(db.Text)
|
||||
duration_ms = db.Column(db.Integer)
|
||||
|
||||
api = db.relationship("ApiConfig", backref="call_logs")
|
||||
105
app/services/api_executor.py
Normal file
105
app/services/api_executor.py
Normal file
@ -0,0 +1,105 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
from requests import Response
|
||||
|
||||
from app.extensions import db
|
||||
from flask import current_app
|
||||
|
||||
from app.models import ApiCallLog, ApiConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_json_field(raw: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def execute_api(api_config: ApiConfig) -> None:
|
||||
"""
|
||||
Execute an API call based on ApiConfig, apply retry logic, and persist ApiCallLog.
|
||||
Only the final attempt is logged to avoid noise.
|
||||
"""
|
||||
request_time = datetime.utcnow()
|
||||
headers = _parse_json_field(api_config.headers) or {}
|
||||
params = _parse_json_field(api_config.query_params) or {}
|
||||
data = api_config.body
|
||||
try:
|
||||
json_body = json.loads(api_config.body) if api_config.body else None
|
||||
except json.JSONDecodeError:
|
||||
json_body = None
|
||||
|
||||
retries = max(api_config.retry_times, 0)
|
||||
delay = max(api_config.retry_interval_seconds, 1)
|
||||
timeout = max(api_config.timeout_seconds, 1)
|
||||
attempt = 0
|
||||
last_response: Optional[Response] = None
|
||||
last_error: Optional[str] = None
|
||||
start_ts = time.time()
|
||||
|
||||
while attempt <= retries:
|
||||
try:
|
||||
attempt += 1
|
||||
last_response = requests.request(
|
||||
method=api_config.http_method,
|
||||
url=api_config.url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=None if json_body is not None else data,
|
||||
json=json_body,
|
||||
timeout=timeout,
|
||||
)
|
||||
if 200 <= last_response.status_code < 300:
|
||||
last_error = None
|
||||
break
|
||||
last_error = f"Non-2xx status: {last_response.status_code}"
|
||||
except requests.RequestException as exc:
|
||||
last_response = None
|
||||
last_error = str(exc)
|
||||
if attempt <= retries:
|
||||
time.sleep(delay)
|
||||
|
||||
duration_ms = int((time.time() - start_ts) * 1000)
|
||||
success = last_error is None
|
||||
|
||||
log_entry = ApiCallLog(
|
||||
api_id=api_config.id,
|
||||
request_time=request_time,
|
||||
response_time=datetime.utcnow(),
|
||||
success=success,
|
||||
http_status_code=last_response.status_code if last_response else None,
|
||||
error_message=last_error,
|
||||
response_body=(last_response.text[:2000] if last_response and last_response.text else None),
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
db.session.add(log_entry)
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to persist ApiCallLog for api_id=%s", api_config.id)
|
||||
db.session.rollback()
|
||||
|
||||
if not success:
|
||||
logger.warning("API call failed for api_id=%s error=%s", api_config.id, last_error)
|
||||
|
||||
|
||||
def execute_api_by_id(api_id: int, app=None) -> None:
|
||||
"""
|
||||
Load ApiConfig by id and execute within app context (for scheduler threads).
|
||||
"""
|
||||
app_obj = app or current_app._get_current_object()
|
||||
with app_obj.app_context():
|
||||
config = ApiConfig.query.get(api_id)
|
||||
if not config:
|
||||
logger.error("ApiConfig not found for id=%s", api_id)
|
||||
return
|
||||
execute_api(config)
|
||||
94
app/services/scheduler.py
Normal file
94
app/services/scheduler.py
Normal file
@ -0,0 +1,94 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from flask import current_app
|
||||
from flask_apscheduler import APScheduler
|
||||
|
||||
from app.models import ApiConfig
|
||||
from flask import current_app
|
||||
|
||||
from app.services.api_executor import execute_api_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
"""
|
||||
Thin wrapper around APScheduler for adding/removing API jobs.
|
||||
"""
|
||||
|
||||
def __init__(self, scheduler: APScheduler) -> None:
|
||||
self.scheduler = scheduler
|
||||
|
||||
def load_enabled_jobs(self) -> None:
|
||||
try:
|
||||
enabled_configs = ApiConfig.query.filter_by(enabled=True).all()
|
||||
except Exception:
|
||||
logger.warning("Could not load jobs; database might not be initialized yet.")
|
||||
return
|
||||
for config in enabled_configs:
|
||||
self.add_job_for_api(config)
|
||||
logger.info("Scheduler loaded %s enabled jobs", len(enabled_configs))
|
||||
|
||||
def add_job_for_api(self, api_config: ApiConfig) -> None:
|
||||
job_id = api_config.job_id()
|
||||
trigger = self._build_trigger(api_config)
|
||||
if not trigger:
|
||||
logger.error("Unsupported schedule type for api_id=%s", api_config.id)
|
||||
return
|
||||
self.remove_job_for_api(api_config.id)
|
||||
app_obj = current_app._get_current_object()
|
||||
self.scheduler.add_job(
|
||||
func=execute_api_by_id,
|
||||
trigger=trigger,
|
||||
args=[api_config.id, app_obj],
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
misfire_grace_time=current_app.config.get("SCHEDULER_MISFIRE_GRACE_TIME", 60),
|
||||
)
|
||||
logger.info("Registered job %s for api_id=%s", job_id, api_config.id)
|
||||
|
||||
def remove_job_for_api(self, api_id: int) -> None:
|
||||
job_id = f"api_job_{api_id}"
|
||||
try:
|
||||
self.scheduler.remove_job(job_id)
|
||||
logger.info("Removed job %s", job_id)
|
||||
except Exception:
|
||||
# 如果任务不存在则静默忽略
|
||||
pass
|
||||
|
||||
def reschedule_job_for_api(self, api_config: ApiConfig) -> None:
|
||||
self.add_job_for_api(api_config)
|
||||
|
||||
def _build_trigger(self, api_config: ApiConfig):
|
||||
schedule_type = api_config.schedule_type
|
||||
expr = api_config.schedule_expression
|
||||
if schedule_type == "cron":
|
||||
try:
|
||||
fields = expr.split()
|
||||
if len(fields) == 5:
|
||||
minute, hour, day, month, day_of_week = fields
|
||||
return CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week)
|
||||
return CronTrigger.from_crontab(expr)
|
||||
except Exception as exc:
|
||||
logger.error("Invalid cron expression for api_id=%s error=%s", api_config.id, exc)
|
||||
return None
|
||||
if schedule_type == "interval":
|
||||
try:
|
||||
seconds = int(expr)
|
||||
return IntervalTrigger(seconds=seconds)
|
||||
except ValueError:
|
||||
logger.error("Invalid interval seconds for api_id=%s", api_config.id)
|
||||
return None
|
||||
if schedule_type == "daily":
|
||||
# 期待格式为 "HH:MM"
|
||||
try:
|
||||
hour, minute = expr.split(":")
|
||||
return CronTrigger(hour=int(hour), minute=int(minute))
|
||||
except Exception as exc:
|
||||
logger.error("Invalid daily expression for api_id=%s error=%s", api_config.id, exc)
|
||||
return None
|
||||
return None
|
||||
69
app/templates/apis/form.html
Normal file
69
app/templates/apis/form.html
Normal file
@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '编辑' if api else '新建' }} API{% endblock %}
|
||||
{% block content %}
|
||||
<h3 class="mb-3">{{ '编辑' if api else '新建' }} API</h3>
|
||||
<form method="post">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.name.label(class="form-label") }}
|
||||
{{ form.name(class="form-control") }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.url.label(class="form-label") }}
|
||||
{{ form.url(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.description.label(class="form-label") }}
|
||||
{{ form.description(class="form-control", rows="2") }}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
{{ form.http_method.label(class="form-label") }}
|
||||
{{ form.http_method(class="form-select") }}
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
{{ form.schedule_type.label(class="form-label") }}
|
||||
{{ form.schedule_type(class="form-select") }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.schedule_expression.label(class="form-label") }}
|
||||
{{ form.schedule_expression(class="form-control") }}
|
||||
<small class="text-muted">Cron: "0 10 * * *",Interval:秒数,Daily:"HH:MM"</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.timeout_seconds.label(class="form-label") }}
|
||||
{{ form.timeout_seconds(class="form-control") }}
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.retry_times.label(class="form-label") }}
|
||||
{{ form.retry_times(class="form-control") }}
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.retry_interval_seconds.label(class="form-label") }}
|
||||
{{ form.retry_interval_seconds(class="form-control") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.headers.label(class="form-label") }}
|
||||
{{ form.headers(class="form-control", rows="2") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.query_params.label(class="form-label") }}
|
||||
{{ form.query_params(class="form-control", rows="2") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.body.label(class="form-label") }}
|
||||
{{ form.body(class="form-control", rows="4") }}
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
{{ form.enabled(class="form-check-input") }}
|
||||
{{ form.enabled.label(class="form-check-label") }}
|
||||
</div>
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
<a class="btn btn-secondary" href="{{ url_for('apis.list_apis') }}">取消</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
71
app/templates/apis/list.html
Normal file
71
app/templates/apis/list.html
Normal file
@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}API 列表{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3>API 配置列表</h3>
|
||||
<a class="btn btn-primary" href="{{ url_for('apis.create_api') }}">新建 API</a>
|
||||
</div>
|
||||
<form class="row g-2 mb-3" method="get">
|
||||
<div class="col-auto">
|
||||
<input type="text" class="form-control" name="name" placeholder="按名称搜索" value="{{ request.args.get('name','') }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select" name="enabled">
|
||||
<option value="">全部</option>
|
||||
<option value="1" {% if request.args.get('enabled')=='1' %}selected{% endif %}>已启用</option>
|
||||
<option value="0" {% if request.args.get('enabled')=='0' %}selected{% endif %}>未启用</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary" type="submit">筛选</button>
|
||||
</div>
|
||||
</form>
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>URL</th>
|
||||
<th>调度</th>
|
||||
<th>启用</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for api in apis %}
|
||||
<tr>
|
||||
<td>{{ api.name }}</td>
|
||||
<td class="text-break">{{ api.url }}</td>
|
||||
<td>
|
||||
{{ api.schedule_type }}: {{ api.schedule_expression }}
|
||||
{% if api.schedule_type == 'cron' %}
|
||||
<div class="text-muted small">{{ api.schedule_expression|cron_human }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if api.enabled %}
|
||||
<span class="badge bg-success">已启用</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">已停用</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('apis.edit_api', api_id=api.id) }}">编辑</a>
|
||||
<form action="{{ url_for('apis.toggle_api', api_id=api.id) }}" method="post" class="d-inline">
|
||||
<button class="btn btn-sm btn-outline-warning" type="submit">
|
||||
{% if api.enabled %}停用{% else %}启用{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form action="{{ url_for('apis.run_now', api_id=api.id) }}" method="post" class="d-inline">
|
||||
<button class="btn btn-sm btn-outline-success" type="submit">立即执行</button>
|
||||
</form>
|
||||
<form action="{{ url_for('apis.delete_api', api_id=api.id) }}" method="post" class="d-inline" onsubmit="return confirm('确认删除该 API 配置?');">
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center">暂无 API 配置。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
21
app/templates/auth/login.html
Normal file
21
app/templates/auth/login.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}登录{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<h3 class="mb-3">登录</h3>
|
||||
<form method="post">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control") }}
|
||||
</div>
|
||||
{{ form.submit(class="btn btn-primary w-100") }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
37
app/templates/base.html
Normal file
37
app/templates/base.html
Normal file
@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}API 定时平台{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('apis.list_apis') }}">API 定时平台</a>
|
||||
<div>
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('apis.list_apis') }}">API 列表</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('logs.list_logs') }}">调用日志</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('auth.logout') }}">退出登录</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
app/templates/logs/detail.html
Normal file
19
app/templates/logs/detail.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}日志详情{% endblock %}
|
||||
{% block content %}
|
||||
<h3 class="mb-3">日志详情</h3>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ log.api.name }}</h5>
|
||||
<p class="card-text mb-1"><strong>请求时间:</strong> {{ log.request_time }}</p>
|
||||
<p class="card-text mb-1"><strong>响应时间:</strong> {{ log.response_time }}</p>
|
||||
<p class="card-text mb-1"><strong>是否成功:</strong> {{ log.success }}</p>
|
||||
<p class="card-text mb-1"><strong>HTTP 状态码:</strong> {{ log.http_status_code or '-' }}</p>
|
||||
<p class="card-text mb-1"><strong>耗时 (ms):</strong> {{ log.duration_ms or '-' }}</p>
|
||||
<p class="card-text mb-1"><strong>错误信息:</strong> {{ log.error_message or '-' }}</p>
|
||||
<p class="card-text mb-1"><strong>响应内容:</strong></p>
|
||||
<pre class="bg-light p-2" style="max-height: 300px; overflow:auto;">{{ log.response_body or '-' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-secondary" href="{{ url_for('logs.list_logs') }}">返回</a>
|
||||
{% endblock %}
|
||||
77
app/templates/logs/list.html
Normal file
77
app/templates/logs/list.html
Normal file
@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}调用日志{% endblock %}
|
||||
{% block content %}
|
||||
<h3 class="mb-3">API 调用日志</h3>
|
||||
<form class="row g-2 mb-3" method="get">
|
||||
<div class="col-md-3">
|
||||
{{ form.api_id.label(class="form-label") }}
|
||||
{{ form.api_id(class="form-select") }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{{ form.success.label(class="form-label") }}
|
||||
{{ form.success(class="form-select") }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{{ form.start_date.label(class="form-label") }}
|
||||
{{ form.start_date(class="form-control", placeholder="YYYY-MM-DD") }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{{ form.end_date.label(class="form-label") }}
|
||||
{{ form.end_date(class="form-control", placeholder="YYYY-MM-DD") }}
|
||||
</div>
|
||||
<div class="col-md-2 align-self-end">
|
||||
<button class="btn btn-outline-secondary" type="submit">筛选</button>
|
||||
</div>
|
||||
</form>
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>API</th>
|
||||
<th>请求时间</th>
|
||||
<th>状态</th>
|
||||
<th>HTTP</th>
|
||||
<th>耗时(ms)</th>
|
||||
<th>错误</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.api.name }}</td>
|
||||
<td>{{ log.request_time }}</td>
|
||||
<td>
|
||||
{% if log.success %}
|
||||
<span class="badge bg-success">成功</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">失败</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ log.http_status_code or '-' }}</td>
|
||||
<td>{{ log.duration_ms or '-' }}</td>
|
||||
<td class="text-truncate" style="max-width: 200px;">{{ log.error_message or '' }}</td>
|
||||
<td><a class="btn btn-sm btn-outline-primary" href="{{ url_for('logs.log_detail', log_id=log.id) }}">详情</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-center">暂无日志。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if pagination.pages > 1 %}
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item"><a class="page-link" href="{{ url_for('logs.list_logs', page=pagination.prev_num, per_page=pagination.per_page, **request.args) }}">上一页</a></li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">上一页</span></li>
|
||||
{% endif %}
|
||||
<li class="page-item disabled"><span class="page-link">第 {{ pagination.page }} / {{ pagination.pages }} 页</span></li>
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="{{ url_for('logs.list_logs', page=pagination.next_num, per_page=pagination.per_page, **request.args) }}">下一页</a></li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">下一页</span></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
115
app/views/apis.py
Normal file
115
app/views/apis.py
Normal file
@ -0,0 +1,115 @@
|
||||
import threading
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for, current_app
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.extensions import db, scheduler
|
||||
from app.forms import ApiConfigForm
|
||||
from app.models import ApiConfig
|
||||
from app.services.api_executor import execute_api_by_id
|
||||
from app.services.scheduler import SchedulerService
|
||||
|
||||
apis_bp = Blueprint("apis", __name__, url_prefix="/apis")
|
||||
|
||||
|
||||
def _scheduler_service() -> SchedulerService:
|
||||
return SchedulerService(scheduler)
|
||||
|
||||
|
||||
@apis_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
def list_apis():
|
||||
name = request.args.get("name")
|
||||
enabled = request.args.get("enabled")
|
||||
query = ApiConfig.query
|
||||
if name:
|
||||
query = query.filter(ApiConfig.name.ilike(f"%{name}%"))
|
||||
if enabled in {"0", "1"}:
|
||||
query = query.filter(ApiConfig.enabled == (enabled == "1"))
|
||||
apis = query.order_by(ApiConfig.updated_at.desc()).all()
|
||||
return render_template("apis/list.html", apis=apis)
|
||||
|
||||
|
||||
@apis_bp.route("/new", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_api():
|
||||
form = ApiConfigForm()
|
||||
if form.validate_on_submit():
|
||||
api = ApiConfig(
|
||||
name=form.name.data,
|
||||
description=form.description.data,
|
||||
url=form.url.data,
|
||||
http_method=form.http_method.data,
|
||||
headers=form.headers.data,
|
||||
query_params=form.query_params.data,
|
||||
body=form.body.data,
|
||||
schedule_type=form.schedule_type.data,
|
||||
schedule_expression=form.schedule_expression.data,
|
||||
timeout_seconds=form.timeout_seconds.data,
|
||||
retry_times=form.retry_times.data,
|
||||
retry_interval_seconds=form.retry_interval_seconds.data,
|
||||
enabled=form.enabled.data,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(api)
|
||||
db.session.commit()
|
||||
if api.enabled:
|
||||
_scheduler_service().add_job_for_api(api)
|
||||
flash("API 配置已创建", "success")
|
||||
return redirect(url_for("apis.list_apis"))
|
||||
return render_template("apis/form.html", form=form, api=None)
|
||||
|
||||
|
||||
@apis_bp.route("/<int:api_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_api(api_id: int):
|
||||
api = ApiConfig.query.get_or_404(api_id)
|
||||
form = ApiConfigForm(obj=api)
|
||||
if form.validate_on_submit():
|
||||
form.populate_obj(api)
|
||||
db.session.commit()
|
||||
if api.enabled:
|
||||
_scheduler_service().reschedule_job_for_api(api)
|
||||
else:
|
||||
_scheduler_service().remove_job_for_api(api.id)
|
||||
flash("API 配置已更新", "success")
|
||||
return redirect(url_for("apis.list_apis"))
|
||||
return render_template("apis/form.html", form=form, api=api)
|
||||
|
||||
|
||||
@apis_bp.route("/<int:api_id>/toggle", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_api(api_id: int):
|
||||
api = ApiConfig.query.get_or_404(api_id)
|
||||
api.enabled = not api.enabled
|
||||
db.session.commit()
|
||||
svc = _scheduler_service()
|
||||
if api.enabled:
|
||||
svc.add_job_for_api(api)
|
||||
flash("API 已启用并加入调度", "success")
|
||||
else:
|
||||
svc.remove_job_for_api(api.id)
|
||||
flash("API 已停用并移除调度", "info")
|
||||
return redirect(url_for("apis.list_apis"))
|
||||
|
||||
|
||||
@apis_bp.route("/<int:api_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_api(api_id: int):
|
||||
api = ApiConfig.query.get_or_404(api_id)
|
||||
db.session.delete(api)
|
||||
db.session.commit()
|
||||
_scheduler_service().remove_job_for_api(api_id)
|
||||
flash("API 配置已删除", "info")
|
||||
return redirect(url_for("apis.list_apis"))
|
||||
|
||||
|
||||
@apis_bp.route("/<int:api_id>/run", methods=["POST"])
|
||||
@login_required
|
||||
def run_now(api_id: int):
|
||||
api = ApiConfig.query.get_or_404(api_id)
|
||||
|
||||
app_obj = current_app._get_current_object()
|
||||
threading.Thread(target=execute_api_by_id, args=(api.id, app_obj), daemon=True).start()
|
||||
flash("已触发立即执行,稍后在日志中查看结果。", "success")
|
||||
return redirect(url_for("apis.list_apis"))
|
||||
28
app/views/auth.py
Normal file
28
app/views/auth.py
Normal file
@ -0,0 +1,28 @@
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_login import login_required, login_user, logout_user
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import LoginForm
|
||||
from app.models import User
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(username=form.username.data).first()
|
||||
if not user or not user.is_active or not user.check_password(form.password.data):
|
||||
flash("用户名或密码错误", "danger")
|
||||
return render_template("auth/login.html", form=form)
|
||||
login_user(user, remember=True)
|
||||
return redirect(request.args.get("next") or url_for("apis.list_apis"))
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for("auth.login"))
|
||||
42
app/views/logs.py
Normal file
42
app/views/logs.py
Normal file
@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, render_template, request
|
||||
from flask_login import login_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import LogFilterForm
|
||||
from app.models import ApiCallLog, ApiConfig
|
||||
|
||||
logs_bp = Blueprint("logs", __name__, url_prefix="/logs")
|
||||
|
||||
|
||||
@logs_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
def list_logs():
|
||||
form = LogFilterForm(request.args)
|
||||
form.api_id.choices = [(-1, "All APIs")] + [(api.id, api.name) for api in ApiConfig.query.order_by(ApiConfig.name)]
|
||||
|
||||
query = ApiCallLog.query.join(ApiConfig)
|
||||
if form.api_id.data and form.api_id.data != -1:
|
||||
query = query.filter(ApiCallLog.api_id == form.api_id.data)
|
||||
if form.success.data in {"1", "0"}:
|
||||
query = query.filter(ApiCallLog.success == (form.success.data == "1"))
|
||||
start = form.parse_date(form.start_date.data or "")
|
||||
end = form.parse_date(form.end_date.data or "")
|
||||
if start:
|
||||
query = query.filter(ApiCallLog.request_time >= start)
|
||||
if end:
|
||||
end_dt = datetime(end.year, end.month, end.day, 23, 59, 59)
|
||||
query = query.filter(ApiCallLog.request_time <= end_dt)
|
||||
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = int(request.args.get("per_page", 10))
|
||||
pagination = query.order_by(ApiCallLog.request_time.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||
return render_template("logs/list.html", form=form, pagination=pagination, logs=pagination.items)
|
||||
|
||||
|
||||
@logs_bp.route("/<int:log_id>", methods=["GET"])
|
||||
@login_required
|
||||
def log_detail(log_id: int):
|
||||
log_entry = ApiCallLog.query.get_or_404(log_id)
|
||||
return render_template("logs/detail.html", log=log_entry)
|
||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@ -0,0 +1,50 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
77
migrations/versions/34b9d2435dd3_init.py
Normal file
77
migrations/versions/34b9d2435dd3_init.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""init
|
||||
|
||||
Revision ID: 34b9d2435dd3
|
||||
Revises:
|
||||
Create Date: 2025-11-28 10:59:58.162614
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '34b9d2435dd3'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=64), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('email', sa.String(length=128), nullable=True),
|
||||
sa.Column('role', sa.String(length=32), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('username')
|
||||
)
|
||||
op.create_table('api_configs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('url', sa.String(length=512), nullable=False),
|
||||
sa.Column('http_method', sa.String(length=10), nullable=False),
|
||||
sa.Column('headers', sa.Text(), nullable=True),
|
||||
sa.Column('query_params', sa.Text(), nullable=True),
|
||||
sa.Column('body', sa.Text(), nullable=True),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=True),
|
||||
sa.Column('schedule_type', sa.String(length=32), nullable=False),
|
||||
sa.Column('schedule_expression', sa.String(length=128), nullable=False),
|
||||
sa.Column('timeout_seconds', sa.Integer(), nullable=True),
|
||||
sa.Column('retry_times', sa.Integer(), nullable=True),
|
||||
sa.Column('retry_interval_seconds', sa.Integer(), nullable=True),
|
||||
sa.Column('created_by', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('api_call_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('api_id', sa.Integer(), nullable=False),
|
||||
sa.Column('request_time', sa.DateTime(), nullable=False),
|
||||
sa.Column('response_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('success', sa.Boolean(), nullable=True),
|
||||
sa.Column('http_status_code', sa.Integer(), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('response_body', sa.Text(), nullable=True),
|
||||
sa.Column('duration_ms', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['api_id'], ['api_configs.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('api_call_logs')
|
||||
op.drop_table('api_configs')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@ -0,0 +1,10 @@
|
||||
Flask==3.0.2
|
||||
Flask-Login==0.6.3
|
||||
Flask-WTF==1.2.1
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Migrate==4.0.5
|
||||
Flask-APScheduler==1.13.1
|
||||
APScheduler==3.10.4
|
||||
requests==2.31.0
|
||||
python-dotenv==1.0.1
|
||||
PyMySQL==1.1.0
|
||||
Loading…
x
Reference in New Issue
Block a user