mirror of
https://github.com/bringout/oca-ai.git
synced 2026-04-24 19:42:00 +02:00
Initial commit: OCA Ai packages (4 packages)
This commit is contained in:
commit
0adb4b78b1
170 changed files with 12385 additions and 0 deletions
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue