diff --git a/account_invoice_import_simple_pdf/__manifest__.py b/account_invoice_import_simple_pdf/__manifest__.py index 62111358f0..f157a716ca 100644 --- a/account_invoice_import_simple_pdf/__manifest__.py +++ b/account_invoice_import_simple_pdf/__manifest__.py @@ -16,7 +16,7 @@ "python": [ "regex", "dateparser", - "pypdf>=3.1.0", + "pypdf>=3.1.0,<5.0", ], "deb": ["libmupdf-dev", "mupdf", "mupdf-tools", "poppler-utils"], }, diff --git a/edi_oca/README.rst b/edi_oca/README.rst index acde589e40..488fe83563 100644 --- a/edi_oca/README.rst +++ b/edi_oca/README.rst @@ -166,6 +166,7 @@ Contributors * Simone Orsi * Enric Tobella +* Thien Vo Maintainers ~~~~~~~~~~~ diff --git a/edi_oca/__manifest__.py b/edi_oca/__manifest__.py index 6bdcbe77b1..64c4b6a17b 100644 --- a/edi_oca/__manifest__.py +++ b/edi_oca/__manifest__.py @@ -29,6 +29,7 @@ "data/sequence.xml", "data/job_channel.xml", "data/job_function.xml", + "data/edi_configuration.xml", "security/res_groups.xml", "security/ir_model_access.xml", "views/edi_backend_views.xml", @@ -36,6 +37,7 @@ "views/edi_exchange_record_views.xml", "views/edi_exchange_type_views.xml", "views/edi_exchange_type_rule_views.xml", + "views/edi_configuration_views.xml", "views/menuitems.xml", "templates/exchange_chatter_msg.xml", "templates/exchange_mixin_buttons.xml", diff --git a/edi_oca/components/__init__.py b/edi_oca/components/__init__.py index 94e4ac1406..aeddb0568b 100644 --- a/edi_oca/components/__init__.py +++ b/edi_oca/components/__init__.py @@ -2,3 +2,4 @@ from . import base_output from . import base_input from . import base_validate +from . import base_listener_config diff --git a/edi_oca/components/base_listener_config.py b/edi_oca/components/base_listener_config.py new file mode 100644 index 0000000000..495f8ab8d1 --- /dev/null +++ b/edi_oca/components/base_listener_config.py @@ -0,0 +1,33 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class EDIBackendListenerComponentConfig(Component): + _name = "edi.component.listener.config" + _inherit = "base.event.listener" + + # Added *_configuration to avoid being called from other create/write actions. + def on_record_write_configuration(self, record, fields=None, **kwargs): + trigger = "on_record_write" + if kwargs.get("vals", False): + for rec in record: + confs = self.env["edi.configuration"].edi_get_conf( + trigger, rec._name, rec.partner_id + ) + for conf in confs: + conf.edi_exec_snippet_do(rec, **kwargs) + + # Added *_configuration to avoid being called from other create/write actions. + def on_record_create_configuration(self, record, fields=None, **kwargs): + trigger = "on_record_create" + val_list = kwargs.get("vals", False) + if val_list: + for rec, vals in zip(record, val_list): + kwargs["vals"] = {rec.id: vals} + confs = self.env["edi.configuration"].edi_get_conf( + trigger, rec._name, rec.partner_id + ) + for conf in confs: + conf.edi_exec_snippet_do(rec, **kwargs) diff --git a/edi_oca/data/edi_configuration.xml b/edi_oca/data/edi_configuration.xml new file mode 100644 index 0000000000..f65eb6286f --- /dev/null +++ b/edi_oca/data/edi_configuration.xml @@ -0,0 +1,18 @@ + + + + Send Via Email + False + send_via_email + on_email_send + record._edi_send_via_email() + + + + + Send Via EDI + False + send_via_edi + record._edi_send_via_edi(conf.type_id) + + diff --git a/edi_oca/models/__init__.py b/edi_oca/models/__init__.py index f40b0abe1e..765479e4fe 100644 --- a/edi_oca/models/__init__.py +++ b/edi_oca/models/__init__.py @@ -5,3 +5,5 @@ from . import edi_exchange_type from . import edi_exchange_type_rule from . import edi_id_mixin +from . import edi_configuration +from . import edi_configuration_mixin diff --git a/edi_oca/models/edi_backend.py b/edi_oca/models/edi_backend.py index 504bcd5b72..7c544ddc1d 100644 --- a/edi_oca/models/edi_backend.py +++ b/edi_oca/models/edi_backend.py @@ -377,6 +377,18 @@ def _cron_check_output_exchange_sync(self, **kw): for backend in self: backend._check_output_exchange_sync(**kw) + def exchange_generate_send(self, recordset, skip_generate=False, skip_send=False): + for rec in recordset: + if skip_generate: + job1 = rec + else: + job1 = rec.delayable().action_exchange_generate() + if not skip_send: + # Chain send job. + # Raise prio to max to send the record out as fast as possible. + job1.on_done(rec.delayable(priority=0).action_exchange_send()) + job1.delay() + def _check_output_exchange_sync( self, skip_send=False, skip_sent=True, record_ids=None ): @@ -396,13 +408,8 @@ def _check_output_exchange_sync( "EDI Exchange output sync: found %d new records to process.", len(new_records), ) - for rec in new_records: - job1 = rec.delayable().action_exchange_generate() - if not skip_send: - # Chain send job. - # Raise prio to max to send the record out as fast as possible. - job1.on_done(rec.delayable(priority=0).action_exchange_send()) - job1.delay() + if new_records: + self.exchange_generate_send(new_records, skip_send=skip_send) if skip_send: return diff --git a/edi_oca/models/edi_configuration.py b/edi_oca/models/edi_configuration.py new file mode 100644 index 0000000000..5d56141593 --- /dev/null +++ b/edi_oca/models/edi_configuration.py @@ -0,0 +1,222 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import datetime + +import pytz + +from odoo import _, api, exceptions, fields, models +from odoo.tools import DotDict, safe_eval + + +def date_to_datetime(dt): + """Convert date to datetime.""" + if isinstance(dt, datetime.date): + return datetime.datetime.combine(dt, datetime.datetime.min.time()) + return dt + + +def to_utc(dt): + """Convert date or datetime to UTC.""" + # Gracefully convert to datetime if needed 1st + return date_to_datetime(dt).astimezone(pytz.UTC) + + +class EdiConfiguration(models.Model): + _name = "edi.configuration" + _description = """ + This model is used to configure EDI (Electronic Data Interchange) flows. + It allows users to create their own configurations, which can be tailored + to meet the specific needs of their business processes. + """ + + name = fields.Char(string="Name", required=True) + active = fields.Boolean(default=True) + code = fields.Char(required=True, copy=False, index=True, unique=True) + description = fields.Char(help="Describe what the conf is for") + backend_id = fields.Many2one(string="Backend", comodel_name="edi.backend") + type_id = fields.Many2one( + string="Exchange Type", + comodel_name="edi.exchange.type", + ondelete="cascade", + auto_join=True, + index=True, + ) + model = fields.Many2one( + "ir.model", + string="Model", + help="Model the conf applies to. Leave blank to apply for all models", + ) + model_name = fields.Char(related="model.model", store=True) + partner_ids = fields.Many2many( + string="Enabled for partners", + comodel_name="res.partner", + relation="res_partner_edi_configuration_rel", + column1="conf_id", + column2="partner_id", + help="Leave blank to apply for all partners.", + ) + trigger = fields.Selection( + [ + ("on_record_write", "Update Record"), + ("on_record_create", "Create Record"), + ("on_email_send", "Send Email"), + ], + string="Trigger", + default=False, + ) + snippet_before_do = fields.Text( + string="Snippet Before Do", + help="Snippet to validate the state and collect records to do", + ) + snippet_do = fields.Text( + string="Snippet Do", + help="""Used to do something specific here. + Receives: operation, edi_action, vals, old_vals.""", + ) + + @api.constrains("backend_id", "type_id") + def _constrains_backend(self): + for rec in self: + if rec.type_id.backend_id: + if rec.type_id.backend_id != rec.backend_id: + raise exceptions.ValidationError( + _("Backend must match with exchange type's backend!") + ) + else: + if rec.type_id.backend_type_id != rec.backend_id.backend_type_id: + raise exceptions.ValidationError( + _("Backend type must match with exchange type's backend type!") + ) + + @api.model + def default_get(self, fields): + vals = super().default_get(fields) + model = self.env.context.get("default_model_name", False) + partners = self.env.context.get("default_partner_ids", False) + if model: + vals["model"] = self.env["ir.model"]._get_id(model) + if partners and isinstance(partners, list): + vals["partner_ids"] = [(6, 0, partners)] + return vals + + def _code_snippet_valued(self, snippet): + snippet = snippet or "" + return bool( + [ + not line.startswith("#") + for line in (snippet.splitlines()) + if line.strip("") + ] + ) + + @staticmethod + def _date_to_string(dt, utc=True): + if not dt: + return "" + if utc: + dt = to_utc(dt) + return fields.Date.to_string(dt) + + @staticmethod + def _datetime_to_string(dt, utc=True): + if not dt: + return "" + if utc: + dt = to_utc(dt) + return fields.Datetime.to_string(dt) + + def _time_utils(self): + return { + "datetime": safe_eval.datetime, + "dateutil": safe_eval.dateutil, + "time": safe_eval.time, + "utc_now": fields.Datetime.now(), + "date_to_string": self._date_to_string, + "datetime_to_string": self._datetime_to_string, + "time_to_string": lambda dt: dt.strftime("%H:%M:%S") if dt else "", + "first_of": fields.first, + } + + def _get_code_snippet_eval_context(self): + """Prepare the context used when evaluating python code + + :returns: dict -- evaluation context given to safe_eval + """ + ctx = { + "uid": self.env.uid, + "user": self.env.user, + "DotDict": DotDict, + "conf": self, + } + ctx.update(self._time_utils()) + return ctx + + def _evaluate_code_snippet(self, snippet, **render_values): + if not self._code_snippet_valued(snippet): + return {} + eval_ctx = dict(render_values, **self._get_code_snippet_eval_context()) + safe_eval.safe_eval(snippet, eval_ctx, mode="exec", nocopy=True) + result = eval_ctx.get("result", {}) + if not isinstance(result, dict): + return {} + return result + + def edi_exec_snippet_before_do(self, record, **kwargs): + self.ensure_one() + vals = { + "todo": kwargs.get("todo", True), + } + # Execute snippet before do + vals_before_do = self._evaluate_code_snippet( + self.snippet_before_do, record=record, **kwargs + ) + + # Prepare data + vals.update( + { + "todo": vals_before_do.get("todo", True), + "snippet_do_vars": vals_before_do.get("snippet_do_vars", False), + "event_only": vals_before_do.get("event_only", False), + "tracked_fields": vals_before_do.get("tracked_fields", False), + "edi_action": vals_before_do.get("edi_action", False), + } + ) + return vals + + def edi_exec_snippet_do(self, record, **kwargs): + self.ensure_one() + + old_value = kwargs.get("old_vals", {}).get(record.id, {}) + new_value = kwargs.get("vals", {}).get(record.id, {}) + vals = { + "todo": kwargs.get("todo", True), + "record": record, + "operation": kwargs.get("operation", False), + "edi_action": kwargs.get("edi_action", False), + "old_value": old_value, + "vals": new_value, + } + if self.snippet_before_do: + before_do_vals = self.edi_exec_snippet_before_do(record, **kwargs) + vals.update(before_do_vals) + if vals["todo"]: + return self._evaluate_code_snippet(self.snippet_do, **vals) + return True + + @api.model + def edi_get_conf(self, trigger, model_name=None, partners=None, backend=None): + domain = [("trigger", "=", trigger)] + if model_name: + domain += ["|", ("model_name", "=", model_name), ("model_name", "=", False)] + if partners: + domain += [ + "|", + ("partner_ids", "in", partners.ids), + ("partner_ids", "=", False), + ] + else: + domain.append(("partner_ids", "=", False)) + if backend: + domain.append(("backend_id", "=", backend.id)) + return self.search(domain) diff --git a/edi_oca/models/edi_configuration_mixin.py b/edi_oca/models/edi_configuration_mixin.py new file mode 100644 index 0000000000..6e1f7d33c7 --- /dev/null +++ b/edi_oca/models/edi_configuration_mixin.py @@ -0,0 +1,81 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +class EDIConfigurationMixin(models.AbstractModel): + + _name = "edi.configuration.mixin" + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + operation = "create" + + new_records = self.browse() + new_vals_list = [] + + for rec, vals in zip(records, vals_list): + if not rec._edi_configuration_skip(operation): + new_records |= rec + new_vals_list.append(vals) + + if new_records: + self._event("on_record_create_configuration").notify( + new_records, + operation=operation, + vals=new_vals_list, + ) + return records + + def write(self, vals): + operation = "write" + new_records = self.browse() + + for rec in self: + if not rec._edi_configuration_skip(operation): + new_records |= rec + + old_vals = {} + for record in new_records: + old_vals[record.id] = {field: record[field] for field in vals.keys()} + + res = super().write(vals) + + new_values = {} + for record in new_records: + new_values[record.id] = {field: record[field] for field in vals.keys()} + + if new_values: + self._event("on_record_write_configuration").notify( + new_records, + operation=operation, + old_vals=old_vals, + vals=new_values, + ) + return res + + def _edi_configuration_skip(self, operation): + skip_reason = None + if self.env.context.get("edi_skip_configuration"): + skip_reason = "edi_skip_configuration ctx key found" + # TODO: Add more skip cases + if skip_reason: + self._edi_configuration_log_skip(operation, skip_reason) + return True + return False + + def _edi_configuration_log_skip(self, operation, reason): + log_msg = "Skip model=%(model)s op=%(op)s" + log_args = { + "model": self._name, + "op": operation, + "reason": reason, + } + log_msg += ": %(reason)s" + _logger.debug(log_msg, log_args) diff --git a/edi_oca/models/edi_exchange_consumer_mixin.py b/edi_oca/models/edi_exchange_consumer_mixin.py index af67a98fe8..e54deea7b8 100644 --- a/edi_oca/models/edi_exchange_consumer_mixin.py +++ b/edi_oca/models/edi_exchange_consumer_mixin.py @@ -7,7 +7,7 @@ from lxml import etree -from odoo import api, fields, models +from odoo import _, api, fields, models from odoo.tools import safe_eval from odoo.addons.base_sparse_field.models.fields import Serialized @@ -291,3 +291,45 @@ def _edi_set_origin(self, exc_record): def _edi_get_origin(self): self.ensure_one() return self.origin_exchange_record_id + + def _edi_send_via_edi(self, exchange_type): + exchange_record = self._edi_create_exchange_record(exchange_type) + exchange_record.action_exchange_generate_send() + msg = _("EDI auto: output generated.") + exchange_record._notify_related_record(msg) + exchange_record._trigger_edi_event("generated") + + def _edi_send_via_email( + self, ir_action=None, subtype_ref=None, partner_method=None, partners=None + ): + # Default action if not provided + if ir_action is None: + # `action_send_email` is just an action name I created + # to be able to generalize across models. + if hasattr(self, "action_send_email"): + ir_action = self.action_send_email() + else: + return False + # Retrieve context and composer model + ctx = ir_action.get("context", {}) + composer_model = self.env[ir_action["res_model"]].with_context(ctx) + + # Determine subtype and partner_ids dynamically based on model-specific logic + subtype = subtype_ref and self.env.ref(subtype_ref) or None + if not subtype: + return False + + composer = composer_model.create({"subtype_id": subtype.id}) + composer.onchange_template_id_wrapper() + + # Dynamically retrieve partners based on the provided method or fallback to parameter + if partner_method and hasattr(self, partner_method): + composer.partner_ids = getattr(self, partner_method)().ids + elif partners: + composer.partner_ids = partners.ids + else: + return False + + # Send the email + composer.send_mail() + return True diff --git a/edi_oca/models/edi_exchange_record.py b/edi_oca/models/edi_exchange_record.py index f5243e7128..d436a7cfb5 100644 --- a/edi_oca/models/edi_exchange_record.py +++ b/edi_oca/models/edi_exchange_record.py @@ -308,6 +308,9 @@ def action_exchange_generate(self, **kw): self.ensure_one() return self.backend_id.exchange_generate(self, **kw) + def action_exchange_generate_send(self): + return self.backend_id.exchange_generate_send(self) + def action_exchange_send(self): self.ensure_one() return self.backend_id.exchange_send(self) diff --git a/edi_oca/readme/CONTRIBUTORS.rst b/edi_oca/readme/CONTRIBUTORS.rst index 4945a3dc40..033aac7124 100644 --- a/edi_oca/readme/CONTRIBUTORS.rst +++ b/edi_oca/readme/CONTRIBUTORS.rst @@ -1,2 +1,3 @@ * Simone Orsi * Enric Tobella +* Thien Vo diff --git a/edi_oca/security/ir_model_access.xml b/edi_oca/security/ir_model_access.xml index 83970fb3c5..5ffa4e7a53 100644 --- a/edi_oca/security/ir_model_access.xml +++ b/edi_oca/security/ir_model_access.xml @@ -113,4 +113,22 @@ [(1, '=', 1)] + + access_edi_configuration manager + + + + + + + + + access_edi_configuration user + + + + + + + diff --git a/edi_oca/static/description/index.html b/edi_oca/static/description/index.html index f263f4a038..f692c9664e 100644 --- a/edi_oca/static/description/index.html +++ b/edi_oca/static/description/index.html @@ -8,11 +8,10 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ +:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. -Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +274,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: gray; } /* line numbers */ +pre.code .ln { color: grey; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +300,7 @@ span.pre { white-space: pre } -span.problematic, pre.problematic { +span.problematic { color: red } span.section-subtitle { @@ -516,14 +515,13 @@

Contributors

Maintainers

This module is maintained by the OCA.

- -Odoo Community Association - +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.

diff --git a/edi_oca/tests/__init__.py b/edi_oca/tests/__init__.py index 30692700a2..1373da895b 100644 --- a/edi_oca/tests/__init__.py +++ b/edi_oca/tests/__init__.py @@ -13,3 +13,4 @@ from . import test_security from . import test_quick_exec from . import test_exchange_type_deprecated_fields +from . import test_edi_configuration diff --git a/edi_oca/tests/fake_models.py b/edi_oca/tests/fake_models.py index 44bd3b73fe..a72eaf3b47 100644 --- a/edi_oca/tests/fake_models.py +++ b/edi_oca/tests/fake_models.py @@ -7,10 +7,11 @@ class EdiExchangeConsumerTest(models.Model): _name = "edi.exchange.consumer.test" - _inherit = ["edi.exchange.consumer.mixin"] + _inherit = ["edi.exchange.consumer.mixin", "edi.configuration.mixin"] _description = "Model used only for test" name = fields.Char() + partner_id = fields.Many2one("res.partner") def _get_edi_exchange_record_name(self, exchange_record): return self.id diff --git a/edi_oca/tests/test_edi_configuration.py b/edi_oca/tests/test_edi_configuration.py new file mode 100644 index 0000000000..89ab62eb8d --- /dev/null +++ b/edi_oca/tests/test_edi_configuration.py @@ -0,0 +1,158 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import os +import unittest + +from odoo_test_helper import FakeModelLoader + +from .common import EDIBackendCommonComponentRegistryTestCase +from .fake_components import 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.") +class TestEDIConfigurations(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() + + @classmethod + def _setup_records(cls): + super()._setup_records() + # Load fake models ->/ + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .fake_models import EdiExchangeConsumerTest + + cls.loader.update_registry((EdiExchangeConsumerTest,)) + cls.exchange_type_out.exchange_filename_pattern = "{record.id}" + cls.edi_configuration = cls.env["edi.configuration"] + cls.create_config = cls.edi_configuration.create( + { + "name": "Create Config", + "active": True, + "code": "create_config", + "backend_id": cls.backend.id, + "type_id": cls.exchange_type_out.id, + "trigger": "on_record_create", + "model": cls.env["ir.model"]._get_id("edi.exchange.consumer.test"), + "snippet_do": "record._edi_send_via_edi(conf.type_id)", + } + ) + cls.write_config = cls.edi_configuration.create( + { + "name": "Write Config 1", + "active": True, + "code": "write_config", + "backend_id": cls.backend.id, + "type_id": cls.exchange_type_out.id, + "trigger": "on_record_write", + "model": cls.env["ir.model"]._get_id("edi.exchange.consumer.test"), + "snippet_do": "record._edi_send_via_edi(conf.type_id)", + } + ) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + def test_edi_send_via_edi_config(self): + # Create new consumer record + consumer_record = self.env["edi.exchange.consumer.test"].create( + {"name": "Test Consumer"} + ) + # Check configuration on create + consumer_record.refresh() + exchange_record = 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 + consumer_record.name = "Fixed Consumer" + # check Configuration on write + consumer_record.refresh() + exchange_record = 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): + # Create new consumer record + consumer_record = self.env["edi.exchange.consumer.test"].create( + {"name": "Test Consumer"} + ) + 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( + consumer_record, + todo=False, + tracked_fields=[], + edi_action="generate", + ) + # Check the new vals after execution + self.assertEqual(vals, expected_value) + + # Check the snippet_do + expected_value = { + "change_state": True, + "snippet_do_vars": { + "a": 1, + "b": 2, + }, + "record": consumer_record, + "tracked_fields": ["state"], + } + snippet_do = """\n +old_state = old_value.get("state", False)\n +new_state = vals.get("state", False)\n +result = {\n + "change_state": True if old_state and new_state and old_state != new_state else False,\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 = consumer_record.id + vals = self.write_config.edi_exec_snippet_do( + consumer_record, + todo=False, + 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_oca/views/edi_configuration_views.xml b/edi_oca/views/edi_configuration_views.xml new file mode 100644 index 0000000000..dd3e39c5d9 --- /dev/null +++ b/edi_oca/views/edi_configuration_views.xml @@ -0,0 +1,110 @@ + + + + edi.configuration.view.search + edi.configuration + + + + + + + + + + + + + + edi.configuration + + + + + + + + + + + + + + + edi.configuration + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + EDI Configuration + ir.actions.act_window + edi.configuration + tree,form + [] + + + + + form + + + + + + tree + + +
diff --git a/edi_oca/views/menuitems.xml b/edi_oca/views/menuitems.xml index 3842809d31..9a2b1fd8f1 100644 --- a/edi_oca/views/menuitems.xml +++ b/edi_oca/views/menuitems.xml @@ -82,4 +82,11 @@ sequence="40" action="act_open_edi_exchange_type_rule_view" /> + diff --git a/edi_purchase_oca/__init__.py b/edi_purchase_oca/__init__.py index 0650744f6b..0f00a6730d 100644 --- a/edi_purchase_oca/__init__.py +++ b/edi_purchase_oca/__init__.py @@ -1 +1,2 @@ from . import models +from . import components diff --git a/edi_purchase_oca/__manifest__.py b/edi_purchase_oca/__manifest__.py index be446cd404..46c835cab4 100644 --- a/edi_purchase_oca/__manifest__.py +++ b/edi_purchase_oca/__manifest__.py @@ -10,6 +10,11 @@ "author": "ForgeFlow, Odoo Community Association (OCA)", "website": "https://github.com/OCA/edi", "depends": ["purchase", "edi_oca", "component_event"], - "data": ["views/purchase_order_views.xml", "views/edi_exchange_record_views.xml"], + "data": [ + "views/purchase_order_views.xml", + "views/edi_exchange_record_views.xml", + "views/res_partner_view.xml", + "data/edi_configuration.xml", + ], "demo": [], } diff --git a/edi_purchase_oca/components/__init__.py b/edi_purchase_oca/components/__init__.py new file mode 100644 index 0000000000..3d4a3c96a2 --- /dev/null +++ b/edi_purchase_oca/components/__init__.py @@ -0,0 +1 @@ +from . import listener_purchase_order diff --git a/edi_purchase_oca/components/listener_purchase_order.py b/edi_purchase_oca/components/listener_purchase_order.py new file mode 100644 index 0000000000..07b29c1daa --- /dev/null +++ b/edi_purchase_oca/components/listener_purchase_order.py @@ -0,0 +1,19 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class EDIConfigPurchaseListener(Component): + _name = "edi.listener.config.purchase.order" + _inherit = "edi.component.listener.config" + _apply_on = ["purchase.order"] + + def on_button_confirm_purchase_order(self, record): + trigger = "on_button_confirm_purchase_order" + for rec in record: + confs = self.env["edi.configuration"].edi_get_conf( + trigger, rec._name, rec.partner_id + ) + for conf in confs: + conf.edi_exec_snippet_do(rec) diff --git a/edi_purchase_oca/data/edi_configuration.xml b/edi_purchase_oca/data/edi_configuration.xml new file mode 100644 index 0000000000..00ee49a68b --- /dev/null +++ b/edi_purchase_oca/data/edi_configuration.xml @@ -0,0 +1,23 @@ + + + + Purchase Confirmation Config + on_button_confirm_purchase_order + on_button_confirm_purchase_order + + +result={ + "snippet_var_do": {} +} + + + + + Send EDI Quotation Config + send_via_email_rfq + send_via_email_rfq + record._edi_send_via_email(ir_action=record.action_rfq_send()) + + diff --git a/edi_purchase_oca/models/__init__.py b/edi_purchase_oca/models/__init__.py index 9f03530643..3c539226fa 100644 --- a/edi_purchase_oca/models/__init__.py +++ b/edi_purchase_oca/models/__init__.py @@ -1 +1,3 @@ from . import purchase_order +from . import res_partner +from . import edi_configuration diff --git a/edi_purchase_oca/models/edi_configuration.py b/edi_purchase_oca/models/edi_configuration.py new file mode 100644 index 0000000000..774188ce77 --- /dev/null +++ b/edi_purchase_oca/models/edi_configuration.py @@ -0,0 +1,15 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class EdiConfiguration(models.Model): + _inherit = "edi.configuration" + + trigger = fields.Selection( + selection_add=[ + ("on_button_confirm_purchase_order", "On Button Confirm Purchase Order"), + ("send_via_email_rfq", "Send via Email RFQ"), + ], + ) diff --git a/edi_purchase_oca/models/purchase_order.py b/edi_purchase_oca/models/purchase_order.py index 6ac69723e7..9e36497047 100644 --- a/edi_purchase_oca/models/purchase_order.py +++ b/edi_purchase_oca/models/purchase_order.py @@ -6,13 +6,16 @@ class PurchaseOrder(models.Model): _name = "purchase.order" - _inherit = ["purchase.order", "edi.exchange.consumer.mixin"] + _inherit = [ + "purchase.order", + "edi.exchange.consumer.mixin", + "edi.configuration.mixin", + ] def button_confirm(self): - result = super().button_confirm() - if self: - self._event("on_button_confirm_purchase_order").notify(self) - return result + res = super().button_confirm() + self._event("on_button_confirm_purchase_order").notify(self) + return res def button_cancel(self): result = super().button_cancel() diff --git a/edi_purchase_oca/models/res_partner.py b/edi_purchase_oca/models/res_partner.py new file mode 100644 index 0000000000..4d512f9d37 --- /dev/null +++ b/edi_purchase_oca/models/res_partner.py @@ -0,0 +1,18 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + edi_purchase_conf_ids = fields.Many2many( + string="EDI Purchase Config Ids", + comodel_name="edi.configuration", + relation="res_partner_edi_configuration_rel", + column1="partner_id", + column2="conf_id", + domain="[('model_name', '=', 'purchase.order')]", + ) diff --git a/edi_purchase_oca/static/description/index.html b/edi_purchase_oca/static/description/index.html index 844cb40338..c7cf8ca0ff 100644 --- a/edi_purchase_oca/static/description/index.html +++ b/edi_purchase_oca/static/description/index.html @@ -1,4 +1,3 @@ - diff --git a/edi_purchase_oca/tests/__init__.py b/edi_purchase_oca/tests/__init__.py new file mode 100644 index 0000000000..6e87be2c8c --- /dev/null +++ b/edi_purchase_oca/tests/__init__.py @@ -0,0 +1 @@ +from . import test_edi_configuration diff --git a/edi_purchase_oca/tests/test_edi_configuration.py b/edi_purchase_oca/tests/test_edi_configuration.py new file mode 100644 index 0000000000..325118b649 --- /dev/null +++ b/edi_purchase_oca/tests/test_edi_configuration.py @@ -0,0 +1,92 @@ +# Copyright 2024 CamptoCamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import mock + +from odoo.exceptions import UserError + +from odoo.addons.edi_oca.tests.common import EDIBackendCommonComponentRegistryTestCase + + +class TestsPurchaseEDIConfiguration(EDIBackendCommonComponentRegistryTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._load_module_components(cls, "edi_purchase_oca") + cls.edi_configuration = cls.env["edi.configuration"] + cls.purchase_order = cls.env["purchase.order"] + cls.product = cls.env["product.product"].create( + { + "name": "Product 1", + "default_code": "1234567", + } + ) + + def test_edi_configuration_snippet_before_do(self): + order = self.purchase_order.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_qty": 10, + "price_unit": 100.0, + }, + ) + ], + } + ) + self.assertTrue(order) + self.assertEqual(order.state, "draft") + + confirm_conf = self.env.ref( + "edi_purchase_oca.edi_conf_button_confirm_purchase_order" + ) + # Replace snippet_before_do for test + confirm_conf.snippet_before_do = "record.button_cancel()" + + order.button_confirm() + # After purchase order is confirmed + # it will be automatically canceled due to edi_configuration execution. + self.assertEqual(order.state, "cancel") + + def test_edi_configuration_snippet_do(self): + self.create_config = self.edi_configuration.create( + { + "name": "Create Config", + "active": True, + "code": "create_config", + "backend_id": self.backend.id, + "type_id": self.exchange_type_out.id, + "trigger": "on_record_create", + "model": self.env["ir.model"]._get_id("purchase.order"), + "snippet_do": "record._edi_send_via_edi(conf.type_id)", + } + ) + with mock.patch.object( + type(self.backend), "exchange_generate", return_value=True + ): + + with self.assertRaises(UserError) as err: + self.purchase_order.create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_qty": 10, + "price_unit": 100.0, + }, + ) + ], + } + ) + self.assertRegex( + err.exception.args[0], r"Record ID=\d+ has no file to send!" + ) diff --git a/edi_purchase_oca/views/res_partner_view.xml b/edi_purchase_oca/views/res_partner_view.xml new file mode 100644 index 0000000000..4340d68f8a --- /dev/null +++ b/edi_purchase_oca/views/res_partner_view.xml @@ -0,0 +1,20 @@ + + + + res.partner.form.inherit.sales_purchases + res.partner + + + + + + + + + + + + diff --git a/pdf_helper/__manifest__.py b/pdf_helper/__manifest__.py index f59b56b1cf..f565a3189b 100644 --- a/pdf_helper/__manifest__.py +++ b/pdf_helper/__manifest__.py @@ -15,5 +15,5 @@ "depends": [ "base", ], - "external_dependencies": {"python": ["pypdf"]}, + "external_dependencies": {"python": ["pypdf>=3.1.0,<5.0"]}, } diff --git a/requirements.txt b/requirements.txt index d72b73d55c..c286b36efc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,7 @@ factur-x invoice2data ovh phonenumbers -pypdf -pypdf>=3.1.0 +pypdf>=3.1.0,<5.0 pyyaml regex xmlschema