From c2665f4c2155590b17209dcebf27479e3b3a2365 Mon Sep 17 00:00:00 2001 From: thienvh Date: Wed, 25 Feb 2026 11:37:04 +0700 Subject: [PATCH 1/2] [IMP] ai_oca_bridge: add form button support and related UI components --- ai_oca_bridge/__manifest__.py | 1 + ai_oca_bridge/models/ai_bridge.py | 28 +++++++-- ai_oca_bridge/models/mail_thread.py | 49 ++++++++++++++- .../components/ai_form_btn/ai_form_btn.esm.js | 36 +++++++++++ .../components/ai_form_btn/ai_form_btn.xml | 16 +++++ .../templates/ai_form_btn_container.xml | 14 +++++ ai_oca_bridge/tests/test_bridge.py | 60 +++++++++++++++++++ ai_oca_bridge/views/ai_bridge.xml | 4 ++ 8 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 ai_oca_bridge/static/src/components/ai_form_btn/ai_form_btn.esm.js create mode 100644 ai_oca_bridge/static/src/components/ai_form_btn/ai_form_btn.xml create mode 100644 ai_oca_bridge/templates/ai_form_btn_container.xml diff --git a/ai_oca_bridge/__manifest__.py b/ai_oca_bridge/__manifest__.py index 6ec0eb8e..0641b23e 100644 --- a/ai_oca_bridge/__manifest__.py +++ b/ai_oca_bridge/__manifest__.py @@ -18,6 +18,7 @@ "views/menu.xml", "views/ai_bridge_execution.xml", "views/ai_bridge.xml", + "templates/ai_form_btn_container.xml", ], "assets": { "web.assets_backend": [ diff --git a/ai_oca_bridge/models/ai_bridge.py b/ai_oca_bridge/models/ai_bridge.py index 5c134078..38ca4428 100644 --- a/ai_oca_bridge/models/ai_bridge.py +++ b/ai_oca_bridge/models/ai_bridge.py @@ -30,13 +30,20 @@ class AiBridge(models.Model): [ ("none", "None"), ("thread", "Thread"), + ("form_btn", "Form Button"), ("ai_thread_create", "On Record Created"), ("ai_thread_write", "On Record Updated"), ("ai_thread_unlink", "On Record Deleted"), ], default="none", help="Defines how this bridge is used. " - "If 'Thread', it will be used in the mail thread context.", + "If 'Thread', it will be used in the mail thread context. " + "If 'Form Button', a button will be injected into the record's form view.", + ) + form_btn_label = fields.Char( + string="Button Label", + translate=True, + help="Label shown on the form button. Defaults to the bridge name if empty.", ) name = fields.Char(required=True, translate=True) active = fields.Boolean(default=True) @@ -167,11 +174,13 @@ def _compute_model_fields(self): record.update(record._get_model_fields()) def _get_model_fields(self): - if self.usage == "thread": - return { - "model_required": True, - } - if self.usage in ["ai_thread_create", "ai_thread_write", "ai_thread_unlink"]: + if self.usage in [ + "thread", + "form_btn", + "ai_thread_create", + "ai_thread_write", + "ai_thread_unlink", + ]: return { "model_required": True, } @@ -204,6 +213,13 @@ def _compute_execution_count(self): def _get_info(self): return {"id": self.id, "name": self.name, "description": self.description} + def _get_form_btn_info(self): + return { + "id": self.id, + "label": self.form_btn_label or self.name, + "description": self.description, + } + def execute_ai_bridge(self, res_model, res_id): self.ensure_one() if not self.active or ( diff --git a/ai_oca_bridge/models/mail_thread.py b/ai_oca_bridge/models/mail_thread.py index f3a3f90c..d3e86af4 100644 --- a/ai_oca_bridge/models/mail_thread.py +++ b/ai_oca_bridge/models/mail_thread.py @@ -10,7 +10,9 @@ class MailThread(models.AbstractModel): _inherit = "mail.thread" - ai_bridge_info = fields.Json(compute="_compute_ai_bridge_info", store=False) + ai_bridge_info = fields.Json(compute="_compute_ai_bridge_info") + ai_form_btn_info = fields.Json(compute="_compute_ai_form_btn_info") + ai_has_form_btn = fields.Boolean(compute="_compute_ai_form_btn_info") @api.depends() def _compute_ai_bridge_info(self): @@ -28,6 +30,24 @@ def _get_ai_bridge_info(self): .filtered(lambda r: r._enabled_for(self)) ) + @api.depends() + def _compute_ai_form_btn_info(self): + for record in self: + bridges = record._get_ai_form_btn_bridge_info() + record.ai_form_btn_info = [ + bridge._get_form_btn_info() for bridge in bridges + ] + record.ai_has_form_btn = bool(bridges) + + def _get_ai_form_btn_bridge_info(self): + self.ensure_one() + model_id = self.env["ir.model"].sudo().search([("model", "=", self._name)]).id + return ( + self.env["ai.bridge"] + .search([("model_id", "=", model_id), ("usage", "=", "form_btn")]) + .filtered(lambda r: r._enabled_for(self)) + ) + @api.model def get_view(self, view_id=None, view_type="form", **options): res = super().get_view(view_id=view_id, view_type=view_type, **options) @@ -52,6 +72,31 @@ def get_view(self, view_id=None, view_type="form", **options): else: all_models[model] = res["models"][model] node.addprevious(new_node) + + # Inject form buttons before if form_btn bridges exist for this model + sheet_nodes = doc.xpath("//sheet[not(ancestor::field)]") + if sheet_nodes: + model_id = self.env["ir.model"].sudo()._get_id(self._name) + if model_id and self.env["ai.bridge"].sudo().search_count( + [ + ("model_id", "=", model_id), + ("usage", "=", "form_btn"), + ("active", "=", True), + ] + ): + str_element = self.env["ir.qweb"]._render( + "ai_oca_bridge.ai_form_btn_container", {} + ) + new_arch, new_models = View.postprocess_and_fields( + etree.fromstring(str_element), self._name + ) + for model, model_fields in new_models.items(): + all_models[model] = tuple( + set(all_models.get(model, ())) | set(model_fields) + ) + for node in sheet_nodes: + node.addprevious(etree.fromstring(new_arch)) + res["arch"] = etree.tostring(doc) res["models"] = frozendict(all_models) return res @@ -65,4 +110,6 @@ def _get_view_fields(self, view_type, models): result = super()._get_view_fields(view_type, models) if view_type == "form": result[self._name].add("ai_bridge_info") + result[self._name].add("ai_form_btn_info") + result[self._name].add("ai_has_form_btn") return result diff --git a/ai_oca_bridge/static/src/components/ai_form_btn/ai_form_btn.esm.js b/ai_oca_bridge/static/src/components/ai_form_btn/ai_form_btn.esm.js new file mode 100644 index 00000000..eda99b89 --- /dev/null +++ b/ai_oca_bridge/static/src/components/ai_form_btn/ai_form_btn.esm.js @@ -0,0 +1,36 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; + +class AiFormBtnWidget extends Component { + setup() { + this.orm = useService("orm"); + this.actionService = useService("action"); + this.notification = useService("notification"); + } + + async onClickBridge(bridge) { + const record = this.props.record; + if (!record.resId) { + return; + } + const result = await this.orm.call("ai.bridge", "execute_ai_bridge", [ + [bridge.id], + record.resModel, + record.resId, + ]); + if (result && result.action) { + this.actionService.doAction(result.action); + } else if (result && result.notification) { + this.notification.add(result.notification.body, result.notification.args); + } + } +} + +AiFormBtnWidget.template = "ai_oca_bridge.AiFormBtn"; +AiFormBtnWidget.props = {...standardFieldProps}; + +registry.category("fields").add("ai_form_btn", AiFormBtnWidget); diff --git a/ai_oca_bridge/static/src/components/ai_form_btn/ai_form_btn.xml b/ai_oca_bridge/static/src/components/ai_form_btn/ai_form_btn.xml new file mode 100644 index 00000000..afe83804 --- /dev/null +++ b/ai_oca_bridge/static/src/components/ai_form_btn/ai_form_btn.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/ai_oca_bridge/templates/ai_form_btn_container.xml b/ai_oca_bridge/templates/ai_form_btn_container.xml new file mode 100644 index 00000000..a5ab524b --- /dev/null +++ b/ai_oca_bridge/templates/ai_form_btn_container.xml @@ -0,0 +1,14 @@ + + + + diff --git a/ai_oca_bridge/tests/test_bridge.py b/ai_oca_bridge/tests/test_bridge.py index 7a06239b..4cfd3c19 100644 --- a/ai_oca_bridge/tests/test_bridge.py +++ b/ai_oca_bridge/tests/test_bridge.py @@ -41,6 +41,15 @@ def setUpClass(cls): "name": "Test Group", } ) + cls.form_btn_bridge = cls.env["ai.bridge"].create( + { + "name": "Test Form Btn", + "model_id": cls.env.ref("base.model_res_partner").id, + "url": "https://example.com/api", + "auth_type": "none", + "usage": "form_btn", + } + ) def test_bridge_none_auth(self): self.assertEqual(self.bridge.auth_type, "none") @@ -365,3 +374,54 @@ def test_bridge_execute_computed_fields(self): self.assertEqual( execution.payload["_id"], json.loads(execution.payload_txt)["_id"] ) + + def test_form_btn_info(self): + self.assertIn( + self.form_btn_bridge.id, + [b["id"] for b in self.partner.ai_form_btn_info], + ) + self.assertTrue(self.partner.ai_has_form_btn) + + def test_form_btn_domain_filtering(self): + self.assertIn( + self.form_btn_bridge.id, + [b["id"] for b in self.partner.ai_form_btn_info], + ) + self.form_btn_bridge.domain = f"[('id', '!=', {self.partner.id})]" + self.partner.invalidate_recordset() + self.assertNotIn( + self.form_btn_bridge.id, + [b["id"] for b in (self.partner.ai_form_btn_info or [])], + ) + self.assertFalse(self.partner.ai_has_form_btn) + + def test_form_btn_group_filtering(self): + self.assertIn( + self.form_btn_bridge.id, + [b["id"] for b in self.partner.ai_form_btn_info], + ) + self.form_btn_bridge.group_ids = [(4, self.group.id)] + self.partner.invalidate_recordset() + self.assertNotIn( + self.form_btn_bridge.id, + [b["id"] for b in (self.partner.ai_form_btn_info or [])], + ) + self.env.user.groups_id |= self.group + self.partner.invalidate_recordset() + self.assertIn( + self.form_btn_bridge.id, + [b["id"] for b in self.partner.ai_form_btn_info], + ) + + def test_form_btn_view_injection(self): + view = self.partner.get_view(view_type="form") + self.assertIn("ai_form_btn_info", view["models"][self.partner._name]) + self.assertIn("ai_has_form_btn", view["models"][self.partner._name]) + self.assertIn(b'name="ai_form_btn_info"', view["arch"]) + + def test_form_btn_thread_isolation(self): + """form_btn bridges must not appear in ai_bridge_info and vice versa.""" + thread_ids = [b["id"] for b in self.partner.ai_bridge_info] + form_btn_ids = [b["id"] for b in self.partner.ai_form_btn_info] + self.assertNotIn(self.form_btn_bridge.id, thread_ids) + self.assertNotIn(self.bridge.id, form_btn_ids) diff --git a/ai_oca_bridge/views/ai_bridge.xml b/ai_oca_bridge/views/ai_bridge.xml index 2536e4cb..a79272ae 100644 --- a/ai_oca_bridge/views/ai_bridge.xml +++ b/ai_oca_bridge/views/ai_bridge.xml @@ -22,6 +22,10 @@ + Date: Wed, 25 Feb 2026 14:56:27 +0700 Subject: [PATCH 2/2] Upgrade project template to fix CI --- .copier-answers.yml | 2 +- .gitattributes | 1 + .github/workflows/pre-commit.yml | 2 + .github/workflows/test.yml | 9 ++++- .pre-commit-config.yaml | 4 +- .pylintrc | 69 ++++++++++++++++---------------- .pylintrc-mandatory | 50 +++++++++++------------ README.md | 5 ++- 8 files changed, 77 insertions(+), 65 deletions(-) create mode 100644 .gitattributes diff --git a/.copier-answers.yml b/.copier-answers.yml index 7cd376fa..fd4618e3 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: v1.32 +_commit: v1.39 _src_path: git+https://github.com/OCA/oca-addons-repo-template ci: GitHub convert_readme_fragments_to_markdown: false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e0d56685 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +test-requirements.txt merge=union diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index afd7524e..43b82fe8 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -17,6 +17,8 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" + cache: 'pip' + cache-dependency-path: '.pre-commit-config.yaml' - name: Get python version run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV - uses: actions/cache@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d0f9528..5ec4eb78 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,7 @@ jobs: makepot: "true" services: postgres: - image: postgres:12.0 + image: postgres:12 env: POSTGRES_USER: odoo POSTGRES_PASSWORD: odoo @@ -63,6 +63,13 @@ jobs: run: oca_init_test_database - name: Run tests run: oca_run_tests + - name: Upload screenshots from JS tests + uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: Screenshots of failed JS tests - ${{ matrix.name }}${{ join(matrix.include) }} + path: /tmp/odoo_tests/${{ env.PGDATABASE }} + if-no-files-found: ignore - uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38326cc5..fe5010b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: language: fail files: '[a-zA-Z0-9_]*/i18n/en\.po$' - repo: https://github.com/oca/maintainer-tools - rev: d5fab7ee87fceee858a3d01048c78a548974d935 + rev: f9b919b9868143135a9c9cb03021089cabba8223 hooks: # update the NOT INSTALLABLE ADDONS section above - id: oca-update-pre-commit-excluded-addons @@ -141,7 +141,7 @@ repos: - --settings=. exclude: /__init__\.py$ - repo: https://github.com/acsone/setuptools-odoo - rev: 3.1.8 + rev: 3.3.2 hooks: - id: setuptools-odoo-make-default - id: setuptools-odoo-get-requirements diff --git a/.pylintrc b/.pylintrc index 55491327..0a521c31 100644 --- a/.pylintrc +++ b/.pylintrc @@ -25,19 +25,25 @@ disable=all enable=anomalous-backslash-in-string, api-one-deprecated, api-one-multi-together, - assignment-from-none, - attribute-deprecated, class-camelcase, - dangerous-default-value, dangerous-view-replace-wo-priority, - development-status-allowed, duplicate-id-csv, - duplicate-key, duplicate-xml-fields, duplicate-xml-record-id, eval-referenced, - eval-used, incoherent-interpreter-exec-perm, + openerp-exception-warning, + redundant-modulename-xml, + relative-import, + rst-syntax-error, + wrong-tabs-instead-of-spaces, + xml-syntax-error, + assignment-from-none, + attribute-deprecated, + dangerous-default-value, + development-status-allowed, + duplicate-key, + eval-used, license-allowed, manifest-author-string, manifest-deprecated-key, @@ -48,73 +54,68 @@ enable=anomalous-backslash-in-string, method-inverse, method-required-super, method-search, - openerp-exception-warning, pointless-statement, pointless-string-statement, print-used, redundant-keyword-arg, - redundant-modulename-xml, reimported, - relative-import, return-in-init, - rst-syntax-error, sql-injection, too-few-format-args, translation-field, translation-required, unreachable, use-vim-comment, - wrong-tabs-instead-of-spaces, - xml-syntax-error, - attribute-string-redundant, character-not-valid-in-resource-link, - consider-merging-classes-inherited, - context-overridden, create-user-wo-reset-password, dangerous-filter-wo-user, dangerous-qweb-replace-wo-priority, deprecated-data-xml-node, deprecated-openerp-xml-node, duplicate-po-message-definition, - except-pass, file-not-used, + missing-newline-extrafiles, + old-api7-method-defined, + po-msgstr-variables, + po-syntax-error, + str-format-used, + unnecessary-utf8-coding-comment, + xml-attribute-translatable, + xml-deprecated-qweb-directive, + xml-deprecated-tree-attribute, + attribute-string-redundant, + consider-merging-classes-inherited, + context-overridden, + except-pass, invalid-commit, manifest-maintainers-list, - missing-newline-extrafiles, missing-readme, missing-return, odoo-addons-relative-import, - old-api7-method-defined, - po-msgstr-variables, - po-syntax-error, renamed-field-parameter, resource-not-exist, - str-format-used, test-folder-imported, translation-contains-variable, translation-positional-used, - unnecessary-utf8-coding-comment, website-manifest-key-not-valid-uri, - xml-attribute-translatable, - xml-deprecated-qweb-directive, - xml-deprecated-tree-attribute, external-request-timeout, - # messages that do not cause the lint step to fail - consider-merging-classes-inherited, + missing-manifest-dependency, + too-complex,, create-user-wo-reset-password, dangerous-filter-wo-user, - deprecated-module, file-not-used, - invalid-commit, - missing-manifest-dependency, missing-newline-extrafiles, - missing-readme, no-utf8-coding-comment, - odoo-addons-relative-import, old-api7-method-defined, + unnecessary-utf8-coding-comment, + # messages that do not cause the lint step to fail + consider-merging-classes-inherited, + deprecated-module, + invalid-commit, + missing-readme, + odoo-addons-relative-import, redefined-builtin, - too-complex, - unnecessary-utf8-coding-comment + manifest-external-assets [REPORTS] diff --git a/.pylintrc-mandatory b/.pylintrc-mandatory index 7a0cd4ef..098393aa 100644 --- a/.pylintrc-mandatory +++ b/.pylintrc-mandatory @@ -17,19 +17,25 @@ disable=all enable=anomalous-backslash-in-string, api-one-deprecated, api-one-multi-together, - assignment-from-none, - attribute-deprecated, class-camelcase, - dangerous-default-value, dangerous-view-replace-wo-priority, - development-status-allowed, duplicate-id-csv, - duplicate-key, duplicate-xml-fields, duplicate-xml-record-id, eval-referenced, - eval-used, incoherent-interpreter-exec-perm, + openerp-exception-warning, + redundant-modulename-xml, + relative-import, + rst-syntax-error, + wrong-tabs-instead-of-spaces, + xml-syntax-error, + assignment-from-none, + attribute-deprecated, + dangerous-default-value, + development-status-allowed, + duplicate-key, + eval-used, license-allowed, manifest-author-string, manifest-deprecated-key, @@ -40,56 +46,50 @@ enable=anomalous-backslash-in-string, method-inverse, method-required-super, method-search, - openerp-exception-warning, pointless-statement, pointless-string-statement, print-used, redundant-keyword-arg, - redundant-modulename-xml, reimported, - relative-import, return-in-init, - rst-syntax-error, sql-injection, too-few-format-args, translation-field, translation-required, unreachable, use-vim-comment, - wrong-tabs-instead-of-spaces, - xml-syntax-error, - attribute-string-redundant, character-not-valid-in-resource-link, - consider-merging-classes-inherited, - context-overridden, create-user-wo-reset-password, dangerous-filter-wo-user, dangerous-qweb-replace-wo-priority, deprecated-data-xml-node, deprecated-openerp-xml-node, duplicate-po-message-definition, - except-pass, file-not-used, + missing-newline-extrafiles, + old-api7-method-defined, + po-msgstr-variables, + po-syntax-error, + str-format-used, + unnecessary-utf8-coding-comment, + xml-attribute-translatable, + xml-deprecated-qweb-directive, + xml-deprecated-tree-attribute, + attribute-string-redundant, + consider-merging-classes-inherited, + context-overridden, + except-pass, invalid-commit, manifest-maintainers-list, - missing-newline-extrafiles, missing-readme, missing-return, odoo-addons-relative-import, - old-api7-method-defined, - po-msgstr-variables, - po-syntax-error, renamed-field-parameter, resource-not-exist, - str-format-used, test-folder-imported, translation-contains-variable, translation-positional-used, - unnecessary-utf8-coding-comment, website-manifest-key-not-valid-uri, - xml-attribute-translatable, - xml-deprecated-qweb-directive, - xml-deprecated-tree-attribute, external-request-timeout [REPORTS] diff --git a/README.md b/README.md index dd1c1d9e..79ba812e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ +[![Support the OCA](https://odoo-community.org/readme-banner-image)](https://odoo-community.org/get-involved?utm_source=repo-readme) + +# ai [![Runboat](https://img.shields.io/badge/runboat-Try%20me-875A7B.png)](https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=16.0) [![Pre-commit Status](https://github.com/OCA/ai/actions/workflows/pre-commit.yml/badge.svg?branch=16.0)](https://github.com/OCA/ai/actions/workflows/pre-commit.yml?query=branch%3A16.0) [![Build Status](https://github.com/OCA/ai/actions/workflows/test.yml/badge.svg?branch=16.0)](https://github.com/OCA/ai/actions/workflows/test.yml?query=branch%3A16.0) @@ -7,8 +10,6 @@ -# ai - ai