ODOO18菜鸟二次开发系列(6)-自定义QWEB组件,ODOO18List(Tree) 视图增加按钮

该文章已生成可运行项目,


前言

从本章开始内容将比较深,建议至少看完开发手册再继续:

odoo18的联系人和此前的一样,联系人中包含了人和法人(公司),打开后看起来非常杂乱,此前因为学的不够深入,因此只能靠默认筛选功能去设置,但对于很多新用户来说,筛选其一要点两次,其二不够直观,因此准备在“新建”动作的侧面加一个仅显示公司的按钮,点击后清单视图自动只保留公司,效果如下。在这里插入图片描述


一、总体思路

最开始用AI去自动修改,但被误导到将按钮加到list的字段中,比如如下的继承写法

            <xpath expr="//list" position="attributes">
                <attribute name="editable">bottom</attribute>
            </xpath>
            <xpath expr="//list/field[@name='complete_name']" position="after">
                <button name="action_view_company" type="object" string="查看" class="oe_highlight" title="查看公司信息"/>

然后就得到了前图中“查看”的状态,每行显示一个botton,刚好因为设置了editable,无法点击查看,因此顺带设置action_view_company可点击打开表单视图,注意new时可浮窗,current新窗。

    def action_view_company(self):
        self.ensure_one()
        # 返回打开公司表单视图的动作
        return {
            'type': 'ir.actions.act_window',
            'name': _('Company'),
            'res_model': 'res.partner',
            'res_id': self.id,
            'view_mode': 'form',
            'target': 'new',  # 在当前窗口打开
            'views': [[False, 'form']], # 强制使用表单视图
        }

此后因为比较菜,在源代码内试了将 <button放到<list前面,弹出错误说<list前面不能时data,印象form视图前面可以时 <button和<div的,这样说的话只能另外想办法。最后参考任务列表,另外加优快云搜索,了解到可以list 内加js-class实现按钮。
总体思路是,创建js_class组件,设置对应的动作和模板,然后组件可放到<list 内。比较奇怪的是“新建”按钮还没有发现在那里。

二、创建步骤

1.文件架构

在这里插入图片描述

2.视图调用

crm_extension_views中作为list的属性调用,注意AI一直沉迷于旧版tree,必须强制唤醒为list。

    <record id="view_partner_tree_inherit_list" model="ir.ui.view">
        <field name="name">res.partner.tree.inherit.list</field>
        <field name="model">res.partner</field>
        <field name="inherit_id" ref="base.view_partner_tree"/>
        <field name="arch" type="xml">
            <xpath expr="//list" position="attributes">
                    <attribute name="js_class">partner_list_company</attribute>
            </xpath>

3.前端js组件定义

Js代码可以改完后直接刷新页面执行,不需要重启服务。res_partner_list_view.js:

/** @odoo-module */

import { registry } from "@web/core/registry";
import { listView } from '@web/views/list/list_view';
import { ListController } from "@web/views/list/list_controller";
import { ListRenderer } from "@web/views/list/list_renderer";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";

export class ResPartnerListController extends ListController {
    constructor() {
        super(...arguments);
        // Bind methods in the constructor
        this.onClickCustomBtn = this.onClickCustomBtn.bind(this);
        this.testMethod = this.testMethod.bind(this);
    }

    // Ensure methods are defined as part of the class prototype
    async onClickCustomBtn() {
        console.log("onClickCustomBtn method called!");
        try {
            const action = await this.orm.call(
                this.props.resModel,
                "action_custom_button",
                [[]]
            );
            console.log("ORM call returned:", action);
            if (action) {
                console.log("Attempting to execute action...");
                await this.actionService.doAction(action); // <-- 恢复这一行
                console.log("Action execution attempted/completed.");
                // 如果 doAction 成功,通知应该会自动显示
            } else {
                this.notification.add(_t("操作已执行,但无后续动作"), { type: "info" });
            }
        } catch (error) {
            console.error("Error during action execution:", error); // 注意错误日志
             if (error.message && error.message.includes("Odoo Server Error")) {
                 this.notification.add(_t("执行操作时发生服务器错误"), { type: "danger" });
             } else {
                 this.notification.add(_t("执行操作时发生前端错误: " + error.message), { type: "danger" });
                 // 如果需要详细堆栈,取消下面注释
                 // throw error;
             }
        }
    }

