【Flask】测试平台开发,工具模块开发 第二十二篇

概述:

开发这个功能的需求是希望本地的工具可以有一个快速查找的地方,这样,其他人员可以直接删除工具使用,另外附加工具的说明书和导出说明书的功能,方便快速使用

最终效果如下

首先我们先设计存储上传工具文件的数据文件表,表结构如下

复制代码
/*
 Navicat Premium Dump SQL

 Source Server         : rebort-测试开发平台开发
 Source Server Type    : MySQL
 Source Server Version : 80042 (8.0.42)
 Source Host           : localhost:3306
 Source Schema         : tpmdatas

 Target Server Type    : MySQL
 Target Server Version : 80042 (8.0.42)
 File Encoding         : 65001

 Date: 08/09/2025 09:08:21
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for file
-- ----------------------------
DROP TABLE IF EXISTS `file`;
CREATE TABLE `file`  (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '文件ID',
  `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '原始文件名',
  `file_path` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '服务器存储路径',
  `file_size` bigint NOT NULL COMMENT '文件大小(字节)',
  `file_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件类型(MIME类型)',
  `md5` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件MD5(防重复上传)',
  `upload_user_id` int NOT NULL COMMENT '上传人ID',
  `upload_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `uk_md5`(`md5` ASC) USING BTREE COMMENT 'MD5唯一索引(避免重复上传)'
) ENGINE = InnoDB AUTO_INCREMENT = 39 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '文件存储表' ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

我们采取直传的方式实现,这样有利于上传大文件,为什么不采用分片上传,后续在合并,这样比较复杂,会有很多维护逻辑,这个功能页面主要是存储一些工具,怎么快速实现,提高效率才是我们考虑的方案

建立文件数据模型

模型中的源码如下

复制代码
from extensions import db
from datetime import datetime

class File(db.Model):
    __tablename__ = 'file'

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    file_name = db.Column(db.String(255), nullable=False)
    file_path = db.Column(db.String(512), nullable=False)
    file_size = db.Column(db.BigInteger, nullable=False)
    file_type = db.Column(db.String(50))
    md5 = db.Column(db.String(32), unique=True)
    upload_user_id = db.Column(db.Integer, nullable=False)
    upload_time = db.Column(db.DateTime, default=datetime.now)

    md5 = db.Column(db.String(32), unique=True, nullable=False)

    def to_dict(self):
        return {
            'id': self.id,
            'file_name': self.file_name,
            'file_path': self.file_path,
            'file_size': self.file_size,
            'file_type': self.file_type,
            'md5': self.md5,
            'upload_user_id': self.upload_user_id,
            'upload_time': self.upload_time.strftime('%Y-%m-%d %H:%M:%S')
        }

定时后端创建上传文件的接口

复制代码
@upload_bp.post('/simple')
def simple_upload():
    try:
        # 1. 基础校验:是否有文件
        if 'file' not in request.files:
            return jsonify({"code": 400, "msg": "未选择文件"}), 400
        file = request.files['file']
        if file.filename == '':
            return jsonify({"code": 400, "msg": "文件名不能为空"}), 400

        # 2. 文件格式校验
        if not allowed_file(file.filename):
            allowed_str = ', '.join(ALLOWED_EXTENSIONS)
            return jsonify({
                "code": 400,
                "msg": f"不支持的文件格式,仅允许:{allowed_str}"
            }), 400

        # 3. 保存文件到服务器(按日期分类)
        date_dir = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
        save_dir = os.path.join(UPLOAD_FOLDER, date_dir)
        os.makedirs(save_dir, exist_ok=True)
        file_path = os.path.join(save_dir, file.filename)
        file.save(file_path)

        # 4. 记录到数据库
        file_size = os.path.getsize(file_path)
        new_file = File(
            file_name=file.filename,
            file_path=os.path.join(date_dir, file.filename),  # 相对路径
            file_size=file_size,
            upload_user_id=1  # 实际从登录态获取
        )
        db.session.add(new_file)
        db.session.commit()

        # 5. 返回成功结果
        return jsonify({
            "code": 200,
            "data": {
                "file_id": new_file.id,
                "file_name": new_file.file_name,
                "file_size": file_size
            },
            "msg": "上传成功"
        })

    except Exception as e:
        return jsonify({"code": 500, "msg": f"上传失败: {str(e)}"}), 500

我们还需要一个工具创建完成后,列表页面获取数据展示的接口,同时需要直接搜索,支持分页

复制代码
@tool_bp.get('/list')
def get_tool_list():
    """获取工具列表(含主文件ID,用于下载)"""
    page = request.args.get('page', 1, type=int)
    limit = request.args.get('limit', 10, type=int)
    search_key = request.args.get('searchKey', '').strip()

    # 1. 基础查询(仅返回ID和main_file_id有效且为正整数的工具)
    query = Tool.query.filter(
        Tool.id > 0,  # 过滤无效工具ID
        Tool.main_file_id > 0  # 过滤无效文件ID
    ).order_by(Tool.created_at.desc())

    # 2. 搜索功能
    if search_key:
        query = query.filter(
            db.or_(
                Tool.name.ilike(f'%{search_key}%'),
                Tool.version.ilike(f'%{search_key}%')
            )
        )

    # 3. 分页查询
    pagination = query.paginate(page=page, per_page=limit)
    tools = pagination.items

    # 4. 格式化响应(确保ID为数字)
    tool_list = []
    for tool in tools:
        main_file = File.query.get(tool.main_file_id)
        tool_list.append({
            "id": tool.id,  # 工具ID(数字)
            "name": tool.name,
            "version": tool.version,
            "main_file_id": tool.main_file_id,  # 文件ID(数字)
            "main_file_name": main_file.file_name if main_file else "未知文件",
            "file_size": main_file.file_size if main_file else 0,
            "created_at": tool.created_at.strftime("%Y-%m-%d %H:%M:%S")
        })

    return jsonify({
        "code": 20000,
        "data": {
            "items": tool_list,
            "total": pagination.total,
            "page": page,
            "limit": limit
        }
    })

另外还需要创建工具上传,此时需要一个创建工具的接口

复制代码
@tool_bp.post('/create')
def create_tool():
    """创建工具接口"""
    data = request.json

    # 1. 提取参数(增加默认值处理,避免KeyError)
    name = data.get('name', '').strip()
    version = data.get('version', '').strip()
    description = data.get('description', '').strip()
    main_file_id = data.get('main_file_id')  # 主文件ID(必填)
    user_id = data.get('user_id', 1)  # 用户ID(实际项目中从登录态获取)

    # 2. 参数校验(必须在return之前执行!)
    if not all([name, version, main_file_id]):
        return jsonify({"code": 40000, "message": "工具名称、版本和主文件ID为必填项"}), 400

    # 3. 主文件ID转换为整数
    try:
        main_file_id = int(main_file_id)
    except (ValueError, TypeError):
        return jsonify({"code": 40001, "message": "主文件ID必须为整数"}), 400

    # 4. 校验主文件是否存在
    main_file = File.query.get(main_file_id)
    if not main_file:
        return jsonify({"code": 40400, "message": f"主文件不存在(ID: {main_file_id})"}), 404

    # 5. 业务逻辑:创建工具记录(核心逻辑)
    try:
        new_tool = Tool(
            name=name,
            version=version,
            description=description,
            main_file_id=main_file_id,
            create_user_id=user_id,  # 关联创建者ID(用于列表接口权限过滤)
            created_at=datetime.now()  # 显式设置创建时间(确保排序正确)
        )
        db.session.add(new_tool)  # 添加到会话
        db.session.commit()       # 提交事务(关键:写入数据库)
        return jsonify({
            "code": 20000,
            "message": "工具创建成功",
            "data": new_tool.to_dict()  # 返回创建的工具详情(可选)
        })
    except Exception as e:
        db.session.rollback()  # 异常时回滚事务(避免数据不一致)
        current_app.logger.error(f"创建工具失败:{str(e)}")  # 记录错误日志
        return jsonify({"code": 50000, "message": f"创建失败:{str(e)}"}), 500

此时后端接口定义完成,并且我们对接口进行了测试,接口流程目前是通的,可以进行使用

接下来编写前端的代码

分别是

工具列表页面

复制代码
<template>
  <el-container>
    <!-- 侧边栏(保留原有宽度5px) -->
    <el-aside width="5px">
      <!-- 侧边导航内容(原样式保留) -->
    </el-aside>
    <!-- 主内容区(保留原有样式结构) -->
    <el-main class="main-content">
      <div class="tool-list-container">
        <!-- 顶部按钮和搜索区(保留原有布局) -->
        <el-row :gutter="20" class="header-row">
          <el-col :span="8">
            <el-input
              v-model="searchKey"
              placeholder="搜索工具名称/版本"
              clearable
              @keyup.enter.native="fetchTools"
              size="small"
            />
          </el-col>
          <el-col :span="4">
            <el-button
              type="primary"
              size="small"
              @click="fetchTools"
            >搜索</el-button>
          </el-col>
          <el-col :span="4" :offset="8" style="text-align: right;">
            <el-button
              type="primary"
              size="small"
              @click="goToCreate"
            >创建工具</el-button>
          </el-col>
        </el-row>

        <!-- 工具表格(保留原有列和样式) -->
        <el-table
          :data="toolList"
          style="width: 100%; margin-top: 20px;"
          border
          stripe
          size="small"
        >
          <el-table-column prop="name" label="工具名称" min-width="150" />
          <el-table-column prop="version" label="版本" min-width="80" />
          <el-table-column prop="main_file_name" label="主文件" min-width="180" />
          <el-table-column label="文件大小" min-width="100">
            <template slot-scope="scope">
              {{ formatFileSize(scope.row.file_size) }}
            </template>
          </el-table-column>
          <el-table-column prop="created_at" label="创建时间" min-width="160" />
          <el-table-column label="操作" min-width="180" fixed="right">
            <template slot-scope="scope">
              <!-- 下载按钮(修复:传递main_file_id,保留原有样式) -->
              <el-button
                type="text"
                size="small"
                @click="downloadTool(scope.row.main_file_id)"
                :disabled="!isValidId(scope.row.main_file_id)">
                <i class="el-icon-download" /> 下载  <!-- 保留原有图标 -->
              </el-button>
              <!-- 查看说明书按钮(修复:传递tool_id,保留原有样式) -->
              <el-button
                type="text"
                size="small"
                @click="viewManual(scope.row.id)"
                :disabled="!isValidId(scope.row.id)">
                <i class="el-icon-document" /> 查看说明书</el-button>
            </template>
          </el-table-column>
        </el-table>

        <!-- 分页控件(保留原有样式和事件) -->
        <el-pagination
          @current-change="handlePageChange"
          @size-change="handleSizeChange"
          :current-page="page"
          :page-size="limit"
          :total="total"
          layout="total, sizes, prev, pager, next, jumper"
          size="small"
          style="margin-top: 15px; text-align: right;"
        />
      </div>
    </el-main>
  </el-container>
</template>

<script>
import { getToolList } from '@/api/tool'// 保留原有接口导入

export default {
  data() {
    return {
      toolList: [], // 工具列表数据(原有)
      searchKey: '', // 搜索关键词(原有)
      page: 1, // 当前页码(原有)
      limit: 10, // 每页条数(原有)
      total: 0 // 总条数(原有)
    }
  },
  watch: {
    // 保留路由刷新监听(原有)
    '$route.query.refresh'(newVal) {
      if (newVal === 'true') {
        this.refreshList()
        this.$router.replace({ query: {}})
      }
    }
  },
  mounted() {
    this.fetchTools() // 页面加载时获取工具列表(原有)
  },
  methods: {
    /** 获取工具列表数据(保留原有逻辑) */
    async fetchTools() {
      try {
        const res = await getToolList({
          page: this.page,
          limit: this.limit,
          searchKey: this.searchKey
        })
        if (res.code === 20000) {
          this.toolList = res.data.items
          this.total = res.data.total
          // 调试:打印工具数据,确认ID是否有效
          console.log('工具列表数据:', this.toolList)
        } else {
          this.$message.error('获取工具列表失败')
        }
      } catch (e) {
        this.$message.error(`接口请求失败: ${e.message}`)
      }
    },
    refreshList() { // 保留原有刷新逻辑
      this.page = 1
      this.fetchTools()
    },
    formatFileSize(size) { // 保留原有文件大小格式化
      if (size <= 0) return '0 B'
      const units = ['B', 'KB', 'MB', 'GB']
      const i = Math.floor(Math.log(size) / Math.log(1024))
      return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
    },
    // 分页相关方法(全部保留原有)
    handlePageChange(page) {
      this.page = page
      this.fetchTools()
    },
    handleSizeChange(limit) {
      this.limit = limit
      this.page = 1
      this.fetchTools()
    },
    goToCreate() { // 保留跳转创建工具页
      this.$router.push('/tool/create')
    },

    /** 🌟 修复:下载功能(校验文件ID有效性) */
    downloadTool(fileId) {
      if (!this.isValidId(fileId)) {
        this.$message.warning('无效的文件ID,无法下载')
        return
      }
      window.open(`/api/tool/download/${fileId}`, '_self')
    },

    /** 🌟 修复:查看说明书(校验工具ID有效性) */
    viewManual(toolId) {
      if (!this.isValidId(toolId)) {
        this.$message.warning('无效的工具ID,无法查看说明书')
        return
      }
      this.$router.push(`/tool/manual/${toolId}`) // 传递有效toolId
    },

    /** 🌟 新增:通用ID校验(正整数判断) */
    isValidId(id) {
      return typeof id === 'number' && !isNaN(id) && id > 0
    }
  }
}
</script>

