Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[14.0] edi_oca: backports from v16 #1052

Draft
wants to merge 17 commits into
base: 14.0
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .oca/oca-port/blacklist/edi_oca.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"pull_requests": {
"5": "No need",
"29": "only for > 16.0",
"63": "FWD port PR from the same version",
"65": "Only valid for v16"
}
}
1 change: 1 addition & 0 deletions edi_account_oca/views/res_partner.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<field name="model">res.partner</field>
<field name="inherit_id" ref="account.view_partner_property_form" />
<field name="arch" type="xml">
<!-- TODO: Move this conf inside `edi` page from edi_oca. -->
<group name="accounting_entries" position="after">
<group name="edi_configuration" string="EDI Configuration" />
</group>
Expand Down
2 changes: 2 additions & 0 deletions edi_oca/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"data": [
"wizards/edi_exchange_record_create_wiz.xml",
"data/cron.xml",
"data/ir_actions_server.xml",
"data/sequence.xml",
"data/job_channel.xml",
"data/job_function.xml",
Expand All @@ -36,6 +37,7 @@
"views/edi_exchange_record_views.xml",
"views/edi_exchange_type_views.xml",
"views/edi_exchange_type_rule_views.xml",
"views/res_partner.xml",
"views/menuitems.xml",
"templates/exchange_chatter_msg.xml",
"templates/exchange_mixin_buttons.xml",
Expand Down
14 changes: 14 additions & 0 deletions edi_oca/data/ir_actions_server.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record model="ir.actions.server" id="action_retry_edi_exchange_record">
<field name="name">Retry</field>
<field name="groups_id" eval="[(4, ref('base_edi.group_edi_manager'))]" />
<field name="model_id" ref="model_edi_exchange_record" />
<field name="binding_model_id" ref="model_edi_exchange_record" />
<field name="state">code</field>
<field name="code">
if records:
action = records.action_retry()
</field>
</record>
</odoo>
47 changes: 39 additions & 8 deletions edi_oca/models/edi_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ class EDIBackend(models.Model):
required=True,
ondelete="restrict",
)
backend_type_code = fields.Char(related="backend_type_id.code")
output_sent_processed_auto = fields.Boolean(
help="""
Automatically set the record as processed after sending.
Usecase: the web service you send the file to processes it on the fly.
"""
)
active = fields.Boolean(default=True)
company_id = fields.Many2one("res.company", string="Company")

def _get_component(self, exchange_record, key):
record_conf = self._get_component_conf_for_record(exchange_record, key)
Expand Down Expand Up @@ -207,16 +209,23 @@ def exchange_generate(self, exchange_record, store=True, force=False, **kw):