    testMethod() {
        console.log("Test method called!");
    }

    setup() {
        super.setup();
        console.log("ResPartnerListController setup", this); // Log controller instance
        this.notification = useService("notification");
        this.orm = useService("orm"); // 注入 ORM 服务
        this.actionService = useService("action"); // 注入 Action 服务

4.定义Qweb模板

注意这里的t-on写法被Gemini2.5误导了至少一天时间,直到自己发现问题,gemini还在嘴硬。
在这里插入图片描述
在这里插入图片描述
好在本人有怀疑一切的精神,而且看了下源代码大部分都是t-on-click,最后成功解决问题。

res_partner_list_view.js:

/** @odoo-module */
<?xml version="1.0" encoding="UTF-8"?>
<!-- static/src/xml/custom_list_buttons.xml -->
<templates>
    <!-- buttonTemplate 指定的模板名称 -->
    <t t-name="custom_crm_extension.ResPartnerListView.Buttons">
        <!-- 这里是默认的创建和导入按钮 -->
        <!-- 您可以在这里根据需要保留或移除 -->
        <t t-call="web.ListView.Buttons"/>

        <!-- 添加您的自定义按钮 -->
        <!-- 使用 t-on="click" 绑定点击事件 -->
        <!-- 通常,Controller 实例在模板中可以通过变量 'controller' 访问 -->
            <button type="button" icon="fa fa-building-o" class="btn btn-secondary o_button_download_import_tmpl"
                t-on-click.stop.prevent="onClickCustomBtn">
                仅显示公司
            </button>

    </t>
</templates>

5.后台模块处理

res_partner_extension:

class ResPartner(models.Model):
    _inherit = 'res.partner' # 或者 _name = 'res.partner'

    # 可以考虑添加 @api.model 装饰器,表明这是一个模型级方法,
    # 但如果不需要访问模型元数据,仅返回固定动作则非必需。
    # @api.model
    def action_custom_button(self):
        # 构建一个 ir.actions.act_window 类型的字典
        action = {
            'name': _('公司'),  # 这个动作的名称,会显示在面包屑导航或窗口标题
            'type': 'ir.actions.act_window',
            'res_model': 'res.partner',  # 目标模型
            'view_mode': 'list,form',  # 保持常用的视图模式
            # 关键:定义过滤条件,只显示 is_company 为 True 的记录
            'domain': [('is_company', '=', True)],
            # target: 'current' 表示在当前主内容区域更新视图,而不是弹窗
            'target': 'current',
            # 可以选择性地传递上下文,比如保留当前的上下文
            'context': self.env.context,
            'views': [(self.env.ref('base.view_partner_tree').id, 'list'), (False, 'form')],
            # 可选:如果你想强制使用特定的 Tree 视图 XML ID
            # 'view_id': self.env.ref('your_module.your_specific_tree_view_id').id,
            # 'search_view_id': [self.env.ref('base.view_res_partner_filter').id, 'search'], # 可以指定搜索视图
        }
        return action

这里要再次强调tree已经被ODOO18抛弃了,但各种大模型由于权重原因,非常容易写tree,导致很多bug,因此要有敏感性看到tree要第一时间改list。


总结

Odoo18 联系人含人和公司,界面杂乱,筛选不便。决定在 “新建” 旁加 “仅显示公司” 按钮。创建步骤包括视图调用中用js_class,前端定义 JS 组件和 Qweb 模板,后台模块处理设置过滤条件,要注意避免大模型生成的 “tree” 写法。
最后不管是Gemini也好还是Claude还是说其他的辅助工具,都要对其有一定的怀疑精神,而且可以互证。

本文章已经生成可运行项目
<think>我们正在处理一个关于Odoo14二次开发的问题:如何实现模型附件的一键打包批量下载功能。根据用户提供的引用信息,我们可以整合出以下步骤:1.在模型中添加计算附件数量的字段和查看附件的动作(引用[3])。2.在前端添加一个按钮,用于触发批量下载(引用[2])。3.编写一个控制器(Controller)来处理批量下载请求,将多个附件打包成ZIP文件并提供下载。具体步骤:###1.模型层(Model)在目标模型中,我们需要有一个字段来记录附件数量(如引用[3]所示),以及一个方法用于打开附件视图。但为了实现批量下载,我们还需要一个方法用于获取指定记录的所有附件。在模型(例如`sale.order`)中添加:```pythonclassSaleOrder(models.Model):_inherit='sale.order'#计算附件数量的方法(引用[3])def_compute_attachment_number(self):#...实现计算附件数量,同引用[3]#新增:获取当前记录集的所有附件def_get_attachments(self):self.ensure_one()returnself.env['ir.attachment'].search([('res_model','=',self._name),('res_id','=',self.id)])```###2.前端按钮(ViewandJS)在列表视图(或其他视图)中添加一个按钮,并绑定点击事件(如引用[2]所示)。在自定义模块的XML文件中,为列表视图添加按钮:```xml<recordid="view_order_tree"model="ir.ui.view"><fieldname="name">sale.order.tree</field><fieldname="model">sale.order</field><fieldname="inherit_id"ref="sale.view_order_tree"/><fieldname="arch"type="xml"><xpathexpr="//tree"position="inside"><buttonname="download_attachments"string="下载附件"type="object"class="oe_highlight"/></xpath></field></record>```注意:这里我们使用了`type="object"`,这样点击按钮会调用模型中的`download_attachments`方法。但通常,我们更倾向于使用客户端动作(如引用[2]中通过JS调用)以避免服务器端长时间处理导致超时。因此,我们可以采用引用[2]的方法,在JS中调用一个控制器。###3.创建控制器(Controller)我们需要一个控制器来接收记录ID,打包附件并返回ZIP文件。首先,在模块中创建控制器文件(如`controllers/main.py`):```python#-*-coding:utf-8-*-importosimportzipfilefromodooimporthttpfromodoo.httpimportrequest,content_dispositionclassDownloadAttachmentsController(http.Controller):@http.route('/web/binary/download_attachments',type='http',auth="user")defdownload_attachments(self,attachment_ids,model_name,**kwargs):#将attachment_ids转换为整数列表attachment_ids=[int(id)foridinattachment_ids.split(',')]#获取附件对象attachments=request.env['ir.attachment'].sudo().search([('id','in',attachment_ids),('res_model','=',model_name)])#创建临时ZIP文件zip_filename='attachments.zip'zip_path='/tmp/'+zip_filenamewithzipfile.ZipFile(zip_path,'w')aszipf:forattachmentinattachments:#获取附件内容file_content=attachment.raw#写入ZIP文件zipf.writestr(attachment.name,file_content)#读取ZIP文件内容withopen(zip_path,'rb')asf:zip_data=f.read()#删除临时文件os.remove(zip_path)#返回响应headers=[('Content-Type','application/zip'),('Content-Disposition',content_disposition(zip_filename)),]returnrequest.make_response(zip_data,headers)```###4.前端JS调用控制器修改之前的JS代码(如引用[2]),在点击按钮时调用这个控制器。在自定义JS中(如`static/src/js/download_button.js`):```javascriptodoo.define('your_module.download_button',function(require){"usestrict";varListController=require('web.ListController');varrpc=require('web.rpc');ListController.include({renderButtons:function($node){this._super.apply(this,arguments);if(this.$buttons){this.$buttons.on('click','.download_attachments_button',this.downloadAttachments.bind(this));}},downloadAttachments:function(){varself=this;varattachment_ids=this.getSelectedIds();varmodel_name=this.modelName;//调用控制器rpc.query({route:'/web/binary/download_attachments',params:{attachment_ids:attachment_ids.join(','),model_name:model_name,},}).then(function(result){//创建一个隐藏的<a>标签用于触发下载vara=document.createElement('a');a.href=window.URL.createObjectURL(newBlob([result],{type:'application/zip'}));a.download='attachments.zip';document.body.appendChild(a);a.click();document.body.removeChild(a);});}});});```注意:上面的JS代码中,我们通过rpc调用控制器,控制器返回ZIP文件的二进制数据,然后我们创建一个临时的a标签触发下载。###5.安全考虑-在控制器中,我们使用`sudo()`来确保用户有权访问附件(因为附件可能属于不同用户)。但注意,我们已经在控制器中检查了当前用户的权限(通过`auth="user"`),并且只下载与指定模型和记录相关的附件。-注意:在搜索附件时,我们添加了`('res_model','=',model_name)`条件,确保只下载属于当前模型的附件。###6.注册JS文件在模块的模板XML中注册JS文件:```xml<templateid="assets_backend"name="yourmoduleassets"inherit_id="web.assets_backend"><xpathexpr="."position="inside"><scripttype="text/javascript"src="/your_module/static/src/js/download_button.js"></script></xpath></template>```###总结通过以上步骤,我们实现了:1.在模型层获取附件。2.在视图添加下载按钮。3.通过控制器将多个附件打包成ZIP文件并提供下载。4.通过前端JS调用控制器并触发下载。这样,用户就可以在列表视图中选择多条记录,然后点击按钮下载所有附件(打包为一个ZIP文件)。###相关问题1.如何优化控制器的打包过程,避免大文件导致的内存问题?2.在Odoo中如何实现后台任务(如使用队列)来处理耗时的打包操作?3.如何限制用户只能下载自己有权限访问的附件?</think>### Odoo 14 实现模型附件一键打包批量下载方案 #### 核心实现步骤: ##### 1. **模型层添加方法** ```python from odoo import models, api, http import zipfile import io class YourModel(models.Model): _inherit = 'your.target.model' # 替换为实际模型名 def action_download_attachments(self): """ 生成ZIP包的核心逻辑 """ zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, 'w') as zip_file: for record in self: attachments = self.env['ir.attachment'].search([ ('res_model', '=', self._name), ('res_id', '=', record.id) ]) for attachment in attachments: zip_file.writestr( f"{record.name}/{attachment.name}", # 按记录名建立文件夹 attachment.raw ) zip_buffer.seek(0) return { 'type': 'ir.actions.act_url', 'url': f'/web/binary/download_zip?model={self._name}&ids={self.ids}', 'target': 'self', } ``` ##### 2. **创建控制器处理下载** ```python class AttachmentController(http.Controller): @http.route('/web/binary/download_zip', type='http', auth="user") def download_zip(self, model, ids, **kwargs): ids = [int(i) for i in ids.strip('[]').split(',')] records = http.request.env[model].browse(ids) zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, 'w') as zip_file: for record in records: attachments = http.request.env['ir.attachment'].search([ ('res_model', '=', model), ('res_id', '=', record.id) ]) for att in attachments: zip_file.writestr( f"{record.name}/{att.name}", att.raw ) zip_buffer.seek(0) return http.request.make_response( zip_buffer.getvalue(), headers=[ ('Content-Type', 'application/zip'), ('Content-Disposition', f'attachment; filename="{model}_attachments.zip"') ] ) ``` ##### 3. **前端添加下载按钮** ```xml <!-- 列表视图添加按钮 --> <record id="view_your_model_tree" model="ir.ui.view"> <field name="name">your.model.tree</field> <field name="model">your.target.model</field> <field name="inherit_id" ref="original_view_id"/> <field name="arch" type="xml"> <xpath expr="//tree" position="inside"> <button name="action_download_attachments" string="下载附件包" type="object" class="oe_highlight"/> </xpath> </field> </record> <!-- 表单视图添加按钮 --> <record id="view_your_model_form" model="ir.ui.view"> <field name="name">your.model.form</field> <field name="model">your.target.model</field> <field name="inherit_id" ref="original_form_view_id"/> <field name="arch" type="xml"> <xpath expr="//sheet" position="inside"> <button name="action_download_attachments" string="下载所有附件" type="object" class="oe_highlight"/> </xpath> </field> </record> ``` #### 关键技术点: 1. **内存优化**:使用 `io.BytesIO` 避免临时文件写入磁盘 2. **安全控制**: - 通过 `auth="user"` 确保用户认证 - 自动应用当前用户的权限规则 3. **批量处理**: - 支持多记录同时打包(列表视图勾选多条记录) - 按记录名称自动创建文件夹归类 4. **文件命名**: - ZIP文件名包含模型名和时间戳:`${model}_attachments_${date}.zip` - 保持原始附件文件名不变 #### 性能优化建议: 1. 大文件处理:添加分片压缩逻辑 2. 后台任务:超过50个附件时启用队列任务 ```python # 在方法中添加 if len(attachments) > 50: return self.enqueue_zip_creation() ``` #### 使用效果: 1. 列表视图:勾选多条记录 → 点击"下载附件包" 2. 表单视图:打开单条记录 → 点击"下载所有附件" 3. 自动下载ZIP包,包含所有附件并按记录名分类存储 [^1]: 基于Odoo开发者模式自定义功能实现 [^2]: 前端批量操作实现参考 [^3]: 附件处理基础方法
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值