Extending FasterRunner (httptunner) with Email Notifications and Dynamic Cron Scheduling
Task Scheduling Configuration
settings.py
from djcelery import setup_loader setup_loader() CELERY_ENABLE_UTC = True CELERY_TIMEZONE = 'Asia/Shanghai' BROKER_URL = 'amqp://guest:guest@127.0.0.1:5672//' CELERYBEAT_SCHEDULER = 'djcelery.schedulers.DatabaseScheduler' CELERY_RESULT_BACKEND = 'djcelery.backends.database:DatabaseBackend' CELERY_ACCEPT_CONTENT = ['application/json'] CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' CELERY_TASK_RESULT_EXPIRES = 7200 CELERYD_CONCURRENCY = 1 if DEBUG else 5 CELERYD_MAX_TASKS_PER_CHILD = 40
Start Celery Services
# Beat scheduler nohup python3 manage.py celery beat -l info >> /Users/zd/Documents/FasterRunner/logs/beat.log 2>&1 & # Worker processes celery multi start w1 -A FasterRunner -l info --logfile=/Users/zd/Documents/FasterRunner/logs/worker.log 2>&1 &
Note: After modifying cron logic, restart both
celery beatandcelery multiservices. Debug logs are available inlogs/worker.log.
Email Integration
settings.py
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_SEND_USERNAME = 'no-reply@fenbi.com' EMAIL_SEND_PASSWORD = '' EMAIL_PORT = 25 EMAIL_USE_TLS = True
fastrunner/utils/email.py
import smtplib
from email.mime.text import MIMEText
from email.header import Header
from FasterRunner.settings import EMAIL_SEND_USERNAME, EMAIL_SEND_PASSWORD
def send_email_reports(receiver, save_summary, Cc=None, title=None):
receiver = receiver.rstrip(';')
all_receivers = receiver.split(';')
if '@sina.com' in EMAIL_SEND_USERNAME:
smtpserver = 'smtp.sina.com'
elif '@163.com' in EMAIL_SEND_USERNAME:
smtpserver = 'smtp.163.com'
else:
smtpserver = 'smtp.exmail.qq.com'
subject = f"【{title}】API Automation Report" if title else "API Automation Report"
smtp = smtplib.SMTP_SSL(smtpserver, 465)
smtp.login(EMAIL_SEND_USERNAME, EMAIL_SEND_PASSWORD)
msg = MIMEText(save_summary, 'html', 'utf-8')
msg['Subject'] = Header(subject, 'utf-8')
msg['From'] = Header('no-reply', 'utf-8')
msg['To'] = receiver
if Cc:
Cc = Cc.rstrip(';')
msg['Cc'] = Cc
all_receivers.extend(Cc.split(';'))
smtp.sendmail(EMAIL_SEND_USERNAME, all_receivers, msg.as_string())fastrunner/tasks.py
from fastrunner.utils.emails import send_email_reports
import time
@shared_task
def schedule_debug_suite(*args, **kwargs):
print("Starting scheduled task...")
project = kwargs["project"]
receiver = kwargs["receiver"]
Cc = kwargs["copy"]
title = kwargs["name"]
receiver = receiver.strip()
Cc = Cc.strip()
suite = []
test_sets = []
config_list = []
for pk in args:
try:
name = models.Case.objects.get(id=pk).name
suite.append({"name": name, "id": pk})
except ObjectDoesNotExist:
continue
for content in suite:
test_list = models.CaseStep.objects.filter(case__id=content["id"]).order_by("step").values("body")
testcase_list = []
config = None
for item in test_list:
body = eval(item["body"])
if "base_url" in body["request"].keys():
config = eval(models.Config.objects.get(name=body["name"], project__id=project).body)
continue
testcase_list.append(body)
config_list.append(config)
test_sets.append(testcase_list)
summary = debug_suite(test_sets, project, suite, config_list, save=False)
save_summary("", summary, project, type=3)
# Format results
testTime = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(summary['time']['start_at']))
durTime = str(summary['time']['duration'])[:8]
totalApi = summary['stat']['testsRun']
successApi = summary['stat']['successes']
FailApi = summary['stat']['failures']
errorApi = summary['stat']['errors']
skipApi = summary['stat']['skipped']
htmll = f"""<table border="1" cellpadding="0" cellspacing="0" width="700px">
<tr style="background-color: #f8f8fa">
<th>Test Time</th>
<th>Duration (sec)</th>
<th>Total</th>
<th>Success</th>
<th>Failed</th>
<th>Error</th>
<th>Skipped</th>
</tr>
<tr>
<td>{testTime}</td>
<td>{durTime}</td>
<td>{totalApi}</td>
<td>{successApi}</td>
<td>{FailApi}</td>
<td>{errorApi}</td>
<td>{skipApi}</td>
</tr>
</table><div style="height: 30px"></div>"""
for detail in summary['details']:
records = detail['records']
for record in records:
name = record['name']
url = record['meta_data']['request']['url']
method = record['meta_data']['request']['method']
response_time = record['meta_data']['response']['response_time_ms']
status = record['status']
htmll += f"""<tr>
<td>{name}</td>
<td>{url}</td>
<td>{method}</td>
<td>{response_time} ms</td>
<td>{status}</td>
</tr>"""
htmll += '</table>'
if Cc:
send_email_reports(receiver, htmll, Cc=Cc, title=title)
else:
send_email_reports(receiver, htmll, title=title)Dynamic Task Management
Frontend: Tasks.vue (Parent Component)
<template>
<el-container>
<el-header style="background: #fff; padding: 0; height: 50px">
<div class="nav-api-header">
<div style="padding-top: 10px; margin-left: 20px">
<el-button type="primary" size="small" icon="el-icon-circle-plus-outline" @click="addTasks2">Add Task</el-button>
<el-button :disabled="!addTasks" type="text" style="position: absolute; right: 30px;" @click="addTasks = false">Back</el-button>
</div>
</div>
</el-header>
<el-container>
<el-header v-if="!addTasks" style="padding: 0; height: 50px; margin-top: 10px">
<div style="padding-top: 8px; padding-left: 30px;">
<el-pagination
:page-size="11"
v-show="tasksData.count !== 0"
background
@current-change="handleCurrentChange"
:current-page.sync="currentPage"
layout="total, prev, pager, next, jumper"
:total="tasksData.count"
></el-pagination>
</div>
</el-header>
<el-main style="padding: 0; margin-left: 10px; margin-top: 10px;">
<div style="position: fixed; bottom: 0; right: 0; left: 230px; top: 160px">
<el-table
v-if="!addTasks"
:data="tasksData.results"
:show-header="tasksData.results.length !== 0"
stripe
highlight-current-row
height="calc(100%)"
@cell-mouse-enter="cellMouseEnter"
@cell-mouse-leave="cellMouseLeave"
>
<el-table-column label="Task Name" width="240">
<template slot-scope="scope">{{ scope.row.name }}</template>
</el-table-column>
<el-table-column width="120" label="Schedule">
<template slot-scope="scope">{{ scope.row.kwargs.corntab }}</template>
</el-table-column>
<el-table-column width="100" label="Email Strategy">
<template slot-scope="scope">{{ scope.row.kwargs.strategy }}</template>
</el-table-column>
<el-table-column width="80" label="Status">
<template slot-scope="scope">
<el-switch disabled v-model="scope.row.enabled" active-color="#13ce66" inactive-color="#ff4949"></el-switch>
</template>
</el-table-column>
<el-table-column width="320" label="Recipients">
<template slot-scope="scope">{{ scope.row.kwargs.receiver }}</template>
</el-table-column>
<el-table-column width="320" label="CC">
<template slot-scope="scope">{{ scope.row.kwargs.copy }}</template>
</el-table-column>
<el-table-column width="200">
<template slot-scope="scope">
<el-row v-show="currentRow === scope.row">
<el-button type="info" icon="el-icon-edit" circle size="mini" @click="editTask(scope.row)"></el-button>
<el-button type="danger" icon="el-icon-delete" circle size="mini" @click="delTasks(scope.row.id)"></el-button>
</el-row>
</template>
</el-table-column>
</el-table>
</div>
</el-main>
<AddTasks
v-if="addTasks"
v-on:changeStatus="changeStatus"
:editValue="editValue"
></AddTasks>
</el-container>
</el-container>
</template>
<script>
import AddTasks from './AddTasks'
export default {
components: { AddTasks },
data() {
return {
editValue: [],
addTasks: false,
currentPage: 1,
currentRow: '',
tasksData: { count: 0, results: [] }
}
},
methods: {
addTasks2() {
this.addTasks = true
this.editValue = []
},
editTask(val) {
this.addTasks = true
this.editValue = val
},
delTasks(id) {
this.$confirm('Delete this task?', 'Warning', {
confirmButtonText: 'Yes',
cancelButtonText: 'No',
type: 'warning'
}).then(() => {
this.$api.deleteTasks(id).then(resp => {
if (resp.success) this.getTaskList()
})
})
},
handleCurrentChange(val) {
this.$api.getTaskPaginationBypage({
params: { page: this.currentPage, project: this.$route.params.id }
}).then(resp => {
this.tasksData = resp
})
},
changeStatus(value) {
this.getTaskList()
this.addTasks = value
},
getTaskList() {
this.$api.taskList({ params: { project: this.$route.params.id } }).then(resp => {
this.tasksData = resp
})
},
cellMouseEnter(row) {
this.currentRow = row
},
cellMouseLeave(row) {
this.currentRow = ''
}
},
mounted() {
this.getTaskList()
}
}
</script>Frontend: AddTasks.vue (Child Component)
<template>
<el-container>
<template v-if="!next">
<el-main style="padding-top: 0">
<div style="margin-top: 10px;">
<el-col :span="12">
<el-form :model="ruleForm" :rules="rules" ref="ruleForm" label-width="100px">
<el-form-item label="Task Name" prop="name">
<el-input v-model="ruleForm.name" placeholder="Enter task name" clearable></el-input>
</el-form-item>
<el-form-item label="Schedule" prop="corntab">
<el-input v-model="ruleForm.corntab" placeholder="e.g., 2 12 * * *" clearable></el-input>
</el-form-item>
<el-form-item label="Enabled" prop="switch">
<el-switch v-model="ruleForm.switch"></el-switch>
</el-form-item>
<el-form-item label="Email Policy" prop="strategy">
<el-radio-group v-model="ruleForm.strategy">
<el-radio label="Always Send"></el-radio>
<el-radio label="Send on Failure"></el-radio>
<el-radio label="Never Send"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="Recipients" prop="receiver">
<el-input type="textarea" v-model="ruleForm.receiver" placeholder="Separate with semicolons" clearable></el-input>
</el-form-item>
<el-form-item label="CC" prop="copy">
<el-input type="textarea" v-model="ruleForm.copy" placeholder="Separate with semicolons" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('ruleForm')">Next</el-button>
<el-button @click="resetForm('ruleForm')">Reset</el-button>
</el-form-item>
</el-form>
</el-col>
</div>
</el-main>
</template>
<template v-if="next">
<el-aside style="margin-top: 10px;">
<div class="nav-api-side">
<div class="api-tree">
<el-input placeholder="Filter by keyword" v-model="filterText" size="medium" clearable prefix-icon="el-icon-search"></el-input>
<el-tree @node-click="handleNodeClick" :data="dataTree" node-key="id" :default-expand-all="false" :expand-on-click-node="false" highlight-current :filter-node-method="filterNode" ref="tree2">
<span class="custom-tree-node" slot-scope="{ node, data }">
<span><i class="iconfont" v-html="expand"></i> {{ node.label }}</span>
</span>
</el-tree>
</div>
</div>
</el-aside>
<el-main style="padding-top: 0px">
<div>
<el-row :gutter="20">
<el-col :span="12">
<el-pagination
:page-size="11"
v-show="suiteData.count !== 0"
background
@current-change="handlePageChange"
:current-page.sync="currentPage"
layout="total, prev, pager, next, jumper"
:total="suiteData.count"
style="text-align: center"
></el-pagination>
</el-col>
<el-col :span="12">
<el-button type="primary" v-if="testData.length > 0" @click="saveTask">Save</el-button>
<el-button v-if="testData.length > 0" @click="next = false">Back</el-button>
</el-col>
</el-row>
</div>
<div>
<el-row :gutter="20">
<el-col :span="12">
<div v-for="(item, index) in suiteData.results" draggable='true' @dragstart="currentSuite = JSON.parse(JSON.stringify(item))" style="cursor: pointer; margin-top: 10px; overflow: auto;" :key="index">
<div class="block block_options">
<span class="block-method block_method_options block_method_color">Case</span>
<span class="block_name">{{ item.name }}</span>
</div>
</div>
</el-col>
<el-col :span="12">
<div style="max-height: 600px; overflow: auto" @drop='drop($event)' @dragover='allowDrop($event)'>
<span v-if="testData.length === 0" style="color: red">
Tip: Select cases from the left tree and drag them here.
Reorder tasks by dragging within this area.
</span>
<div class='test-list'>
<draggable v-model="testData" @end="dragEnd" @start="length = testData.length" :options="{animation:200}">
<div v-for="(test, index) in testData" :key="index" class="block block_test" @mousemove="currentTest = index">
<span class="block-method block_method_test block_method_color">Tasks</span>
<span class="block-test-name">{{ test.name }}</span>
<el-button style="position: absolute; right: 12px; top: 8px" v-show="currentTest === index" type="danger" icon="el-icon-delete" circle size="mini" @click="testData.splice(index, 1)"></el-button>
</div>
</draggable>
</div>
</div>
</el-col>
</el-row>
</div>
</el-main>
</template>
</el-container>
</template>
<script>
import draggable from 'vuedraggable'
export default {
name: "AddTasks",
watch: {
filterText(val) {
this.$refs.tree2.filter(val);
}
},
components: { draggable },
props: ['editValue'],
data() {
return {
currentTest: '',
length: 0,
testData: [],
currentSuite: '',
search: '',
next: false,
node: '',
currentPage: 1,
filterText: '',
expand: '',
dataTree: [],
suiteData: { count: 0, results: [] },
ruleForm: {
name: '',
switch: true,
corntab: '',
strategy: 'Always Send',
receiver: '',
copy: ''
},
rules: {
name: [
{ required: true, message: 'Enter task name', trigger: 'blur' },
{ min: 1, max: 50, message: '1 to 50 characters', trigger: 'blur' }
],
corntab: [
{ required: true, message: 'Enter valid cron expression', trigger: 'blur' }
]
}
}
},
methods: {
saveTask() {
const taskIds = this.testData.map(t => t.id);
const form = { ...this.ruleForm, data: taskIds, project: this.$route.params.id };
if (this.editValue?.kwargs) {
form.taskId = this.editValue.id;
this.$api.updateTask11(form).then(resp => {
if (!resp.success) this.$message.error(resp.msg)
else this.$emit("changeStatus", false)
})
} else {
this.$api.addTask(form).then(resp => {
if (!resp.success) this.$message.error(resp.msg)
else this.$emit("changeStatus", false)
})
}
},
dragEnd(event) {
if (this.testData.length > this.length) this.testData.splice(this.length, 1)
},
drop(event) {
event.preventDefault();
this.testData.push(this.currentSuite);
},
allowDrop(event) {
event.preventDefault();
},
handlePageChange(val) {
this.$api.getTestPaginationBypage({
params: { page: this.currentPage, node: this.node, project: this.$route.params.id, search: '' }
}).then(res => {
this.suiteData = res;
});
},
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
this.next = true;
this.testData = [];
// Populate existing cases
this.suiteData.results.forEach(item => {
this.editValue.args.forEach(id => {
if (item.id === id) this.testData.push(item);
});
});
}
});
},
resetForm(formName) {
this.$refs[formName].resetFields();
},
filterNode(value, data) {
return !value || data.label.indexOf(value) !== -1;
},
getTree() {
this.$api.getTree(this.$route.params.id, {params: {type: 2}}).then(resp => {
this.dataTree = resp.tree;
});
},
handleNodeClick(node) {
this.node = node.id;
this.getTestList();
},
getTestList() {
this.$api.testList({
params: { project: this.$route.params.id, node: this.node, search: this.search }
}).then(resp => {
this.suiteData = resp;
});
},
getEditData() {
if (this.editValue?.kwargs) {
this.ruleForm.name = this.editValue.name;
this.ruleForm.corntab = this.editValue.kwargs.corntab;
this.ruleForm.receiver = this.editValue.kwargs.receiver;
this.ruleForm.copy = this.editValue.kwargs.copy;
this.ruleForm.strategy = this.editValue.kwargs.strategy;
}
}
},
mounted() {
this.getTree();
this.getTestList();
this.getEditData();
}
}
</script>API Update Endpoint
export const updateTask11 = params => {
return axios.patch('/api/fastrunner/schedule/', params).then(res => res.data);
};Backend: schedule.py
@method_decorator(request_log(level='INFO')) def update(self, request): project_id = request.data['project'] try: project_obj = models2.Project.objects.get(id=project_id) except (KeyError, ObjectDoesNotExist): return Response(response.SYSTEM_ERROR) project_obj.name = request.data['name'] project_obj.save() task_obj = Task(**request.data) resp = task_obj.update_task() return Response(response.PROJECT_UPDATE_SUCCESS) @method_decorator(request_log(level='INFO')) def delete(self, request, **kwargs): task = models.PeriodicTask.objects.get(id=kwargs['pk']) task.enabled = False task.delete() schedule = models.CrontabSchedule.objects.get(id=task.crontab_id) schedule.delete() return Response(response.TASK_DEL_SUCCESS)
Backend: task.py
import json
import logging
from djcelery import models as celery_models
from fastrunner.utils import response
from fastrunner.utils.parser import format_json
logger = logging.getLogger('FasterRunner')
class Task(object):
def __init__(self, **kwargs):
logger.info(f"Processing task data: {format_json(kwargs)}")
self.__name = kwargs["name"]
self.__data = kwargs["data"]
self.__corntab = kwargs["corntab"]
self.__switch = kwargs["switch"]
self.__task = "fastrunner.tasks.schedule_debug_suite"
self.__project = kwargs["project"]
self.__email = {
"strategy": kwargs["strategy"],
"copy": kwargs["copy"],
"receiver": kwargs["receiver"],
"corntab": self.__corntab,
"project": self.__project
}
self.__corntab_time = None
self.__taskId = kwargs.get("taskId", "")
def format_corntab(self):
parts = self.__corntab.split(' ')
if len(parts) != 5:
return response.TASK_TIME_ILLEGAL
try:
self.__corntab_time = {
'day_of_week': parts[4],
'month_of_year': parts[3],
'day_of_month': parts[2],
'hour': parts[1],
'minute': parts[0]
}
except Exception:
return response.TASK_TIME_ILLEGAL
return response.TASK_ADD_SUCCESS
def add_task(self):
if celery_models.PeriodicTask.objects.filter(name__exact=self.__name).count() > 0:
logger.info(f"{self.__name} already exists")
return response.TASK_HAS_EXISTS
if self.__email["strategy"] in ['Always Send', 'Send on Failure'] and not self.__email["receiver"]:
return response.TASK_EMAIL_ILLEGAL
resp = self.format_corntab()
if not resp["success"]:
return resp
task, created = celery_models.PeriodicTask.objects.get_or_create(name=self.__name, task=self.__task)
crontab = celery_models.CrontabSchedule.objects.filter(**self.__corntab_time).first()
if crontab is None:
crontab = celery_models.CrontabSchedule.objects.create(**self.__corntab_time)
task.crontab = crontab
task.enabled = self.__switch
task.args = json.dumps(self.__data, ensure_ascii=False)
task.kwargs = json.dumps(self.__email, ensure_ascii=False)
task.description = self.__project
task.save()
logger.info(f"{self.__name} saved successfully")
return response.TASK_ADD_SUCCESS
def update_task(self):
print("Updating task...")
try:
if self.__email["strategy"] in ['Always Send', 'Send on Failure'] and not self.__email["receiver"]:
return response.TASK_EMAIL_ILLEGAL
obj = celery_models.PeriodicTask.objects.filter(id=self.__taskId).first()
if not obj:
return response.TASK_NOT_FOUND
obj.name = self.__name
obj.enabled = self.__switch
obj.args = json.dumps(self.__data, ensure_ascii=False)
obj.kwargs = json.dumps(self.__email, ensure_ascii=False)
obj.description = self.__project
obj.save()
crontab_obj = celery_models.CrontabSchedule.objects.filter(id=obj.crontab_id).first()
if not crontab_obj:
return response.SCHEDULE_NOT_FOUND
times = self.__corntab.split(' ')
crontab_obj.minute = times[0]
crontab_obj.hour = times[1]
crontab_obj.day_of_month = times[2]
crontab_obj.month_of_year = times[3]
crontab_obj.day_of_week = times[4]
crontab_obj.save()
return response.TASK_UPDATE_SUCCESS
except Exception as e:
return f"Update failed: {e}"Startup and Shutdown Scripts
start.sh
#!/bin/bash # Start frontend echo "Starting frontend..." cd /home/conan/conan-ta/FasterWeb/ nohup npm run build >> /home/shared/log/npm.log 2>&1 & # Start backend echo "Starting backend..." cd /home/conan/conan-ta/FasterRunner/ nohup python3 manage.py runserver 0.0.0.0:9000 >> /home/shared/log/django.log 2>&1 & # Start Celery beat echo "Starting Celery beat..." cd /home/conan/conan-ta/FasterRunner/ nohup python3 manage.py celery beat -l info >> /Users/zd/Documents/FasterRunner/logs/beat.log 2>&1 & # Start Celery workers echo "Starting Celery workers..." cd /home/conan/conan-ta/FasterRunner/ celery multi start w1 -A FasterRunner -l info --logfile=/Users/zd/Documents/FasterRunner/logs/worker.log 2>&1 &
stop.sh
#!/bin/bash
# Kill Django process
echo "Stopping Django..."
pids=$(ps aux | grep "python" | grep "runserver" | awk '{print $2}')
for pid in $pids; do
kill -9 $pid
done
# Kill Celery processes
echo "Stopping Celery..."
pids=$(ps aux | grep "celery" | grep "FasterRunner" | awk '{print $2}')
for pid in $pids; do
kill -9 $pid
doneEnsure proper environment variables and database configurations are set before running startup scripts.