:param exchange_record: edi.exchange.record recordset
:param store: store output on the record itself
:param force: allow to re-genetate the content
:param force: allow to re-generate the content
:param kw: keyword args to be propagated to output generate handler
"""
self.ensure_one()
if force and exchange_record.exchange_file:
# Remove file to regenerate
exchange_record.exchange_file = False
self._check_exchange_generate(exchange_record, force=force)
output = self._exchange_generate(exchange_record, **kw)
message = None
encoding = exchange_record.type_id.encoding or "UTF-8"
encoding_error_handler = (
exchange_record.type_id.encoding_out_error_handler or "strict"
)
if output and store:
if not isinstance(output, bytes):
output = output.encode()
output = output.encode(encoding, errors=encoding_error_handler)
exchange_record.update(
{
"exchange_file": base64.b64encode(output),
Expand Down Expand Up @@ -280,6 +289,18 @@ def _exchange_generate(self, exchange_record, **kw):

# TODO: add tests
def _validate_data(self, exchange_record, value=None, **kw):
if exchange_record.direction == "input" and not exchange_record.exchange_file:
if not exchange_record.type_id.allow_empty_files_on_receive:
raise ValueError(
_(
"Empty files are not allowed for exchange type %(name)s (%(code)s)"
)
% {
"name": exchange_record.type_id.name,
"code": exchange_record.type_id.code,
}
)

component = self._get_component(exchange_record, "validate")
if component:
return component.validate(value)
Expand All @@ -291,7 +312,7 @@ def exchange_send(self, exchange_record):
# In case already sent: skip sending and check the state
check = self._output_check_send(exchange_record)
if not check:
return "Nothing to do. Likely already sent."
return self._failed_output_check_send_msg()
state = exchange_record.edi_exchange_state
error = False
message = None
Expand Down Expand Up @@ -389,9 +410,7 @@ def _check_output_exchange_sync(
:param skip_sent: ignore records that were already sent.
"""
# Generate output files
new_records = self.exchange_record_model.search(
self._output_new_records_domain(record_ids=record_ids)
)
new_records = self._get_new_output_exchange_records(record_ids=record_ids)
_logger.info(
"EDI Exchange output sync: found %d new records to process.",
len(new_records),
Expand Down Expand Up @@ -422,6 +441,11 @@ def _check_output_exchange_sync(
# TODO: run in job as well?
self._exchange_output_check_state(rec)

def _get_new_output_exchange_records(self, record_ids=None):
return self.exchange_record_model.search(
self._output_new_records_domain(record_ids=record_ids)
)

def _output_new_records_domain(self, record_ids=None):
"""Domain for output records needing output content generation."""
domain = [
Expand Down Expand Up @@ -464,7 +488,10 @@ def _exchange_process_check(self, exchange_record):
raise exceptions.UserError(
_("Record ID=%d is not meant to be processed") % exchange_record.id
)
if not exchange_record.exchange_file:
if (
not exchange_record.exchange_file
and not exchange_record.type_id.allow_empty_files_on_receive
):
raise exceptions.UserError(
_("Record ID=%d has no file to process!") % exchange_record.id
)
Expand Down Expand Up @@ -535,7 +562,8 @@ def exchange_receive(self, exchange_record):
content = None
try:
content = self._exchange_receive(exchange_record)
if content:
# Ignore result of FileNotFoundError/OSError
if content is not None:
exchange_record._set_file_content(content)
self._validate_data(exchange_record)
except EDIValidationError:
Expand Down Expand Up @@ -678,3 +706,6 @@ def _is_valid_edi_action(self, action, raise_if_not=False):
if raise_if_not:
raise
return False

def _failed_output_check_send_msg(self):
return "Nothing to do. Likely already sent."
4 changes: 3 additions & 1 deletion edi_oca/models/edi_exchange_consumer_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ def fields_view_get(
)
if view_type == "form":
doc = etree.XML(res["arch"])
for node in doc.xpath("//sheet"):
# Select main `sheet` only as they can be nested into fields custom forms.
# I'm looking at you `account.view_move_line_form` on v16 :S
for node in doc.xpath("//sheet[not(ancestor::field)]"):
# TODO: add a default group
group = False
if hasattr(self, "_edi_generate_group"):
Expand Down
13 changes: 12 additions & 1 deletion edi_oca/models/edi_exchange_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ class EDIExchangeRecord(models.Model):
compute="_compute_retryable",
help="The record state can be rolled back manually in case of failure.",
)
company_id = fields.Many2one("res.company", string="Company")

_sql_constraints = [
("identifier_uniq", "unique(identifier)", "The identifier must be unique."),
Expand Down Expand Up @@ -225,11 +226,17 @@ def _get_file_content(
):
"""Handy method to not have to convert b64 back and forth."""
self.ensure_one()
encoding = self.type_id.encoding or "UTF-8"
decoding_error_handler = self.type_id.encoding_in_error_handler or "strict"
if not self[field_name]:
return ""
if binary:
res = base64.b64decode(self[field_name])
return res.decode() if not as_bytes else res
return (
res.decode(encoding, errors=decoding_error_handler)
if not as_bytes
else res
)
return self[field_name]

def name_get(self):
Expand Down Expand Up @@ -357,6 +364,10 @@ def _retry_exchange_action(self):
self._execute_next_action()
return True

def action_regenerate(self):
for rec in self:
rec.action_exchange_generate(force=True)

def action_open_related_record(self):
self.ensure_one()
if not self.related_record_exists:
Expand Down
67 changes: 65 additions & 2 deletions edi_oca/models/edi_exchange_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,21 @@ class EDIExchangeType(models.Model):
direction = fields.Selection(
selection=[("input", "Input"), ("output", "Output")], required=True
)
exchange_filename_pattern = fields.Char(default="{record_name}-{type.code}-{dt}")
exchange_filename_pattern = fields.Char(
default="{record_name}-{type.code}-{dt}",
help="For output exchange types this should be a formatting string "
"with the following variables available (to be used between "
"brackets, `{}`): `exchange_record`, `record_name`, `type`, "
"`dt` and `seq`. For instance, a valid string would be "
"{record_name}-{type.code}-{dt}-{seq}\n"
"For more information:\n"
"- `exchange_record` means exchange record\n"
"- `record_name` means name of the exchange record\n"
"- `type` means code of the exchange record type\n"
"- `dt` means datetime\n"
"- `seq` means sequence. You need a sequence to be defined in "
"`Exchange Filename Sequence` to use `seq`\n",
)
# TODO make required if exchange_filename_pattern is
exchange_file_ext = fields.Char()
# TODO: this flag should be probably deprecated
Expand Down Expand Up @@ -152,6 +166,44 @@ class EDIExchangeType(models.Model):
"Use it directly or within models rules (domain or snippet)."
),
)
exchange_filename_sequence_id = fields.Many2one(
"ir.sequence",
"Exchange Filename Sequence",
help="If the `Exchange Filename Pattern` has `{seq}`, "
"you should define a sequence in this field to show "
"the sequence in your filename",
)
# https://docs.python.org/3/library/codecs.html#standard-encodings
encoding = fields.Char(
help="Encoding to be applied to generate/process the exchanged file.\n"
"Example: UTF-8, Windows-1252, ASCII...(default is always 'UTF-8')",
)
# https://docs.python.org/3/library/codecs.html#codec-base-classes
encoding_out_error_handler = fields.Selection(
string="Encoding Error Handler",
selection=[
("strict", "Raise Error"),
("ignore", "Ignore"),
("replace", "Replace with Replacement Marker"),
("backslashreplace", "Replace with Backslashed Escape Sequences"),
("surrogateescape", "Replace Byte with Individual Surrogate Code"),
("xmlcharrefreplace", "Replace with XML/HTML Numeric Character Reference"),
],
help="Handling of encoding errors on generate (default is always 'Raise Error').",
)
# https://docs.python.org/3/library/codecs.html#codec-base-classes
encoding_in_error_handler = fields.Selection(
string="Decoding Error Handler",
selection=[
("strict", "Raise Error"),
("ignore", "Ignore"),
("replace", "Replace with Replacement Marker"),
("backslashreplace", "Replace with Backslashed Escape Sequences"),
("surrogateescape", "Replace Byte with Individual Surrogate Code"),
],
help="Handling of decoding errors on process (default is always 'Raise Error').",
)
allow_empty_files_on_receive = fields.Boolean(string="Allow Empty Files")

_sql_constraints = [
(
Expand Down Expand Up @@ -216,12 +268,22 @@ def _make_exchange_filename_datetime(self):
now = datetime.now(utc).astimezone(tz)
return slugify(now.strftime(date_pattern))

def _make_exchange_filename_sequence(self):
self.ensure_one()
return (
self.exchange_filename_sequence_id.next_by_id()
if self.exchange_filename_sequence_id
else ""
)

def _make_exchange_filename(self, exchange_record):
"""Generate filename."""
pattern = self.exchange_filename_pattern
ext = self.exchange_file_ext
pattern = pattern + ".{ext}"
if ext:
pattern += ".{ext}"
dt = self._make_exchange_filename_datetime()
seq = self._make_exchange_filename_sequence()
record_name = self._get_record_name(exchange_record)
record = exchange_record
if exchange_record.model and exchange_record.res_id:
Expand All @@ -232,6 +294,7 @@ def _make_exchange_filename(self, exchange_record):
record_name=record_name,
type=self,
dt=dt,
seq=seq,
ext=ext,
)

Expand Down
14 changes: 14 additions & 0 deletions edi_oca/security/ir_model_access.xml
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,18 @@
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base_edi.group_edi_manager'))]" />
</record>
<record id="rule_edi_exchange_record_multi_company" model="ir.rule">
<field name="name">edi_exchange_record multi-company</field>
<field name="model_id" ref="model_edi_exchange_record" />
<field
name="domain_force"
>['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
</record>
<record id="rule_edi_backend_multi_company" model="ir.rule">
<field name="name">edi_backend multi-company</field>
<field name="model_id" ref="model_edi_backend" />
<field
name="domain_force"
>['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
</record>
</odoo>
1 change: 1 addition & 0 deletions edi_oca/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
from . import test_security
from . import test_quick_exec
from . import test_exchange_type_deprecated_fields
from . import test_exchange_type_encoding
10 changes: 9 additions & 1 deletion edi_oca/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class EDIBackendTestMixin(object):
@classmethod
def _setup_context(cls, **kw):
return dict(
cls.env.context, tracking_disable=True, test_queue_job_no_delay=True, **kw
cls.env.context, tracking_disable=True, queue_job__no_delay=True, **kw
)

@classmethod
Expand Down Expand Up @@ -55,6 +55,14 @@ def _setup_records(cls):
cls.exchange_type_out.ack_type_id = cls.exchange_type_out_ack
cls.partner = cls.env.ref("base.res_partner_1")
cls.partner.ref = "EDI_EXC_TEST"
cls.sequence = cls.env["ir.sequence"].create(
{
"code": "test_sequence",
"name": "Test sequence",
"implementation": "no_gap",
"padding": 7,
}
)

def read_test_file(self, filename):
path = os.path.join(os.path.dirname(__file__), "examples", filename)
Expand Down
23 changes: 23 additions & 0 deletions edi_oca/tests/test_backend_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,26 @@ def test_receive_record(self):
self.backend.with_context(fake_output="yeah!").exchange_receive(self.record)
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(
fake_output="", _edi_receive_break_on_error=False
).exchange_receive(self.record)
# Check the record
msg = "Empty files are not allowed for exchange type"
self.assertIn(msg, self.record.exchange_error)
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.with_context(
fake_output="", _edi_receive_break_on_error=False
).exchange_receive(self.record)
# Check the record
self.assertEqual(self.record._get_file_content(), "")
self.assertRecordValues(self.record, [{"edi_exchange_state": "input_received"}])
Loading
Loading