<style scoped>
/* 保留所有原有样式,未做任何修改 */
.main-content {
  padding: 20px;
  margin: 0;
  background-color: #f5f5f5;
}
.tool-list-container {
  background-color: white;
  padding: 20px;
  border-radius: 4px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.header-row {
  margin-bottom: 10px;
}
</style>

工具创建页面

复制代码
<template>
  <div class="tool-create">
    <el-card>
      <el-form :model="toolForm" :rules="rules" ref="toolForm" label-width="100px">
        <!-- 工具名称 -->
        <el-form-item label="工具名称" prop="name">
          <el-input v-model="toolForm.name" placeholder="请输入工具名称" />
        </el-form-item>

        <!-- 工具版本 -->
        <el-form-item label="工具版本" prop="version">
          <el-input v-model="toolForm.version" placeholder="请输入工具版本(如1.0.0)" />
        </el-form-item>

        <!-- 主文件上传 -->
        <el-form-item label="主文件上传" prop="mainFileId">
          <!-- 引入上传组件,传递后端接口地址 -->
          <file-upload
            :upload-url="uploadUrl"
            @upload-success="handleFileSuccess"
          />
          <!-- 显示已上传文件信息 -->
          <p v-if="mainFileInfo" class="file-info">
            已上传: {{ mainFileInfo.file_name }} ({{ formatFileSize(mainFileInfo.file_size) }})
          </p>
        </el-form-item>
<!--        <el-form-item label="说明书上传">-->
<!--          <file-upload-->
<!--            :upload-url="uploadUrl"-->
<!--            @upload-success="handleManualSuccess"-->
<!--          />-->
<!--          <p v-if="manualFileInfo" class="file-info">-->
<!--            已上传说明书: {{ manualFileInfo.file_name }} ({{ formatFileSize(manualFileInfo.file_size) }})-->
<!--          </p>-->
<!--        </el-form-item>-->

        <!-- 工具简介 -->
        <el-form-item label="工具简介" prop="description">
          <el-input type="textarea" v-model="toolForm.description" rows="4" placeholder="请输入工具简介(选填)" />
        </el-form-item>

        <!-- 提交按钮 -->
        <el-form-item>
          <el-button type="primary" @click="submitForm">创建工具</el-button>
          <el-button @click="resetForm">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script>
import FileUpload from '@/views/tools/FileUpload' // 引入上传组件
import { createTool } from '@/api/tool' // 引入创建工具接口

export default {
  components: { FileUpload },
  data() {
    return {
      // 后端接口地址(统一管理,方便切换环境)
      uploadUrl: 'http://172.16.60.60:5000/api/upload/simple', // 文件上传接口
      createToolUrl: 'http://172.16.60.60:5000/api/tool/create', // 创建工具接口
      manualFileInfo: null, // 说明书文件信息
      // 表单数据
      toolForm: {
        name: '', // 工具名称(必填)
        version: '', // 工具版本(必填)
        description: '', // 工具简介(选填)
        mainFileId: null // 主文件ID(上传后由子组件赋值)
      },
      mainFileInfo: null, // 已上传文件信息
      manual_file_id: null, // 新增:说明书文件ID
      // 表单校验规则
      rules: {
        name: [
          { required: true, message: '请输入工具名称', trigger: 'blur' },
          { min: 2, max: 50, message: '名称长度必须在2-50之间', trigger: 'blur' }
        ],
        version: [
          { required: true, message: '请输入工具版本', trigger: 'blur' },
          { pattern: /^[\d.]+$/, message: '版本号只能包含数字和点', trigger: 'blur' }
        ],
        mainFileId: [
          { required: true, message: '请上传主文件', trigger: 'change' }
        ]
      }
    }
  },
  methods: {
    // 接收说明书上传成功事件
    handleManualSuccess(fileId, fileInfo) {
      this.toolForm.manual_file_id = Number(fileId) // 存储说明书文件ID
      this.manualFileInfo = fileInfo
    },
    // 接收子组件传递的文件ID
    handleFileSuccess(fileId, fileInfo) {
      this.toolForm.mainFileId = Number(fileId) // 强制转为数字(关键:避免字符串ID)
      this.mainFileInfo = fileInfo
      console.log('文件上传成功,mainFileId:', this.toolForm.mainFileId) // 调试日志
    },

    // 提交表单
    async submitForm() {
      this.$refs.toolForm.validate(async(valid) => {
        if (valid) {
          try {
            // 构造创建工具的参数
            const toolParams = {
              name: this.toolForm.name.trim(),
              version: this.toolForm.version.trim(),
              description: this.toolForm.description.trim() || '', // 空字符串代替null
              main_file_id: this.toolForm.mainFileId, // 确保是数字类型
              user_id: 1, // 实际项目中从登录态获取,如store.state.user.id
              manual_file_id: this.toolForm.manual_file_id // 新增:传递说明书ID
            }
            console.log('提交创建工具参数:', toolParams) // 调试日志

            // 调用创建工具接口
            const response = await createTool(toolParams)
            console.log('创建工具接口响应:', response) // 调试日志

            // 后端返回成功(根据后端实际响应格式调整)
            if (response.code === 20000) {
              this.$message.success('工具创建成功!')
              // this.$router.push({path:'/tool/list',query:{refresh:'true'}}),// 跳转到工具列表页
              this.$router.push({
                path: '/tool/list',
                query: { refresh: 'true' }})
            } else {
              this.$message.error(`创建失败: ${response.msg || '未知错误'}`)
            }
          } catch (e) {
            // 捕获网络错误或接口异常
            console.error('创建工具失败(异常):', {
              status: e.response?.status,
              data: e.response?.data,
              message: e.message
            })
            this.$message.error(`创建失败: ${e.response?.data?.msg || e.message || '网络错误'}`)
          }
        } else {
          this.$message.warning('表单填写不完整,请检查!')
        }
      })
    },

    // 重置表单
    resetForm() {
      this.$refs.toolForm.resetFields()
      this.mainFileInfo = null
      this.toolForm.mainFileId = null
    },

    // 格式化文件大小
    formatFileSize(size) {
      if (!size) return '0 KB'
      if (size < 1024 * 1024) {
        return (size / 1024).toFixed(2) + ' KB'
      } else {
        return (size / (1024 * 1024)).toFixed(2) + ' MB'
      }
    }
  }
}
</script>

<style scoped>
.file-info { margin-top: 8px; color: #606266; font-size: 14px; }
</style>

定义前端的接口和路由部分,这里我们就不写了,都是一样的操作,想想看过前面的文章,定义路由和接口,你已经很熟悉了

重新启动前端和后端,此时我们测试页面是不是正常,功能正常可以使用

上传工具后跳转工具列表页面,展示工具的名称和大小信息,其他人员在这个页面就可以下载和获取工具去使用,菜单栏中有个查看说明书的功能,点击后可以查看说明书信息,我们在前面章节讲解了vue中集成编辑器的功能,查看上一章,编辑器的作用主要用在这里进行说明书的编写,导出说明书

效果如下

可以看到说明书信息,也可以导出文件到本地发送给其他人员

工具上传后,当然我们还需要能将这个工具下载下来进行使用,别的同事可以在工具页面点击后直接可以下载使用

实现下载接口

复制代码
@tool_bp.get('/download/<int:file_id>')
def download_file(file_id):
    try:
        # 参数校验
        if file_id <= 0:
            return jsonify({"code": 40000, "message": "文件ID必须为正整数"}), 400

        # 查询文件记录
        file = File.query.get(file_id)
        if not file:
            return jsonify({"code": 40400, "message": "文件记录不存在"}), 404

        # 构建绝对路径(关键修改点)
        base_dir = "/workplaywright/Flask_vss_rebort/uploads"  # 必须与原上传接口存储根目录一致
        full_path = os.path.join(base_dir, file.file_path)  # 拼接绝对路径
        full_path = os.path.abspath(full_path)  # 标准化路径

        # 安全检查:防止路径穿越
        if not full_path.startswith(os.path.abspath(base_dir)):
            return jsonify({"code": 40300, "message": "非法文件路径"}), 403

        # 验证文件存在
        if not os.path.exists(full_path):
            current_app.logger.error(f"文件不存在: {full_path}")
            return jsonify({"code": 40400, "message": "文件不存在或已删除"}), 404

        # 发送文件
        return send_file(
            full_path,
            as_attachment=True,
            download_name=file.file_name,
            mimetype='application/octet-stream'
        )

    except Exception as e:
        current_app.logger.error(f"下载失败: {str(e)}")
        return jsonify({"code": 50000, "message": f"文件下载失败: {str(e)}"}), 500

定义前端下载调用

复制代码
downloadTool(fileId) {
      if (!this.isValidId(fileId)) {
        this.$message.warning('无效的文件ID,无法下载')
        return
      }
      window.open(`/api/tool/download/${fileId}`, '_self')
    },

重新启动前端和后端服务,上传一个工具,验证此时点击下载,是不是可以下载成功

前端点击下载后,可以下载文件,工具列表展示,下载,工具实现完成

相关推荐
薰衣草23337 小时前
滑动窗口(2)——不定长
python·算法·leetcode
User_芊芊君子8 小时前
【JavaSE】复习总结
java·开发语言·python
计算机毕业设计木哥8 小时前
计算机毕业设计 基于Python+Django的医疗数据分析系统
开发语言·hadoop·后端·python·spark·django·课程设计
Python×CATIA工业智造8 小时前
Python索引-值对迭代完全指南:从基础到高性能系统设计
python·pycharm
Luchang-Li9 小时前
sglang pytorch NCCL hang分析
pytorch·python·nccl
Digitally9 小时前
如何在安卓手机/平板上找到下载文件?
android·智能手机·电脑
硬件学长森哥12 小时前
Android影像基础--cameraAPI2核心流程
android·计算机视觉
一个天蝎座 白勺 程序猿14 小时前
Python爬虫(47)Python异步爬虫与K8S弹性伸缩:构建百万级并发数据采集引擎
爬虫·python·kubernetes
XiaoMu_00115 小时前
基于Django+Vue3+YOLO的智能气象检测系统
python·yolo·django