Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion edi_webservice_core_oca/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
"maintainers": ["etobella", "simahawk"],
"website": "https://github.com/OCA/edi-framework",
"depends": ["edi_core_oca", "webservice_core"],
"data": ["views/edi_backend.xml", "security/ir.model.access.csv"],
"data": [
"views/edi_backend.xml",
"views/edi_exchange_type.xml",
"views/webservice_endpoint.xml",
"security/ir.model.access.csv",
],
"demo": ["demo/edi_backend.xml"],
"pre_init_hook": "pre_init_hook",
}
266 changes: 266 additions & 0 deletions edi_webservice_core_oca/hooks.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import re
from urllib.parse import parse_qsl, urlsplit, urlunsplit

import yaml
from openupgradelib import openupgrade

_logger = logging.getLogger(__name__)

# xids to pass to the new module
MOVED_XMLIDS = [
"access_webservice_backend_edi_manager",
Expand All @@ -18,3 +25,262 @@ def pre_init_hook(env):
for xmlid in MOVED_XMLIDS
],
)


def migrate_component_based_exchange_types(env, types=None, handler_model=None):
"""Convert exchange types off the `component`-based webservice send.

Switches them onto the new, component-free `edi.webservice.send`
handler. This is an explicit, opt-in tool - NOT run automatically on
install or upgrade. Staying on `edi_webservice_component_oca` is a
fully supported choice; this is only for exchange types you actively
want to move onto the handler/endpoint-based style. Call it by hand,
e.g. from an Odoo shell, against the types you want migrated::

from odoo.addons.edi_webservice_core_oca.hooks import (
migrate_component_based_exchange_types,
)
migrated = migrate_component_based_exchange_types(env)

Pass an explicit `types` recordset to restrict the run (e.g. to test on
a handful of records first); left out, every `edi.exchange.type` whose
`send_model_id` is still the generic `component` handler
(`edi_component_oca.model_edi_oca_component_handler`) and whose backend
has a `webservice_backend_id` is migrated.

See `migrate_exchange_type` for what happens to each type. Placeholders
moved to an endpoint path are removed from the backend URL here, once
all types are processed.

"""
component_model = env.ref(
"edi_component_oca.model_edi_oca_component_handler", raise_if_not_found=False
)
if not component_model:
# `edi_component_oca` was never installed: nothing relied on it.
return env["edi.exchange.type"].browse()
if types is None:
types = env["edi.exchange.type"].search(
[
("send_model_id", "=", component_model.id),
("backend_id.webservice_backend_id", "!=", False),
]
)
handler_model = handler_model or env.ref(
"edi_webservice_core_oca.model_edi_webservice_send"
)
moved_keys_by_backend = {}
for etype in types:
backend = etype.backend_id.webservice_backend_id
moved_keys = migrate_exchange_type(env, etype)
moved_keys_by_backend.setdefault(backend, set()).update(moved_keys or ())
# Strip only once every type was classified, so that a placeholder
# removed for one type is still recognised for the others on that backend.
for backend, keys in moved_keys_by_backend.items():
if not backend:
continue
backend.url = _strip_url_placeholders(backend.url, keys)
_move_url_query_to_params(backend)
types.write({"send_model_id": handler_model.id})
return types


# HELPERS FOR CUSTOM TYPES MIGRATION
#
# Help you convert old style exchange types to no component setup
# and webservice.endpoint usage.
#
# DISCLAIMER: do your own tests!


def migrate_exchange_type(env, etype):
"""Migrate a single exchange type's `component`-based `webservice` settings.

Updates in place, without touching `send_model_id` (see
`migrate_component_based_exchange_types`, which also does that).

Reads the old `components.send.work_ctx.webservice` YAML and applies:

- `method` is removed - it now lives on the `webservice.endpoint`
itself, found or created (named after the type's own `code`, unless
a `webservice_endpoint_id` is already set) and assigned to
`webservice_endpoint_id`.
- `send_as_bytes` is removed and moved to the
`webservice_send_as_bytes` field on the type.
- `pargs`/`kwargs` are kept (still usable as call-time overrides), just
moved from `components.send.work_ctx.webservice` to the new
`execution_model.send.webservice` key.
- `kwargs.url_params` entries are checked against the backend's own URL:
a key that fills a `{placeholder}` in it becomes the endpoint's
`path` and is dropped from the YAML; anything else stays in
`url_params` for manual review.

NOTE: rewriting the YAML re-serializes it, so any comments in
`advanced_settings_edit` are lost for migrated types.

Returns the set of `url_params` keys moved to an endpoint path.

"""
settings = yaml.safe_load(etype.advanced_settings_edit or "") or {}
components = settings.get("components", {})
send_conf = components.get("send", {})
work_ctx = send_conf.get("work_ctx", {})
ws_settings = work_ctx.pop("webservice", None)
if ws_settings is None:
# Nothing to move: the type relied on the backend-level auto
# selection without any explicit `webservice` YAML settings.
return set()
if not work_ctx:
send_conf.pop("work_ctx", None)
if not send_conf:
components.pop("send", None)
if not components:
settings.pop("components", None)

