diff --git a/edi_core_oca/__manifest__.py b/edi_core_oca/__manifest__.py index 2c3677573..0ac0a4f23 100644 --- a/edi_core_oca/__manifest__.py +++ b/edi_core_oca/__manifest__.py @@ -52,6 +52,9 @@ "edi_core_oca/static/tests/**/*", ], }, - "demo": ["demo/edi_backend_demo.xml"], + "demo": [ + "demo/edi_backend_demo.xml", + "demo/edi_configuration_demo.xml", + ], "installable": True, } diff --git a/edi_core_oca/demo/edi_configuration_demo.xml b/edi_core_oca/demo/edi_configuration_demo.xml new file mode 100644 index 000000000..ba9535873 --- /dev/null +++ b/edi_core_oca/demo/edi_configuration_demo.xml @@ -0,0 +1,46 @@ + + + + On hourly schedule + on_cron_hourly + Trigger when the hourly EDI scheduled action runs + + + On daily schedule + on_cron_daily + Trigger when the daily EDI scheduled action runs + + + + EDI configuration hourly trigger + + + 1 + hours + + + code + model._cron_run_by_trigger("on_cron_hourly") + + + EDI configuration daily trigger + + + 1 + days + + + code + model._cron_run_by_trigger("on_cron_daily") + + diff --git a/edi_core_oca/models/edi_configuration.py b/edi_core_oca/models/edi_configuration.py index 54654390b..d1a6e7913 100644 --- a/edi_core_oca/models/edi_configuration.py +++ b/edi_core_oca/models/edi_configuration.py @@ -3,13 +3,16 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import datetime +import logging import pytz -from psycopg2.extensions import AsIs from odoo import api, exceptions, fields, models +from odoo.fields import Domain from odoo.tools import DotDict, safe_eval +_logger = logging.getLogger(__name__) + def date_to_datetime(dt): """Convert date to datetime.""" @@ -239,22 +242,56 @@ def edi_get_conf_global(self, exchange_record, trigger): ] return self.search(domain) - def action_view_partners(self): - # TODO: add tests - partner_model = self.env["res.partner"] - partner_ids = set() - # Find partners linked to this conf no matter which field - query = "SELECT DISTINCT(partner_id) FROM %(table)s WHERE conf_id=%(conf_id)s" - for field in partner_model._fields.values(): - if field.type == "many2many" and field.comodel_name == self._name: - self.env.cr.execute( - query, {"table": AsIs(field.relation), "conf_id": self.id} + @api.model + def _cron_run_by_trigger(self, trigger_code): + """Execute every configuration listening to a scheduled trigger. + + Scheduled triggers carry no originating record: the scheduled action + knows the trigger code and nothing else. A global configuration is + therefore executed once, bound to nothing; any other one is executed + against each of the records subscribed to it. + """ + for conf in self.search([("trigger", "=", trigger_code)]): + if not conf.model_name: + # Event triggers fall back on the record that fired them, a + # scheduled one has none. Without a model there is not even an + # empty recordset to hand over to the snippet. + _logger.warning( + "Scheduled EDI configuration %s has no model: skipped.", + conf.display_name, ) - partner_ids.update([r[0] for r in self.env.cr.fetchall()]) + continue + if conf.is_global: + # A global configuration is bound to no relation, so there is + # nothing to resolve: the snippet runs once and selects the + # records it works on by itself. + conf.edi_exec_snippet_do(self.env[conf.model_name]) + continue + for record in conf._get_records(self.env[conf.model_name]): + conf.edi_exec_snippet_do(record) + + def _get_records(self, model): + """Return the records of ``model`` subscribed to this configuration. + + A record subscribes to a configuration through a many2many declared on + its own model -- there may be several such relations, one per EDI flow, + and a model holding none yields no record. + """ + self.ensure_one() + return model.search( + Domain.OR( + Domain(fname, "in", self.ids) + for fname, field in model._fields.items() + if field.type == "many2many" and field.comodel_name == self._name + ) + ) + + def action_view_partners(self): + partners = self._get_records(self.env["res.partner"]) return { "type": "ir.actions.act_window", "name": self.env._("Partners"), "res_model": "res.partner", "view_mode": "list,form", - "domain": [("id", "in", list(partner_ids))], + "domain": [("id", "in", partners.ids)], } diff --git a/edi_core_oca/readme/CONFIGURE.md b/edi_core_oca/readme/CONFIGURE.md index a1957bbe0..f31ce1985 100644 --- a/edi_core_oca/readme/CONFIGURE.md +++ b/edi_core_oca/readme/CONFIGURE.md @@ -92,6 +92,16 @@ The snippet receives at least two variables in its evaluation context: Plus the standard `edi_exec_snippet_do` extras (`operation`, `edi_action`, `old_value`, `vals`, ...). +Triggers can also be fired by a scheduled action instead of by an +exchange. Add an `edi.configuration.trigger` with a code of your own, +then a scheduled action on `edi.configuration` running +`model._cron_run_by_trigger("your_code")` on whatever period you need. +No such trigger ships as data, since the periods a database needs are +its own; the demo data has an hourly and a daily one to copy from. + +A scheduled trigger has no originating record, so its configurations run +on the records linked to them, or once when they are global. + Two complementary lookup modes are available, and they can be combined freely on the same flow. diff --git a/edi_core_oca/tests/test_edi_configuration.py b/edi_core_oca/tests/test_edi_configuration.py index e6bfb21f1..ab0c77c23 100644 --- a/edi_core_oca/tests/test_edi_configuration.py +++ b/edi_core_oca/tests/test_edi_configuration.py @@ -4,7 +4,11 @@ import os import unittest +from odoo import Command from odoo.orm.model_classes import add_to_registry +from odoo.tests.common import RecordCapturer +from odoo.tools import mute_logger +from odoo.tools.convert import convert_file from .common import EDIBackendCommonTestCase @@ -61,6 +65,21 @@ def _setup_records(cls): # pylint:disable=missing-return cls.exchange_type_out.send_model_id = cls.model cls.exchange_type_out.exchange_filename_pattern = "{record.id}" cls.edi_configuration = cls.env["edi.configuration"] + # The scheduled triggers are demo data, so load that file rather than + # declare a near-copy of it here -- which also tells us when the demo + # data itself stops loading. + convert_file( + cls.env, + "edi_core_oca", + "demo/edi_configuration_demo.xml", + None, + mode="init", + noupdate=True, + ) + cls.cron_hourly_trigger = cls.env.ref( + "edi_core_oca.edi_conf_trigger_cron_hourly" + ) + cls.cron_daily_trigger = cls.env.ref("edi_core_oca.edi_conf_trigger_cron_daily") cls.create_trigger = cls.env.ref("edi_core_oca.edi_conf_trigger_record_create") cls.write_trigger = cls.env.ref("edi_core_oca.edi_conf_trigger_record_write") cls.create_config = cls.edi_configuration.create( @@ -86,6 +105,164 @@ def _setup_records(cls): # pylint:disable=missing-return } ) + def test_cron_run_by_trigger(self): + """A scheduled configuration runs on the records linked to it. + + Scenario: + 1. Add a configuration listening to the hourly schedule. + 2. Link it to one consumer record, and leave another one alone. + 3. Run the hourly scheduled action. + Expected: + - Only the linked record is processed. + """ + conf = self.edi_configuration.create( + { + "name": "Hourly Config", + "trigger_id": self.cron_hourly_trigger.id, + "model_id": self.env["ir.model"]._get_id("edi.exchange.consumer.test"), + "snippet_do": 'record.write({"ref": "processed"})', + } + ) + consumer_model = self.env["edi.exchange.consumer.test"] + linked = consumer_model.create( + {"name": "Linked", "edi_config_ids": [Command.link(conf.id)]} + ) + unlinked = consumer_model.create({"name": "Unlinked"}) + + self.edi_configuration._cron_run_by_trigger("on_cron_hourly") + + self.assertEqual(linked.ref, "processed") + self.assertFalse(unlinked.ref) + + def test_cron_run_by_trigger_ignores_other_schedules(self): + """Each schedule only runs the configurations listening to it. + + Scenario: + 1. Add a configuration listening to the hourly schedule. + 2. Link a consumer record to it. + 3. Run the daily scheduled action. + Expected: + - The record is left untouched. + """ + conf = self.edi_configuration.create( + { + "name": "Hourly Config", + "trigger_id": self.cron_hourly_trigger.id, + "model_id": self.env["ir.model"]._get_id("edi.exchange.consumer.test"), + "snippet_do": 'record.write({"ref": "processed"})', + } + ) + record = self.env["edi.exchange.consumer.test"].create( + {"name": "Linked", "edi_config_ids": [Command.link(conf.id)]} + ) + + self.edi_configuration._cron_run_by_trigger("on_cron_daily") + + self.assertFalse(record.ref) + + @mute_logger("odoo.addons.edi_core_oca.models.edi_configuration") + def test_cron_run_by_trigger_skips_config_without_model(self): + """A scheduled configuration with no model does not stop the others. + + Scenario: + 1. Add a configuration listening to the hourly schedule without + telling it which model it applies to. + 2. Add a second, complete one and link a consumer record to it. + 3. Run the hourly scheduled action. + Expected: + - The incomplete configuration is skipped. + - The linked record is still processed. + """ + hourly_trigger = self.cron_hourly_trigger + self.edi_configuration.create( + { + "name": "Hourly Config No Model", + "trigger_id": hourly_trigger.id, + "snippet_do": 'record.write({"ref": "processed"})', + } + ) + conf = self.edi_configuration.create( + { + "name": "Hourly Config", + "trigger_id": hourly_trigger.id, + "model_id": self.env["ir.model"]._get_id("edi.exchange.consumer.test"), + "snippet_do": 'record.write({"ref": "processed"})', + } + ) + record = self.env["edi.exchange.consumer.test"].create( + {"name": "Linked", "edi_config_ids": [Command.link(conf.id)]} + ) + + self.edi_configuration._cron_run_by_trigger("on_cron_hourly") + + self.assertEqual(record.ref, "processed") + + def test_get_records_model_without_relation(self): + """A model that cannot be linked to a configuration yields no record. + + Scenario: + 1. Ask a configuration for the records of a model carrying no + relation to EDI configurations. + Expected: + - It resolves to no record at all, rather than to every record of + that model. + """ + conf = self.edi_configuration.create({"name": "Config On Countries"}) + self.assertFalse(conf._get_records(self.env["res.country"])) + + def test_cron_run_by_trigger_global_config(self): + """A global scheduled configuration runs once, bound to no record. + + Scenario: + 1. Add a global configuration listening to the hourly schedule, + with nothing subscribed to it, whose snippet picks the records + to work on by itself. + 2. Run the hourly scheduled action. + Expected: + - The snippet runs, even though no record is linked to the + configuration. + - It runs exactly once, not once per record of the model. + """ + consumer_model = self.env["edi.exchange.consumer.test"] + consumer_model.create({"name": "Target"}) + consumer_model.create({"name": "Another target"}) + self.edi_configuration.create( + { + "name": "Hourly Global Config", + "trigger_id": self.cron_hourly_trigger.id, + "model_id": self.env["ir.model"]._get_id("edi.exchange.consumer.test"), + "is_global": True, + "snippet_do": ( + 'env["edi.exchange.consumer.test"].create(' + '{"name": "By global conf"})' + ), + } + ) + + with RecordCapturer(consumer_model, []) as capture: + self.edi_configuration._cron_run_by_trigger("on_cron_hourly") + + self.assertEqual(len(capture.records), 1) + self.assertEqual(capture.records.name, "By global conf") + + def test_action_view_partners(self): + """The button opens the partner list on the configuration's customers. + + No relation from ``res.partner`` ships here, so there is nothing to + find and only the action itself can be asserted. + """ + conf = self.edi_configuration.create( + { + "name": "Partner Config", + "model_id": self.env["ir.model"]._get_id("res.partner"), + } + ) + + action = conf.action_view_partners() + + self.assertEqual(action["res_model"], "res.partner") + self.assertEqual(action["domain"], [("id", "in", [])]) + def test_edi_send_via_edi_config(self): # Check configuration on create self.consumer_record.invalidate_recordset()