Initial commit: OCA Ai packages (4 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:05 +02:00
commit 0adb4b78b1
170 changed files with 12385 additions and 0 deletions

View file

@ -0,0 +1,5 @@
from . import ai_bridge_thread
from . import ai_bridge
from . import ai_bridge_execution
from . import mail_thread
from . import ir_model

View file

@ -0,0 +1,321 @@
# Copyright 2025 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import base64
import json
import logging
from datetime import date, datetime
from odoo import _, api, fields, models
from odoo.tools.safe_eval import safe_eval
_logger = logging.getLogger(__name__)
class AiBridge(models.Model):
_name = "ai.bridge"
_inherit = ["mail.thread", "mail.activity.mixin"]
_description = "Ai Bridge Configuration"
_order = "sequence, id"
sequence = fields.Integer(
default=10,
)
company_id = fields.Many2one(
"res.company",
# We leave it empty to allow multiple companies to use the same bridge.
)
usage = fields.Selection(
[
("none", "None"),
("thread", "Thread"),
("ai_thread_create", "AI Thread Create"),
("ai_thread_write", "AI Thread Write"),
("ai_thread_unlink", "AI Thread Unlink"),
],
default="none",
help="Defines how this bridge is used. "
"If 'Thread', it will be used in the mail thread context.",
)
name = fields.Char(required=True, translate=True)
active = fields.Boolean(default=True)
description = fields.Html(translate=True)
user_id = fields.Many2one(
"res.users",
default=lambda self: self.env.user,
help="The user that will be shown when executing this AI bridge.",
)
payload_type = fields.Selection(
[
("none", "No payload"),
("record", "Record"),
("record_v0", "Record v0"), # Deprecated, use 'record' instead
],
required=True,
store=True,
readonly=False,
compute="_compute_payload_type",
default="record",
)
result_type = fields.Selection(
[
("none", "No processing"),
("message", "Post a Message"),
("action", "Action"),
],
required=True,
default="none",
help="Defines the type of result expected from the AI system.",
)
result_kind = fields.Selection(
[("immediate", "Immediate"), ("async", "Asynchronous")],
default="immediate",
help="""
Defines how the result from the AI system is processed.
- 'Immediate': The result is processed immediately after the AI system responds.
- 'Asynchronous': The result is processed in the background.
It allows longer operations.
Odoo will provide a URL to the AI system where the response will be sent.
Users will receive a notification when the operation is started.
No notification will be sent when it is finished.
""",
)
async_timeout = fields.Integer(
default=300,
help="Timeout in seconds for asynchronous operations. "
"If the operation does not complete within this time, it will be considered failed.",
)
execution_ids = fields.One2many("ai.bridge.execution", "ai_bridge_id")
execution_count = fields.Integer(
compute="_compute_execution_count",
)
url = fields.Char(
string="URL",
help="The URL of the external AI system to which this bridge connects.",
)
auth_type = fields.Selection(
selection=[
("none", "None"),
("basic", "Basic Authentication"),
("token", "Token Authentication"),
],
default="none",
string="Authentication Type",
help="The type of authentication used to connect to the external AI system.",
)
auth_username = fields.Char(groups="base.group_system")
auth_password = fields.Char(groups="base.group_system")
auth_token = fields.Char(groups="base.group_system")
group_ids = fields.Many2many(
"res.groups",
help="User groups allowed to use this AI bridge.",
)
sample_payload = fields.Text(
help="Sample payload to be sent to the AI system. "
"This is used for testing and debugging purposes.",
compute="_compute_sample_payload",
)
model_id = fields.Many2one(
"ir.model",
string="Model",
required=False,
ondelete="cascade",
help="The model to which this bridge is associated.",
)
model_required = fields.Boolean(compute="_compute_model_fields")
#######################################
# Payload type 'record' specific fields
#######################################
field_ids = fields.Many2many(
"ir.model.fields",
help="Fields to include in the AI bridge.",
compute="_compute_field_ids",
store=True,
readonly=False,
)
model = fields.Char(
related="model_id.model",
string="Model Name",
)
domain = fields.Char(
string="Filter", compute="_compute_domain", readonly=False, store=True
)
@api.onchange("usage")
def _compute_payload_type(self):
for record in self:
if record.usage == "ai_thread_unlink":
record.payload_type = "none"
@api.constrains("usage", "payload_type")
def _check_payload_type_usage_compatibility(self):
for record in self:
if record.usage == "ai_thread_unlink" and record.payload_type != "none":
raise models.ValidationError(
_(
"When usage is 'AI Thread Unlink', "
"the Payload Type must be 'No payload'."
)
)
@api.depends("usage")
def _compute_model_fields(self):
for record in self:
record.update(record._get_model_fields())
def _get_model_fields(self):
if self.usage == "thread":
return {
"model_required": True,
}
if self.usage in ["ai_thread_create", "ai_thread_write", "ai_thread_unlink"]:
return {
"model_required": True,
}
return {
"model_required": False,
}
@api.depends("model_id")
def _compute_domain(self):
for record in self:
record.domain = "[]"
@api.depends("model_id")
def _compute_field_ids(self):
for record in self:
record.field_ids = False
@api.depends("field_ids", "model_id", "payload_type")
def _compute_sample_payload(self):
for record in self:
record.sample_payload = json.dumps(
record.with_context(sample_payload=True)._prepare_payload(), indent=4
)
@api.depends("execution_ids")
def _compute_execution_count(self):
for record in self:
record.execution_count = len(record.execution_ids)
def _get_info(self):
return {"id": self.id, "name": self.name, "description": self.description}
def execute_ai_bridge(self, res_model, res_id):
self.ensure_one()
if not self.active or (
self.group_ids and not self.env.user.groups_id & self.group_ids
):
return {
"body": _("%s is not active.", self.name),
"args": {"type": "warning", "title": _("AI Bridge Inactive")},
}
record = self.env[res_model].browse(res_id).exists()
if record:
execution = self.env["ai.bridge.execution"].create(
{
"ai_bridge_id": self.id,
"model_id": self.sudo().env["ir.model"]._get_id(res_model),
"res_id": res_id,
}
)
result = execution._execute()
if result:
return result
if execution.state == "done":
return {
"notification": {
"body": _("%s executed successfully.", self.name),
"args": {"type": "success", "title": _("AI Bridge Executed")},
}
}
return {
"notification": {
"body": _("%s failed.", self.name),
"args": {"type": "danger", "title": _("AI Bridge Failed")},
}
}
def _enabled_for(self, record):
"""Check if the bridge is enabled for the given record."""
self.ensure_one()
domain = safe_eval(self.domain)
if self.group_ids and not self.env.user.groups_id & self.group_ids:
return False
if domain:
return bool(record.filtered_domain(domain))
return True
def _prepare_payload(self, **kwargs):
method = getattr(self, f"_prepare_payload_{self.payload_type}", None)
if not method:
raise ValueError(
f"Unsupported payload type: {self.payload_type}. "
"Please implement a method for this payload type."
)
return method(**kwargs)
def _prepare_payload_none(self, res_model=False, res_id=False, **kwargs):
return {
"_model": res_model,
"_id": res_id,
}
def _prepare_payload_record(self, record=None, **kwargs):
"""Prepare the payload to be sent to the AI system."""
self.ensure_one()
if not self.model_id:
return {}
if record is None and self.env.context.get("sample_payload"):
record = self.env[self.model_id.model].search([], limit=1)
if not record:
return {}
vals = {}
if self.sudo().field_ids:
vals = record.read(self.sudo().field_ids.mapped("name"))[0]
return json.loads(
json.dumps(
{
"record": vals,
"_model": record._name,
"_id": record.id,
},
default=self.custom_serializer,
)
)
def _prepare_payload_record_v0(self, record=None, **kwargs):
"""Prepare the payload to be sent to the AI system."""
_logger.warning(
"The 'record_v0' payload type is deprecated. " "Use 'record' instead."
)
self.ensure_one()
if not self.model_id:
return {}
if record is None and self.env.context.get("sample_payload"):
record = self.env[self.model_id.model].search([], limit=1)
if not record:
return {}
vals = {}
if self.sudo().field_ids:
vals = record.read(self.sudo().field_ids.mapped("name"))[0]
return json.loads(
json.dumps(
{
**vals,
"_model": record._name,
"_id": record.id,
},
default=self.custom_serializer,
)
)
def custom_serializer(self, obj):
if isinstance(obj, datetime) or isinstance(obj, date):
return obj.isoformat()
if isinstance(obj, bytes):
return base64.b64encode(obj).decode("utf-8")
raise TypeError(f"Type {type(obj)} not serializable")

View file

@ -0,0 +1,218 @@
# Copyright 2025 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import json
import traceback
from datetime import timedelta
from io import StringIO
import requests
from werkzeug import urls
from odoo import _, api, fields, models, tools
class AiBridgeExecution(models.Model):
_name = "ai.bridge.execution"
_description = "Ai Execution"
_order = "id desc"
name = fields.Char(
store=True,
compute="_compute_name",
)
ai_bridge_id = fields.Many2one(
"ai.bridge",
required=True,
ondelete="cascade",
)
res_id = fields.Integer(required=False)
state = fields.Selection(
[
("draft", "Draft"),
("done", "Done"),
("error", "Error"),
],
default="draft",
required=True,
)
model_id = fields.Many2one(
"ir.model",
required=False,
ondelete="cascade",
)
payload = fields.Json(readonly=True)
payload_txt = fields.Text(
compute="_compute_payload_txt",
)
result = fields.Text(readonly=True)
error = fields.Text(readonly=True)
company_id = fields.Many2one(
"res.company",
compute="_compute_company_id",
store=True,
readonly=True,
)
expiration_date = fields.Datetime(
readonly=True,
help="Expiration date for the async operation token.",
)
@api.depends("model_id", "res_id", "ai_bridge_id")
def _compute_name(self):
for record in self:
model = record.sudo().model_id.name or "Unknown Model"
related = self.env[record.sudo().model_id.model].browse(record.res_id)
record.name = (
f"{model} - {related.display_name} - {record.ai_bridge_id.name}"
)
@api.depends("payload")
def _compute_payload_txt(self):
for record in self:
if record.payload:
try:
record.payload_txt = json.dumps(record.payload, indent=4)
except (TypeError, ValueError):
record.payload_txt = str(record.payload)
else:
record.payload_txt = ""
@api.depends("ai_bridge_id")
def _compute_company_id(self):
for record in self:
record.company_id = record.ai_bridge_id.company_id
def _add_extra_payload_fields(self, payload):
"""Add extra fields to the payload if needed."""
self.ensure_one()
if self.ai_bridge_id.result_kind == "async":
self.expiration_date = fields.Datetime.now() + timedelta(
seconds=self.ai_bridge_id.async_timeout
)
token = self._generate_token()
payload["_response_url"] = urls.url_join(
self.get_base_url(), f"/ai/response/{self.id}/{token}"
)
IrParamSudo = self.env["ir.config_parameter"].sudo()
dbuuid = IrParamSudo.get_param("database.uuid")
db_create_date = IrParamSudo.get_param("database.create_date")
payload["_odoo"] = {
"db": dbuuid,
"db_name": self.env.cr.dbname,
"db_hash": tools.hmac(
self.env(su=True),
"database-hash",
(dbuuid, db_create_date, self.env.cr.dbname),
),
"user_id": self.env.user.id,
}
return payload
def _execute(self, **kwargs):
self.ensure_one()
record = None
if self.res_id and self.model_id:
record = self.env[self.sudo().model_id.model].browse(self.res_id)
payload = self.ai_bridge_id._prepare_payload(
record=record,
res_id=self.res_id,
model=self.sudo().model_id.model,
**kwargs,
)
payload = self._add_extra_payload_fields(payload)
try:
response = requests.post(
self.ai_bridge_id.url,
json=payload,
auth=self._get_auth(),
headers=self._get_headers(),
timeout=30, # Default timeout, can be overridden by _execute_kwargs
**self._execute_kwargs(**kwargs),
)
self.result = response.content
response.raise_for_status()
self.state = "done"
self.payload = payload
if self.ai_bridge_id.result_kind == "immediate":
return self._process_response(response.json())
except Exception:
self.state = "error"
self.payload = payload
buff = StringIO()
traceback.print_exc(file=buff)
self.error = buff.getvalue()
buff.close()
def _execute_kwargs(self, timeout=False, **kwargs):
self.ensure_one()
result = {}
if timeout:
result["timeout"] = timeout
return result
def _get_auth(self):
"""Return authentication for the request."""
if self.ai_bridge_id.auth_type == "none":
return None
elif self.ai_bridge_id.auth_type == "basic":
return (
self.ai_bridge_id.sudo().auth_username,
self.ai_bridge_id.sudo().auth_password,
)
elif self.ai_bridge_id.auth_type == "token":
return {"Authorization": f"Bearer {self.ai_bridge_id.sudo().auth_token}"}
else:
raise ValueError(_("Unsupported authentication type."))
def _get_headers(self):
"""Return headers for the request."""
return {
"Content-Type": "application/json",
"Accept": "application/json",
}
def _generate_token(self):
"""Generate a token for async operations."""
self.ensure_one()
return tools.hmac(
self.env(su=True),
"ai_bridge-access_token",
(
self.id,
self.expiration_date and self.expiration_date.isoformat() or "expired",
),
)
def _process_response(self, response):
"""Process the response from the AI bridge."""
self.ensure_one()
self.expiration_date = None
return getattr(
self.with_user(self.ai_bridge_id.user_id.id),
f"_process_response_{self.ai_bridge_id.result_type}",
self._process_response_none,
)(response)
def _process_response_none(self, response):
return {}
def _process_response_message(self, response):
return {"id": self._get_channel().message_post(**response).id}
def _process_response_action(self, response):
if response.get("action"):
action = self.env["ir.actions.actions"]._for_xml_id(response["action"])
if response.get("context"):
action["context"] = response["context"]
if response.get("res_id"):
action["res_id"] = response["res_id"]
return {"action": action}
return {}
def _get_channel(self):
if self.model_id and self.res_id:
return self.env[self.model_id.model].browse(self.res_id)
return None

View file

@ -0,0 +1,77 @@
# Copyright 2025 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class AiBridgeThread(models.AbstractModel):
_name = "ai.bridge.thread"
_description = "AI Bridge Mixin"
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
model_id = self.sudo().env["ir.model"]._get_id(self._name)
for bridge in self.env["ai.bridge"].search(
[("model_id", "=", model_id), ("usage", "=", "ai_thread_create")]
):
for record in records:
if bridge._enabled_for(record):
try:
bridge.execute_ai_bridge(record._name, record.id)
except Exception as e:
_logger.error(
"Error creating AI thread for creation on %s: %s",
record,
e,
)
return records
def write(self, values):
result = super().write(values)
model_id = self.sudo().env["ir.model"]._get_id(self._name)
for bridge in self.env["ai.bridge"].search(
[("model_id", "=", model_id), ("usage", "=", "ai_thread_write")]
):
for record in self:
if bridge._enabled_for(record):
try:
bridge.execute_ai_bridge(record._name, record.id)
except Exception as e:
_logger.error(
"Error writing AI thread for writing on %s: %s",
record,
e,
)
return result
def unlink(self):
model_id = self.sudo().env["ir.model"]._get_id(self._name)
executions = self.env["ai.bridge.execution"]
for bridge in self.env["ai.bridge"].search(
[("model_id", "=", model_id), ("usage", "=", "ai_thread_unlink")]
):
for record in self:
if bridge._enabled_for(record):
executions |= self.env["ai.bridge.execution"].create(
{
"ai_bridge_id": bridge.id,
"model_id": model_id,
"res_id": record.id,
}
)
result = super().unlink()
for execution in executions:
try:
execution._execute()
except Exception as e:
_logger.error(
"Error executing AI thread unlink for %s: %s",
self,
e,
)
return result

View file

@ -0,0 +1,28 @@
# Copyright 2025 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class IrModel(models.Model):
_inherit = "ir.model"
is_ai_bridge_thread = fields.Boolean()
ai_usage = fields.Char(store=False, search="_search_ai_usage")
def _reflect_model_params(self, model):
vals = super(IrModel, self)._reflect_model_params(model)
vals["is_ai_bridge_thread"] = (
isinstance(model, self.pool["ai.bridge.thread"]) and not model._abstract
)
return vals
def _search_ai_usage(self, operator, value):
if operator not in ("="):
return []
if value == "thread":
return [("is_mail_thread", "=", True), ("transient", "=", False)]
if value in ["ai_thread_create", "ai_thread_write", "ai_thread_unlink"]:
return [("is_ai_bridge_thread", "=", True), ("transient", "=", False)]
return []

View file

@ -0,0 +1,68 @@
# Copyright 2025 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from lxml import etree
from odoo import api, fields, models
from odoo.tools.misc import frozendict
class MailThread(models.AbstractModel):
_inherit = "mail.thread"
ai_bridge_info = fields.Json(compute="_compute_ai_bridge_info", store=False)
@api.depends()
def _compute_ai_bridge_info(self):
for record in self:
record.ai_bridge_info = [
bridge._get_info() for bridge in record._get_ai_bridge_info()
]
def _get_ai_bridge_info(self):
self.ensure_one()
model_id = self.env["ir.model"].sudo().search([("model", "=", self._name)]).id
return (
self.env["ai.bridge"]
.search([("model_id", "=", model_id), ("usage", "=", "thread")])
.filtered(lambda r: r._enabled_for(self))
)
@api.model
def get_view(self, view_id=None, view_type="form", **options):
res = super().get_view(view_id=view_id, view_type=view_type, **options)
if view_type == "form":
View = self.env["ir.ui.view"]
if view_id and res.get("base_model", self._name) != self._name:
View = View.with_context(base_model_name=res["base_model"])
doc = etree.XML(res["arch"])
# We need to copy, because it is a frozen dict
all_models = res["models"].copy()
for node in doc.xpath("/form/div[hasclass('oe_chatter')]"):
# _add_tier_validation_label process
new_node = etree.fromstring(
"<field name='ai_bridge_info' invisible='1'/>"
)
new_arch, new_models = View.postprocess_and_fields(new_node, self._name)
new_node = etree.fromstring(new_arch)
for model in list(filter(lambda x: x not in all_models, new_models)):
if model not in res["models"]:
all_models[model] = new_models[model]
else:
all_models[model] = res["models"][model]
node.addprevious(new_node)
res["arch"] = etree.tostring(doc)
res["models"] = frozendict(all_models)
return res
@api.model
def _get_view_fields(self, view_type, models):
"""
We need to add this in order to fix the usage of form opening from
trees inside a form
"""
result = super()._get_view_fields(view_type, models)
if view_type == "form":
result[self._name].add("ai_bridge_info")
return result