Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Extending FasterRunner (httptunner) with Email Notifications and Dynamic Cron Scheduling

Tech 2

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 beat and celery multi services. Debug logs are available in logs/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>&nbsp;&nbsp;{{ 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: '&#xe65f;',
      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
done

Ensure proper environment variables and database configurations are set before running startup scripts.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.