diff --git a/edi_component_oca/README.rst b/edi_component_oca/README.rst new file mode 100644 index 000000000..4af284595 --- /dev/null +++ b/edi_component_oca/README.rst @@ -0,0 +1,126 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================= +Edi Connector Oca +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:81c11c0d670f363513d25e5d2d6cb038f1fc56580f20c837c2d2a7665798018d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fedi--framework-lightgray.png?logo=github + :target: https://github.com/OCA/edi-framework/tree/19.0/edi_component_oca + :alt: OCA/edi-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-framework-19-0/edi-framework-19-0-edi_component_oca + :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/edi-framework&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to use components to handle code to execute on EDI +Exchanges. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Component definition +-------------------- + +The component used on edi must inherit from: + +- ``edi.component.input.mixin`` for processing and implement the process + function +- ``edi.component.receive.mixin`` for reception and implement the + receive function +- ``edi.component.output.mixin`` for generation and implement the + generate function +- ``edi.component.send.mixin`` for sending and implement the send + function +- ``edi.component.check.mixin`` for checking and implement the check + function +- ``edi.component.validate.mixin`` for validation and implement the + validate function + +Also, the components may have the following elements that will be used +to use the right component: + +- ``_backend_type``: code of the backend type +- ``_exchange_type``: code of the exchange type +- ``_usage``: Automatically set by the inherited component + +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 +------- + +* ACSONE +* Dixmit +* Camptocamp + +Contributors +------------ + +- Simone Orsi +- Enric Tobella +- Manuel Regidor +- Thien Vo +- Jordi Masvidal + +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. + +.. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk +.. |maintainer-etobella| image:: https://github.com/etobella.png?size=40px + :target: https://github.com/etobella + :alt: etobella + +Current `maintainers `__: + +|maintainer-simahawk| |maintainer-etobella| + +This module is part of the `OCA/edi-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/edi_component_oca/__init__.py b/edi_component_oca/__init__.py new file mode 100644 index 000000000..f24d3e242 --- /dev/null +++ b/edi_component_oca/__init__.py @@ -0,0 +1,2 @@ +from . import components +from . import models diff --git a/edi_component_oca/__manifest__.py b/edi_component_oca/__manifest__.py new file mode 100644 index 000000000..e3ba0c0f4 --- /dev/null +++ b/edi_component_oca/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Dixmit +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +{ + "name": "Edi Connector Oca", + "summary": """Allow to use Connector as a source in EDI""", + "version": "19.0.1.0.0", + "license": "LGPL-3", + "author": "ACSONE,Dixmit,Camptocamp,Odoo Community Association (OCA)", + "maintainers": ["simahawk", "etobella"], + "website": "https://github.com/OCA/edi-framework", + "depends": ["base_edi", "component_event", "edi_core_oca"], + "data": [], + "demo": [], +} diff --git a/edi_component_oca/components/__init__.py b/edi_component_oca/components/__init__.py new file mode 100644 index 000000000..94e4ac140 --- /dev/null +++ b/edi_component_oca/components/__init__.py @@ -0,0 +1,4 @@ +from . import base +from . import base_output +from . import base_input +from . import base_validate diff --git a/edi_component_oca/components/base.py b/edi_component_oca/components/base.py new file mode 100644 index 000000000..1b4c1e6b0 --- /dev/null +++ b/edi_component_oca/components/base.py @@ -0,0 +1,72 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.core import AbstractComponent + + +class EDIBackendComponentMixin(AbstractComponent): + """Generic mixin for all EDI components.""" + + _name = "edi.component.base.mixin" + _collection = "edi.backend" + _usage = None + _backend_type = None + _exchange_type = None + + def __init__(self, work_context): + super().__init__(work_context) + self.backend = work_context.backend + + @staticmethod + def _match_attrs(): + """Attributes to be used for matching this component. + + By default, match by backend and exchange type. + + NOTE: the class attribute must have an underscore, the name here not. + """ + return ("backend_type", "exchange_type") + + @classmethod + def _component_match(cls, work, usage=None, model_name=None, **kw): + """Override to customize match. + + Registry lookup filtered by usage and model_name when landing here. + Now, narrow match to `_match_attrs` attributes. + """ + match_attrs = cls._match_attrs() + if not any([kw.get(k) for k in match_attrs]): + # No attr to check + return True + + backend_type = kw.get("backend_type") + exchange_type = kw.get("exchange_type") + + if cls._backend_type and cls._exchange_type: + # They must match both + return ( + cls._backend_type == backend_type + and cls._exchange_type == exchange_type + ) + + if cls._backend_type not in (None, kw.get("backend_type")): + return False + + if cls._exchange_type not in (None, kw.get("exchange_type")): + return False + + return True + + +class EDIBackendRecordComponentMixin(AbstractComponent): + """Generic mixin for record-bound components.""" + + _name = "edi.component.mixin" + _inherit = "edi.component.base.mixin" + + def __init__(self, work_context): + super().__init__(work_context) + self.exchange_record = work_context.exchange_record + self.record = self.exchange_record.record + self.type_settings = self.exchange_record.type_id.get_settings() diff --git a/edi_component_oca/components/base_input.py b/edi_component_oca/components/base_input.py new file mode 100644 index 000000000..14e84f7b3 --- /dev/null +++ b/edi_component_oca/components/base_input.py @@ -0,0 +1,23 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.core import AbstractComponent + + +class EDIBackendInputComponentMixin(AbstractComponent): + """Generate input content.""" + + _name = "edi.component.input.mixin" + _inherit = "edi.component.mixin" + + def process(self): + raise NotImplementedError() + + +class EDIBackendReceiveComponentMixin(AbstractComponent): + _name = "edi.component.receive.mixin" + _inherit = "edi.component.mixin" + + def receive(self): + raise NotImplementedError() diff --git a/edi_component_oca/components/base_output.py b/edi_component_oca/components/base_output.py new file mode 100644 index 000000000..9a97f1f2b --- /dev/null +++ b/edi_component_oca/components/base_output.py @@ -0,0 +1,36 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.core import AbstractComponent + + +class EDIBackendOutputComponentMixin(AbstractComponent): + """Generate output content.""" + + _name = "edi.component.output.mixin" + _inherit = "edi.component.mixin" + _usage = "edi.output.generate.*" + + def generate(self): + raise NotImplementedError() + + +class EDIBackendSendComponentMixin(AbstractComponent): + """Send output records.""" + + _name = "edi.component.send.mixin" + _inherit = "edi.component.mixin" + _usage = "edi.output.send.*" + + def send(self): + raise NotImplementedError() + + +class EDIBackendCheckComponentMixin(AbstractComponent): + _name = "edi.component.check.mixin" + _inherit = "edi.component.mixin" + _usage = "edi.output.check.*" + + def check(self): + raise NotImplementedError() diff --git a/edi_component_oca/components/base_validate.py b/edi_component_oca/components/base_validate.py new file mode 100644 index 000000000..324f0401a --- /dev/null +++ b/edi_component_oca/components/base_validate.py @@ -0,0 +1,20 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.core import AbstractComponent + + +class EDIBackendValidateComponentMixin(AbstractComponent): + """Validate exchange data.""" + + _name = "edi.component.validate.mixin" + _inherit = "edi.component.mixin" + _usage = "edi.validate.*" + + def validate(self, value=None): + self._validate(value) + + def _validate(self, value=None): + """Return None validated, raise `edi.exceptions.EDIValidationError` if not.""" + raise NotImplementedError() diff --git a/edi_component_oca/i18n/edi_component_oca.pot b/edi_component_oca/i18n/edi_component_oca.pot new file mode 100644 index 000000000..dc4613309 --- /dev/null +++ b/edi_component_oca/i18n/edi_component_oca.pot @@ -0,0 +1,34 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_component_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: edi_component_oca +#: model:ir.model,name:edi_component_oca.model_edi_exchange_consumer_mixin +msgid "Abstract record where exchange records can be assigned" +msgstr "" + +#. module: edi_component_oca +#: model:ir.model,name:edi_component_oca.model_edi_oca_component_handler +msgid "Component Handler for EDI" +msgstr "" + +#. module: edi_component_oca +#: model:ir.model,name:edi_component_oca.model_edi_backend +msgid "EDI Backend" +msgstr "" + +#. module: edi_component_oca +#: model:ir.model,name:edi_component_oca.model_edi_exchange_record +msgid "EDI exchange Record" +msgstr "" diff --git a/edi_component_oca/i18n/it.po b/edi_component_oca/i18n/it.po new file mode 100644 index 000000000..a36207c60 --- /dev/null +++ b/edi_component_oca/i18n/it.po @@ -0,0 +1,37 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_component_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-10-14 06:34+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.4\n" + +#. module: edi_component_oca +#: model:ir.model,name:edi_component_oca.model_edi_exchange_consumer_mixin +msgid "Abstract record where exchange records can be assigned" +msgstr "Record astratto dove i record di scambio possono essere assegnati" + +#. module: edi_component_oca +#: model:ir.model,name:edi_component_oca.model_edi_oca_component_handler +msgid "Component Handler for EDI" +msgstr "Gestore componente per EDI" + +#. module: edi_component_oca +#: model:ir.model,name:edi_component_oca.model_edi_backend +msgid "EDI Backend" +msgstr "Backend EDI" + +#. module: edi_component_oca +#: model:ir.model,name:edi_component_oca.model_edi_exchange_record +msgid "EDI exchange Record" +msgstr "Record di scambio EDI" diff --git a/edi_component_oca/models/__init__.py b/edi_component_oca/models/__init__.py new file mode 100644 index 000000000..0aeb0d076 --- /dev/null +++ b/edi_component_oca/models/__init__.py @@ -0,0 +1,4 @@ +from . import edi_backend +from . import edi_exchange_record +from . import edi_exchange_consumer_mixin +from . import edi_oca_component_handler diff --git a/edi_component_oca/models/edi_backend.py b/edi_component_oca/models/edi_backend.py new file mode 100644 index 000000000..fc2a85d68 --- /dev/null +++ b/edi_component_oca/models/edi_backend.py @@ -0,0 +1,116 @@ +# Copyright 2025 Dixmit +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import logging + +from odoo import models + +from odoo.addons.component.exception import NoComponentError + +_logger = logging.getLogger(__name__) + + +class EdiBackend(models.Model): + _name = "edi.backend" + _inherit = ["edi.backend", "collection.base"] + + def _get_component(self, exchange_record, key): + record_conf = self._get_component_conf_for_record(exchange_record, key) + # Load additional ctx keys if any + collection = self + # TODO: document/test this + env_ctx = self._get_component_env_ctx(record_conf, key) + collection = collection.with_context(**env_ctx) + exchange_record = exchange_record.with_context(**env_ctx) + work_ctx = {"exchange_record": exchange_record} + # Inject work context from advanced settings + work_ctx.update(record_conf.get("work_ctx", {})) + # Model is not granted to be there + model = exchange_record.model or self._name + candidates = self._get_component_usage_candidates(exchange_record, key) + match_attrs = self._component_match_attrs(exchange_record, key) + return collection._find_component( + model, + candidates, + work_ctx=work_ctx, + **match_attrs, + ) + + def _component_match_attrs(self, exchange_record, key): + """Attributes that will be used to lookup components. + + They will be set in the work context and propagated to components. + """ + return { + "backend_type": self.backend_type_id.code, + "exchange_type": exchange_record.type_id.code, + } + + def _component_sort_key(self, component_class): + """Determine the order of matched components. + + The order can be very important if your implementation + allow generic / default components to be registered. + """ + return ( + 1 if component_class._backend_type else 0, + 1 if component_class._exchange_type else 0, + ) + + def _find_component(self, model, usage_candidates, safe=True, work_ctx=None, **kw): + """Retrieve component for current backend. + + :param usage_candidates: + list of usage to try by priority. 1st found, 1st returned + :param safe: boolean, if true does not break if component is not found + :param work_ctx: dictionary with work context params + :param kw: keyword args to lookup for components (eg: usage) + """ + component = None + work_ctx = work_ctx or {} + if "backend" not in work_ctx: + work_ctx["backend"] = self + with self.work_on(model, **work_ctx) as work: + for usage in usage_candidates: + components, c_work_ctx = work._matching_components(usage=usage, **kw) + if not components: + continue + # Sort components and pick the 1st one matching. + # In this way we support generic components registration + # and specific components registrations + components = sorted( + components, key=lambda x: self._component_sort_key(x), reverse=True + ) + component = components[0](c_work_ctx) + _logger.debug("using component %s", component._name) + break + if not component and not safe: + raise NoComponentError( + f"No component found matching any of: {usage_candidates}" + ) + return component or None + + def _get_component_usage_candidates(self, exchange_record, key): + """Retrieve usage candidates for components.""" + # fmt:off + base_usage = ".".join([ + exchange_record.direction, + key, + ]) + # fmt:on + record_conf = self._get_component_conf_for_record(exchange_record, key) + candidates = [record_conf["usage"]] if record_conf else [] + candidates += [ + base_usage, + ] + return candidates + + def _get_component_conf_for_record(self, exchange_record, key): + settings = exchange_record.type_id.get_settings() + return settings.get("components", {}).get(key, {}) + + def _get_component_env_ctx(self, record_conf, key): + env_ctx = record_conf.get("env_ctx", {}) + # You can use `edi_session` down in the stack to control logics. + env_ctx.update(dict(edi_framework_action=key)) + return env_ctx diff --git a/edi_component_oca/models/edi_exchange_consumer_mixin.py b/edi_component_oca/models/edi_exchange_consumer_mixin.py new file mode 100644 index 000000000..83a20a70e --- /dev/null +++ b/edi_component_oca/models/edi_exchange_consumer_mixin.py @@ -0,0 +1,30 @@ +# Copyright 2025 Camptocamp +# Copyright 2025 Dixmit +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class EdiExchangeConsumerMixin(models.AbstractModel): + _inherit = "edi.exchange.consumer.mixin" + + def _manual_notify_edi_generation(self, exchange_record): + self._event("on_edi_generate_manual").notify(self, exchange_record) + return super()._manual_notify_edi_generation(exchange_record) + + def write(self, vals): + # Generic event to match a state change + # TODO: this can be added to component_event for models having the state field + state_change = "state" in vals and "state" in self._fields + if state_change: + for rec in self: + rec._event(f"on_edi_{self._table}_before_state_change").notify( + rec, state=vals["state"] + ) + res = super().write(vals) + if state_change: + for rec in self: + rec._event(f"on_edi_{self._table}_state_change").notify( + rec, state=vals["state"] + ) + return res diff --git a/edi_component_oca/models/edi_exchange_record.py b/edi_component_oca/models/edi_exchange_record.py new file mode 100644 index 000000000..551d1fa6b --- /dev/null +++ b/edi_component_oca/models/edi_exchange_record.py @@ -0,0 +1,22 @@ +# Copyright 2025 Camptocamp +# Copyright 2025 Dixmit +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import models + + +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) + target = target or self + target._event(name).notify(self, **kw) + return super()._trigger_edi_event(name, suffix=suffix, target=target, **kw) diff --git a/edi_component_oca/models/edi_oca_component_handler.py b/edi_component_oca/models/edi_oca_component_handler.py new file mode 100644 index 000000000..3d67150ba --- /dev/null +++ b/edi_component_oca/models/edi_oca_component_handler.py @@ -0,0 +1,84 @@ +# Copyright 2025 Dixmit +# @author Enric Tobella +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +# Copyright 2025 Dixmit +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import logging + +from odoo import models + +from odoo.addons.edi_core_oca.exceptions import EDINotImplementedError + +_logger = logging.getLogger(__name__) + + +class EdiOcaHandlerGenerate(models.AbstractModel): + _name = "edi.oca.component.handler" + _inherit = [ + "edi.oca.handler.generate", + "edi.oca.handler.input.validate", + "edi.oca.handler.output.validate", + "edi.oca.handler.send", + "edi.oca.handler.receive", + "edi.oca.handler.process", + "edi.oca.handler.check", + ] + _description = "Component Handler for EDI" + + def generate(self, exchange_record): + component = exchange_record.backend_id._get_component( + exchange_record, "generate" + ) + if component: + return component.generate() + raise EDINotImplementedError("No component found to generate EDI document.") + + def input_validate(self, exchange_record, value=None, **kw): + component = exchange_record.backend_id._get_component( + exchange_record, "validate" + ) + if component: + return component.validate(value) + raise EDINotImplementedError( + "No component found to validate EDI document input." + ) + + def output_validate(self, exchange_record, value=None, **kw): + component = exchange_record.backend_id._get_component( + exchange_record, "validate" + ) + if component: + return component.validate(value) + raise EDINotImplementedError( + "No component found to validate EDI document output." + ) + + def send(self, exchange_record): + component = exchange_record.backend_id._get_component(exchange_record, "send") + if component: + return component.send() + raise EDINotImplementedError("No component found to send EDI document.") + + def receive(self, exchange_record): + component = exchange_record.backend_id._get_component( + exchange_record, "receive" + ) + if component: + return component.receive() + raise EDINotImplementedError("No component found to receive EDI document.") + + def process(self, exchange_record): + component = exchange_record.backend_id._get_component( + exchange_record, "process" + ) + if component: + return component.process() + raise EDINotImplementedError("No component found to process EDI document.") + + def check(self, exchange_record): + component = exchange_record.backend_id._get_component(exchange_record, "check") + if component: + return component.check() + raise EDINotImplementedError("No component found to check EDI document.") diff --git a/edi_component_oca/pyproject.toml b/edi_component_oca/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/edi_component_oca/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/edi_component_oca/readme/CONFIGURE.md b/edi_component_oca/readme/CONFIGURE.md new file mode 100644 index 000000000..b1369af2d --- /dev/null +++ b/edi_component_oca/readme/CONFIGURE.md @@ -0,0 +1,16 @@ +## Component definition + +The component used on edi must inherit from: + +- `edi.component.input.mixin` for processing and implement the process function +- `edi.component.receive.mixin` for reception and implement the receive function +- `edi.component.output.mixin` for generation and implement the generate function +- `edi.component.send.mixin` for sending and implement the send function +- `edi.component.check.mixin` for checking and implement the check function +- `edi.component.validate.mixin` for validation and implement the validate function + +Also, the components may have the following elements that will be used to use the right component: + +- `_backend_type`: code of the backend type +- `_exchange_type`: code of the exchange type +- `_usage`: Automatically set by the inherited component diff --git a/edi_component_oca/readme/CONTRIBUTORS.md b/edi_component_oca/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..405f579f4 --- /dev/null +++ b/edi_component_oca/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- Simone Orsi \<\> +- Enric Tobella \<\> +- Manuel Regidor \<\> +- Thien Vo \<\> +- Jordi Masvidal \<\> diff --git a/edi_component_oca/readme/DESCRIPTION.md b/edi_component_oca/readme/DESCRIPTION.md new file mode 100644 index 000000000..4c38a75cd --- /dev/null +++ b/edi_component_oca/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module allows to use components to handle code to execute on EDI Exchanges. diff --git a/edi_component_oca/static/description/icon.png b/edi_component_oca/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/edi_component_oca/static/description/icon.png differ diff --git a/edi_component_oca/static/description/index.html b/edi_component_oca/static/description/index.html new file mode 100644 index 000000000..3eef4e3f9 --- /dev/null +++ b/edi_component_oca/static/description/index.html @@ -0,0 +1,470 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Edi Connector Oca

+ +

Beta License: LGPL-3 OCA/edi-framework Translate me on Weblate Try me on Runboat

+

This module allows to use components to handle code to execute on EDI +Exchanges.

+

Table of contents

+ +
+

Configuration

+
+

Component definition

+

The component used on edi must inherit from:

+
    +
  • edi.component.input.mixin for processing and implement the process +function
  • +
  • edi.component.receive.mixin for reception and implement the +receive function
  • +
  • edi.component.output.mixin for generation and implement the +generate function
  • +
  • edi.component.send.mixin for sending and implement the send +function
  • +
  • edi.component.check.mixin for checking and implement the check +function
  • +
  • edi.component.validate.mixin for validation and implement the +validate function
  • +
+

Also, the components may have the following elements that will be used +to use the right component:

+
    +
  • _backend_type: code of the backend type
  • +
  • _exchange_type: code of the exchange type
  • +
  • _usage: Automatically set by the inherited component
  • +
+
+
+
+

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

+
    +
  • ACSONE
  • +
  • Dixmit
  • +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

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.

+

Current maintainers:

+

simahawk etobella

+

This module is part of the OCA/edi-framework project on GitHub.

+

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

+
+
+
+
+ + diff --git a/edi_component_oca/tests/__init__.py b/edi_component_oca/tests/__init__.py new file mode 100644 index 000000000..6f2143c72 --- /dev/null +++ b/edi_component_oca/tests/__init__.py @@ -0,0 +1,10 @@ +from . import test_backend_base +from . import test_backend_input +from . import test_backend_output +from . import test_backend_process +from . import test_backend_validate +from . import test_component_match +from . import test_edi_backend_cron +from . import test_edi_configuration +from . import test_exchange_type_encoding +from . import test_quick_exec diff --git a/edi_component_oca/tests/common.py b/edi_component_oca/tests/common.py new file mode 100644 index 000000000..4690f8bae --- /dev/null +++ b/edi_component_oca/tests/common.py @@ -0,0 +1,73 @@ +# Copyright 2020 ACSONE +# Copyright 2020 Dixmit +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from odoo.orm.model_classes import add_to_registry +from odoo.tests.common import tagged + +from odoo.addons.component.tests.common import ( + ComponentRegistryCase, + TransactionComponentCase, +) +from odoo.addons.edi_core_oca.tests.common import EDIBackendTestMixin + + +@tagged("-at_install", "post_install") +class EDIBackendCommonComponentTestCase(TransactionComponentCase, EDIBackendTestMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_env() + cls._setup_records() + + @classmethod + def _create_exchange_type(cls, **kw): + model = cls.env.ref("edi_component_oca.model_edi_oca_component_handler") + kw.setdefault("receive_model_id", model.id) + kw.setdefault("generate_model_id", model.id) + kw.setdefault("input_validate_model_id", model.id) + kw.setdefault("output_validate_model_id", model.id) + kw.setdefault("send_model_id", model.id) + kw.setdefault("process_model_id", model.id) + kw.setdefault("check_model_id", model.id) + return super()._create_exchange_type(**kw) + + +@tagged("-at_install", "post_install") +class EDIBackendCommonComponentRegistryTestCase( + ComponentRegistryCase, EDIBackendTestMixin +): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_env() + cls._setup_records() + cls._setup_registry(cls) + # Load fake models ->/ + from odoo.addons.edi_core_oca.tests.fake_models import EdiExchangeConsumerTest + + EdiExchangeConsumerTest._edi_config_field_relation = lambda self: self.env[ + "edi.configuration" + ] + # We need to override it, as we want to test the usage with components + + add_to_registry(cls.registry, EdiExchangeConsumerTest) + cls.registry._setup_models__(cls.env.cr, ["edi.exchange.consumer.test"]) + cls.registry.init_models( + cls.env.cr, ["edi.exchange.consumer.test"], {"models_to_check": True} + ) + cls.addClassCleanup(cls.registry.__delitem__, "edi.exchange.consumer.test") + # Load the EDI component module components into the isolated test registry + cls._load_module_components(cls, "edi_component_oca") + + @classmethod + def _create_exchange_type(cls, **kw): + model = cls.env.ref("edi_component_oca.model_edi_oca_component_handler") + kw.setdefault("receive_model_id", model.id) + kw.setdefault("generate_model_id", model.id) + kw.setdefault("input_validate_model_id", model.id) + kw.setdefault("output_validate_model_id", model.id) + kw.setdefault("send_model_id", model.id) + kw.setdefault("process_model_id", model.id) + kw.setdefault("check_model_id", model.id) + return super()._create_exchange_type(**kw) diff --git a/edi_component_oca/tests/fake_components.py b/edi_component_oca/tests/fake_components.py new file mode 100644 index 000000000..6ab994792 --- /dev/null +++ b/edi_component_oca/tests/fake_components.py @@ -0,0 +1,155 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.core import Component + + +class FakeComponentMixin(Component): + FAKED_COLLECTOR = [] + + # only for testing + _action = "" + + def _fake_it(self): + self.FAKED_COLLECTOR.append(self._call_key(self.exchange_record)) + if self.env.context.get("test_break_" + self._action): + exception = self.env.context.get( + "test_break_" + self._action, "YOU BROKE IT!" + ) + if not isinstance(exception, Exception): + exception = ValueError(exception) + raise exception + update_values = self.env.context.get("fake_update_values") + if update_values: + self.exchange_record.write(update_values) + return self.env.context.get("fake_output", self._call_key(self.exchange_record)) + + @classmethod + def _call_key(cls, rec): + return f"{cls._name}: {rec.id}" + + @classmethod + def reset_faked(cls): + cls.FAKED_COLLECTOR = [] + + @classmethod + def check_called_for(cls, rec): + return cls._call_key(rec) in cls.FAKED_COLLECTOR + + @classmethod + def check_not_called_for(cls, rec): + return cls._call_key(rec) not in cls.FAKED_COLLECTOR + + +class FakeOutputGenerator(FakeComponentMixin): + _name = "fake.output.generator" + _inherit = "edi.component.output.mixin" + _usage = "output.generate" + _backend_type = "demo_backend" + _exchange_type = "test_csv_output" + + _action = "generate" + + def generate(self): + return self._fake_it() + + +class FakeOutputSender(FakeComponentMixin): + _name = "fake.output.sender" + _inherit = "edi.component.send.mixin" + _usage = "output.send" + _backend_type = "demo_backend" + _exchange_type = "test_csv_output" + + _action = "send" + + def send(self): + return self._fake_it() + + +class FakeOutputChecker(FakeComponentMixin): + _name = "fake.output.checker" + _inherit = "edi.component.check.mixin" + _usage = "output.check" + _backend_type = "demo_backend" + _exchange_type = "test_csv_output" + + _action = "check" + + def check(self): + return self._fake_it() + + +class FakeInputProcess(FakeComponentMixin): + _name = "fake.input.process" + _inherit = "edi.component.input.mixin" + _usage = "input.process" + _backend_type = "demo_backend" + _exchange_type = "test_csv_input" + + _action = "process" + + def process(self): + return self._fake_it() + + +class FakeInputReceive(FakeComponentMixin): + _name = "fake.input.receive" + _inherit = "edi.component.input.mixin" + _usage = "input.receive" + _backend_type = "demo_backend" + _exchange_type = "test_csv_input" + + _action = "receive" + + def receive(self): + return self._fake_it() + + +class FakeOutputValidate(FakeComponentMixin): + _name = "fake.out.validate" + _inherit = "edi.component.validate.mixin" + _usage = "output.validate" + _backend_type = "demo_backend" + _exchange_type = "test_csv_output" + + _action = "validate" + + def validate(self, value=None): + self._fake_it() + return + + +class FakeInputValidate(FakeComponentMixin): + _name = "fake.in.validate" + _inherit = "edi.component.validate.mixin" + _usage = "input.validate" + _backend_type = "demo_backend" + _exchange_type = "test_csv_input" + + _action = "validate" + + def validate(self, value=None): + self._fake_it() + return + + +class FakeConfigurationListener(FakeComponentMixin): + _name = "fake.configuration.listener" + _inherit = "base.event.listener" + _apply_on = ["edi.exchange.consumer.test"] + + def on_record_write(self, record, fields=None, **kwargs): + trigger = "on_record_write" + confs = record.edi_config_ids.edi_get_conf(trigger) + for conf in confs: + conf.edi_exec_snippet_do(record, **kwargs) + return True + + def on_record_create(self, record, fields=None, **kwargs): + trigger = "on_record_create" + confs = record.edi_config_ids.edi_get_conf(trigger) + for conf in confs: + conf.edi_exec_snippet_do(record, **kwargs) + return True diff --git a/edi_component_oca/tests/test_backend_base.py b/edi_component_oca/tests/test_backend_base.py new file mode 100644 index 000000000..3d0b2bd9a --- /dev/null +++ b/edi_component_oca/tests/test_backend_base.py @@ -0,0 +1,77 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from freezegun import freeze_time + +from odoo.addons.edi_core_oca.tests.common import EDIBackendCommonTestCase + + +class EDIBackendTestCaseBase(EDIBackendCommonTestCase): + @freeze_time("2020-10-21 10:00:00") + def test_create_record(self): + self.env.user.tz = None # Have no timezone used in generated filename + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + record = self.backend.create_record("test_csv_input", vals) + # Force date pattern and timezone on advanced settings + self.exchange_type_in.advanced_settings_edit = """ + filename_pattern: + force_tz: UTC + date_pattern: '%Y-%m-%d-%H-%M-%S' + """ + expected = { + "type_id": self.exchange_type_in.id, + "edi_exchange_state": "new", + "exchange_filename": "EDI_EXC_TEST-test_csv_input-2020-10-21-10-00-00.csv", + } + self.assertRecordValues(record, [expected]) + self.assertEqual(record.record, self.partner) + self.assertEqual(record.edi_exchange_state, "new") + + def test_get_component_usage(self): + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + record = self.backend.create_record("test_csv_input", vals) + candidates = self.backend._get_component_usage_candidates(record, "process") + self.assertEqual( + candidates, + ["input.process"], + ) + record = self.backend.create_record("test_csv_output", vals) + candidates = self.backend._get_component_usage_candidates(record, "generate") + self.assertEqual( + candidates, + ["output.generate"], + ) + # set advanced settings on type + settings = """ + components: + generate: + usage: my.special.generate + send: + usage: my.special.send + """ + record.type_id.advanced_settings_edit = settings + candidates = self.backend._get_component_usage_candidates(record, "generate") + self.assertEqual( + candidates, + ["my.special.generate", "output.generate"], + ) + candidates = self.backend._get_component_usage_candidates(record, "send") + self.assertEqual( + candidates, + ["my.special.send", "output.send"], + ) + + def test_action_view_exchanges(self): + # Just testing is not broken + self.assertTrue(self.backend.action_view_exchanges()) + + def test_action_view_exchange_types(self): + # Just testing is not broken + self.assertTrue(self.backend.action_view_exchange_types()) diff --git a/edi_component_oca/tests/test_backend_input.py b/edi_component_oca/tests/test_backend_input.py new file mode 100644 index 000000000..f0108e1ab --- /dev/null +++ b/edi_component_oca/tests/test_backend_input.py @@ -0,0 +1,72 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from .common import EDIBackendCommonComponentRegistryTestCase +from .fake_components import FakeInputReceive + + +class EDIBackendTestInputCase(EDIBackendCommonComponentRegistryTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._build_components( + # TODO: test all components lookup + cls, + FakeInputReceive, + ) + vals = { + "model": cls.partner._name, + "res_id": cls.partner.id, + } + cls.record = cls.backend.create_record("test_csv_input", vals) + + @classmethod + def _setup_context(cls): + return dict( + super()._setup_context(), + _edi_receive_break_on_error=True, + _edi_process_break_on_error=True, + ) + + def setUp(self): + super().setUp() + FakeInputReceive.reset_faked() + + def test_receive_record_nothing_todo(self): + self.backend.exchange_receive(self.record.with_context(fake_output="yeah!")) + self.assertEqual(self.record._get_file_content(), "") + self.assertRecordValues(self.record, [{"edi_exchange_state": "new"}]) + + def test_receive_record(self): + self.record.edi_exchange_state = "input_pending" + self.backend.exchange_receive(self.record.with_context(fake_output="yeah!")) + self.assertEqual(self.record._get_file_content(), "yeah!") + self.assertRecordValues(self.record, [{"edi_exchange_state": "input_received"}]) + + def test_receive_no_allow_empty_file_record(self): + self.record.edi_exchange_state = "input_pending" + self.backend.with_context(_edi_receive_break_on_error=False).exchange_receive( + self.record.with_context(fake_output="") + ) + # Check the record + msg = ( + "Empty files are not allowed for exchange type " + f"{self.exchange_type_in.name} ({self.exchange_type_in.code})" + ) + self.assertEqual(msg, self.record.exchange_error) + self.assertIn(msg, self.record.exchange_error_traceback) + self.assertEqual(self.record._get_file_content(), "") + self.assertRecordValues( + self.record, [{"edi_exchange_state": "input_receive_error"}] + ) + + def test_receive_allow_empty_file_record(self): + self.record.edi_exchange_state = "input_pending" + self.record.type_id.allow_empty_files_on_receive = True + self.backend.exchange_receive( + self.record.with_context(fake_output="", _edi_receive_break_on_error=False) + ) + # Check the record + self.assertEqual(self.record._get_file_content(), "") + self.assertRecordValues(self.record, [{"edi_exchange_state": "input_received"}]) diff --git a/edi_component_oca/tests/test_backend_output.py b/edi_component_oca/tests/test_backend_output.py new file mode 100644 index 000000000..034b51484 --- /dev/null +++ b/edi_component_oca/tests/test_backend_output.py @@ -0,0 +1,114 @@ +# Copyright 2020 ACSONE +# Copyright 2021 Camptocamp +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from unittest import mock + +from freezegun import freeze_time + +from odoo import fields, tools +from odoo.exceptions import UserError + +from .common import EDIBackendCommonComponentRegistryTestCase +from .fake_components import FakeOutputChecker, FakeOutputGenerator, FakeOutputSender + + +class EDIBackendTestOutputCase(EDIBackendCommonComponentRegistryTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._build_components( + cls, + FakeOutputGenerator, + FakeOutputSender, + FakeOutputChecker, + ) + vals = { + "model": cls.partner._name, + "res_id": cls.partner.id, + } + cls.record = cls.backend.create_record("test_csv_output", vals) + + def setUp(self): + super().setUp() + FakeOutputGenerator.reset_faked() + FakeOutputSender.reset_faked() + FakeOutputChecker.reset_faked() + + def test_generate_record_output(self): + self.record.with_context(fake_output="yeah!").action_exchange_generate() + self.assertEqual(self.record._get_file_content(), "yeah!") + + def test_generate_record_output_pdf(self): + pdf_content = tools.file_open( + "addons/edi_core_oca/tests/result.pdf", mode="rb" + ).read() + self.record.with_context(fake_output=pdf_content).action_exchange_generate() + + def test_send_record(self): + self.record.write({"edi_exchange_state": "output_pending"}) + self.record._set_file_content(f"TEST {self.record.id}") + self.assertFalse(self.record.exchanged_on) + with freeze_time("2020-10-21 10:00:00"): + self.record.action_exchange_send() + self.assertTrue(FakeOutputSender.check_called_for(self.record)) + self.assertRecordValues( + self.record, [{"edi_exchange_state": "output_sent"}] + ) + self.assertEqual( + fields.Datetime.to_string(self.record.exchanged_on), + "2020-10-21 10:00:00", + ) + + def test_send_record_with_error(self): + self.record.write({"edi_exchange_state": "output_pending"}) + self.record._set_file_content(f"TEST {self.record.id}") + self.assertFalse(self.record.exchanged_on) + self.record.with_context( + test_break_send="OOPS! Something went wrong :(" + ).action_exchange_send() + self.assertTrue(FakeOutputSender.check_called_for(self.record)) + self.assertRecordValues( + self.record, + [ + { + "edi_exchange_state": "output_error_on_send", + "exchange_error": "OOPS! Something went wrong :(", + } + ], + ) + self.assertIn( + "OOPS! Something went wrong :(", self.record.exchange_error_traceback + ) + + def test_send_invalid_direction(self): + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + record = self.backend.create_record("test_csv_input", vals) + with mock.patch.object(type(self.backend), "_exchange_send") as mocked: + mocked.return_value = "AAA" + with self.assertRaises(UserError) as err: + record.action_exchange_send() + self.assertEqual( + err.exception.args[0], + f"Record ID={record.id} is not meant to be sent!", + ) + mocked.assert_not_called() + + def test_send_not_generated_record(self): + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + record = self.backend.create_record("test_csv_output", vals) + with mock.patch.object(type(self.backend), "_exchange_send") as mocked: + mocked.return_value = "AAA" + with self.assertRaises(UserError) as err: + record.action_exchange_send() + self.assertEqual( + err.exception.args[0], f"Record ID={record.id} has no file to send!" + ) + mocked.assert_not_called() diff --git a/edi_component_oca/tests/test_backend_process.py b/edi_component_oca/tests/test_backend_process.py new file mode 100644 index 000000000..72e8411ba --- /dev/null +++ b/edi_component_oca/tests/test_backend_process.py @@ -0,0 +1,95 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 + +from freezegun import freeze_time + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tools import mute_logger + +from .common import EDIBackendCommonComponentRegistryTestCase +from .fake_components import FakeInputProcess + + +class EDIBackendTestProcessCase(EDIBackendCommonComponentRegistryTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._build_components( + # TODO: test all components lookup + cls, + FakeInputProcess, + ) + vals = { + "model": cls.partner._name, + "res_id": cls.partner.id, + "exchange_file": base64.b64encode(b"1234"), + } + cls.record = cls.backend.create_record("test_csv_input", vals) + + def setUp(self): + super().setUp() + FakeInputProcess.reset_faked() + + def test_process_record(self): + self.record.write({"edi_exchange_state": "input_received"}) + with freeze_time("2020-10-22 10:00:00"): + self.record.action_exchange_process() + self.assertTrue(FakeInputProcess.check_called_for(self.record)) + self.assertRecordValues( + self.record, [{"edi_exchange_state": "input_processed"}] + ) + self.assertEqual( + fields.Datetime.to_string(self.record.exchanged_on), "2020-10-22 10:00:00" + ) + + def test_process_record_with_error(self): + self.record.write({"edi_exchange_state": "input_received"}) + self.record._set_file_content(f"TEST {self.record.id}") + self.record.with_context( + test_break_process="OOPS! Something went wrong :(" + ).action_exchange_process() + self.assertTrue(FakeInputProcess.check_called_for(self.record)) + self.assertRecordValues( + self.record, + [ + { + "edi_exchange_state": "input_processed_error", + "exchange_error": "OOPS! Something went wrong :(", + } + ], + ) + self.assertIn( + "OOPS! Something went wrong :(", self.record.exchange_error_traceback + ) + + @mute_logger("odoo.models.unlink") + def test_process_no_file_record(self): + self.record.write({"edi_exchange_state": "input_received"}) + self.record.exchange_file = False + self.exchange_type_in.allow_empty_files_on_receive = False + with self.assertRaises(UserError): + self.record.action_exchange_process() + + @mute_logger("odoo.models.unlink") + def test_process_allow_no_file_record(self): + self.record.write({"edi_exchange_state": "input_received"}) + self.record.exchange_file = False + self.exchange_type_in.allow_empty_files_on_receive = True + self.record.action_exchange_process() + self.assertEqual(self.record.edi_exchange_state, "input_processed") + + def test_process_outbound_record(self): + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + record = self.backend.create_record("test_csv_output", vals) + record._set_file_content(f"TEST {record.id}") + with self.assertRaises(UserError): + record.action_exchange_process() + + # TODO: test ack file are processed diff --git a/edi_component_oca/tests/test_backend_validate.py b/edi_component_oca/tests/test_backend_validate.py new file mode 100644 index 000000000..5107e0d88 --- /dev/null +++ b/edi_component_oca/tests/test_backend_validate.py @@ -0,0 +1,121 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 + +from odoo.addons.edi_core_oca.exceptions import EDIValidationError + +from .common import EDIBackendCommonComponentRegistryTestCase +from .fake_components import ( + FakeInputReceive, + FakeInputValidate, + FakeOutputGenerator, + FakeOutputValidate, +) + + +class EDIBackendTestValidateCase(EDIBackendCommonComponentRegistryTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._build_components( + # TODO: test all components lookup + cls, + FakeInputValidate, + FakeOutputValidate, + FakeInputReceive, + FakeOutputGenerator, + ) + vals = { + "model": cls.partner._name, + "res_id": cls.partner.id, + "exchange_file": base64.b64encode(b"1234"), + } + cls.record_in = cls.backend.create_record("test_csv_input", vals) + vals.pop("exchange_file") + cls.record_out = cls.backend.create_record("test_csv_output", vals) + + def setUp(self): + super().setUp() + FakeInputValidate.reset_faked() + FakeOutputValidate.reset_faked() + FakeInputReceive.reset_faked() + FakeOutputGenerator.reset_faked() + + def test_receive_validate_record(self): + self.record_in.write({"edi_exchange_state": "input_pending"}) + self.backend.exchange_receive(self.record_in) + self.assertTrue(FakeInputValidate.check_called_for(self.record_in)) + self.assertRecordValues( + self.record_in, [{"edi_exchange_state": "input_received"}] + ) + + def test_receive_validate_record_error(self): + self.record_in.write({"edi_exchange_state": "input_pending"}) + exc = EDIValidationError("Data seems wrong!") + self.backend.exchange_receive( + self.record_in.with_context(test_break_validate=exc) + ) + self.assertTrue(FakeInputValidate.check_called_for(self.record_in)) + self.assertRecordValues( + self.record_in, + [ + { + "edi_exchange_state": "validate_error", + "exchange_error": "Data seems wrong!", + } + ], + ) + self.assertIn("Data seems wrong!", self.record_in.exchange_error_traceback) + + def test_generate_validate_record(self): + self.record_out.write({"edi_exchange_state": "new"}) + self.backend.exchange_generate(self.record_out) + self.assertTrue(FakeOutputValidate.check_called_for(self.record_out)) + self.assertRecordValues( + self.record_out, [{"edi_exchange_state": "output_pending"}] + ) + + def test_generate_validate_record_error(self): + self.record_out.write({"edi_exchange_state": "new"}) + exc = EDIValidationError("Data seems wrong!") + self.backend.exchange_generate( + self.record_out.with_context(test_break_validate=exc) + ) + self.assertTrue(FakeOutputValidate.check_called_for(self.record_out)) + self.assertRecordValues( + self.record_out, + [ + { + "edi_exchange_state": "validate_error", + "exchange_error": "Data seems wrong!", + } + ], + ) + self.assertIn("Data seems wrong!", self.record_out.exchange_error_traceback) + + def test_validate_record_error_regenerate(self): + self.record_out.write({"edi_exchange_state": "new"}) + exc = EDIValidationError("Data seems wrong!") + self.backend.exchange_generate( + self.record_out.with_context(test_break_validate=exc) + ) + self.assertRecordValues( + self.record_out, + [ + { + "edi_exchange_state": "validate_error", + } + ], + ) + self.record_out.with_context(fake_output="yeah!").action_regenerate() + self.assertEqual(self.record_out._get_file_content(), "yeah!") + self.assertRecordValues( + self.record_out, + [ + { + "edi_exchange_state": "output_pending", + } + ], + ) diff --git a/edi_component_oca/tests/test_component_match.py b/edi_component_oca/tests/test_component_match.py new file mode 100644 index 000000000..7fcc5b08f --- /dev/null +++ b/edi_component_oca/tests/test_component_match.py @@ -0,0 +1,71 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.core import Component + +from .common import EDIBackendCommonComponentRegistryTestCase + + +class EDIBackendTestMatchCase(EDIBackendCommonComponentRegistryTestCase): + def test_component_match(self): + """Lookup with special match method.""" + + class MatchByBackendTypeOnly(Component): + _name = "backend_type.only" + _inherit = "edi.component.mixin" + _usage = "generate" + _backend_type = "demo_backend" + _apply_on = ["res.partner"] + + class MatchByExchangeTypeOnly(Component): + _name = "exchange_type.only" + _inherit = "edi.component.mixin" + _usage = "generate" + _exchange_type = "test_csv_output" + _apply_on = ["res.partner"] + + class MatchByBackendExchangeType(Component): + _name = "backend_type.and.exchange_type" + _inherit = "edi.component.mixin" + _usage = "generate" + _backend_type = "demo_backend" + _exchange_type = "test_csv_output" + _apply_on = ["res.partner"] + + self._build_components( + MatchByBackendTypeOnly, + MatchByExchangeTypeOnly, + # This one is registered last but since edi.backend + # is going to sort them by priority, we'll get the right one + # when looking for both match + MatchByBackendExchangeType, + ) + + # Record not relevant for these tests + work_ctx = {"exchange_record": self.env["edi.exchange.record"].browse()} + + # Search by both backend and exchange type + component = self.backend._find_component( + "res.partner", + ["generate"], + work_ctx=work_ctx, + backend_type="demo_backend", + exchange_type="test_csv_output", + ) + self.assertEqual(component._name, MatchByBackendExchangeType._name) + + # Search by backend type only + component = self.backend._find_component( + "res.partner", ["generate"], work_ctx=work_ctx, backend_type="demo_backend" + ) + self.assertEqual(component._name, MatchByBackendTypeOnly._name) + + # Search by exchange type only + component = self.backend._find_component( + "res.partner", + ["generate"], + work_ctx=work_ctx, + exchange_type="test_csv_output", + ) + self.assertEqual(component._name, MatchByExchangeTypeOnly._name) diff --git a/edi_component_oca/tests/test_edi_backend_cron.py b/edi_component_oca/tests/test_edi_backend_cron.py new file mode 100644 index 000000000..8d1ab2eea --- /dev/null +++ b/edi_component_oca/tests/test_edi_backend_cron.py @@ -0,0 +1,98 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.tools import mute_logger + +from .common import EDIBackendCommonComponentRegistryTestCase +from .fake_components import FakeOutputChecker, FakeOutputGenerator, FakeOutputSender + +LOGGERS = ( + "odoo.addons.edi_core_oca.models.edi_backend", + "odoo.addons.queue_job.delay", + "odoo.addons.edi_exchange_template_oca.models.edi_backend", +) + + +class EDIBackendTestCronCase(EDIBackendCommonComponentRegistryTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._build_components( + cls, FakeOutputGenerator, FakeOutputSender, FakeOutputChecker + ) + cls.partner2 = cls.env["res.partner"].create({"name": "EDI Test Partner 2"}) + cls.partner3 = cls.env["res.partner"].create({"name": "EDI Test Partner 3"}) + cls.record1 = cls.backend.create_record( + "test_csv_output", {"model": cls.partner._name, "res_id": cls.partner.id} + ) + cls.record2 = cls.backend.create_record( + "test_csv_output", {"model": cls.partner._name, "res_id": cls.partner2.id} + ) + cls.record3 = cls.backend.create_record( + "test_csv_output", {"model": cls.partner._name, "res_id": cls.partner3.id} + ) + cls.records = cls.record1 + cls.record1 + cls.record3 + + def setUp(self): + super().setUp() + FakeOutputGenerator.reset_faked() + FakeOutputSender.reset_faked() + FakeOutputChecker.reset_faked() + + @mute_logger(*LOGGERS) + def test_exchange_generate_new_no_auto(self): + # No content ready to be sent, no auto-generate, nothing happens + for rec in self.records: + self.assertEqual(rec.edi_exchange_state, "new") + self.backend._cron_check_output_exchange_sync() + for rec in self.records: + self.assertEqual(rec.edi_exchange_state, "new") + + @mute_logger(*LOGGERS) + def test_exchange_generate_new_auto_skip_send(self): + self.exchange_type_out.exchange_file_auto_generate = True + # No content ready to be sent, will get the content but not send it + for rec in self.records: + self.assertEqual(rec.edi_exchange_state, "new") + self.backend._cron_check_output_exchange_sync(skip_send=True) + for rec in self.records: + self.assertEqual(rec.edi_exchange_state, "output_pending") + self.assertTrue(FakeOutputGenerator.check_called_for(rec)) + self.assertEqual( + rec._get_file_content(), FakeOutputGenerator._call_key(rec) + ) + # TODO: test better? + self.assertFalse(rec.ack_exchange_id) + + @mute_logger(*LOGGERS) + def test_exchange_generate_new_auto_send(self): + self.exchange_type_out.exchange_file_auto_generate = True + # No content ready to be sent, will get the content and send it + for rec in self.records: + self.assertEqual(rec.edi_exchange_state, "new") + self.backend._cron_check_output_exchange_sync() + for rec in self.records: + self.assertEqual(rec.edi_exchange_state, "output_sent") + self.assertTrue(FakeOutputGenerator.check_called_for(rec)) + self.assertEqual( + rec._get_file_content(), FakeOutputGenerator._call_key(rec) + ) + self.assertTrue(FakeOutputSender.check_called_for(rec)) + + @mute_logger(*LOGGERS) + def test_exchange_generate_output_ready_auto_send(self): + # No content ready to be sent, will get the content and send it + for rec in self.records: + self.assertEqual(rec.edi_exchange_state, "new") + self.record1._set_file_content("READY") + self.record1.edi_exchange_state = "output_sent" + self.backend.with_context( + fake_update_values={"edi_exchange_state": "output_sent_and_processed"} + )._cron_check_output_exchange_sync(skip_sent=False) + for rec in self.records - self.record1: + self.assertEqual(rec.edi_exchange_state, "new") + self.assertEqual(self.record1.edi_exchange_state, "output_sent_and_processed") + self.assertTrue(FakeOutputGenerator.check_not_called_for(self.record1)) + self.assertTrue(FakeOutputSender.check_not_called_for(self.record1)) + self.assertTrue(FakeOutputChecker.check_called_for(self.record1)) diff --git a/edi_component_oca/tests/test_edi_configuration.py b/edi_component_oca/tests/test_edi_configuration.py new file mode 100644 index 000000000..e35968528 --- /dev/null +++ b/edi_component_oca/tests/test_edi_configuration.py @@ -0,0 +1,159 @@ +# Copyright 2024 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import os +import unittest + +from odoo import Command +from odoo.tests.common import tagged + +from .common import EDIBackendCommonComponentRegistryTestCase +from .fake_components import ( + FakeConfigurationListener, + FakeOutputChecker, + FakeOutputGenerator, + FakeOutputSender, +) + + +# This clashes w/ some setup (eg: run tests w/ pytest when edi_storage is installed) +# If you still want to run `edi` tests w/ pytest when this happens, set this env var. +@unittest.skipIf(os.getenv("SKIP_EDI_CONSUMER_CASE"), "Consumer test case disabled.") +@tagged("at_install", "-post_install") +class TestEDIConfigurations(EDIBackendCommonComponentRegistryTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._build_components( + cls, + FakeOutputGenerator, + FakeOutputSender, + FakeOutputChecker, + FakeConfigurationListener, + ) + vals = { + "model": cls.partner._name, + "res_id": cls.partner.id, + } + cls.record = cls.backend.create_record("test_csv_output", vals) + + def setUp(self): + super().setUp() + FakeOutputGenerator.reset_faked() + FakeOutputSender.reset_faked() + FakeOutputChecker.reset_faked() + + self.create_config = self.env["edi.configuration"].create( + { + "name": "Create Config", + "active": True, + "backend_id": self.backend.id, + "type_id": self.exchange_type_out.id, + "trigger_id": self.env.ref( + "edi_core_oca.edi_conf_trigger_record_create" + ).id, + "model_id": self.env["ir.model"]._get_id("edi.exchange.consumer.test"), + "snippet_do": "record._edi_send_via_edi(conf.type_id)", + } + ) + self.write_config = self.env["edi.configuration"].create( + { + "name": "Write Config 1", + "active": True, + "backend_id": self.backend.id, + "type_id": self.exchange_type_out.id, + "trigger_id": self.env.ref( + "edi_core_oca.edi_conf_trigger_record_write" + ).id, + "model_id": self.env["ir.model"]._get_id("edi.exchange.consumer.test"), + "snippet_do": "record._edi_send_via_edi(conf.type_id)", + } + ) + + self.consumer_record = self.env["edi.exchange.consumer.test"].create( + { + "name": "Test Consumer", + "edi_config_ids": [ + Command.link(self.create_config.id), + Command.link(self.write_config.id), + ], + } + ) + + @classmethod + def _setup_records(cls): # pylint:disable=missing-return + super()._setup_records() + cls.exchange_type_out.exchange_filename_pattern = "{record.id}" + + def test_edi_send_via_edi_config(self): + # Check configuration on create + self.consumer_record.invalidate_recordset() + exchange_record = self.consumer_record.exchange_record_ids + self.assertEqual(len(exchange_record), 1) + self.assertEqual(exchange_record.type_id, self.exchange_type_out) + self.assertEqual(exchange_record.edi_exchange_state, "output_sent") + # Write the existed consumer record + self.consumer_record.name = "Fixed Consumer" + # check Configuration on write + self.consumer_record.invalidate_recordset() + exchange_record = self.consumer_record.exchange_record_ids - exchange_record + self.assertEqual(len(exchange_record), 1) + self.assertEqual(exchange_record.type_id, self.exchange_type_out) + self.assertEqual(exchange_record.edi_exchange_state, "output_sent") + + def test_edi_code_snippet(self): + expected_value = { + "todo": True, + "snippet_do_vars": { + "a": 1, + "b": 2, + }, + "event_only": True, + "tracked_fields": ["state"], + "edi_action": "new_action", + } + # Simulate the snippet_before_do + self.write_config.snippet_before_do = "result = " + str(expected_value) + # Execute with the raw data + vals = self.write_config.edi_exec_snippet_before_do( + self.consumer_record, + tracked_fields=[], + edi_action="generate", + ) + # Check the new vals after execution + expected_value["conf"] = self.write_config + self.assertEqual(vals, expected_value) + + # Check the snippet_do + expected_value = { + "change_state": True, + "snippet_do_vars": { + "a": 1, + "b": 2, + }, + "record": self.consumer_record, + "tracked_fields": ["state"], + } + snippet_do = """\n +old_state = old_value.get("state", False)\n +new_state = vals.get("state", False)\n +change_state = True if old_state and new_state and old_state != new_state else False +result = {\n + "change_state": change_state,\n + "snippet_do_vars": snippet_do_vars,\n + "record": record,\n + "tracked_fields": tracked_fields,\n +} + """ + self.write_config.snippet_do = snippet_do + # Execute with the raw data + record_id = self.consumer_record.id + vals = self.write_config.edi_exec_snippet_do( + self.consumer_record, + tracked_fields=[], + edi_action="generate", + old_vals={record_id: dict(state="draft")}, + vals={record_id: dict(state="confirmed")}, + ) + # Check the new vals after execution + self.assertEqual(vals, expected_value) diff --git a/edi_component_oca/tests/test_exchange_type_encoding.py b/edi_component_oca/tests/test_exchange_type_encoding.py new file mode 100644 index 000000000..60af3ca08 --- /dev/null +++ b/edi_component_oca/tests/test_exchange_type_encoding.py @@ -0,0 +1,84 @@ +# Copyright 2024 ForgeFlow S.L. (https://www.forgeflow.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import base64 + +import chardet + +from .common import EDIBackendCommonComponentRegistryTestCase +from .fake_components import FakeOutputGenerator + + +class EDIBackendTestOutputCase(EDIBackendCommonComponentRegistryTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._build_components( + cls, + FakeOutputGenerator, + ) + vals = { + "model": cls.partner._name, + "res_id": cls.partner.id, + } + cls.record = cls.backend.create_record("test_csv_output", vals) + + def setUp(self): + super().setUp() + FakeOutputGenerator.reset_faked() + + def test_encoding_default(self): + """ + Test default output/input encoding (UTF-8). Use string with special + character to test the encoding applied. + """ + self.backend.exchange_generate( + self.record.with_context(fake_output="Palmotićeva") + ) + # Test decoding is applied correctly + self.assertEqual(self.record._get_file_content(), "Palmotićeva") + # Test encoding used + content = base64.b64decode(self.record.exchange_file) + encoding = chardet.detect(content)["encoding"].lower() + self.assertEqual(encoding, "utf-8") + + def test_encoding(self): + """ + Test specific output/input encoding. Use string with special + character to test the encoding applied. + """ + self.exchange_type_out.write({"encoding": "UTF-16"}) + self.backend.exchange_generate( + self.record.with_context(fake_output="Palmotićeva") + ) + # Test decoding is applied correctly + self.assertEqual(self.record._get_file_content(), "Palmotićeva") + # Test encoding used + content = base64.b64decode(self.record.exchange_file) + encoding = chardet.detect(content)["encoding"].lower() + self.assertEqual(encoding, "utf-16") + + def test_encoding_error_handler(self): + self.exchange_type_out.write({"encoding": "ascii"}) + # By default, error handling raises error + with self.assertRaises(UnicodeEncodeError): + self.backend.exchange_generate( + self.record.with_context(fake_output="Palmotićeva") + ) + self.exchange_type_out.write({"encoding_out_error_handler": "ignore"}) + self.backend.exchange_generate( + self.record.with_context(fake_output="Palmotićeva") + ) + self.assertEqual(self.record._get_file_content(), "Palmotieva") + + def test_decoding_error_handler(self): + self.backend.exchange_generate( + self.record.with_context(fake_output="Palmotićeva") + ) + # Change encoding to ascii to check the decoding + self.exchange_type_out.write({"encoding": "ascii"}) + # By default, error handling raises error + with self.assertRaises(UnicodeDecodeError): + content = self.record._get_file_content() + self.exchange_type_out.write({"encoding_in_error_handler": "ignore"}) + content = self.record._get_file_content() + self.assertEqual(content, "Palmotieva") diff --git a/edi_component_oca/tests/test_quick_exec.py b/edi_component_oca/tests/test_quick_exec.py new file mode 100644 index 000000000..2e7f5d6a6 --- /dev/null +++ b/edi_component_oca/tests/test_quick_exec.py @@ -0,0 +1,115 @@ +# Copyright 2022 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 +from unittest import mock + +from odoo.tools import mute_logger + +from .common import EDIBackendCommonComponentRegistryTestCase +from .fake_components import ( + FakeInputProcess, + FakeOutputChecker, + FakeOutputGenerator, + FakeOutputSender, +) + +LOGGERS = ( + "odoo.addons.edi_core_oca.models.edi_backend", + "odoo.addons.queue_job.delay", + "odoo.addons.edi_exchange_template_oca.models.edi_backend", +) + + +class EDIQuickExecTestCase(EDIBackendCommonComponentRegistryTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._build_components( + cls, + FakeOutputGenerator, + FakeOutputSender, + FakeOutputChecker, + FakeInputProcess, + ) + + def setUp(self): + super().setUp() + FakeOutputGenerator.reset_faked() + FakeOutputSender.reset_faked() + FakeOutputChecker.reset_faked() + FakeInputProcess.reset_faked() + + @mute_logger(*LOGGERS) + def test_quick_exec_on_create_no_call(self): + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + model = self.env["edi.exchange.record"] + # quick exec is off, we should not get any call + with mock.patch.object(type(model), "_execute_next_action") as mocked: + record0 = self.backend.create_record("test_csv_output", vals) + mocked.assert_not_called() + self.assertEqual(record0.edi_exchange_state, "new") + # enabled but bypassed + self.exchange_type_out.exchange_file_auto_generate = True + self.exchange_type_out.quick_exec = True + with mock.patch.object(type(model), "_execute_next_action") as mocked: + record0 = self.backend.with_context( + edi__skip_quick_exec=True + ).create_record("test_csv_output", vals) + # quick exec is off, we should not get any call + mocked.assert_not_called() + self.assertEqual(record0.edi_exchange_state, "new") + + @mute_logger(*LOGGERS) + def test_quick_exec_on_create_out(self): + self.exchange_type_out.exchange_file_auto_generate = True + self.exchange_type_out.quick_exec = True + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + } + record0 = self.backend.create_record("test_csv_output", vals) + # File generated and sent! + self.assertEqual(record0.edi_exchange_state, "output_sent") + self.assertTrue(FakeOutputGenerator.check_called_for(record0)) + self.assertEqual( + record0._get_file_content(), FakeOutputGenerator._call_key(record0) + ) + + @mute_logger(*LOGGERS) + def test_quick_exec_on_create_in(self): + self.exchange_type_in.quick_exec = True + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + "exchange_file": base64.b64encode(b"1234"), + "edi_exchange_state": "input_received", + } + record0 = self.backend.create_record("test_csv_input", vals) + self.assertEqual(record0.edi_exchange_state, "input_processed") + self.assertTrue(FakeInputProcess.check_called_for(record0)) + + @mute_logger(*LOGGERS) + def test_quick_exec_on_retry(self): + self.exchange_type_in.quick_exec = True + vals = { + "model": self.partner._name, + "res_id": self.partner.id, + "edi_exchange_state": "input_processed_error", + "exchange_file": base64.b64encode(b"1234"), + } + record0 = self.backend.with_context(edi__skip_quick_exec=True).create_record( + "test_csv_input", vals + ) + self.assertEqual(record0.edi_exchange_state, "input_processed_error") + self.assertTrue(record0.retryable) + # get record w/ a clean context + record0 = self.backend.exchange_record_model.browse(record0.id) + record0.action_retry() + # The file has been rolled back and processed right away + self.assertEqual(record0.edi_exchange_state, "input_processed") + self.assertTrue(FakeInputProcess.check_called_for(record0))