diff --git a/edi_component_oca/models/edi_exchange_record.py b/edi_component_oca/models/edi_exchange_record.py
index 551d1fa6b..c37c17f93 100644
--- a/edi_component_oca/models/edi_exchange_record.py
+++ b/edi_component_oca/models/edi_exchange_record.py
@@ -8,12 +8,6 @@
class EdiExchangeRecord(models.Model):
_inherit = "edi.exchange.record"
- def _trigger_edi_event_make_name(self, name, suffix=None):
- return "on_edi_exchange_{name}{suffix}".format(
- name=name,
- suffix=("_" + suffix) if suffix else "",
- )
-
def _trigger_edi_event(self, name, suffix=None, target=None, **kw):
"""Trigger a component event linked to this backend and edi exchange."""
name = self._trigger_edi_event_make_name(name, suffix=suffix)
diff --git a/edi_core_oca/data/edi_configuration.xml b/edi_core_oca/data/edi_configuration.xml
index 565919c74..867212198 100644
--- a/edi_core_oca/data/edi_configuration.xml
+++ b/edi_core_oca/data/edi_configuration.xml
@@ -15,6 +15,18 @@
on_record_write
Trigger when a record is updated
+
+
+ On record exchange done
+ on_edi_exchange_done
+ Trigger when a record exchange is done
+
+
+ On record exchange error
+ on_edi_exchange_error
+ Trigger when a record exchange has an error
+
+
Send via email
diff --git a/edi_core_oca/models/edi_configuration.py b/edi_core_oca/models/edi_configuration.py
index 93d7412f3..82bade7d5 100644
--- a/edi_core_oca/models/edi_configuration.py
+++ b/edi_core_oca/models/edi_configuration.py
@@ -67,16 +67,25 @@ class EdiConfiguration(models.Model):
help="""Used to do something specific here.
Receives: operation, edi_action, vals, old_vals.""",
)
+ # You can use this to avoid component events ;)
+ is_global = fields.Boolean(
+ string="Global Configuration",
+ help="If checked, this configuration will be executed for all records, "
+ "regardless of the partner relation.",
+ default=False,
+ )
@api.constrains("backend_id", "type_id")
def _constrains_backend(self):
for rec in self:
+ if not rec.backend_id:
+ continue
if rec.type_id.backend_id:
if rec.type_id.backend_id != rec.backend_id:
raise exceptions.ValidationError(
self.env._("Backend must match with exchange type's backend!")
)
- else:
+ elif rec.type_id:
if rec.type_id.backend_type_id != rec.backend_id.backend_type_id:
raise exceptions.ValidationError(
self.env._(
@@ -201,6 +210,35 @@ def edi_get_conf(self, trigger, backend=None):
domain.append(("backend_id", "in", backend_ids))
return self.filtered_domain(domain)
+ @api.model
+ def edi_get_conf_global(self, exchange_record, trigger):
+ """Return active global configurations matching the given event.
+
+ Unlike :meth:`edi_get_conf` -- which runs on a recordset of
+ configurations already linked to a partner -- global configurations
+ are not bound to any partner. We therefore have to derive the
+ filtering keys from the originating exchange record:
+
+ * ``trigger`` must match the event code
+ * ``is_global`` must be True
+ * ``type_id`` must match the exchange type or be empty (applies to all)
+ * ``backend_id`` must match the backend or be empty (applies to all)
+ * ``model_name`` must match the related record model or be empty
+ (applies to all)
+ """
+ related_model = exchange_record.model
+ model_options = [False]
+ if related_model:
+ model_options.append(related_model)
+ domain = [
+ ("trigger", "=", trigger),
+ ("is_global", "=", True),
+ ("type_id", "in", [exchange_record.type_id.id, False]),
+ ("backend_id", "in", [exchange_record.backend_id.id, False]),
+ ("model_name", "in", model_options),
+ ]
+ return self.search(domain)
+
def action_view_partners(self):
# TODO: add tests
partner_model = self.env["res.partner"]
diff --git a/edi_core_oca/models/edi_exchange_record.py b/edi_core_oca/models/edi_exchange_record.py
index a87b4506e..685a98bb0 100644
--- a/edi_core_oca/models/edi_exchange_record.py
+++ b/edi_core_oca/models/edi_exchange_record.py
@@ -533,8 +533,19 @@ def _notify_related_record(self, message, level="info"):
rec._notify_related_record(message, level)
def _trigger_edi_event(self, name, suffix=None, target=None, **kw):
- """Hook to be implemented in other modules"""
- pass
+ event_name = self._trigger_edi_event_make_name(name, suffix)
+ target = target or self
+ global_configs = self.env["edi.configuration"].edi_get_conf_global(
+ self, event_name
+ )
+ for conf in global_configs:
+ conf.edi_exec_snippet_do(target, **kw)
+
+ def _trigger_edi_event_make_name(self, name, suffix=None):
+ return "on_edi_exchange_{name}{suffix}".format(
+ name=name,
+ suffix=("_" + suffix) if suffix else "",
+ )
def _notify_done(self):
self._notify_related_record(self._exchange_status_message("process_ok"))
diff --git a/edi_core_oca/readme/CONFIGURE.md b/edi_core_oca/readme/CONFIGURE.md
index 48045deba..a1957bbe0 100644
--- a/edi_core_oca/readme/CONFIGURE.md
+++ b/edi_core_oca/readme/CONFIGURE.md
@@ -63,3 +63,98 @@ backend to be used for the exchange.
In case of "Custom" kind, you'll have to define your own logic to do
something.
+
+## Custom event handlers via `edi.configuration`
+
+The framework can dispatch EDI lifecycle events to user-defined
+configurations, providing a declarative alternative to component events.
+Each `edi.configuration` record links a **trigger** (an
+`edi.configuration.trigger` code) to a **snippet** (`snippet_do`) that is
+executed every time the matching event fires on an exchange record.
+
+Built-in events fired by `EDIExchangeRecord` include:
+
+- `on_edi_exchange_done` — exchange processed successfully
+- `on_edi_exchange_error` — exchange ended in error
+- `on_edi_exchange_done_ack_received` — ACK file received
+- `on_edi_exchange_done_ack_missing` — expected ACK not received
+- `on_edi_exchange_done_ack_received_error` — ACK received with errors
+- `on_edi_exchange__complete` — generic action completion (e.g.
+ `generate_complete`, `send_complete`), fired once on the exchange
+ record and once on its related record when present
+
+The snippet receives at least two variables in its evaluation context:
+
+- `conf` — the current `edi.configuration` record
+- `record` — the target of the event (either the `edi.exchange.record`
+ itself or its related business record)
+
+Plus the standard `edi_exec_snippet_do` extras (`operation`,
+`edi_action`, `old_value`, `vals`, ...).
+
+Two complementary lookup modes are available, and they can be combined
+freely on the same flow.
+
+### Global event configurations
+
+Use this mode when you want a configuration to react to events on **any
+business record** that travels through EDI, with no per-partner setup.
+
+Tick **Global Configuration** (`is_global`) on the `edi.configuration`.
+When an event fires, the framework calls
+`edi.configuration.edi_get_conf_global(exchange_record, trigger)` which
+selects all active global configurations whose `trigger` matches the
+event code, filtered by the originating exchange record:
+
+- **Exchange type** (`type_id`): must match the exchange record's type,
+ or be left empty to apply to every type
+- **Backend** (`backend_id`): must match the exchange record's backend,
+ or be left empty to apply to every backend
+- **Model** (`model_id` / `model_name`): must match the related record
+ model (e.g. `sale.order`, `account.move`), or be left empty to apply
+ to every model
+
+Empty values mean "applies to all". Inactive configurations and
+non-global configurations are ignored. All matching configurations are
+executed in sequence.
+
+Typical use cases:
+
+- Posting a generic chatter message on every exchange that ends in error
+- Pushing a notification to an external system every time an ACK is
+ received for a given backend
+- Logging extra audit information for every exchange of a given type
+
+### Partner-specific (relation-based) event configurations
+
+Use this mode when the reaction must depend on the partner (or any
+other related record) involved in the exchange.
+
+In this case configurations are **not** marked as global. Instead, the
+business record exposes an `edi_config_ids` relation (via
+`edi.exchange.consumer.mixin._edi_config_field_relation`, which by
+default returns `self.env["edi.configuration"]` and can be overridden,
+for example to point at `self.partner_id.edi_config_ids`). When an
+event fires on the business record (e.g. on create, on write,
+on send-via-email/EDI), the framework calls
+`edi_confs.edi_get_conf(trigger)` on that relation and runs the
+matching snippets.
+
+Compared with global configurations:
+
+- **Discovery** comes from the record's own relation, not from a
+ database-wide search; this is the right place to model "this partner
+ wants this behaviour" rules
+- **Filtering** is reduced to `trigger` and (optionally) `backend_id`,
+ since the recordset is already narrowed by the relation
+- The same `snippet_do` API applies, so a snippet can be reused
+ verbatim between global and partner-specific configurations
+
+Typical use cases:
+
+- Sending a specific EDI flow only for a subset of partners
+- Customising the document generation per customer (e.g. different
+ email template, different transport)
+- Switching between EDI and email delivery based on partner
+ preferences
+
diff --git a/edi_core_oca/readme/DESCRIPTION.md b/edi_core_oca/readme/DESCRIPTION.md
index 8cfcb472b..f9aee7085 100644
--- a/edi_core_oca/readme/DESCRIPTION.md
+++ b/edi_core_oca/readme/DESCRIPTION.md
@@ -8,4 +8,11 @@ Provides following models:
3. EDI Exchange Type, to define file types of exchange
4. EDI Exchange Record, to define a record exchanged between systems
-Also define a mixin to be inherited by records that will generate EDIs
+Also define a mixin to be inherited by records that will generate EDIs.
+
+In addition, the module ships an ``edi.configuration`` mechanism that lets
+users react to EDI events declaratively, by writing small Python snippets
+attached to event triggers. This can be used as a lightweight alternative
+to component event listeners: configurations can react globally (on any
+exchange) or be scoped to a specific partner (or any related record),
+exchange type, backend and target model. See ``CONFIGURE.md`` for details.
diff --git a/edi_core_oca/readme/newsfragments/.gitkeep b/edi_core_oca/readme/newsfragments/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/edi_core_oca/readme/newsfragments/global-edi-conf-events.feature b/edi_core_oca/readme/newsfragments/global-edi-conf-events.feature
new file mode 100644
index 000000000..d2678fe28
--- /dev/null
+++ b/edi_core_oca/readme/newsfragments/global-edi-conf-events.feature
@@ -0,0 +1,20 @@
+Introduce a new system for **global EDI events** based on ``edi.configuration``
+that can replace the use of component events.
+
+Any ``edi.configuration`` flagged as ``is_global`` is now picked up by
+``EDIExchangeRecord._trigger_edi_event`` and its ``snippet_do`` is executed
+whenever the matching event fires (``done``, ``error``, ``ack_received``,
+``ack_missing``, ``ack_received_error``, ``_complete``, ...).
+
+Filtering is performed via the new ``edi.configuration.edi_get_conf_global``
+model method, which selects active global configurations matching the event
+trigger code and, when set, the exchange type, the backend and the related
+record model carried by the exchange record (empty values still mean "applies
+to all"). This lets integrators subscribe to EDI events declaratively from
+the UI instead of writing component listeners.
+
+Full test coverage is included for the dispatch on all ``notify_*`` events
+(both on the exchange record and on the related record target) and for the
+new filtering rules.
+
+Last but not lease: add minimal docs for edi.configuration.
diff --git a/edi_core_oca/tests/test_edi_configuration.py b/edi_core_oca/tests/test_edi_configuration.py
index 689a8f6c1..d624b697e 100644
--- a/edi_core_oca/tests/test_edi_configuration.py
+++ b/edi_core_oca/tests/test_edi_configuration.py
@@ -152,3 +152,256 @@ def test_edi_code_snippet(self):
)
# Check the new vals after execution
self.assertEqual(vals, expected_value)
+
+
+class TestEDIConfigurationGlobalEvents(EDIBackendCommonTestCase):
+ """Test the global event dispatch via edi.configuration.
+
+ `EDIExchangeRecord._trigger_edi_event` looks up all `edi.configuration`
+ records flagged as `is_global` and matching the event trigger code,
+ then executes their `snippet_do` against the target record.
+ These tests verify the dispatch happens for all `notify_*` events
+ and that the proper target (exchange record vs related record)
+ is passed to the snippet.
+ """
+
+ # Snippet appends a marker per call so we can verify multiple invocations
+ # against different targets within the same transaction.
+ _marker_snippet = (
+ "conf.write({'description': (conf.description or '') + '|' + record._name})"
+ )
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ vals = {
+ "model": cls.partner._name,
+ "res_id": cls.partner.id,
+ }
+ cls.record = cls.backend.create_record("test_csv_output", vals)
+ cls.trigger_model = cls.env["edi.configuration.trigger"]
+ cls.conf_model = cls.env["edi.configuration"]
+ # Reuse existing data triggers when available, create the missing ones.
+ cls.trigger_done = cls.env.ref("edi_core_oca.edi_config_trigger_record_done")
+ cls.trigger_error = cls.env.ref("edi_core_oca.edi_config_trigger_record_error")
+ cls.trigger_ack_received = cls._get_or_create_trigger(
+ "on_edi_exchange_done_ack_received", "On ACK received"
+ )
+ cls.trigger_ack_missing = cls._get_or_create_trigger(
+ "on_edi_exchange_done_ack_missing", "On ACK missing"
+ )
+ cls.trigger_ack_received_error = cls._get_or_create_trigger(
+ "on_edi_exchange_done_ack_received_error", "On ACK received error"
+ )
+ cls.trigger_generate_complete = cls._get_or_create_trigger(
+ "on_edi_exchange_generate_complete", "On generate complete"
+ )
+
+ @classmethod
+ def _get_or_create_trigger(cls, code, name):
+ trigger = cls.trigger_model.search([("code", "=", code)], limit=1)
+ if not trigger:
+ trigger = cls.trigger_model.create({"name": name, "code": code})
+ return trigger
+
+ def _make_conf(self, trigger, name, is_global=True, snippet=None, **overrides):
+ vals = {
+ "name": name,
+ "active": True,
+ "backend_id": self.backend.id,
+ "type_id": self.exchange_type_out.id,
+ "trigger_id": trigger.id,
+ "is_global": is_global,
+ "snippet_do": snippet or self._marker_snippet,
+ }
+ vals.update(overrides)
+ return self.conf_model.create(vals)
+
+ def test_notify_done_triggers_global_conf(self):
+ conf = self._make_conf(self.trigger_done, "Global Done")
+ self.record._notify_done()
+ self.assertEqual(conf.description, f"|{self.record._name}")
+
+ def test_notify_error_triggers_global_conf(self):
+ conf = self._make_conf(self.trigger_error, "Global Error")
+ self.record._notify_error("send_ko")
+ self.assertEqual(conf.description, f"|{self.record._name}")
+
+ def test_notify_ack_received_triggers_global_conf(self):
+ conf = self._make_conf(self.trigger_ack_received, "Global ACK received")
+ self.record._notify_ack_received()
+ self.assertEqual(conf.description, f"|{self.record._name}")
+
+ def test_notify_ack_missing_triggers_global_conf(self):
+ conf = self._make_conf(self.trigger_ack_missing, "Global ACK missing")
+ self.record._notify_ack_missing()
+ self.assertEqual(conf.description, f"|{self.record._name}")
+
+ def test_notify_ack_received_error_triggers_global_conf(self):
+ conf = self._make_conf(
+ self.trigger_ack_received_error, "Global ACK received error"
+ )
+ self.record._notify_ack_received_error()
+ self.assertEqual(conf.description, f"|{self.record._name}")
+
+ def test_non_global_conf_is_ignored(self):
+ conf = self._make_conf(self.trigger_done, "Non Global Done", is_global=False)
+ self.record._notify_done()
+ self.assertFalse(conf.description)
+
+ def test_inactive_global_conf_is_ignored(self):
+ conf = self._make_conf(self.trigger_done, "Inactive Global Done")
+ conf.active = False
+ self.record._notify_done()
+ self.assertFalse(conf.description)
+
+ def test_notify_action_complete_dispatches_to_both_targets(self):
+ """`notify_action_complete` fires the event twice when the related
+ record exists: once with the exchange record as target, once with the
+ related record (partner here)."""
+ conf = self._make_conf(
+ self.trigger_generate_complete, "Global generate complete"
+ )
+ # Sanity check: the exchange record has a related record.
+ self.assertTrue(self.record.related_record_exists)
+ self.record.notify_action_complete("generate")
+ # The snippet appended one marker per call: exchange record then partner.
+ self.assertEqual(
+ conf.description,
+ f"|{self.record._name}|{self.partner._name}",
+ )
+
+ def test_notify_action_complete_no_related_record(self):
+ """When no related record exists, the event fires only on the
+ exchange record itself."""
+ conf = self._make_conf(
+ self.trigger_generate_complete, "Global generate complete - no related"
+ )
+ # Create an exchange record with no related record.
+ orphan_record = self.backend.create_record(
+ "test_csv_output", {"model": False, "res_id": False}
+ )
+ orphan_record.notify_action_complete("generate")
+ self.assertEqual(conf.description, f"|{orphan_record._name}")
+
+ def test_snippet_receives_conf_and_record(self):
+ """The snippet eval context must expose both `conf` (the configuration)
+ and `record` (the target of the event)."""
+ snippet = (
+ "conf.write({'description': 'conf=%s|record=%s' % "
+ "(conf.name, record.display_name)})"
+ )
+ conf = self._make_conf(self.trigger_done, "Context check", snippet=snippet)
+ self.record._notify_done()
+ self.assertEqual(
+ conf.description,
+ f"conf={conf.name}|record={self.record.display_name}",
+ )
+
+ def test_multiple_global_confs_all_executed(self):
+ """All global confs matching the trigger are executed."""
+ conf1 = self._make_conf(self.trigger_done, "Global Done 1")
+ conf2 = self._make_conf(self.trigger_done, "Global Done 2")
+ self.record._notify_done()
+ self.assertEqual(conf1.description, f"|{self.record._name}")
+ self.assertEqual(conf2.description, f"|{self.record._name}")
+
+ # ------------------------------------------------------------------
+ # Filtering tests for `edi_get_conf_global`
+ # ------------------------------------------------------------------
+ def test_filter_by_type_mismatch(self):
+ """A conf bound to a different exchange type must not fire."""
+ conf = self._make_conf(
+ self.trigger_done,
+ "Wrong type",
+ type_id=self.exchange_type_in.id,
+ )
+ self.record._notify_done()
+ self.assertFalse(conf.description)
+
+ def test_filter_by_type_empty_matches(self):
+ """A conf without a type matches any exchange record's type."""
+ conf = self._make_conf(self.trigger_done, "No type", type_id=False)
+ self.record._notify_done()
+ self.assertEqual(conf.description, f"|{self.record._name}")
+
+ def test_filter_by_backend_mismatch(self):
+ """A conf bound to a different backend must not fire."""
+ other_backend = self.env["edi.backend"].create(
+ {
+ "name": "Other backend",
+ "backend_type_id": self.backend.backend_type_id.id,
+ }
+ )
+ # `_constrains_backend` requires backend to be compatible with the type's
+ # backend if the type has one set. Detach the type from the conf to test
+ # only the backend filter.
+ conf = self._make_conf(
+ self.trigger_done,
+ "Wrong backend",
+ backend_id=other_backend.id,
+ type_id=False,
+ )
+ self.record._notify_done()
+ self.assertFalse(conf.description)
+
+ def test_filter_by_backend_empty_matches(self):
+ """A conf without a backend matches any exchange record's backend."""
+ conf = self._make_conf(
+ self.trigger_done,
+ "No backend",
+ backend_id=False,
+ type_id=False,
+ )
+ self.record._notify_done()
+ self.assertEqual(conf.description, f"|{self.record._name}")
+
+ def test_filter_by_model_mismatch(self):
+ """A conf bound to a different model must not fire."""
+ other_model = self.env["ir.model"]._get("res.users")
+ conf = self._make_conf(
+ self.trigger_done,
+ "Wrong model",
+ model_id=other_model.id,
+ )
+ self.record._notify_done()
+ self.assertFalse(conf.description)
+
+ def test_filter_by_model_match(self):
+ """A conf bound to the related record model fires."""
+ partner_model = self.env["ir.model"]._get(self.partner._name)
+ conf = self._make_conf(
+ self.trigger_done,
+ "Matching model",
+ model_id=partner_model.id,
+ )
+ self.record._notify_done()
+ self.assertEqual(conf.description, f"|{self.record._name}")
+
+ def test_filter_by_model_orphan_record(self):
+ """A conf with a model is skipped on records with no related model."""
+ partner_model = self.env["ir.model"]._get(self.partner._name)
+ conf_with_model = self._make_conf(
+ self.trigger_done,
+ "Model bound",
+ model_id=partner_model.id,
+ )
+ conf_no_model = self._make_conf(self.trigger_done, "Model-less")
+ orphan_record = self.backend.create_record(
+ "test_csv_output", {"model": False, "res_id": False}
+ )
+ orphan_record._notify_done()
+ self.assertFalse(conf_with_model.description)
+ self.assertEqual(conf_no_model.description, f"|{orphan_record._name}")
+
+ def test_edi_get_conf_global_returns_only_matching(self):
+ """Direct check on the new helper method."""
+ matching = self._make_conf(self.trigger_done, "Matching")
+ wrong_trigger = self._make_conf(self.trigger_error, "Wrong trigger")
+ non_global = self._make_conf(self.trigger_done, "Non global", is_global=False)
+ result = self.env["edi.configuration"].edi_get_conf_global(
+ self.record, self.trigger_done.code
+ )
+ self.assertIn(matching, result)
+ self.assertNotIn(wrong_trigger, result)
+ self.assertNotIn(non_global, result)
diff --git a/edi_core_oca/views/edi_configuration_views.xml b/edi_core_oca/views/edi_configuration_views.xml
index 053db7764..674e88f8b 100644
--- a/edi_core_oca/views/edi_configuration_views.xml
+++ b/edi_core_oca/views/edi_configuration_views.xml
@@ -74,6 +74,7 @@
name="model_id"
options="{'no_create': True, 'no_create_edit': True}"
/>
+