在我项目改造的过程中使用了django-crontab对定时任务进行初步的管理,也算不上管理,只能算是把代码写到项目里。真正谈得上管理的还是APScheduler,大概看了一遍官方文档,基本没有障碍,也在pypi站上试了一个封装的组件 django-apscheduler,按照文档的示例迁移了部分定时任务, 但我需要更灵活一点的管理方式,和一些业务化的定制。 于是我在django-apscheduler的基础上做了一些自定义的功能。
在管理后台添加、编辑任务,增加了一些自定义属性,比如任务执行间隔,需下发的接口等。


以下介绍整个改造过程
第一步,安装
pip install django-apscheduler
第二步,修改settings.py文件,注册此应用
INSTALLED_APPS = (
# ...
"django_apscheduler",
)
第三步,执行数据库变更(很多文档都叫迁移,我更习惯叫变更)
python manage.py migrate
第四步,启动调度服务, 找到一个admin.py文件,然后在文件中添加:
admin.site.scheduler = BackgroundScheduler(timezone=pytz.timezone(settings.TIME_ZONE)) admin.site.scheduler.add_jobstore(DjangoJobStore(), 'default') admin.site.scheduler.start()
我把scheduler放在admin.site中的原因,是后面要在任务编辑模块中使用这个scheduler
第五步,定义定时任务执行的方法,该方法带了一个参数args,是为了在编辑页面中传参数,比如上图中的应用、接口、环境。此处没有按照文档中的 @register_job直接注册任务。
def dispatch_employee_job(args): ...
第六步, 扩展django-apscheduler的model, 编辑应用下的models.py
class MyJob(DjangoJob):
class Meta:
proxy = True
verbose_name = '任务'
verbose_name_plural = '任务列表'
def __str__(self):
return self.id
# 自定义model,添加扩展字段而不破坏原有的代码结构
class MyJobProps(models.Model):
job = models.OneToOneField(to=MyJob, on_delete=models.CASCADE, related_name='props')
status = models.IntegerField('状态', null=True, blank=True, default=1, choices=((1, '启用'), (0, '停用')))
props = models.TextField('任务属性')
class Meta:
managed = True
db_table = 'my_job_props'
verbose_name = '任务属性'
def __str__(self):
return self.job.id
第七步,编辑应用下的admin.py
@admin.register(MyJob)
class MyJobAdmin(DjangoJobAdmin):
list_display = ["id", "lbl_freq", "local_run_time", "average_duration", "lbl_operation"]
actions = ['start_selected_jobs', 'shutdown_selected_jobs', 'run_selected_jobs']
ordering = 'id',
list_display_links = None
@admin.display(description='操作')
def lbl_operation(self, obj):
return render_to_string('admin/myapp/myjob/lbl_operate.html', context={'obj': {'id': obj.id}})
@admin.display(description='启用选中任务')
def start_selected_jobs(self, request, queryset):
for r in queryset:
job = admin.site.scheduler.get_job(r.id, 'default')
job.resume()
try:
r.props.status = 1
r.props.save()
except:
r.props = MyJobProps.objects.create(job=r, status=1, props=json.dumps(get_aps_job_prop(r.id, r), ensure_ascii=False))
r.status = 1
r.save()
@admin.display(description='停用选中任务')
def shutdown_selected_jobs(self, request, queryset):
for r in queryset:
job = admin.site.scheduler.get_job(r.id)
job.pause()
try:
r.props.status = 0
r.props.save()
except:
r.props = MyJobProps.objects.create(job=r, status=0, props=json.dumps(get_aps_job_prop(r.id, r), ensure_ascii=False))
@admin.display(description='执行选中任务')
def run_selected_jobs(self, request, queryset):
super().run_selected_jobs(request, queryset)
def has_change_permission(self, request, obj=None):
return False
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.display(description='下次执行时间')
def local_run_time(self, obj):
if obj.next_run_time:
return obj.next_run_time.astimezone(pytz.timezone(settings.TIME_ZONE)).strftime('%Y-%m-%d %H:%M:%S')
return "暂停"
@admin.display(description='平均执行时长(秒)')
def average_duration(self, obj):
try:
return self.avg_duration_qs.get(job_id=obj.id)[1]
except Exception:
return "-"
@admin.display(description='执行频率')
def lbl_freq(self, obj):
state = pickle.loads(obj.job_state)
seconds = state['trigger'].interval.seconds
day = state['trigger'].interval.days
day_lbl = '天'
second_lbl = '秒'
minute = 0
minute_lbl = '分钟'
hour = 0
hour_lbl = '小时'
if seconds >=60:
minute = int(seconds / 60)
seconds = seconds % 60
minute_lbl = '分钟'
if minute >= 60:
hour = int(minute / 60)
minute = minute % 60
hour_lbl = '小时'
return f"每{day if day > 0 else ''}{day_lbl if day>0 else ''}{hour if hour > 0 else ''}{hour_lbl if hour>0 else ''}{minute if minute>0 else ''}{minute_lbl if minute>0 else ''}{seconds if seconds > 0 else ''}{second_lbl if seconds > 0 else ''}"
以上代码中,将原来组件显示的列都进行了自定义,并且还在列表中增加了一个操作列, 该操作列引用了一个模板文件 lbl_operate.html
第八步, 编辑 lbl_operate.html
<!-- lbl_operate.html -->
<div id="lbl_operate_{{obj.id}}">
<el-button type="text" size="small" @click="window.jobEditor.editJob('{{ obj.id}}')">编辑任务</el-button>
</div>
<script>
new Vue({
el: '#lbl_operate_{{obj.id}}'
})
</script>
模板使用了vue的语法,编辑任务按钮的点击事件触发了另一个vue对象的事件 editJob,
第九步,定义vue对象 window.jobEditor:
<!-- job-editor.html -->
<span id="job-editor">
<el-dialog size="small" :visible.sync="dialog.visible" :title="dialog.title" @open="dialogOpen" width="50%">
<el-form v-loading="loading" size="small" :model="form" label-width="120px">
<el-form-item required label="任务名称:">
<el-input v-model="form.id" />
</el-form-item>
<el-form-item required label="应用:">
<el-select v-model="form.app" placeholder="请选择应用" @change="appChanged" style="width:100%">
<el-option v-for="item in appList" :key="item.id" :value="item.id" :label="item.server_name" />
</el-select>
</el-form-item>
<el-form-item required label="接口:">
<el-select v-model="form.api" placeholder="请选择接口" style="width:100%">
<el-option v-for="item in dialog.currentApp.apis" :key="item.id" :value="item.id" :label="item.id + '.' + item.title" />
</el-select>
</el-form-item>
<el-form-item required label="环境:">
<el-select v-model="form.env" placeholder="请选择环境" style="width:100%">
<el-option v-for="item in dialog.currentApp.envs" :key="item.id" :value="item.id" :label="item.env_name" />
</el-select>
</el-form-item>
<el-form-item required label="方法:">
<el-select v-model="form.method" placeholder="请选择方法" style="width:100%">
<el-option v-for="item in dialog.currentApp.methods" :key="item" :value="item" :label="item" />
</el-select>
</el-form-item>
<el-form-item label="数据范围:">
<span>最近
<el-input size="small" type="number" v-model="form.rangeValue" style="width:60px"></el-input>
<el-select slot="append" v-model="form.rangeUnit" placeholder="请选择" style="width:80px">
<el-option label="分" value="minutes" ></el-option>
<el-option label="小时" value="hours" ></el-option>
<el-option label="天" value="days" ></el-option>
<el-option label="周" value="weeks" ></el-option>
<el-option label="月" value="months" ></el-option>
</el-select>
</span>
</el-form-item>
<el-form-item label="任务类型:">
<el-select v-model="form.trigger" placeholder="请选择执行方式">
<el-option label="CRON表达式" value="cron" ></el-option>
<el-option label="时间间隔" value="interval" ></el-option>
</el-select>
</el-form-item>
<el-form-item label="任务参数:">
<template v-if="form.trigger == 'cron'" >
<el-input size="small" v-model="form.cronExpr" placeholder="秒 分 时 每月第N天 月 每周第N天" width="100%"/>
</template>
<template v-else-if="form.trigger == 'interval'">
<span>每
<el-input size="small" type="number" v-model="form.intervalValue" style="width:60px">
</el-input>
<el-select slot="append" v-model="form.intervalType" placeholder="请选择" style="width:80px">
<el-option label="秒" value="seconds" ></el-option>
<el-option label="分钟" value="minutes" ></el-option>
<el-option label="小时" value="hours" ></el-option>
<el-option label="天" value="days" ></el-option>
</el-select>
</span>
</template>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button size="small" type="primary" @click="submit">确 定</el-button>
<el-button size="small" @click="dialog.visible = false">取 消</el-button>
</div>
</el-dialog>
</span>
<script type="text/javascript">
window.jobEditor = new Vue({
el: '#job-editor',
data: {
jobId: '',
loading: false,
appList: [],
form: {
id: '',
app: null,
api: null,
env: null,
method: '',
rangeValue: 1,
rangeUnit: 'days',
trigger: '',
cronExpr: '',
intervalValue: 1,
intervalType: 'hours'
},
dialog: {
visible: false,
title: '添加任务',
currentApp: {apis:[], envs:[], methods: []},
},
},
methods: {
dialogOpen(){
this.loading = true;
fetch('/myapp/myJob/getAppList', {
method: 'GET',
credentials: "same-origin",
headers: { 'Content-Type': 'application/json', "X-CSRFToken": getCookie('csrftoken') },
}).then(res=>res.json()).then(res=>{
//此处调用接口,获取appList数据
})
},
editJob(jobId){
this.jobId = jobId
this.dialog.visible = true
},
submit() {
fetch('/myapp/myjob/submitJob?id='+this.jobId, {
method: 'POST',
credentials: "same-origin",
headers: { 'Content-Type': 'application/json', "X-CSRFToken": getCookie('csrftoken') },
body: JSON.stringify(this.form)
}).then(res=>res.json()).then(res=>{
if (res.code == 200) {
this.$message.success(res.msg)
this.dialog.visible = false
window.location.reload()
} else {
this.$message.error(res.msg)
}
})
},
appChanged(v) {
var self = this;
this.appList.some(item=>{
console.log(v, item)
if (item.id==v) {
self.dialog.currentApp.apis = item.apis
self.dialog.currentApp.envs = item.envs
self.dialog.currentApp.methods = item.methods
return true;
}
})
}
}
})
</script>
第十步, 自定义模板的actions.html,自定义一个 “添加按钮”
此步略,django admin的actions属性针对数据集操作的,这里需要自定义一个功能按钮,方法可参考官方文档,将django安装目录django/contrib/admin/templates/actions.html复制到当前app或项目的对应目录,然后修改模板文件,增加自定义的按钮,在合适的位置增加:
<el-button type="default" size="small" @click="window.jobEditor.editJob('')">添加任务</el-button>
此项目使用了django-simpleui,全局引用了vue和element-ui,所以我习惯直接使用 element-ui组件
其他,关于任务编辑器中使用的后端接口属于业务范围,与本文关系不大,略。
2025/9/15 于 北京 上东廓