diff --git a/ai_oca_bridge_scheduler/README.rst b/ai_oca_bridge_scheduler/README.rst new file mode 100644 index 00000000..0f252cfa --- /dev/null +++ b/ai_oca_bridge_scheduler/README.rst @@ -0,0 +1,85 @@ +======================= +AI OCA Bridge Scheduler +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b6a296baef3bf9d99ce13963514e8dd449b361c9e198ae986092e59436cc8efd + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/16.0/ai_oca_bridge_scheduler + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-16-0/ai-16-0-ai_oca_bridge_scheduler + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends AI OCA Bridge to allow any bridge to run +automatically on a configurable schedule, in addition to its existing +triggers. + +A bridge with scheduling enabled will periodically execute against all +records of its configured model that match its domain filter, without +requiring any user interaction. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Trobz + +Contributors +------------ + +- `Trobz `__ + + - Hai Le Nguyen + - Thien Vo + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_oca_bridge_scheduler/__init__.py b/ai_oca_bridge_scheduler/__init__.py new file mode 100644 index 00000000..27b77aaa --- /dev/null +++ b/ai_oca_bridge_scheduler/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/ai_oca_bridge_scheduler/__manifest__.py b/ai_oca_bridge_scheduler/__manifest__.py new file mode 100644 index 00000000..0e9c062c --- /dev/null +++ b/ai_oca_bridge_scheduler/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "AI OCA Bridge Scheduler", + "summary": """Schedule automatic execution of AI bridges""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Trobz,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "category": "AI", + "development_status": "Beta", + "depends": ["ai_oca_bridge"], + "data": [ + "views/ai_bridge.xml", + ], +} diff --git a/ai_oca_bridge_scheduler/models/__init__.py b/ai_oca_bridge_scheduler/models/__init__.py new file mode 100644 index 00000000..00eac2ed --- /dev/null +++ b/ai_oca_bridge_scheduler/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import ai_bridge diff --git a/ai_oca_bridge_scheduler/models/ai_bridge.py b/ai_oca_bridge_scheduler/models/ai_bridge.py new file mode 100644 index 00000000..d062a7f9 --- /dev/null +++ b/ai_oca_bridge_scheduler/models/ai_bridge.py @@ -0,0 +1,129 @@ +# Copyright 2026 Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.tools.safe_eval import safe_eval + + +class AiBridge(models.Model): + + _inherit = "ai.bridge" + + is_scheduled = fields.Boolean( + string="Enable Scheduler", + default=False, + help="If enabled, this bridge runs automatically on a schedule " + "against all records matching the configured domain.", + ) + cron_id = fields.Many2one( + "ir.cron", + readonly=True, + copy=False, + ondelete="set null", + ) + schedule_interval_number = fields.Integer(default=1) + schedule_interval_type = fields.Selection( + [ + ("minutes", "Minutes"), + ("hours", "Hours"), + ("days", "Days"), + ("weeks", "Weeks"), + ("months", "Months"), + ], + default="weeks", + ) + schedule_nextcall = fields.Datetime( + related="cron_id.nextcall", + string="Next Execution Date", + readonly=False, + ) + + def _get_model_fields(self): + res = super()._get_model_fields() + if self.is_scheduled: + res["model_required"] = True + return res + + def _get_cron_vals(self): + self.ensure_one() + return { + "name": _("AI Bridge: %s") % self.name, + "model_id": self.env["ir.model"]._get_id("ai.bridge"), + "state": "code", + "code": "model.browse(%s)._run_schedule()" % self.id, + "active": self.active, + "interval_number": self.schedule_interval_number or 1, + "interval_type": self.schedule_interval_type or "weeks", + "numbercall": -1, + "doall": False, + } + + def _sync_cron(self): + for bridge in self: + if bridge.is_scheduled and bridge.active: + cron_vals = bridge._get_cron_vals() + if bridge.cron_id: + bridge.cron_id.sudo().write(cron_vals) + else: + cron = self.env["ir.cron"].sudo().create(cron_vals) + # Use SQL to avoid recursion through write override + self.env.cr.execute( + "UPDATE ai_bridge SET cron_id = %s WHERE id = %s", + (cron.id, bridge.id), + ) + bridge.invalidate_cache( + ["cron_id", "schedule_nextcall"], + [bridge.id], + ) + else: + if bridge.cron_id: + bridge.cron_id.sudo().unlink() + self.env.cr.execute( + "UPDATE ai_bridge SET cron_id = NULL WHERE id = %s", + (bridge.id,), + ) + bridge.invalidate_cache( + ["cron_id", "schedule_nextcall"], + [bridge.id], + ) + + def _run_schedule(self): + self.ensure_one() + if not self.is_scheduled or not self.active or not self.model_id: + return + domain = safe_eval(self.domain or "[]") + records = self.env[self.model_id.model].search(domain) + for record in records: + self.execute_ai_bridge(record._name, record.id) + + @api.model_create_multi + def create(self, vals_list): + nextcalls = [vals.pop("schedule_nextcall", None) for vals in vals_list] + records = super().create(vals_list) + records.filtered("is_scheduled")._sync_cron() + for record, nextcall in zip(records, nextcalls): + if nextcall and record.cron_id: + record.cron_id.sudo().write({"nextcall": nextcall}) + return records + + def write(self, vals): + result = super().write(vals) + schedule_fields = { + "is_scheduled", + "active", + "name", + "schedule_interval_number", + "schedule_interval_type", + "model_id", + } + if schedule_fields & set(vals.keys()): + for bridge in self: + if bridge.is_scheduled or "is_scheduled" in vals: + bridge._sync_cron() + return result + + def unlink(self): + crons = self.mapped("cron_id") + result = super().unlink() + crons.sudo().unlink() + return result diff --git a/ai_oca_bridge_scheduler/readme/CONTRIBUTORS.md b/ai_oca_bridge_scheduler/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2cc91b82 --- /dev/null +++ b/ai_oca_bridge_scheduler/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- [Trobz](https://www.trobz.com) + + - Hai Le Nguyen + - Thien Vo + diff --git a/ai_oca_bridge_scheduler/readme/DESCRIPTION.md b/ai_oca_bridge_scheduler/readme/DESCRIPTION.md new file mode 100644 index 00000000..bc6ad18c --- /dev/null +++ b/ai_oca_bridge_scheduler/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module extends AI OCA Bridge to allow any bridge to run automatically on a configurable schedule, in addition to its existing triggers. + +A bridge with scheduling enabled will periodically execute against all records of its configured model that match its domain filter, without requiring any user interaction. diff --git a/ai_oca_bridge_scheduler/static/description/index.html b/ai_oca_bridge_scheduler/static/description/index.html new file mode 100644 index 00000000..2d4929b8 --- /dev/null +++ b/ai_oca_bridge_scheduler/static/description/index.html @@ -0,0 +1,432 @@ + + + + + +AI OCA Bridge Scheduler + + + +
+

AI OCA Bridge Scheduler

+ + +

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

+

This module extends AI OCA Bridge to allow any bridge to run +automatically on a configurable schedule, in addition to its existing +triggers.

+

A bridge with scheduling enabled will periodically execute against all +records of its configured model that match its domain filter, without +requiring any user interaction.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Trobz
  • +
+
+
+

Contributors

+
    +
  • Trobz
      +
    • Hai Le Nguyen
    • +
    • Thien Vo
    • +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/ai project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/ai_oca_bridge_scheduler/tests/__init__.py b/ai_oca_bridge_scheduler/tests/__init__.py new file mode 100644 index 00000000..0365e7af --- /dev/null +++ b/ai_oca_bridge_scheduler/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_scheduler diff --git a/ai_oca_bridge_scheduler/tests/test_scheduler.py b/ai_oca_bridge_scheduler/tests/test_scheduler.py new file mode 100644 index 00000000..3af3ced4 --- /dev/null +++ b/ai_oca_bridge_scheduler/tests/test_scheduler.py @@ -0,0 +1,126 @@ +# Copyright 2026 Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest import mock + +from odoo.tests.common import TransactionCase + + +class TestScheduler(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create( + { + "name": "Test Partner", + "email": "test@example.com", + } + ) + cls.bridge = cls.env["ai.bridge"].create( + { + "name": "Test Scheduler Bridge", + "model_id": cls.env.ref("base.model_res_partner").id, + "url": "https://example.com/api", + "auth_type": "none", + "usage": "none", + "is_scheduled": True, + "schedule_interval_number": 1, + "schedule_interval_type": "days", + } + ) + + def test_scheduler_creates_cron(self): + self.assertTrue(self.bridge.cron_id) + self.assertEqual(self.bridge.cron_id.interval_number, 1) + self.assertEqual(self.bridge.cron_id.interval_type, "days") + self.assertTrue(self.bridge.cron_id.active) + + def test_scheduler_cron_deactivated_on_bridge_inactive(self): + self.assertTrue(self.bridge.cron_id.active) + self.bridge.active = False + self.assertFalse(self.bridge.cron_id.active) + self.bridge.active = True + self.assertTrue(self.bridge.cron_id.active) + + def test_scheduler_toggle_is_scheduled(self): + self.assertTrue(self.bridge.cron_id) + cron = self.bridge.cron_id + self.bridge.is_scheduled = False + self.assertFalse(self.bridge.cron_id) + self.assertFalse(self.env["ir.cron"].search([("id", "=", cron.id)])) + self.bridge.is_scheduled = True + self.assertTrue(self.bridge.cron_id) + + def test_scheduler_run(self): + with mock.patch("requests.post") as mock_post: + self.bridge._run_schedule() + mock_post.assert_called() + executions = self.env["ai.bridge.execution"].search( + [("ai_bridge_id", "=", self.bridge.id)] + ) + self.assertTrue(executions) + self.assertIn(self.partner.id, executions.mapped("res_id")) + + def test_scheduler_domain_filtering(self): + # Domain matches nothing → no executions at all + self.bridge.domain = "[('id', '=', -1)]" + with mock.patch("requests.post") as mock_post: + self.bridge._run_schedule() + mock_post.assert_not_called() + self.assertFalse( + self.env["ai.bridge.execution"].search( + [("ai_bridge_id", "=", self.bridge.id)] + ) + ) + + def test_scheduler_no_model(self): + bridge_no_model = self.env["ai.bridge"].create( + { + "name": "No Model Bridge", + "url": "https://example.com/api", + "auth_type": "none", + "usage": "none", + "is_scheduled": True, + } + ) + # Should return silently without error + bridge_no_model._run_schedule() + self.assertFalse( + self.env["ai.bridge.execution"].search( + [("ai_bridge_id", "=", bridge_no_model.id)] + ) + ) + + def test_scheduler_combined_thread(self): + """Bridge with usage='thread' + is_scheduled=True: both features work independently.""" + bridge = self.env["ai.bridge"].create( + { + "name": "Thread + Scheduled Bridge", + "model_id": self.env.ref("base.model_res_partner").id, + "url": "https://example.com/api", + "auth_type": "none", + "usage": "thread", + "is_scheduled": True, + "schedule_interval_number": 1, + "schedule_interval_type": "weeks", + } + ) + # Cron is created + self.assertTrue(bridge.cron_id) + # Bridge appears in chatter ai_bridge_info + self.assertIn(bridge.id, [b["id"] for b in self.partner.ai_bridge_info]) + + def test_scheduler_unlink_removes_cron(self): + bridge = self.env["ai.bridge"].create( + { + "name": "To Delete Bridge", + "model_id": self.env.ref("base.model_res_partner").id, + "url": "https://example.com/api", + "auth_type": "none", + "is_scheduled": True, + } + ) + cron_id = bridge.cron_id.id + self.assertTrue(cron_id) + bridge.unlink() + self.assertFalse(self.env["ir.cron"].search([("id", "=", cron_id)])) diff --git a/ai_oca_bridge_scheduler/views/ai_bridge.xml b/ai_oca_bridge_scheduler/views/ai_bridge.xml new file mode 100644 index 00000000..5c3597f0 --- /dev/null +++ b/ai_oca_bridge_scheduler/views/ai_bridge.xml @@ -0,0 +1,37 @@ + + + + + + ai.bridge + + + + + + + + + + + + + + + + + diff --git a/setup/ai_oca_bridge_scheduler/odoo/addons/ai_oca_bridge_scheduler b/setup/ai_oca_bridge_scheduler/odoo/addons/ai_oca_bridge_scheduler new file mode 120000 index 00000000..a7bf670e --- /dev/null +++ b/setup/ai_oca_bridge_scheduler/odoo/addons/ai_oca_bridge_scheduler @@ -0,0 +1 @@ +../../../../ai_oca_bridge_scheduler \ No newline at end of file diff --git a/setup/ai_oca_bridge_scheduler/setup.py b/setup/ai_oca_bridge_scheduler/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/ai_oca_bridge_scheduler/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)