在django中管理定时任务(二)APScheduler

0
(0)

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

这篇文章有用吗?

点击星号为它评分!

平均评分 0 / 5. 投票数: 0

到目前为止还没有投票!成为第一位评论此文章。

很抱歉,这篇文章对您没有用!

让我们改善这篇文章!

告诉我们我们如何改善这篇文章?

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注