method = ws_settings.pop("method", None)
send_as_bytes = ws_settings.pop("send_as_bytes", None)
if send_as_bytes is not None:
etype.webservice_send_as_bytes = bool(send_as_bytes)

endpoint = etype.webservice_endpoint_id
if not endpoint:
if not method:
_logger.warning(
"edi.exchange.type(%s): no `method` found in `webservice` "
"settings, cannot create a webservice.endpoint - please "
"set `webservice_endpoint_id` manually.",
etype.id,
)
else:
endpoint = _get_or_create_endpoint(env, etype, method)
etype.webservice_endpoint_id = endpoint.id

moved_keys = set()
if endpoint:
moved_keys = _move_matching_url_params(endpoint, ws_settings)
if ws_settings:
settings.setdefault("execution_model", {}).setdefault("send", {})[
"webservice"
] = ws_settings

etype.advanced_settings_edit = yaml.safe_dump(settings, sort_keys=False)
return moved_keys


def _get_or_create_endpoint(env, etype, method):
ws_backend = etype.backend_id.webservice_backend_id
tech_name = etype.code
endpoint = env["webservice.endpoint"].search(
[("backend_id", "=", ws_backend.id), ("tech_name", "=", tech_name)], limit=1
)
if endpoint:
return endpoint
endpoint = env["webservice.endpoint"].create(
{
"name": etype.name,
"tech_name": tech_name,
"description": (
f"Auto-migrated from exchange type '{etype.code}' " "advanced settings."
),
"backend_id": ws_backend.id,
"http_method": method.lower(),
}
)
_logger.info(
"edi.exchange.type(%s): created webservice.endpoint(%s) from "
"advanced settings.",
etype.id,
endpoint.id,
)
return endpoint


def _move_matching_url_params(endpoint, ws_settings):
"""Move `{placeholder}` url_params into the endpoint's path.

A key that fills a `{placeholder}` in the backend's URL becomes the
endpoint's `path`, and is dropped from the YAML. Anything else is kept
in the YAML `url_params` for manual review (no querystring param is
created from it).

Returns the set of keys moved to the path.

"""
kwargs = ws_settings.get("kwargs", {})
url_params = kwargs.get("url_params")
if not url_params:
return set()
backend_url = endpoint.backend_id.url or ""
remaining = {}
moved = set()
for key, value in url_params.items():
if f"{{{key}}}" not in backend_url:
_logger.warning(
"webservice.endpoint(%s): url_param %r is not a placeholder in "
"the backend URL, kept in YAML for manual review.",
endpoint.id,
key,
)
remaining[key] = value
continue
if endpoint.path and endpoint.path != value:
_logger.warning(
"webservice.endpoint(%s): path already set to %r, url_param "
"%r=%r kept in YAML for manual review.",
endpoint.id,
endpoint.path,
key,
value,
)
remaining[key] = value
continue
endpoint.path = value
stale = endpoint.querystring_param_ids.filtered(lambda p, k=key: p.name == k)
if stale:
_logger.info(
"webservice.endpoint(%s): removing querystring param %r, "
"now carried by the endpoint path.",
endpoint.id,
key,
)
stale.unlink()
moved.add(key)
if remaining:
kwargs["url_params"] = remaining
else:
kwargs.pop("url_params", None)
if not kwargs:
ws_settings.pop("kwargs", None)
return moved


def _strip_url_placeholders(url, keys):
for key in keys:
url = re.sub(r"/?\{" + re.escape(key) + r"\}", "", url or "")
return url


