在我项目改造的过程中使用了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 于 北京 上东廓