def _move_url_query_to_params(backend):
parts = urlsplit(backend.url or "")
if not parts.query:
return
existing = backend.querystring_param_ids.mapped("name")
pairs = [
(name, value)
for name, value in parse_qsl(parts.query, keep_blank_values=True)
if name not in existing
]
backend.env["webservice.querystring.param"].create(
[
{
"res_model": backend._name,
"res_id": backend.id,
"name": name,
"value": value,
}
for name, value in pairs
]
)
backend.url = urlunsplit(
(parts.scheme, parts.netloc, parts.path, "", parts.fragment)
)
2 changes: 2 additions & 0 deletions edi_webservice_core_oca/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
from . import edi_backend
from . import edi_exchange_type
from . import edi_webservice_send
from . import webservice_endpoint
42 changes: 42 additions & 0 deletions edi_webservice_core_oca/models/edi_exchange_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models


class EDIExchangeType(models.Model):
_inherit = "edi.exchange.type"

webservice_backend_id = fields.Many2one(
related="backend_id.webservice_backend_id",
)
webservice_send_enabled = fields.Boolean(
compute="_compute_webservice_send_enabled",
help="Technical flag: whether `send_model_id` inherits from the "
"webservice send handler (`edi.webservice.send`), directly or via "
"a custom extension. Used to show/hide webservice-specific "
"settings on this type.",
)
webservice_endpoint_id = fields.Many2one(
"webservice.endpoint",
string="WebService Endpoint",
help="Webservice endpoint to call when sending through this "
"exchange type (see `edi.webservice.send`). Extra call "
"parameters can still be provided via the advanced YAML settings.",
)
webservice_send_as_bytes = fields.Boolean(
string="Send As Bytes",
help="By sending as bytes, `requests` won't try to guess and/or "
"alter the encoding of the file being sent.",
)

@api.depends("send_model_id")
def _compute_webservice_send_enabled(self):
registry = self.env.registry
handler_cls = registry["edi.webservice.send"]
for rec in self:
model_name = rec.send_model_id.model
rec.webservice_send_enabled = bool(
model_name
and model_name in registry
and issubclass(registry[model_name], handler_cls)
)
29 changes: 12 additions & 17 deletions edi_webservice_core_oca/models/edi_webservice_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,28 @@

from requests import Response

from odoo import exceptions, models
from odoo import models


class EDIWebserviceSend(models.AbstractModel):
"""Generic handler for sending EDI exchange files through a webservice.

Configuration is expected to come from the exchange type's advanced
The exchange type's ``webservice_endpoint_id`` is used to perform the
call - its own HTTP method applies. Extra call parameters (headers,
query params, ...) can be provided via the exchange type's advanced
settings, under ``execution_model.send.webservice``.

"""

_name = "edi.webservice.send"
_inherit = "edi.oca.handler.send"
_description = "EDI WebService Send Handler"

def send(self, exchange_record):
endpoint = exchange_record.type_id.webservice_endpoint_id
ws_settings = self._get_ws_settings(exchange_record)
method, pargs, kwargs = self._get_call_params(exchange_record, ws_settings)
response = exchange_record.backend_id.webservice_backend_id.call(
method, *pargs, **kwargs
)
kwargs = self._get_call_params(exchange_record, ws_settings)
response = endpoint.call(**kwargs)
if not isinstance(response, Response):
# backward compat for obsolete `content_only` param
return response
Expand All @@ -34,18 +36,11 @@ def _get_ws_settings(self, exchange_record):
return settings.get("execution_model", {}).get("send", {}).get("webservice", {})

def _get_call_params(self, exchange_record, ws_settings):
try:
method = ws_settings["method"].lower()
except KeyError as err:
raise exceptions.UserError(
self.env._("`method` is required in `webservice` type settings.")
) from err
pargs = ws_settings.get("pargs", [])
kwargs = ws_settings.get("kwargs", {})
kwargs["data"] = self._get_data(exchange_record, ws_settings)
return method, pargs, kwargs
kwargs["data"] = self._get_data(exchange_record)
return kwargs

def _get_data(self, exchange_record, ws_settings):
def _get_data(self, exchange_record):
# By sending as bytes `requests` won't try to guess and/or alter the encoding.
as_bytes = ws_settings.get("send_as_bytes")
as_bytes = exchange_record.type_id.webservice_send_as_bytes
return exchange_record._get_file_content(as_bytes=as_bytes)
Loading
Loading