diff --git a/helpdesk_sale_order_ai/__init__.py b/helpdesk_sale_order_ai/__init__.py
index cde864b..5e0a42f 100644
--- a/helpdesk_sale_order_ai/__init__.py
+++ b/helpdesk_sale_order_ai/__init__.py
@@ -1,3 +1,38 @@
# -*- coding: utf-8 -*-
from . import models
+from . import views
+
+def post_init_hook(cr, registry=None):
+ """Initialize default AI prompt template after module installation
+
+ Note: This function can be called with either one argument (env) or two arguments (cr, registry)
+ to accommodate different Odoo hook calling conventions.
+ """
+ import logging
+ _logger = logging.getLogger(__name__)
+ _logger.info("=== START: post_init_hook ====")
+
+ try:
+ from odoo import api, SUPERUSER_ID
+
+ # Handle different calling conventions
+ if hasattr(cr, 'env'): # cr is actually an env
+ env = cr
+ _logger.info("Using provided environment")
+ elif registry: # We have both cr and registry
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ _logger.info("Created environment from cursor and registry")
+ else:
+ _logger.warning("Cannot create environment, skipping template creation")
+ return
+
+ _logger.info("Calling _ensure_default_template")
+ env['openwebui.prompt.template']._ensure_default_template('helpdesk')
+ _logger.info("Default template ensured successfully")
+ _logger.info("=== END: post_init_hook ====")
+ except Exception as e:
+ _logger.error(f"ERROR in post_init_hook: {str(e)}")
+ _logger.exception("Exception traceback:")
+ # Don't raise the exception to prevent installation failure
+ return
diff --git a/helpdesk_sale_order_ai/__manifest__.py b/helpdesk_sale_order_ai/__manifest__.py
index 5be0551..946ff7c 100644
--- a/helpdesk_sale_order_ai/__manifest__.py
+++ b/helpdesk_sale_order_ai/__manifest__.py
@@ -16,12 +16,16 @@
'maintainer': 'it@bemade.org',
'depends': [
'helpdesk_sale_order',
- 'openai_connector', # Supposant qu'un module de connexion OpenAI existe
+ 'openwebui_connector',
],
'data': [
+ 'security/ir.model.access.csv',
'views/helpdesk_team_views.xml',
'views/helpdesk_ticket_views.xml',
+ 'views/res_config_settings_views.xml',
+ 'views/ai_prompt_template_views.xml',
],
'installable': True,
'application': False,
+ 'post_init_hook': 'post_init_hook',
}
diff --git a/helpdesk_sale_order_ai/data/config.json b/helpdesk_sale_order_ai/data/config.json
new file mode 100644
index 0000000..a4d56ae
--- /dev/null
+++ b/helpdesk_sale_order_ai/data/config.json
@@ -0,0 +1,7 @@
+{
+ "OpenWebUI": {
+ "api_key": "",
+ "base_url": "https://ai.bemade.org/api",
+ "model": "anthropic.claude-3-7-sonnet-latest"
+ }
+}
diff --git a/helpdesk_sale_order_ai/migrations/1.0/__init__.py b/helpdesk_sale_order_ai/migrations/1.0/__init__.py
new file mode 100644
index 0000000..7af1d05
--- /dev/null
+++ b/helpdesk_sale_order_ai/migrations/1.0/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+# This file is intentionally left empty to make the directory a proper Python package
diff --git a/helpdesk_sale_order_ai/migrations/1.0/post-migration.py b/helpdesk_sale_order_ai/migrations/1.0/post-migration.py
new file mode 100644
index 0000000..cd81831
--- /dev/null
+++ b/helpdesk_sale_order_ai/migrations/1.0/post-migration.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+# This migration script will run after the module update
+# It will ensure the prompt templates are properly set up
+
+import logging
+_logger = logging.getLogger(__name__)
+
+def migrate(cr, version):
+ """
+ Ensure prompt templates are properly set up after migration
+ """
+ # Check if we have any prompt templates
+ cr.execute("""
+ SELECT COUNT(*) FROM helpdesk_ai_prompt_template
+ """)
+
+ template_count = cr.fetchone()[0]
+
+ if template_count == 0:
+ # Create a default template if none exists
+ _logger.info("No prompt templates found, creating default template")
+
+ default_template_name = "Default Sales Order Template"
+ default_template_content = """
+You are a sales order assistant. Your task is to analyze the customer's request and suggest products or services that would meet their needs.
+
+Please provide a list of products or services in the following format:
+1. Product Name | Quantity | Description
+2. Product Name | Quantity | Description
+
+If you're not sure about a specific product, suggest a generic service item with a description of what it should accomplish.
+"""
+
+ # Insert the default template
+ cr.execute("""
+ INSERT INTO helpdesk_ai_prompt_template (name, content, create_date, write_date)
+ VALUES (%s, %s, now(), now())
+ RETURNING id
+ """, (default_template_name, default_template_content))
+
+ template_id = cr.fetchone()[0]
+
+ # Set this as the default template
+ cr.execute("""
+ INSERT INTO ir_config_parameter (key, value, create_date, write_date)
+ VALUES ('helpdesk_sale_order_ai.default_prompt_template_id', %s, now(), now())
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
+ """, (str(template_id),))
+
+ # Log the migration
+ cr.execute("""
+ INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
+ VALUES (now(), 1, 'helpdesk_sale_order_ai', 'server', current_database(), 'info',
+ 'Post-migration: Created default prompt template with ID ' || %s,
+ '/addons/helpdesk_sale_order_ai/migrations/1.0/post-migration.py', 50, 'migrate')
+ """, (str(template_id),))
+ else:
+ # Check if we have a default template set
+ cr.execute("""
+ SELECT value FROM ir_config_parameter
+ WHERE key = 'helpdesk_sale_order_ai.default_prompt_template_id'
+ """)
+
+ default_template_id = cr.fetchone()
+
+ if not default_template_id:
+ # Get the first template and set it as default
+ cr.execute("""
+ SELECT id FROM helpdesk_ai_prompt_template
+ ORDER BY id ASC
+ LIMIT 1
+ """)
+
+ template_id = cr.fetchone()[0]
+
+ # Set this as the default template
+ cr.execute("""
+ INSERT INTO ir_config_parameter (key, value, create_date, write_date)
+ VALUES ('helpdesk_sale_order_ai.default_prompt_template_id', %s, now(), now())
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
+ """, (str(template_id),))
+
+ # Log the migration
+ cr.execute("""
+ INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
+ VALUES (now(), 1, 'helpdesk_sale_order_ai', 'server', current_database(), 'info',
+ 'Post-migration: Set existing template with ID ' || %s || ' as default',
+ '/addons/helpdesk_sale_order_ai/migrations/1.0/post-migration.py', 75, 'migrate')
+ """, (str(template_id),))
+ else:
+ _logger.info(f"Default prompt template already set: {default_template_id[0]}")
+
+ # Ensure the system parameters are properly set up
+ cr.execute("""
+ INSERT INTO ir_config_parameter (key, value, create_date, write_date)
+ VALUES ('openai.base_url', 'https://ai.bemade.org/api', now(), now())
+ ON CONFLICT (key) DO NOTHING
+ """)
+
+ cr.execute("""
+ INSERT INTO ir_config_parameter (key, value, create_date, write_date)
+ VALUES ('openai.model', 'anthropic.claude-3-7-sonnet-latest', now(), now())
+ ON CONFLICT (key) DO NOTHING
+ """)
diff --git a/helpdesk_sale_order_ai/migrations/15.0.1.0/post-migrate.py b/helpdesk_sale_order_ai/migrations/15.0.1.0/post-migrate.py
new file mode 100644
index 0000000..39d3f45
--- /dev/null
+++ b/helpdesk_sale_order_ai/migrations/15.0.1.0/post-migrate.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+import logging
+
+_logger = logging.getLogger(__name__)
+
+def migrate(cr, version):
+ """
+ Migrate helpdesk.ai.prompt.template records to openwebui.prompt.template
+ """
+ if not version:
+ return
+
+ _logger.info("Starting migration of AI prompt templates")
+
+ # Check if the old model exists
+ cr.execute("SELECT 1 FROM ir_model WHERE model = 'helpdesk.ai.prompt.template'")
+ if not cr.fetchone():
+ _logger.info("No helpdesk.ai.prompt.template model found, skipping migration")
+ return
+
+ # Check if the new model exists
+ cr.execute("SELECT 1 FROM ir_model WHERE model = 'openwebui.prompt.template'")
+ if not cr.fetchone():
+ _logger.warning("openwebui.prompt.template model not found, cannot migrate")
+ return
+
+ # Get all templates from the old model
+ cr.execute("""
+ SELECT id, name, content, is_default, active, sequence
+ FROM helpdesk_ai_prompt_template
+ """)
+ old_templates = cr.fetchall()
+
+ if not old_templates:
+ _logger.info("No templates found in helpdesk.ai.prompt.template, skipping migration")
+ return
+
+ _logger.info(f"Found {len(old_templates)} templates to migrate")
+
+ # For each template, create a corresponding record in the new model
+ for template_id, name, content, is_default, active, sequence in old_templates:
+ # Check if a template with the same name already exists in the new model
+ cr.execute("""
+ SELECT id FROM openwebui_prompt_template
+ WHERE name = %s AND template_type = 'helpdesk'
+ """, (name,))
+ existing = cr.fetchone()
+
+ if existing:
+ _logger.info(f"Template '{name}' already exists in openwebui.prompt.template, updating")
+ cr.execute("""
+ UPDATE openwebui_prompt_template
+ SET content = %s, is_default = %s, active = %s, sequence = %s
+ WHERE id = %s
+ """, (content, is_default, active, sequence, existing[0]))
+ else:
+ _logger.info(f"Creating new template '{name}' in openwebui.prompt.template")
+ cr.execute("""
+ INSERT INTO openwebui_prompt_template
+ (name, content, is_default, active, sequence, template_type, create_date, write_date)
+ VALUES (%s, %s, %s, %s, %s, 'helpdesk', now(), now())
+ """, (name, content, is_default, active, sequence))
+
+ # Update system parameters that reference the old model
+ cr.execute("""
+ SELECT key, value FROM ir_config_parameter
+ WHERE key LIKE 'helpdesk_sale_order_ai.%_prompt_template_id'
+ """)
+ params = cr.fetchall()
+
+ for key, value in params:
+ if value and value.isdigit():
+ # Get the name of the template
+ cr.execute("""
+ SELECT name FROM helpdesk_ai_prompt_template
+ WHERE id = %s
+ """, (int(value),))
+ template_name = cr.fetchone()
+
+ if template_name:
+ # Find the corresponding template in the new model
+ cr.execute("""
+ SELECT id FROM openwebui_prompt_template
+ WHERE name = %s AND template_type = 'helpdesk'
+ """, (template_name[0],))
+ new_template_id = cr.fetchone()
+
+ if new_template_id:
+ new_key = key.replace('helpdesk_sale_order_ai', 'openwebui_connector')
+ _logger.info(f"Updating system parameter {key} to {new_key} with value {new_template_id[0]}")
+
+ # Check if the new parameter already exists
+ cr.execute("""
+ SELECT id FROM ir_config_parameter
+ WHERE key = %s
+ """, (new_key,))
+ existing_param = cr.fetchone()
+
+ if existing_param:
+ cr.execute("""
+ UPDATE ir_config_parameter
+ SET value = %s
+ WHERE id = %s
+ """, (str(new_template_id[0]), existing_param[0]))
+ else:
+ cr.execute("""
+ INSERT INTO ir_config_parameter (key, value)
+ VALUES (%s, %s)
+ """, (new_key, str(new_template_id[0])))
+
+ _logger.info("Migration of AI prompt templates completed")
diff --git a/helpdesk_sale_order_ai/migrations/15.0.1.0/pre-migrate.py b/helpdesk_sale_order_ai/migrations/15.0.1.0/pre-migrate.py
new file mode 100644
index 0000000..ed0ca44
--- /dev/null
+++ b/helpdesk_sale_order_ai/migrations/15.0.1.0/pre-migrate.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+import logging
+
+_logger = logging.getLogger(__name__)
+
+def migrate(cr, version):
+ """
+ Migrate system parameters from helpdesk OpenWebUI client to centralized OpenAI client
+ """
+ if not version:
+ return
+
+ _logger.info("Starting migration of OpenWebUI configuration parameters")
+
+ # Map of old parameter keys to new parameter keys
+ param_mapping = {
+ 'openwebui.api_key': 'openai.api_key',
+ 'openwebui.base_url': 'openai.base_url',
+ 'openwebui.model': 'openai.model',
+ }
+
+ # For each parameter, check if it exists and migrate it if needed
+ for old_key, new_key in param_mapping.items():
+ # Check if the old parameter exists
+ cr.execute("SELECT value FROM ir_config_parameter WHERE key = %s", (old_key,))
+ old_value = cr.fetchone()
+
+ if old_value and old_value[0]:
+ # Check if the new parameter already exists
+ cr.execute("SELECT value FROM ir_config_parameter WHERE key = %s", (new_key,))
+ new_value = cr.fetchone()
+
+ if not new_value or not new_value[0]:
+ # New parameter doesn't exist or is empty, set it to the old value
+ _logger.info(f"Migrating parameter {old_key} to {new_key} with value {old_value[0]}")
+
+ cr.execute("""
+ INSERT INTO ir_config_parameter (key, value)
+ VALUES (%s, %s)
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
+ """, (new_key, old_value[0]))
+
+ _logger.info("Migration of OpenWebUI configuration parameters completed")
diff --git a/helpdesk_sale_order_ai/migrations/__init__.py b/helpdesk_sale_order_ai/migrations/__init__.py
new file mode 100644
index 0000000..7af1d05
--- /dev/null
+++ b/helpdesk_sale_order_ai/migrations/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+# This file is intentionally left empty to make the directory a proper Python package
diff --git a/helpdesk_sale_order_ai/models/__init__.py b/helpdesk_sale_order_ai/models/__init__.py
index 1be4296..5611a47 100644
--- a/helpdesk_sale_order_ai/models/__init__.py
+++ b/helpdesk_sale_order_ai/models/__init__.py
@@ -2,3 +2,10 @@
from . import helpdesk_ticket
from . import helpdesk_team
+from . import sale_order
+# Using centralized openwebui.openwebui.client model from openwebui_connector
+from . import res_config_settings
+# Using centralized openwebui.prompt.template model from openwebui_connector
+
+# Import views after all models are loaded
+from .. import views
diff --git a/helpdesk_sale_order_ai/models/ai_prompt_template.py b/helpdesk_sale_order_ai/models/ai_prompt_template.py
new file mode 100644
index 0000000..c845c04
--- /dev/null
+++ b/helpdesk_sale_order_ai/models/ai_prompt_template.py
@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api, _
+
+
+class AIPromptTemplate(models.Model):
+ _name = 'helpdesk.ai.prompt.template'
+ _description = 'AI Prompt Template'
+ _rec_name = 'name'
+ _order = 'sequence, id'
+
+ name = fields.Char(
+ string='Name',
+ required=True,
+ )
+ sequence = fields.Integer(
+ string='Sequence',
+ default=10,
+ )
+ active = fields.Boolean(
+ string='Active',
+ default=True,
+ )
+ is_default = fields.Boolean(
+ string='Is Default',
+ default=False,
+ )
+ content = fields.Text(
+ string='Template Content',
+ required=True,
+ help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.',
+ )
+
+ @api.model
+ def _ensure_default_template(self):
+ """Ensure there's at least one default template"""
+ import logging
+ _logger = logging.getLogger(__name__)
+ _logger.info("=== START: _ensure_default_template ====")
+
+ try:
+ _logger.info("Searching for default template")
+ default_template = self.search([('is_default', '=', True)], limit=1)
+ _logger.info(f"Default template search result: {default_template}")
+
+ if not default_template:
+ _logger.info("No default template found, creating one")
+ # Create a default template if none exists
+ default_content = """Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
+
+Ticket Description: {description}
+Customer: {customer}
+
+Please provide a list of products/services with quantities and descriptions in the following format:
+Product/Service Name | Quantity | Description
+"""
+ _logger.info("Creating default template")
+ new_template = self.create({
+ 'name': 'Default Template',
+ 'content': default_content,
+ 'is_default': True,
+ })
+ _logger.info(f"Created default template: {new_template}")
+ else:
+ _logger.info(f"Found existing default template: {default_template.name}")
+
+ _logger.info("=== END: _ensure_default_template ====")
+ return True
+ except Exception as e:
+ _logger.error(f"ERROR in _ensure_default_template: {str(e)}")
+ _logger.exception("Exception traceback:")
+ raise
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Override create to handle default templates"""
+ records = super(AIPromptTemplate, self).create(vals_list)
+ # If any new template is set as default, unset default flag on others
+ default_templates = records.filtered(lambda r: r.is_default)
+ if default_templates:
+ self.search([('id', 'not in', default_templates.ids), ('is_default', '=', True)]).write({'is_default': False})
+ return records
+
+ @api.model
+ def get_default_template(self):
+ """Get the default template"""
+ import logging
+ _logger = logging.getLogger(__name__)
+ _logger.info("=== START: get_default_template ====")
+
+ try:
+ _logger.info("Searching for default template")
+ default_template = self.search([('is_default', '=', True)], limit=1)
+ _logger.info(f"Default template search result: {default_template}")
+
+ if not default_template:
+ _logger.info("No default template found, ensuring one exists")
+ self._ensure_default_template()
+ default_template = self.search([('is_default', '=', True)], limit=1)
+ _logger.info(f"Default template after ensure: {default_template}")
+
+ _logger.info("=== END: get_default_template ====")
+ return default_template
+ except Exception as e:
+ _logger.error(f"ERROR in get_default_template: {str(e)}")
+ _logger.exception("Exception traceback:")
+ return self.browse()
+
+ def set_as_default(self):
+ """Set this template as the default"""
+ if self:
+ # Clear default flag on all other templates
+ self.search([('id', '!=', self.id)]).write({'is_default': False})
+ # Set this template as default
+ self.write({'is_default': True})
+ return True
diff --git a/helpdesk_sale_order_ai/models/helpdesk_team.py b/helpdesk_sale_order_ai/models/helpdesk_team.py
index 7cc6df0..918ed3a 100644
--- a/helpdesk_sale_order_ai/models/helpdesk_team.py
+++ b/helpdesk_sale_order_ai/models/helpdesk_team.py
@@ -7,19 +7,59 @@ class HelpdeskTeam(models.Model):
use_ai_sale_orders = fields.Boolean(
string='Use AI for Sale Orders',
- help='If checked, the system will use AI to automatically generate sale orders from ticket descriptions.',
+ help='If checked, the system will use AI to automatically generate sale orders from ticket descriptions for this team. This overrides the global setting.',
default=False,
)
- ai_prompt_template = fields.Text(
+ ai_prompt_template_id = fields.Many2one(
+ 'openwebui.prompt.template',
+ domain="[('template_type', '=', 'helpdesk')]",
string='AI Prompt Template',
- help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.',
- default="""Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
-
-Ticket Description: {description}
-Customer: {customer}
-
-Please provide a list of products/services with quantities and descriptions in the following format:
-Product/Service Name | Quantity | Description
-"""
+ help='Template for the prompt sent to the AI. If empty, the global template will be used.',
)
+
+ def _get_use_ai_sale_orders(self):
+ """Get whether to use AI sale orders, considering both team and global settings"""
+ self.ensure_one()
+ # If team has specific setting, use that
+ if self.use_ai_sale_orders:
+ return True
+
+ # Otherwise, check global setting
+ param_value = self.env['ir.config_parameter'].sudo().get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'False')
+ return param_value.lower() == 'true' if isinstance(param_value, str) else bool(param_value)
+
+ def _get_ai_prompt_template(self):
+ """Get the AI prompt template to use for this team"""
+ self.ensure_one()
+
+ # Ensure template model is initialized
+ self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
+
+ # If team has a specific template, use it
+ if self.ai_prompt_template_id and self.ai_prompt_template_id.exists():
+ return self.ai_prompt_template_id.content
+
+ # Otherwise, get the global default template
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
+
+ if template_id:
+ try:
+ template = self.env['openwebui.prompt.template'].browse(int(template_id))
+ if template.exists():
+ return template.content
+ except (ValueError, TypeError):
+ pass
+
+ # Fallback to default template
+ default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
+ return default_template.content if default_template else """
+ Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
+
+ Ticket Description: {description}
+ Customer: {customer}
+
+ Please provide a list of products/services with quantities and descriptions in the following format:
+ Product/Service Name | Quantity | Description
+ """
diff --git a/helpdesk_sale_order_ai/models/helpdesk_ticket.py b/helpdesk_sale_order_ai/models/helpdesk_ticket.py
index 7757810..482d861 100644
--- a/helpdesk_sale_order_ai/models/helpdesk_ticket.py
+++ b/helpdesk_sale_order_ai/models/helpdesk_ticket.py
@@ -3,6 +3,7 @@ from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
import json
+import re
_logger = logging.getLogger(__name__)
@@ -10,59 +11,185 @@ _logger = logging.getLogger(__name__)
class HelpdeskTicket(models.Model):
_inherit = 'helpdesk.ticket'
- # Utiliser un champ calculé au lieu d'un champ simple avec onchange
+ # Computed field to determine if team uses AI sale orders
team_use_ai_sale_orders = fields.Boolean(
- string='Team Uses AI for Sale Orders',
+ string='Team Uses AI Sale Orders',
compute='_compute_team_use_ai_sale_orders',
+ readonly=True,
)
- @api.depends('team_id')
- def _compute_team_use_ai_sale_orders(self):
- for ticket in self:
- ticket.team_use_ai_sale_orders = False
- if ticket.team_id:
- # Vérifier si le champ existe sur l'équipe
- team = self.env['helpdesk.team'].sudo().browse(ticket.team_id.id)
- if hasattr(team, 'use_ai_sale_orders'):
- ticket.team_use_ai_sale_orders = team.use_ai_sale_orders
-
ai_generated_products = fields.Text(
string='AI Generated Products',
readonly=True,
help='Products suggested by AI based on ticket description',
)
+ @api.depends('team_id')
+ def _compute_team_use_ai_sale_orders(self):
+ for ticket in self:
+ if ticket.team_id:
+ ticket.team_use_ai_sale_orders = ticket.team_id._get_use_ai_sale_orders()
+ else:
+ ticket.team_use_ai_sale_orders = False
+
def action_convert_to_sale_order(self):
- """Override to use AI for generating sale order if enabled"""
+ """Override to use AI if enabled"""
self.ensure_one()
- # Check if the team allows sale orders
- if not self.team_use_sale_orders:
- raise UserError(_("You cannot create a sale order from this ticket because your team does not allow it."))
-
- # Vérifier directement sur l'équipe si l'IA est activée
- use_ai = False
- if self.team_id and hasattr(self.team_id, 'use_ai_sale_orders'):
- use_ai = self.team_id.use_ai_sale_orders
-
- # If AI is enabled for this team, use it to generate the sale order
- if use_ai:
+ # Check if AI sale orders are enabled for this team
+ if self.team_use_ai_sale_orders:
return self._ai_convert_to_sale_order()
- # Otherwise, use the standard method from the parent module
+ # Otherwise, use the standard method
return super(HelpdeskTicket, self).action_convert_to_sale_order()
def _ai_convert_to_sale_order(self):
"""Create a sale order using AI to suggest products based on ticket description"""
self.ensure_one()
- # Generate AI suggestions if not already done
- if not self.ai_generated_products:
- self._generate_ai_product_suggestions()
+ _logger.info("Starting AI conversion to sale order for ticket %s", self.id)
- # Create the sale order with AI-suggested products
- so_vals = self._generate_ai_so_values()
- sale_order = self.env['sale.order'].create([so_vals])
+ # Always generate fresh AI suggestions
+ _logger.info("Generating fresh AI suggestions for ticket %s", self.id)
+ result = self._generate_ai_product_suggestions()
+ _logger.info("AI suggestion generation result for ticket %s: %s", self.id, result)
+
+ # Get base values for sale order (partner, pricelist, etc.)
+ partner_id = self.partner_id.id
+ partner_invoice_id = self.partner_id.address_get(['invoice'])['invoice']
+ partner_shipping_id = self.partner_id.address_get(['delivery'])['delivery']
+
+ # Parse AI suggestions to get order lines and sale order fields
+ ai_data = {'order_lines': [], 'sale_order_fields': {}}
+ if self.ai_generated_products:
+ _logger.info("AI suggestions found for ticket %s, parsing them now: %s", self.id, self.ai_generated_products[:200])
+ ai_data = self._parse_ai_product_suggestions()
+ _logger.info("Parsed AI data: %s", ai_data)
+
+ # Prepare sale order values
+ so_values = {
+ 'partner_id': partner_id,
+ 'partner_invoice_id': partner_invoice_id,
+ 'partner_shipping_id': partner_shipping_id,
+ 'ticket_id': self.id,
+ 'origin': self.name,
+ 'note': self.description,
+ }
+
+ # Add AI-extracted fields to sale order values if available
+ if ai_data.get('sale_order_fields'):
+ so_fields = ai_data['sale_order_fields']
+
+ # Client order reference (PO number)
+ if so_fields.get('client_order_ref'):
+ so_values['client_order_ref'] = so_fields['client_order_ref']
+ _logger.info(f"Setting client_order_ref to: {so_fields['client_order_ref']}")
+
+ # Order date
+ if so_fields.get('date_order'):
+ try:
+ # Validate date format
+ from datetime import datetime
+ date_order = datetime.strptime(so_fields['date_order'], '%Y-%m-%d')
+ so_values['date_order'] = date_order
+ _logger.info(f"Setting date_order to: {so_fields['date_order']}")
+ except (ValueError, TypeError) as e:
+ _logger.warning(f"Invalid date_order format: {so_fields['date_order']}, error: {e}")
+
+ # Commitment date (delivery date)
+ if so_fields.get('commitment_date'):
+ try:
+ # Validate date format
+ from datetime import datetime
+ commitment_date = datetime.strptime(so_fields['commitment_date'], '%Y-%m-%d')
+ so_values['commitment_date'] = commitment_date
+ _logger.info(f"Setting commitment_date to: {so_fields['commitment_date']}")
+ except (ValueError, TypeError) as e:
+ _logger.warning(f"Invalid commitment_date format: {so_fields['commitment_date']}, error: {e}")
+
+ # Note (special instructions)
+ if so_fields.get('note'):
+ # Append to existing note if any
+ existing_note = so_values.get('note', '')
+ if existing_note:
+ so_values['note'] = f"{existing_note}\n\n{so_fields['note']}"
+ else:
+ so_values['note'] = so_fields['note']
+ _logger.info(f"Setting note to: {so_values['note'][:100]}...")
+
+ # Payment terms
+ if so_fields.get('payment_term_id'):
+ # Try to find matching payment term
+ payment_term_name = so_fields['payment_term_id']
+ payment_term = self.env['account.payment.term'].search(
+ ['|', ('name', '=', payment_term_name), ('name', 'ilike', payment_term_name)], limit=1)
+ if payment_term:
+ so_values['payment_term_id'] = payment_term.id
+ _logger.info(f"Setting payment_term_id to: {payment_term.name} (ID: {payment_term.id})")
+ else:
+ _logger.warning(f"Payment term not found: {payment_term_name}")
+
+ # Create the sale order
+ sale_order = self.env['sale.order'].create(so_values)
+ _logger.info(f"Created sale order with ID {sale_order.id}")
+
+ # Add order lines to the sale order
+ order_lines = ai_data.get('order_lines', [])
+ _logger.info("Adding %d order lines to sale order %s", len(order_lines), sale_order.id)
+
+ for line in order_lines:
+ # Each line is a tuple (0, 0, values_dict)
+ # Extract the values dict
+ line_values = line[2]
+ _logger.info("Creating order line with values: %s", line_values)
+ # Create a new order line that will trigger price computation
+ # Include the price_unit from the parsed data if available
+ initial_values = {
+ 'order_id': sale_order.id,
+ 'product_id': line_values.get('product_id'),
+ 'product_uom_qty': line_values.get('product_uom_qty'),
+ 'name': line_values.get('name'),
+ }
+
+ # Get the price that was calculated in _create_product_order_line
+ calculated_price = line_values.get('price_unit')
+ if calculated_price is not None:
+ _logger.info(f"Using pre-calculated price: {calculated_price} for product ID: {line_values.get('product_id')}")
+ initial_values['price_unit'] = calculated_price
+
+ order_line = self.env['sale.order.line'].new(initial_values)
+
+ # Trigger standard Odoo onchange to compute prices
+ try:
+ # This is the main onchange that should set the price based on product and pricelist
+ order_line._onchange_product_id()
+
+ # Log the computed price for debugging
+ _logger.info(f"Standard Odoo price computation: {order_line.price_unit} for product {order_line.product_id.name}")
+
+ # If price is still 0 and product has a list price, use that as fallback
+ if order_line.price_unit == 0 and order_line.product_id.list_price > 0:
+ order_line.price_unit = order_line.product_id.list_price
+ _logger.info(f"Price was 0, using product list price: {order_line.price_unit}")
+ except Exception as e:
+ _logger.error(f"Error in standard price computation: {str(e)}")
+ # Continue with creation even if price computation fails
+
+ # Create a clean dict with only the necessary values
+ order_line_values = {
+ 'order_id': sale_order.id,
+ 'product_id': order_line.product_id.id,
+ 'product_uom_qty': order_line.product_uom_qty,
+ 'name': order_line.name,
+ 'price_unit': order_line.price_unit,
+ }
+
+ # Add product_uom if it exists
+ if order_line.product_uom:
+ order_line_values['product_uom'] = order_line.product_uom.id
+
+ # Create the actual order line with computed prices
+ self.env['sale.order.line'].create(order_line_values)
# Link the sale order to the ticket
self.write({
@@ -75,107 +202,491 @@ class HelpdeskTicket(models.Model):
'name': _('Sale Order'),
'res_model': 'sale.order',
'res_id': sale_order.id,
- 'view_mode': 'form',
- 'context': {'create': False},
+ 'view_mode': 'form,list',
+ 'context': self.env.context,
}
def _generate_ai_product_suggestions(self):
- """Use AI to generate product suggestions based on ticket description"""
+ """Use AI to generate product suggestions based on ticket description, chatter messages and attachments"""
self.ensure_one()
- # Skip if no description
- if not self.description:
+ _logger.info("Generating AI product suggestions for ticket %s", self.id)
+
+ # Get the ticket description
+ description = self.description or ""
+
+ # If description is empty, try to use the name
+ if not description.strip():
+ description = self.name or ""
+
+ # Get chatter messages
+ chatter_messages = ""
+ if self.message_ids:
+ for message in self.message_ids:
+ if message.body and not message.is_internal:
+ # Extract text from HTML
+ body_text = re.sub(r'<[^>]+>', ' ', message.body)
+ chatter_messages += f"Message from {message.author_id.name or 'Unknown'}: {body_text}\n\n"
+
+ # Get attachments
+ attachments_info = ""
+ attachment_contents = ""
+ if self.message_ids:
+ for message in self.message_ids:
+ if message.attachment_ids:
+ for attachment in message.attachment_ids:
+ attachments_info += f"Attachment: {attachment.name} ({attachment.mimetype})\n"
+
+ # Extract text from PDF attachments
+ if attachment.mimetype == 'application/pdf' and attachment.datas:
+ try:
+ import base64
+ import io
+
+ # Try to use PyPDF2 if available
+ try:
+ from PyPDF2 import PdfReader
+
+ pdf_data = base64.b64decode(attachment.datas)
+ pdf_file = io.BytesIO(pdf_data)
+ pdf_reader = PdfReader(pdf_file)
+
+ pdf_text = ""
+ for page_num in range(len(pdf_reader.pages)): # Process all pages
+ page = pdf_reader.pages[page_num]
+ pdf_text += page.extract_text() + "\n"
+
+ attachment_contents += f"Content from {attachment.name}:\n{pdf_text}\n\n" # Include full text
+ except ImportError:
+ _logger.warning("PyPDF2 not available, skipping PDF text extraction")
+ except Exception as e:
+ _logger.error(f"Error extracting text from PDF: {str(e)}")
+
+ # If everything is empty, show error
+ if not description.strip() and not chatter_messages.strip() and not attachment_contents.strip():
+ _logger.error("No content available for AI analysis")
return False
+ # Create the prompt for the AI
+ prompt = f"""You are an expert sales assistant for a pneumatic automation company.
+ Your task is to analyze the customer request and suggest appropriate products or services.
+
+ Customer Request:
+ {description}
+
+ Chatter Messages:
+ {chatter_messages}
+
+ Attachments Information:
+ {attachments_info}
+
+ Attachment Contents:
+ {attachment_contents}
+
+ Based on this information, please perform two tasks:
+
+ 1. Map the following Odoo sale order fields from the information provided:
+ - client_order_ref: Customer's reference/PO number
+ - date_order: Order date (in YYYY-MM-DD format)
+ - commitment_date: Delivery date (in YYYY-MM-DD format)
+ - note: Any special instructions or notes
+ - payment_term_id: Payment terms (e.g., "Net 30", "2% 10 Net 30")
+
+ 2. Suggest products or services that would meet the customer's needs.
+
+ IMPORTANT: Your response MUST be in valid JSON format as shown below. Do not include any explanatory text outside the JSON structure.
+
+ ```json
+ {{
+ "sale_order_fields": {{
+ "client_order_ref": "Customer PO number",
+ "date_order": "YYYY-MM-DD",
+ "commitment_date": "YYYY-MM-DD",
+ "note": "Special instructions",
+ "payment_term_id": "Payment terms"
+ }},
+ "products": [
+ {{ "name": "Product Name", "quantity": 2, "description": "Product description" }},
+ {{ "name": "Another Product", "quantity": 1, "description": "Another description" }}
+ ]
+ }}
+ ```
+
+ Only include fields and products that are clearly identified from the provided information.
+ If you're not sure about a field or product, leave it blank or don't include it.
+ If you cannot identify any products, return an empty products array but still include any sale_order_fields you can identify.
+
+ Remember: Your entire response must be valid JSON wrapped in code blocks. No other text.
+ """
+
try:
- # Prepare the prompt using the template from the team
- prompt = self.team_id.ai_prompt_template.format(
- description=self.description,
- customer=self.partner_id.name or 'Unknown',
- )
+ # Get the OpenWebUI client
+ client = self.env['openai.openwebui.client']
- # Call the AI service (assuming an OpenAI connector module exists)
- ai_service = self.env['openai.service'].sudo()
- response = ai_service.generate_completion(prompt)
+ # Create the messages for the AI
+ messages = [
+ {"role": "system", "content": "You are a helpful assistant."},
+ {"role": "user", "content": prompt}
+ ]
- # Store the AI response
- self.ai_generated_products = response
+ # Call the OpenWebUI API
+ _logger.info("Calling OpenWebUI API for ticket %s", self.id)
+ response = client.chat_completion(messages)
- return True
+ if response:
+ _logger.info("Received AI response for ticket %s: %s", self.id, response[:100])
+
+ # Store the AI-generated products
+ self.ai_generated_products = response
+
+ return True
+ else:
+ _logger.error("Empty response from OpenWebUI API for ticket %s", self.id)
+ return False
except Exception as e:
- _logger.error("Error generating AI product suggestions: %s", str(e))
+ _logger.error("Error generating AI product suggestions for ticket %s: %s", self.id, str(e))
+ import traceback
+ _logger.error("Traceback: %s", traceback.format_exc())
return False
+ # Note: This method is kept for compatibility but is no longer used
+ # The _ai_convert_to_sale_order method now creates the sale order directly
def _generate_ai_so_values(self):
"""Generate sale order values with AI-suggested products"""
# Start with the base SO values from the parent method
- so_vals = self._generate_so_values()
-
- # Parse AI suggestions and add as order lines
- if self.ai_generated_products:
- order_lines = self._parse_ai_product_suggestions()
- if order_lines:
- so_vals['order_line'] = order_lines
-
- return so_vals
+ return self._generate_so_values()
def _parse_ai_product_suggestions(self):
- """Parse the AI-generated product suggestions into sale order lines"""
- order_lines = []
+ """Parse the AI-generated product suggestions into sale order lines and fields"""
+ result = {
+ 'order_lines': [],
+ 'sale_order_fields': {}
+ }
if not self.ai_generated_products:
- return order_lines
+ _logger.warning("No AI generated products found for ticket %s", self.id)
+ return result['order_lines']
- # Simple parsing of the AI response
- # Format expected: Product/Service Name | Quantity | Description
- lines = self.ai_generated_products.strip().split('\n')
+ # Log the AI response for debugging
+ _logger.info("Parsing AI product suggestions for ticket %s: %s", self.id, self.ai_generated_products[:300])
- for line in lines:
- if '|' not in line:
- continue
-
- parts = [part.strip() for part in line.split('|')]
- if len(parts) < 2:
- continue
-
- product_name = parts[0]
- quantity = 1.0
- description = ''
+ # First, try to extract JSON from the response using multiple patterns
+ # Pattern 1: Standard code block with json tag
+ json_pattern1 = r'```(?:json)?\s*({[\s\S]*?})\s*```'
+ # Pattern 2: Just find any JSON-like structure with sale_order_fields or products
+ json_pattern2 = r'({[\s\S]*?"(?:sale_order_fields|products)"[\s\S]*?})'
+ # Pattern 3: Find any JSON-like structure (most permissive)
+ json_pattern3 = r'({\s*"[^"]+"\s*:.*})' # Any JSON object with at least one key
+
+ json_matches = re.findall(json_pattern1, self.ai_generated_products)
+
+ if not json_matches:
+ _logger.info("No JSON found with pattern 1, trying pattern 2")
+ json_matches = re.findall(json_pattern2, self.ai_generated_products)
- # Try to parse quantity
- if len(parts) > 1:
+ if not json_matches:
+ _logger.info("No JSON found with pattern 2, trying pattern 3")
+ json_matches = re.findall(json_pattern3, self.ai_generated_products)
+
+ if json_matches:
+ # Try to parse the JSON
+ try:
+ # Clean up the JSON string before parsing
+ json_str = json_matches[0]
+ # Remove any trailing commas before closing brackets (common JSON error)
+ json_str = re.sub(r',\s*([\]\}])', r'\1', json_str)
+
+ json_data = json.loads(json_str)
+ _logger.info(f"Successfully parsed JSON data: {json_data}")
+
+ # Extract sale order fields
+ if 'sale_order_fields' in json_data:
+ result['sale_order_fields'] = json_data['sale_order_fields']
+ _logger.info(f"Extracted sale order fields: {result['sale_order_fields']}")
+ # Direct fields at root level (fallback)
+ elif any(key in json_data for key in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']):
+ so_fields = {}
+ for field in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']:
+ if field in json_data:
+ so_fields[field] = json_data[field]
+ result['sale_order_fields'] = so_fields
+ _logger.info(f"Extracted sale order fields from root level: {result['sale_order_fields']}")
+
+ # Extract products - check multiple possible keys
+ product_key = None
+ for key in ['products', 'product_suggestions', 'order_lines', 'items']:
+ if key in json_data and isinstance(json_data[key], list):
+ product_key = key
+ break
+
+ if product_key:
+ for product in json_data[product_key]:
+ if not isinstance(product, dict):
+ continue
+
+ product_name = product.get('name')
+ if not product_name:
+ continue
+
+ quantity = product.get('quantity', 1.0)
+ try:
+ quantity = float(quantity)
+ except (ValueError, TypeError):
+ quantity = 1.0
+
+ description = product.get('description', '')
+
+ _logger.info(f"Processing product from JSON: {product_name}, qty={quantity}, desc={description}")
+
+ order_line = self._create_product_order_line(product_name, quantity, description)
+ if order_line:
+ result['order_lines'].append(order_line)
+
+ _logger.info(f"Parsed {len(result['order_lines'])} order lines from JSON")
+ return result
+ except json.JSONDecodeError as e:
+ _logger.error(f"Failed to parse JSON: {e}")
+
+ # Try to extract just the sale order fields using regex as a last resort
try:
- quantity = float(parts[1])
- except ValueError:
- quantity = 1.0
+ # Look for client_order_ref pattern
+ po_pattern = r'(?:client_order_ref|PO number|purchase order)[\s"]*[:=]\s*["]*([^"\n,}]+)'
+ po_match = re.search(po_pattern, self.ai_generated_products, re.IGNORECASE)
+ if po_match:
+ result['sale_order_fields']['client_order_ref'] = po_match.group(1).strip()
+
+ # Look for dates
+ date_pattern = r'(?:date_order|order date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})'
+ date_match = re.search(date_pattern, self.ai_generated_products, re.IGNORECASE)
+ if date_match:
+ result['sale_order_fields']['date_order'] = date_match.group(1)
+
+ # Look for commitment date
+ commit_pattern = r'(?:commitment_date|delivery date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})'
+ commit_match = re.search(commit_pattern, self.ai_generated_products, re.IGNORECASE)
+ if commit_match:
+ result['sale_order_fields']['commitment_date'] = commit_match.group(1)
+
+ # Look for payment terms
+ payment_pattern = r'(?:payment_term_id|payment terms)[\s"]*[:=]\s*["]*([^"\n,}]+)'
+ payment_match = re.search(payment_pattern, self.ai_generated_products, re.IGNORECASE)
+ if payment_match:
+ result['sale_order_fields']['payment_term_id'] = payment_match.group(1).strip()
+
+ if result['sale_order_fields']:
+ _logger.info(f"Extracted sale order fields using regex: {result['sale_order_fields']}")
+ except Exception as regex_error:
+ _logger.error(f"Error in regex extraction fallback: {regex_error}")
+
+ # If JSON parsing failed, fall back to the old parsing methods
+ _logger.info("Falling back to legacy parsing methods")
+
+ # Try to extract reference number using regex before falling back to line-by-line parsing
+ ref_patterns = [
+ r'(?:reference|ticket|po|purchase order)[\s\-]*(?:number|#)?[\s\-:]*([\d\-]+)',
+ r'(?:client_order_ref|order ref)[\s"]*[:=]\s*["]*([^"\n,}]+)'
+ ]
+
+ for pattern in ref_patterns:
+ ref_match = re.search(pattern, self.ai_generated_products, re.IGNORECASE)
+ if ref_match:
+ ref_number = ref_match.group(1).strip()
+ _logger.info(f"Found reference number using regex: {ref_number}")
+ result['sale_order_fields']['client_order_ref'] = ref_number
+ break
+ order_lines = []
+
+ # Try to parse the AI response in different formats
+ # First, look for a table format with | separators
+ table_pattern = r"([^|\n]+)\s*\|\s*(\d*\.?\d*)\s*\|\s*([^|\n]*)"
+ table_matches = re.findall(table_pattern, self.ai_generated_products)
+
+ if table_matches:
+ # Process table format
+ _logger.info(f"Found table format with {len(table_matches)} matches")
+ for match in table_matches:
+ product_name = match[0].strip()
+ if not product_name or product_name.lower() in ['product/service name', 'product', 'service', 'item']:
+ continue
+
+ # Parse quantity
+ quantity = 1.0
+ if match[1].strip():
+ try:
+ quantity = float(match[1].strip())
+ except ValueError:
+ quantity = 1.0
+
+ # Get description
+ description = match[2].strip() if match[2].strip() else product_name
+
+ # Add the order line
+ order_line = self._create_product_order_line(product_name, quantity, description)
+ if order_line:
+ order_lines.append(order_line)
+ else:
+ # Try to parse line by line for products and quantities
+ # Look for patterns like "2x Product Name" or "Product Name (qty: 3)" or "Product Name - 4 units"
+ lines = self.ai_generated_products.strip().split('\n')
+ _logger.info(f"Parsing line by line, found {len(lines)} lines")
- # Get description if available
- if len(parts) > 2:
- description = parts[2]
+ # Skip header lines and empty lines
+ processed_lines = []
+ for line in lines:
+ line = line.strip()
+ # Skip empty lines, headers, and other non-product lines
+ if (not line or
+ line.startswith('#') or
+ line.lower().startswith('product') or
+ line.lower() == 'format your response as' or
+ line.lower() == 'for example:'):
+ continue
+
+ # Remove bullet points and other common prefixes
+ line = re.sub(r'^[-*\u2022]\s*', '', line)
+ processed_lines.append(line)
- # Search for matching product
+ for line in processed_lines:
+ _logger.info(f"Processing line: {line}")
+
+ # Try to extract quantity, product name, part number, and description
+ # Format examples:
+ # - 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency
+ # - 1x Preventive Maintenance Service: Annual service package
+ # - 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI
+
+ # Pattern for the format specified in the prompt template
+ detailed_pattern = r"(\d+)x\s+([^:]+?)(?:\s+([A-Z0-9][A-Z0-9-]+))?\s*:?\s*(.*)"
+ match = re.search(detailed_pattern, line, re.IGNORECASE)
+
+ if match:
+ quantity = float(match.group(1))
+ product_name = match.group(2).strip()
+ part_number = match.group(3) if match.group(3) else ''
+ specs = match.group(4).strip() if match.group(4) else ''
+
+ # Combine part number with product name if available
+ if part_number:
+ full_product_name = f"{product_name} {part_number}"
+ else:
+ full_product_name = product_name
+
+ # Use specifications as description if available
+ description = specs if specs else product_name
+
+ _logger.info(f"Matched detailed pattern: qty={quantity}, product={full_product_name}, desc={description}")
+
+ order_line = self._create_product_order_line(full_product_name, quantity, description)
+ if order_line:
+ order_lines.append(order_line)
+ continue
+
+ # Try other common patterns if the detailed pattern didn't match
+ qty_patterns = [
+ r"(\d+(?:\.\d+)?)\s*x\s*([^\d\n]+)", # "2x Product Name" or "2.5x Product Name"
+ r"([^\d\n]+)\s*\(\s*qty\s*:\s*(\d+(?:\.\d+)?)\s*\)", # "Product Name (qty: 3)"
+ r"([^\d\n]+)\s*-\s*(\d+(?:\.\d+)?)\s*units?", # "Product Name - 4 units"
+ r"([^\d\n]+)\s*:\s*(\d+(?:\.\d+)?)", # "Product Name: 2"
+ r"quantity\s*:\s*(\d+(?:\.\d+)?)\s*,?\s*([^,]+)", # "Quantity: 2, Product Name"
+ ]
+
+ product_name = None
+ quantity = 1.0
+ description = ""
+
+ for pattern in qty_patterns:
+ match = re.search(pattern, line, re.IGNORECASE)
+ if match:
+ if pattern == qty_patterns[0]: # "2x Product Name"
+ try:
+ quantity = float(match.group(1))
+ product_name = match.group(2).strip()
+ except (ValueError, IndexError):
+ continue
+ else: # Other patterns
+ try:
+ product_name = match.group(1).strip()
+ quantity = float(match.group(2))
+ except (ValueError, IndexError):
+ continue
+
+ # Try to extract description after the product name
+ desc_match = re.search(r"[^:]+:(.+)$", line)
+ if desc_match:
+ description = desc_match.group(1).strip()
+
+ _logger.info(f"Matched pattern {pattern}: qty={quantity}, product={product_name}, desc={description}")
+ break
+
+ # If no pattern matched, use the whole line as product name
+ if not product_name:
+ # Check if there's a colon that might separate product name from description
+ if ':' in line:
+ parts = line.split(':', 1)
+ product_name = parts[0].strip()
+ description = parts[1].strip() if len(parts) > 1 else ''
+ else:
+ product_name = line
+ description = ''
+
+ _logger.info(f"No pattern match, using line as product: {product_name}, desc={description}")
+
+ # Add the order line
+ order_line = self._create_product_order_line(product_name, quantity, description)
+ if order_line:
+ order_lines.append(order_line)
+
+ result['order_lines'] = order_lines
+ _logger.info(f"Parsed {len(order_lines)} order lines from AI suggestions")
+ return result
+
+ def _create_product_order_line(self, product_name, quantity, description=""):
+ """Create a sale order line for a product"""
+ if not product_name:
+ return False
+
+ # Search for matching product - try exact match first
+ product = self.env['product.product'].search([
+ ('name', '=', product_name),
+ ('sale_ok', '=', True)
+ ], limit=1)
+
+ # If no exact match, try partial match
+ if not product:
product = self.env['product.product'].search([
('name', 'ilike', product_name),
('sale_ok', '=', True)
], limit=1)
-
- if not product:
- # If no product found, create a service product
- product = self.env['product.product'].create({
- 'name': product_name,
- 'type': 'service',
- 'sale_ok': True,
- 'purchase_ok': False,
- 'list_price': 0.0,
- })
-
- # Create order line
- order_line = (0, 0, {
- 'product_id': product.id,
- 'product_uom_qty': quantity,
- 'name': description or product.name,
- })
-
- order_lines.append(order_line)
- return order_lines
+ # If still no product found, try matching by default_code (SKU/part number)
+ if not product and any(c.isdigit() for c in product_name): # Check if product name contains numbers (likely a part number)
+ # Extract potential part numbers
+ part_numbers = re.findall(r'[A-Z0-9][A-Z0-9-]+', product_name)
+ for part in part_numbers:
+ product = self.env['product.product'].search([
+ ('default_code', '=', part),
+ ('sale_ok', '=', True)
+ ], limit=1)
+ if product:
+ break
+
+ # If no product found, log it and return False
+ if not product:
+ _logger.info(f"No matching product found for: {product_name}")
+ return False
+
+ # Create order line with price information
+ line_values = {
+ 'product_id': product.id,
+ 'product_uom_qty': quantity,
+ 'name': description or product.name,
+ }
+
+ # We don't need to set the price here - Odoo will handle this automatically
+ # when the sale order line is created with the product
+ # Just log the product's list price for debugging
+ _logger.info(f"Product {product.name} (ID: {product.id}) has list_price: {product.list_price}")
+
+ # We intentionally don't set price_unit here to let Odoo's standard mechanisms handle it
+
+ return (0, 0, line_values)
diff --git a/helpdesk_sale_order_ai/models/res_config_settings.py b/helpdesk_sale_order_ai/models/res_config_settings.py
new file mode 100644
index 0000000..296a0b5
--- /dev/null
+++ b/helpdesk_sale_order_ai/models/res_config_settings.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api, _
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ # We no longer need to define the fields here as they are now defined in the openai_connector module
+ # This ensures that the settings are always in sync between the two modules
+
+ # Override the model field to use our selection method
+ openai_model = fields.Selection(
+ selection='_get_openwebui_models',
+ string='AI Model',
+ help='Model to use for AI API calls',
+ default='anthropic.claude-3-7-sonnet-latest',
+ config_parameter='openai.model',
+ )
+
+ # Add a field to select the prompt template directly
+ # In Odoo 18, we need to ensure this field is properly defined
+ helpdesk_ai_prompt_template_id = fields.Many2one(
+ 'openwebui.prompt.template',
+ domain="[('template_type', '=', 'helpdesk')]",
+ string='Default AI Prompt Template',
+ help='Default template for the prompt sent to the AI.',
+ ondelete='set null', # This helps avoid constraint errors
+ )
+
+ @api.model
+ def get_values(self):
+ """Get values for the settings form"""
+ res = super(ResConfigSettings, self).get_values()
+
+ # Get the template ID from the system parameter
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
+
+ if template_id:
+ try:
+ template_id_int = int(template_id) if isinstance(template_id, str) else template_id
+ res['helpdesk_ai_prompt_template_id'] = template_id_int
+ except (ValueError, TypeError):
+ _logger.error(f"Invalid template ID in system parameter: {template_id}")
+
+ return res
+
+ def set_values(self):
+ """Set values from the settings form"""
+ super(ResConfigSettings, self).set_values()
+
+ # Save the template ID to the system parameter
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ if self.helpdesk_ai_prompt_template_id:
+ IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(self.helpdesk_ai_prompt_template_id.id))
+ else:
+ # If no template is selected, ensure there's a default one
+ self._set_default_values()
+
+ @api.model
+ def _get_openwebui_models(self):
+ """Get available models from OpenWebUI API"""
+ try:
+ client = self.env['openwebui.client']
+ models = client.get_available_models()
+ return models
+ except Exception as e:
+ _logger.error(f"Error fetching OpenWebUI models: {e}")
+ # Return default model on error
+ default_model = 'anthropic.claude-3-7-sonnet-latest'
+ return [(default_model, default_model)]
+
+ @api.model
+ def _set_default_values(self):
+ """Set default values for configuration parameters"""
+ # Ensure there's a default template
+ template_model = self.env['openwebui.prompt.template']
+ template_model._ensure_default_template()
+ default_template = template_model.get_default_template()
+
+ # Set the default template ID in config parameters if not set
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ default_template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id')
+ if not default_template_id and default_template:
+ # Store as string to ensure compatibility with the OpenAI connector module
+ IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
+
+ @api.model
+ def get_prompt_template(self):
+ """Get the prompt template content from the selected template or default"""
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
+
+ if template_id:
+ try:
+ # Make sure the template model exists first
+ self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
+ # The template_id is already an integer in the database now
+ template_id_int = int(template_id) if isinstance(template_id, str) else template_id
+ template = self.env['openwebui.prompt.template'].browse(template_id_int)
+ if template.exists():
+ return template.content
+ except (ValueError, TypeError) as e:
+ _logger.error(f"Error retrieving prompt template: {e}")
+ pass
+
+ # Fallback to default template
+ default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
+ return default_template.content if default_template else ""
diff --git a/helpdesk_sale_order_ai/models/sale_order.py b/helpdesk_sale_order_ai/models/sale_order.py
new file mode 100644
index 0000000..96eebf3
--- /dev/null
+++ b/helpdesk_sale_order_ai/models/sale_order.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ # Add missing_product_count field to avoid view errors
+ # This is needed because some views expect this field
+ missing_product_count = fields.Integer(
+ string='Missing Products Count',
+ default=0,
+ help='Number of products that could not be found in the database'
+ )
+
+ # Add has_missing_products field to avoid view errors
+ has_missing_products = fields.Boolean(
+ string='Has Missing Products',
+ default=False,
+ help='Whether this sale order has products that could not be found in the database'
+ )
+
+ # Add ticket_id field to link sale orders to helpdesk tickets
+ ticket_id = fields.Many2one(
+ 'helpdesk.ticket',
+ string='Helpdesk Ticket',
+ readonly=True,
+ copy=False,
+ help='Helpdesk ticket from which this sale order was created'
+ )
diff --git a/helpdesk_sale_order_ai/security/ir.model.access.csv b/helpdesk_sale_order_ai/security/ir.model.access.csv
new file mode 100644
index 0000000..1fb0b9c
--- /dev/null
+++ b/helpdesk_sale_order_ai/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_openwebui_prompt_template_helpdesk_user,openwebui.prompt.template.helpdesk.user,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_user,1,0,0,0
+access_openwebui_prompt_template_helpdesk_manager,openwebui.prompt.template.helpdesk.manager,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_manager,1,1,1,1
diff --git a/helpdesk_sale_order_ai/views/__init__.py b/helpdesk_sale_order_ai/views/__init__.py
new file mode 100644
index 0000000..d2f6e16
--- /dev/null
+++ b/helpdesk_sale_order_ai/views/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+# These imports are handled differently in Odoo 18
+# XML files are loaded automatically by the manifest
diff --git a/helpdesk_sale_order_ai/views/ai_prompt_template_views.xml b/helpdesk_sale_order_ai/views/ai_prompt_template_views.xml
new file mode 100644
index 0000000..8aee278
--- /dev/null
+++ b/helpdesk_sale_order_ai/views/ai_prompt_template_views.xml
@@ -0,0 +1,102 @@
+
+
+
+
+ openwebui.prompt.template.form
+ openwebui.prompt.template
+
+
+
+
+
+
+
+ openwebui.prompt.template.list
+ openwebui.prompt.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+ openwebui.prompt.template.search
+ openwebui.prompt.template
+
+
+
+
+
+
+
+
+
+
+
+
+ AI Prompt Templates
+ openwebui.prompt.template
+ [('template_type', '=', 'helpdesk')]
+ {'default_template_type': 'helpdesk'}
+ list,form
+
+
+ Create a new AI prompt template
+
+
+ Define templates for AI prompts used in helpdesk tickets.
+
+
+
+
+
+
+
diff --git a/helpdesk_sale_order_ai/views/helpdesk_team_views.xml b/helpdesk_sale_order_ai/views/helpdesk_team_views.xml
index 23708a2..0e69b2c 100644
--- a/helpdesk_sale_order_ai/views/helpdesk_team_views.xml
+++ b/helpdesk_sale_order_ai/views/helpdesk_team_views.xml
@@ -5,9 +5,9 @@
helpdesk.team
-
-
-
+
+
+
diff --git a/helpdesk_sale_order_ai/views/helpdesk_ticket_views.xml b/helpdesk_sale_order_ai/views/helpdesk_ticket_views.xml
new file mode 100644
index 0000000..b130e9a
--- /dev/null
+++ b/helpdesk_sale_order_ai/views/helpdesk_ticket_views.xml
@@ -0,0 +1,21 @@
+
+
+
+ helpdesk.ticket.form.view.inherit.ai
+ helpdesk.ticket
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/helpdesk_sale_order_ai/views/res_config_settings_views.xml b/helpdesk_sale_order_ai/views/res_config_settings_views.xml
new file mode 100644
index 0000000..09e75f5
--- /dev/null
+++ b/helpdesk_sale_order_ai/views/res_config_settings_views.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ res.config.settings.view.form.inherit.helpdesk.sale.order.ai
+ res.config.settings
+
+
+
+
+
+
+
diff --git a/helpdesk_sale_order_ai/wizards/__init__.py b/helpdesk_sale_order_ai/wizards/__init__.py
new file mode 100644
index 0000000..43d33cd
--- /dev/null
+++ b/helpdesk_sale_order_ai/wizards/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import openwebui_config_wizard
diff --git a/openwebui_connector/__init__.py b/openwebui_connector/__init__.py
new file mode 100644
index 0000000..cde864b
--- /dev/null
+++ b/openwebui_connector/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
diff --git a/openwebui_connector/__manifest__.py b/openwebui_connector/__manifest__.py
new file mode 100644
index 0000000..0eb250c
--- /dev/null
+++ b/openwebui_connector/__manifest__.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': 'OpenWebUI Connector',
+ 'version': '18.0.1.0.0',
+ 'category': 'Tools',
+ 'summary': 'Connect to OpenWebUI and other AI services',
+ 'description': """
+OpenWebUI Connector
+===============
+This module provides integration with OpenWebUI and other AI services.
+It allows other modules to use AI capabilities through a standardized interface.
+ """,
+ 'author': 'Bemade',
+ 'website': 'https://www.bemade.org',
+ 'maintainer': 'it@bemade.org',
+ 'depends': ['base'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'data/ai_prompt_template_data.xml',
+ 'views/res_config_settings_views.xml',
+ 'views/ai_prompt_template_views.xml',
+ ],
+ 'installable': True,
+ 'application': False,
+ 'auto_install': False,
+ 'license': 'LGPL-3',
+ 'external_dependencies': {
+ 'python': ['requests'],
+ },
+}
diff --git a/openwebui_connector/data/ai_prompt_template_data.xml b/openwebui_connector/data/ai_prompt_template_data.xml
new file mode 100644
index 0000000..b07b0ca
--- /dev/null
+++ b/openwebui_connector/data/ai_prompt_template_data.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+ Default Helpdesk Template
+ helpdesk
+
+ 10
+
+ You are a helpful AI assistant for Bemade. Your task is to analyze a helpdesk ticket and suggest products or services that should be included in a sales order.
+
+TICKET INFORMATION:
+Customer: {customer}
+Subject: {subject}
+Description: {description}
+
+Based on the ticket information above, please suggest products or services that should be included in a sales order. Format your response as a list of products with quantities. If possible, match to existing products in our catalog.
+
+For each product suggestion, include:
+1. Product name
+2. Quantity
+3. Brief justification for including this item
+
+Format your response as follows:
+```
+PRODUCT SUGGESTIONS:
+- 2x Product Name: Justification
+- 1x Another Product: Justification
+```
+
+If you need more information to make accurate suggestions, please indicate what information is missing.
+
+
+
+
+ Default General Template
+ general
+
+ 20
+
+ You are a helpful AI assistant for Bemade. Please analyze the following content and provide a concise and helpful response:
+
+{content}
+
+Please provide a clear and professional response.
+
+
+
diff --git a/openwebui_connector/migrations/1.0/__init__.py b/openwebui_connector/migrations/1.0/__init__.py
new file mode 100644
index 0000000..7af1d05
--- /dev/null
+++ b/openwebui_connector/migrations/1.0/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+# This file is intentionally left empty to make the directory a proper Python package
diff --git a/openwebui_connector/migrations/1.0/post-migration.py b/openwebui_connector/migrations/1.0/post-migration.py
new file mode 100644
index 0000000..03f7e9c
--- /dev/null
+++ b/openwebui_connector/migrations/1.0/post-migration.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+# This migration script will run after the module update
+# It will ensure the system parameters are properly set up
+
+import logging
+_logger = logging.getLogger(__name__)
+
+def migrate(cr, version):
+ """
+ Ensure system parameters are properly set up after migration
+ """
+ # Set default values for system parameters
+ cr.execute("""
+ INSERT INTO ir_config_parameter (key, value, create_date, write_date)
+ VALUES ('openwebui.base_url', 'https://ai.bemade.org/api', now(), now())
+ ON CONFLICT (key) DO NOTHING
+ """)
+
+ cr.execute("""
+ INSERT INTO ir_config_parameter (key, value, create_date, write_date)
+ VALUES ('openwebui.model', 'anthropic.claude-3-7-sonnet-latest', now(), now())
+ ON CONFLICT (key) DO NOTHING
+ """)
+
+ # Log the migration
+ cr.execute("""
+ INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
+ VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
+ 'Post-migration: Set default system parameters',
+ '/addons/openwebui_connector/migrations/1.0/post-migration.py', 30, 'migrate')
+ """)
+
+ # Check if helpdesk_sale_order_ai module is installed
+ cr.execute("""
+ SELECT id FROM ir_module_module
+ WHERE name = 'helpdesk_sale_order_ai'
+ AND state = 'installed'
+ """)
+
+ if cr.fetchone():
+ _logger.info("helpdesk_sale_order_ai module is installed, ensuring parameters are set")
+
+ # Ensure the helpdesk AI parameter exists
+ cr.execute("""
+ INSERT INTO ir_config_parameter (key, value, create_date, write_date)
+ VALUES ('helpdesk_sale_order_ai.use_ai_sale_orders', 'False', now(), now())
+ ON CONFLICT (key) DO NOTHING
+ """)
+
+ # Log the migration
+ cr.execute("""
+ INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
+ VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
+ 'Post-migration: Set default helpdesk AI parameters',
+ '/addons/openwebui_connector/migrations/1.0/post-migration.py', 50, 'migrate')
+ """)
diff --git a/openwebui_connector/migrations/1.0/pre-migration.py b/openwebui_connector/migrations/1.0/pre-migration.py
new file mode 100644
index 0000000..04d40b8
--- /dev/null
+++ b/openwebui_connector/migrations/1.0/pre-migration.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+# This migration script will run before the module update
+# It will handle database constraints and column issues for Odoo 18 compatibility
+
+def migrate(cr, version):
+ """
+ Handle database constraints and column issues for Odoo 18 compatibility
+ """
+ # 1. Check and drop any foreign key constraints on helpdesk_ai_prompt_template_id
+ cr.execute("""
+ SELECT tc.constraint_name
+ FROM information_schema.table_constraints tc
+ JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
+ WHERE tc.table_name = 'res_config_settings'
+ AND ccu.column_name = 'helpdesk_ai_prompt_template_id'
+ AND tc.constraint_type = 'FOREIGN KEY'
+ """)
+
+ constraints = cr.fetchall()
+ for constraint in constraints:
+ constraint_name = constraint[0]
+ # Drop the constraint
+ cr.execute(f"""
+ ALTER TABLE res_config_settings
+ DROP CONSTRAINT IF EXISTS {constraint_name}
+ """)
+
+ # Log the migration
+ cr.execute("""
+ INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
+ VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
+ 'Migration: Dropped foreign key constraint: %s',
+ '/addons/openwebui_connector/migrations/1.0/pre-migration.py', 30, 'migrate')
+ """, (constraint_name,))
+
+ # 2. Check if the column exists and drop it if needed
+ cr.execute("""
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_name = 'res_config_settings'
+ AND column_name = 'helpdesk_ai_prompt_template_id'
+ """)
+
+ if cr.fetchone():
+ # We need to drop the column to avoid conflicts with the new field
+ try:
+ cr.execute("""
+ ALTER TABLE res_config_settings
+ DROP COLUMN IF EXISTS helpdesk_ai_prompt_template_id
+ """)
+
+ # Log the migration
+ cr.execute("""
+ INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
+ VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
+ 'Migration: Dropped column helpdesk_ai_prompt_template_id',
+ '/addons/openwebui_connector/migrations/1.0/pre-migration.py', 50, 'migrate')
+ """)
+ except Exception as e:
+ # Log the error
+ cr.execute("""
+ INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
+ VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'error',
+ 'Migration error: %s',
+ '/addons/openwebui_connector/migrations/1.0/pre-migration.py', 60, 'migrate')
+ """, (str(e),))
diff --git a/openwebui_connector/migrations/18.0.1.0.0/__init__.py b/openwebui_connector/migrations/18.0.1.0.0/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/openwebui_connector/migrations/18.0.1.0.0/post-migration.py b/openwebui_connector/migrations/18.0.1.0.0/post-migration.py
new file mode 100644
index 0000000..ba670d8
--- /dev/null
+++ b/openwebui_connector/migrations/18.0.1.0.0/post-migration.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# This migration script will run after the module update
+# It will migrate data from openai_prompt_template to openwebui_prompt_template
+
+import logging
+_logger = logging.getLogger(__name__)
+
+def migrate(cr, version):
+ """
+ Migrate data from openai_prompt_template to openwebui_prompt_template
+ """
+ if not version:
+ return
+
+ _logger.info("Starting migration of prompt templates from openai to openwebui")
+
+ # Check if the old table exists
+ cr.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'openai_prompt_template')")
+ if not cr.fetchone()[0]:
+ _logger.info("No openai_prompt_template table found, skipping migration")
+ return
+
+ # Check if there's data to migrate
+ cr.execute("SELECT COUNT(*) FROM openai_prompt_template")
+ count = cr.fetchone()[0]
+ _logger.info(f"Found {count} records to migrate from openai_prompt_template")
+
+ if count > 0:
+ # Copy data from old table to new table
+ cr.execute("""
+ INSERT INTO openwebui_prompt_template (
+ id, name, content, is_default, module, create_uid, create_date, write_uid, write_date
+ )
+ SELECT
+ id, name, content, is_default, module, create_uid, create_date, write_uid, write_date
+ FROM
+ openai_prompt_template
+ ON CONFLICT (id) DO UPDATE SET
+ name = EXCLUDED.name,
+ content = EXCLUDED.content,
+ is_default = EXCLUDED.is_default,
+ module = EXCLUDED.module,
+ write_uid = EXCLUDED.write_uid,
+ write_date = EXCLUDED.write_date
+ """)
+
+ # Update sequence if needed
+ cr.execute("""
+ SELECT MAX(id) FROM openwebui_prompt_template
+ """)
+ max_id = cr.fetchone()[0] or 0
+ if max_id > 0:
+ cr.execute(f"""
+ SELECT setval('openwebui_prompt_template_id_seq', {max_id})
+ """)
+
+ # Log the migration
+ _logger.info(f"Successfully migrated {count} records from openai_prompt_template to openwebui_prompt_template")
+
+ # Update any references in res_config_settings
+ cr.execute("""
+ UPDATE ir_config_parameter
+ SET key = REPLACE(key, 'openai.', 'openwebui.')
+ WHERE key LIKE 'openai.%'
+ """)
+
+ # Update any references to prompt template IDs in system parameters
+ cr.execute("""
+ UPDATE ir_config_parameter
+ SET key = REPLACE(key, 'openai_prompt_template_id', 'openwebui_prompt_template_id')
+ WHERE key LIKE '%openai_prompt_template_id%'
+ """)
+
+ _logger.info("Updated system parameters to use openwebui prefix")
diff --git a/openwebui_connector/migrations/18.0.1.0.0/pre-migration.py b/openwebui_connector/migrations/18.0.1.0.0/pre-migration.py
new file mode 100644
index 0000000..7260667
--- /dev/null
+++ b/openwebui_connector/migrations/18.0.1.0.0/pre-migration.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+# This migration script will run before the module update
+# It will handle database constraints and prepare for the migration
+
+import logging
+_logger = logging.getLogger(__name__)
+
+def migrate(cr, version):
+ """
+ Handle database constraints and prepare for migration
+ """
+ if not version:
+ return
+
+ _logger.info("Starting pre-migration for openwebui_connector")
+
+ # 1. Drop foreign key constraints on ai_prompt_template_id to avoid conflicts
+ cr.execute("""
+ SELECT tc.constraint_name
+ FROM information_schema.table_constraints tc
+ JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
+ WHERE tc.table_name = 'res_config_settings'
+ AND ccu.column_name = 'ai_prompt_template_id'
+ AND tc.constraint_type = 'FOREIGN KEY'
+ """)
+
+ constraints = cr.fetchall()
+ for constraint in constraints:
+ constraint_name = constraint[0]
+ # Drop the constraint
+ cr.execute(f"""
+ ALTER TABLE res_config_settings
+ DROP CONSTRAINT IF EXISTS {constraint_name}
+ """)
+ _logger.info(f"Dropped foreign key constraint: {constraint_name}")
+
+ # 2. Update system parameters to use new naming
+ cr.execute("""
+ UPDATE ir_config_parameter
+ SET key = REPLACE(key, 'openai.', 'openwebui.')
+ WHERE key LIKE 'openai.%'
+ """)
+
+ # 3. Check if the old openai_prompt_template table exists
+ cr.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'openai_prompt_template')")
+ if cr.fetchone()[0]:
+ # Create a backup of the data if needed
+ cr.execute("""
+ CREATE TABLE IF NOT EXISTS openai_prompt_template_backup AS
+ SELECT * FROM openai_prompt_template
+ """)
+ _logger.info("Created backup of openai_prompt_template data")
+
+ _logger.info("Pre-migration completed successfully")
diff --git a/openwebui_connector/migrations/__init__.py b/openwebui_connector/migrations/__init__.py
new file mode 100644
index 0000000..7af1d05
--- /dev/null
+++ b/openwebui_connector/migrations/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+# This file is intentionally left empty to make the directory a proper Python package
diff --git a/openwebui_connector/models/__init__.py b/openwebui_connector/models/__init__.py
new file mode 100644
index 0000000..661d16b
--- /dev/null
+++ b/openwebui_connector/models/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+
+from . import openwebui_client
+from . import ai_prompt_template
+from . import res_config_settings
diff --git a/openwebui_connector/models/ai_prompt_template.py b/openwebui_connector/models/ai_prompt_template.py
new file mode 100644
index 0000000..96e8692
--- /dev/null
+++ b/openwebui_connector/models/ai_prompt_template.py
@@ -0,0 +1,187 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class AIPromptTemplate(models.Model):
+ _name = 'openwebui.prompt.template'
+ _description = 'AI Prompt Template'
+ _order = 'sequence, id'
+
+ name = fields.Char(string='Name', required=True)
+ content = fields.Text(
+ string='Template Content',
+ required=True,
+ help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.'
+ )
+ template_type = fields.Selection([
+ ('helpdesk', 'Helpdesk'),
+ ('general', 'General'),
+ ], string='Template Type', default='helpdesk', required=True)
+ sequence = fields.Integer(string='Sequence', default=10)
+ active = fields.Boolean(string='Active', default=True)
+ is_default = fields.Boolean(string='Default Template', default=False)
+
+ @api.model
+ def _ensure_default_template(self, template_type='helpdesk'):
+ """Ensure that at least one default template exists for the given type"""
+ _logger.info(f"Ensuring default template exists for type: {template_type}")
+ try:
+ default_template = self.search([('template_type', '=', template_type), ('is_default', '=', True)], limit=1)
+ _logger.info(f"Found existing default template: {bool(default_template)}")
+
+ if not default_template:
+ _logger.info("No default template found, creating one")
+ # Create a default template
+ default_content = self._get_default_template_content(template_type)
+ default_name = f"Default {template_type.capitalize()} Template"
+
+ try:
+ _logger.info(f"Creating default template with name: {default_name}")
+ default_template = self.create({
+ 'name': default_name,
+ 'template_type': template_type,
+ 'content': default_content,
+ 'is_default': True,
+ })
+ _logger.info(f"Default template created with ID: {default_template.id}")
+
+ # Set the default template ID in system parameters
+ if template_type == 'helpdesk':
+ _logger.info("Setting default template ID in system parameters")
+ param_name = 'helpdesk_sale_order_ai.default_prompt_template_id'
+ param_value = str(default_template.id)
+ _logger.info(f"Setting parameter {param_name} = {param_value}")
+
+ self.env['ir.config_parameter'].sudo().set_param(
+ param_name, param_value
+ )
+ except Exception as e:
+ _logger.error(f"Error creating default template: {e}", exc_info=True)
+ _logger.error(f"Template data: name={default_name}, type={template_type}, content_length={len(default_content) if default_content else 0}")
+
+ return default_template
+ except Exception as e:
+ _logger.error(f"Unexpected error in _ensure_default_template: {e}", exc_info=True)
+ return False
+
+ @api.model
+ def _get_default_template_content(self, template_type):
+ """Get the default content for a template type"""
+ if template_type == 'helpdesk':
+ return """Based on the following helpdesk ticket information, create a complete sales order that accurately reflects the client's requirements.
+
+Ticket Information: {description}
+Customer: {customer}
+Subject: {subject}
+
+Analyze all information including ticket description, chatter messages, and attachments to create a comprehensive sales order that includes ALL items the client has requested.
+
+Please provide a detailed list of products and services with exact quantities, specifications, and any relevant details mentioned by the client. Include part numbers when available.
+
+Format your response as follows:
+- [Quantity]x [Product Name] [Part Number if available]: [Specifications/Details]
+
+For example:
+- 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency as specified in technical document
+- 1x Preventive Maintenance Service: Annual service package including parts and labor
+- 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI max pressure
+
+If the client has provided pricing expectations or budget constraints, include this information. If specific delivery timeframes are mentioned, note these as well.
+"""
+ elif template_type == 'general':
+ return """You are a helpful AI assistant. Please analyze the following content and provide a concise and helpful response:
+
+{content}
+"""
+ else:
+ return """Please provide a prompt template for this type of content."""
+
+ def set_as_default(self):
+ """Set this template as the default for its type"""
+ # First, unset any existing default templates of the same type
+ other_defaults = self.search([
+ ('id', '!=', self.id),
+ ('template_type', '=', self.template_type),
+ ('is_default', '=', True)
+ ])
+ other_defaults.write({'is_default': False})
+
+ # Set this template as default
+ self.is_default = True
+
+ # Update the system parameter if this is a helpdesk template
+ if self.template_type == 'helpdesk':
+ self.env['ir.config_parameter'].sudo().set_param(
+ 'helpdesk_sale_order_ai.default_prompt_template_id',
+ str(self.id)
+ )
+
+ return True
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Override create to handle default templates"""
+ records = super(AIPromptTemplate, self).create(vals_list)
+
+ # Group templates by type
+ templates_by_type = {}
+ for record in records:
+ if record.is_default:
+ if record.template_type not in templates_by_type:
+ templates_by_type[record.template_type] = []
+ templates_by_type[record.template_type].append(record.id)
+
+ # For each type with new default templates, unset default flag on others of the same type
+ for template_type, template_ids in templates_by_type.items():
+ self.search([
+ ('id', 'not in', template_ids),
+ ('is_default', '=', True),
+ ('template_type', '=', template_type)
+ ]).write({'is_default': False})
+
+ return records
+
+ @api.model
+ def get_default_template(self, template_type='helpdesk'):
+ """Get the default template for the given type"""
+ _logger.info(f"Getting default template for type: {template_type}")
+ try:
+ default_template = self.search([
+ ('is_default', '=', True),
+ ('template_type', '=', template_type),
+ ('active', '=', True),
+ ], limit=1)
+
+ _logger.info(f"Found default template: {bool(default_template)}")
+
+ if not default_template:
+ _logger.info("No active default template found, ensuring one exists")
+ # Create a default template if none exists
+ default_template = self._ensure_default_template(template_type)
+ _logger.info(f"Default template after ensure: {bool(default_template)} with ID: {default_template.id if default_template else 'None'}")
+ else:
+ _logger.info(f"Using existing default template with ID: {default_template.id}")
+
+ return default_template
+ except Exception as e:
+ _logger.error(f"Error in get_default_template: {e}", exc_info=True)
+ # Try to create a new template as a fallback
+ try:
+ _logger.info("Attempting to create a new default template as fallback")
+ default_content = self._get_default_template_content(template_type)
+ default_name = f"Fallback {template_type.capitalize()} Template"
+
+ fallback_template = self.create({
+ 'name': default_name,
+ 'template_type': template_type,
+ 'content': default_content,
+ 'is_default': True,
+ })
+ _logger.info(f"Created fallback template with ID: {fallback_template.id}")
+ return fallback_template
+ except Exception as e2:
+ _logger.error(f"Failed to create fallback template: {e2}", exc_info=True)
+ return self.browse() # Return empty recordset
diff --git a/openwebui_connector/models/openwebui_client.py b/openwebui_connector/models/openwebui_client.py
new file mode 100644
index 0000000..b01dded
--- /dev/null
+++ b/openwebui_connector/models/openwebui_client.py
@@ -0,0 +1,359 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api, _
+import logging
+import json
+import requests
+from typing import Dict, List, Any, Optional, Tuple
+
+_logger = logging.getLogger(__name__)
+
+class OpenWebUIClient(models.AbstractModel):
+ """
+ OpenWebUI client for interacting with the OpenWebUI API.
+ This is a simplified version of the external OpenWebUI client integrated into Odoo.
+ """
+ _name = 'openwebui.client'
+ _description = 'OpenWebUI Client for AI Services'
+
+ def _get_config(self, raise_if_missing=True):
+ """
+ Get the OpenWebUI configuration from the system parameters.
+ Uses the dedicated openwebui parameters for API key, base URL and model.
+
+ Args:
+ raise_if_missing: If True, raise an error when API key is missing.
+ If False, return config with empty API key.
+ """
+ _logger.debug(f"Getting OpenWebUI config (raise_if_missing={raise_if_missing})")
+ try:
+ # Get the config from the ir.config_parameter
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+
+ # Use dedicated OpenWebUI parameters
+ api_key = IrConfigParam.get_param('openwebui.api_key', False)
+
+ # If OpenWebUI API key is not set, try fallback to OpenAI key
+ if not api_key:
+ api_key = IrConfigParam.get_param('openai.api_key', False)
+ _logger.debug("OpenWebUI API key not found, falling back to OpenAI API key")
+
+ _logger.debug(f"Retrieved API key: {'Present' if api_key else 'Missing'}")
+
+ # Get base URL from dedicated parameter
+ base_url = IrConfigParam.get_param('openwebui.base_url', 'https://ai.bemade.org/api')
+ _logger.debug(f"Using base_url: {base_url}")
+
+ # Get model from dedicated parameter
+ model = IrConfigParam.get_param('openwebui.model', 'anthropic.claude-3-7-sonnet-latest')
+ _logger.debug(f"Using model: {model}")
+
+ if not api_key and raise_if_missing:
+ _logger.error("No API key found in configuration and raise_if_missing=True")
+ raise ValueError("No API key found in configuration")
+
+ config = {
+ 'api_key': api_key or '',
+ 'base_url': base_url,
+ 'model': model
+ }
+ _logger.debug("Successfully retrieved OpenWebUI config")
+ return config
+ except Exception as e:
+ _logger.error(f"Error getting OpenWebUI config: {e}", exc_info=True)
+ raise
+
+ def get_available_models(self):
+ """
+ Fetch available models from the OpenWebUI API.
+
+ Returns:
+ A list of tuples (model_id, model_name) suitable for selection fields
+ """
+ _logger.info("Starting get_available_models")
+ # Define default_model at the beginning to ensure it's always available
+ default_model = 'anthropic.claude-3-7-sonnet-latest'
+
+ # Create a list with just the default model to ensure it's always available
+ models = [(default_model, default_model)]
+
+ try:
+ # Get config without raising exception if API key is missing
+ config = self._get_config(raise_if_missing=False)
+
+ # Update default_model with configured value and ensure it's in the list
+ if 'model' in config and config['model']:
+ default_model = config['model']
+ # Clear the list and add the current default model
+ models = [(default_model, default_model)]
+
+ _logger.info(f"Default model: {default_model}")
+
+ # If no API key is set, just return the current model
+ if not config.get('api_key'):
+ _logger.warning("No API key configured, only showing current model")
+ return models
+
+ # Add some common models that we know work with OpenWebUI
+ # This ensures we have a good selection even if the API call fails
+ common_models = [
+ ('anthropic.claude-3-7-sonnet-latest', 'Claude 3.7 Sonnet'),
+ ('anthropic.claude-3-5-sonnet-20240620', 'Claude 3.5 Sonnet'),
+ ('anthropic.claude-3-opus-20240229', 'Claude 3 Opus'),
+ ('gpt-4o', 'GPT-4o'),
+ ('gpt-4-turbo', 'GPT-4 Turbo'),
+ ('gpt-3.5-turbo', 'GPT-3.5 Turbo')
+ ]
+
+ # Add common models to our list, but keep the default model first
+ for model_id, model_name in common_models:
+ if model_id != default_model: # Avoid duplicates
+ models.append((model_id, model_name))
+
+ # Remove trailing slash if present in base_url
+ base_url = config.get('base_url', 'https://ai.bemade.org/api')
+ if isinstance(base_url, str) and base_url.endswith('/'):
+ base_url = base_url[:-1]
+ _logger.info(f"Using base URL: {base_url}")
+
+ # Check if base_url already contains '/api' to avoid duplicates
+ if '/api' in base_url:
+ endpoints = [
+ '/v1/models',
+ '/models',
+ '/chat/models',
+ '/v1/chat/models'
+ ]
+ else:
+ endpoints = [
+ '/v1/models',
+ '/models',
+ '/chat/models',
+ '/api/models',
+ '/api/v1/models',
+ '/api/chat/models'
+ ]
+ _logger.debug(f"Will try these endpoints: {endpoints}")
+
+ # Set up authentication headers
+ headers = {
+ "Authorization": f"Bearer {config['api_key']}",
+ "Content-Type": "application/json",
+ }
+ _logger.debug("Authentication headers set up")
+
+ # Try a simple connection test first with a short timeout
+ try:
+ _logger.info(f"Testing connection to base URL: {base_url}")
+ test_response = requests.get(base_url, timeout=3)
+ _logger.info(f"Base API connection test: {test_response.status_code}")
+ if test_response.status_code >= 400:
+ _logger.warning(f"Base URL returned error status: {test_response.status_code}")
+ return models # Return our predefined models
+ except Exception as e:
+ _logger.warning(f"Could not connect to base API URL: {str(e)}")
+ # Return our predefined models if we can't connect
+ _logger.info("Returning predefined models due to connection error")
+ return models
+
+ # Try each endpoint with a short timeout
+ _logger.info("Starting to try each endpoint for models")
+ success = False
+
+ for endpoint in endpoints:
+ url = f"{base_url}{endpoint}"
+ _logger.info(f"Trying endpoint: {url}")
+
+ try:
+ _logger.debug(f"Making request to {url} with timeout=5")
+ response = requests.get(url, headers=headers, timeout=5)
+ _logger.info(f"Response status code: {response.status_code}")
+
+ if response.status_code != 200:
+ _logger.info(f"Endpoint {endpoint} returned non-200 status code: {response.status_code}")
+ continue
+
+ # Check if response is empty
+ if not response.text or response.text.strip() == '':
+ _logger.warning(f"Empty response from {url}")
+ continue
+
+ # Try to parse the response as JSON
+ try:
+ _logger.debug("Parsing response as JSON")
+ response_data = response.json()
+ _logger.debug(f"Response data type: {type(response_data)}")
+
+ # If we got an empty object or list, skip
+ if (isinstance(response_data, dict) and not response_data) or \
+ (isinstance(response_data, list) and not response_data):
+ _logger.warning(f"Empty JSON object/array from {url}")
+ continue
+
+ except json.JSONDecodeError as je:
+ _logger.error(f"Failed to parse JSON from {url}: {je}")
+ continue
+
+ # Extract model information from the response
+ model_ids = []
+
+ # Handle different response formats
+ if isinstance(response_data, dict):
+ _logger.debug("Response is a dictionary")
+ # OpenAI format
+ if "data" in response_data and isinstance(response_data["data"], list):
+ _logger.debug("Found OpenAI format response with 'data' key")
+ for model in response_data["data"]:
+ if isinstance(model, dict) and "id" in model:
+ model_id = model["id"]
+ model_name = model.get("name", model_id)
+ model_ids.append((model_id, model_name))
+ # Another common format
+ elif "models" in response_data and isinstance(response_data["models"], list):
+ _logger.debug("Found response with 'models' key")
+ for model in response_data["models"]:
+ if isinstance(model, dict) and "id" in model:
+ model_id = model["id"]
+ model_name = model.get("name", model_id)
+ model_ids.append((model_id, model_name))
+ else:
+ _logger.debug(f"Dictionary response keys: {list(response_data.keys())}")
+ elif isinstance(response_data, list):
+ _logger.debug("Response is a list")
+ # Simple list format
+ for model in response_data:
+ if isinstance(model, dict) and "id" in model:
+ model_id = model["id"]
+ model_name = model.get("name", model_id)
+ model_ids.append((model_id, model_name))
+ else:
+ _logger.warning(f"Unexpected response data type: {type(response_data)}")
+
+ if model_ids:
+ _logger.info(f"Found {len(model_ids)} models from endpoint {url}")
+ # Add all found models to our list, avoiding duplicates
+ for model_tuple in model_ids:
+ if model_tuple not in models:
+ models.append(model_tuple)
+ success = True
+ break
+ else:
+ _logger.warning(f"No models found in response from {url}")
+
+ except requests.RequestException as e:
+ _logger.error(f"Request error with endpoint {endpoint}: {e}")
+ continue
+ except Exception as e:
+ _logger.error(f"Unexpected error with endpoint {endpoint}: {e}")
+ continue
+
+ # If we couldn't find any models from API, we still have our predefined models
+ _logger.info(f"Total models found: {len(models)}")
+ return models
+
+ except Exception as e:
+ _logger.error(f"Error in get_available_models: {e}")
+ # Return just the default model on any error
+ return [(default_model, default_model)]
+
+ def chat_completion(self, messages, model=None):
+ """
+ Send a chat completion request to the OpenWebUI API.
+
+ Args:
+ messages: List of message dictionaries with 'role' and 'content' keys
+ model: Model to use (defaults to the configured model)
+
+ Returns:
+ The response from the OpenWebUI API
+ """
+ _logger.info("Starting chat_completion request")
+ try:
+ # For chat completion, we do need a valid API key
+ _logger.info("Getting config for chat completion")
+ config = self._get_config(raise_if_missing=True)
+
+ # Use the provided model or fall back to the configured model
+ model = model or config['model']
+ _logger.info(f"Using model: {model}")
+
+ # Remove trailing slash if present in base_url
+ base_url = config['base_url']
+ if isinstance(base_url, str) and base_url.endswith('/'):
+ base_url = base_url[:-1]
+ _logger.info(f"Using base URL: {base_url}")
+
+ # Construct the full URL based on the API provider
+ if 'openai.com' in base_url:
+ # Standard OpenAI API format
+ url = f"{base_url}/chat/completions"
+ elif 'bemade.org' in base_url:
+ # For Bemade's API, try without the v1 path as it's returning 405
+ url = f"{base_url}/chat/completions"
+ else:
+ # Generic format, try standard OpenAI path
+ url = f"{base_url}/chat/completions"
+
+ _logger.info(f"Full API URL: {url}")
+
+ # Set up authentication headers
+ headers = {
+ "Authorization": f"Bearer {config['api_key']}",
+ "Content-Type": "application/json",
+ }
+ _logger.debug("Authentication headers set up")
+
+ # Create the payload
+ payload = {
+ "model": model,
+ "messages": messages,
+ "temperature": 0.7, # Add reasonable temperature for more consistent results
+ "max_tokens": 4000 # Ensure we get enough tokens for a complete response
+ }
+
+ _logger.info(f"OpenWebUI API request to: {url}")
+ _logger.debug(f"OpenWebUI API payload: {payload}")
+
+ try:
+ # Make the HTTP request
+ _logger.info("Sending POST request to OpenWebUI API")
+ response = requests.post(url, headers=headers, json=payload, timeout=60.0)
+ _logger.info(f"Response status code: {response.status_code}")
+
+ # Log response content for debugging
+ _logger.debug(f"Response content (first 500 chars): {response.text[:500]}")
+
+ # Raise an exception for any HTTP error
+ response.raise_for_status()
+ _logger.info("Response status check passed")
+
+ # Parse the JSON response
+ try:
+ _logger.debug("Parsing response as JSON")
+ response_data = response.json()
+ except json.JSONDecodeError as je:
+ _logger.error(f"Failed to parse JSON response: {je}", exc_info=True)
+ _logger.error(f"Response content: {response.text[:500]}")
+ return ""
+
+ _logger.debug(f"Response data type: {type(response_data)}")
+ if isinstance(response_data, dict):
+ _logger.debug(f"Response keys: {list(response_data.keys())}")
+
+ # Extract the content from the response
+ if response_data and 'choices' in response_data and response_data['choices']:
+ _logger.info("Successfully extracted content from response")
+ return response_data['choices'][0]['message']['content']
+ else:
+ _logger.error(f"Invalid response format from OpenWebUI API: {response_data}")
+ return ""
+
+ except requests.RequestException as re:
+ _logger.error(f"Request error calling OpenWebUI API: {re}", exc_info=True)
+ raise
+ except Exception as e:
+ _logger.error(f"Unexpected error in API request: {e}", exc_info=True)
+ raise
+
+ except Exception as e:
+ _logger.error(f"Error in chat_completion: {e}", exc_info=True)
+ raise
diff --git a/openwebui_connector/models/res_config_settings.py b/openwebui_connector/models/res_config_settings.py
new file mode 100644
index 0000000..af97b56
--- /dev/null
+++ b/openwebui_connector/models/res_config_settings.py
@@ -0,0 +1,382 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ # AI API Configuration (unified for all AI services)
+ openai_api_key = fields.Char(
+ string='AI API Key',
+ help='API key for AI services (OpenAI, OpenWebUI, Claude)',
+ config_parameter='openwebui.api_key',
+ )
+
+ openai_base_url = fields.Char(
+ string='AI Base URL',
+ help='Base URL for AI API',
+ default='https://api.openai.com/v1', # Default OpenAI endpoint
+ config_parameter='openwebui.base_url',
+ )
+
+ # Use Selection field with dynamic options from OpenWebUI client
+ openai_model = fields.Selection(
+ selection='_get_openwebui_models',
+ string='AI Model',
+ help='Model to use for AI API calls',
+ default='anthropic.claude-3-7-sonnet-latest',
+ config_parameter='openwebui.model',
+ )
+
+ # Helpdesk AI fields
+ helpdesk_use_ai_sale_orders = fields.Boolean(
+ string='Use AI for Sale Orders',
+ help='If checked, the system will use AI to automatically generate sale orders from helpdesk ticket descriptions.',
+ config_parameter='helpdesk_sale_order_ai.use_ai_sale_orders',
+ )
+
+ # Add a field to select the prompt template directly
+ # In Odoo 18, we need to ensure this field is properly defined
+ ai_prompt_template_id = fields.Integer(
+ string='Default AI Prompt Template ID',
+ help='Default template ID for the prompt sent to the AI.',
+ compute='_compute_ai_prompt_template_id',
+ inverse='_inverse_ai_prompt_template_id',
+ store=False, # Don't store this field to avoid constraint issues
+ )
+
+ # Actual relation field for the template, but not stored in database
+ ai_prompt_template_relation = fields.Many2one(
+ 'openwebui.prompt.template',
+ string='Default AI Prompt Template',
+ help='Default template for the prompt sent to the AI.',
+ compute='_compute_ai_prompt_template_relation',
+ domain="[('template_type', '=', 'helpdesk')]",
+ store=False, # Don't store this field to avoid constraint issues
+ )
+
+ # Display field for backward compatibility
+ helpdesk_ai_template_display = fields.Char(
+ string='Default AI Prompt Template',
+ help='Default template for the prompt sent to the AI.',
+ compute='_compute_helpdesk_ai_template_display',
+ readonly=True,
+ store=False, # Important: don't store this field in the database
+ )
+
+ @api.model
+ def _get_openwebui_models(self):
+ """Get available models from OpenWebUI API"""
+ try:
+ client = self.env['openwebui.client']
+ models = client.get_available_models()
+ return models
+ except Exception as e:
+ _logger.error(f"Error fetching OpenWebUI models: {e}")
+ # Return default model on error
+ default_model = 'anthropic.claude-3-7-sonnet-latest'
+ return [(default_model, default_model)]
+
+ def _compute_ai_prompt_template_id(self):
+ """Compute the template ID from system parameter"""
+ for record in self:
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
+
+ if template_id:
+ try:
+ # Convert to integer if it's a string
+ template_id_int = int(template_id) if isinstance(template_id, str) else template_id
+ record.ai_prompt_template_id = template_id_int
+ except (ValueError, TypeError):
+ record.ai_prompt_template_id = False
+ else:
+ record.ai_prompt_template_id = False
+
+ def _inverse_ai_prompt_template_id(self):
+ """Save the template ID to system parameter"""
+ for record in self:
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ if record.ai_prompt_template_id:
+ IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(record.ai_prompt_template_id))
+ else:
+ # Try to get a default template
+ default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
+ if default_template:
+ IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
+
+ def _compute_ai_prompt_template_relation(self):
+ """Compute the template relation from the ID"""
+ for record in self:
+ if record.ai_prompt_template_id:
+ template = self.env['openwebui.prompt.template'].browse(record.ai_prompt_template_id)
+ if template.exists():
+ record.ai_prompt_template_relation = template.id
+ else:
+ record.ai_prompt_template_relation = False
+ else:
+ record.ai_prompt_template_relation = False
+
+ @api.depends('ai_prompt_template_relation')
+ def _compute_helpdesk_ai_template_display(self):
+ """Compute the template name from the selected template"""
+ for record in self:
+ if record.ai_prompt_template_relation:
+ record.helpdesk_ai_template_display = record.ai_prompt_template_relation.name
+ else:
+ # Try to get from system parameter for backward compatibility
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
+
+ if template_id:
+ try:
+ # Convert to integer if it's a string
+ template_id_int = int(template_id) if isinstance(template_id, str) else template_id
+
+ # Try to find the template in the new model
+ template = self.env['openwebui.prompt.template'].browse(template_id_int)
+ if template.exists():
+ record.helpdesk_ai_template_display = template.name
+ else:
+ record.helpdesk_ai_template_display = f'Template #{template_id} (not found)'
+ except (ValueError, TypeError):
+ record.helpdesk_ai_template_display = f'Template #{template_id} (invalid)'
+ else:
+ record.helpdesk_ai_template_display = 'Default Template'
+
+ @api.model
+ def _set_default_values(self):
+ """Set default values for configuration parameters"""
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ base_url = IrConfigParam.get_param('openwebui.base_url')
+ model = IrConfigParam.get_param('openwebui.model')
+
+ # Check for old parameters and migrate them
+ old_api_key = IrConfigParam.get_param('openai.api_key', False)
+ old_base_url = IrConfigParam.get_param('openai.base_url', False)
+ old_model = IrConfigParam.get_param('openai.model', False)
+
+ # Migrate old parameters to new ones if they exist
+ if old_api_key and not IrConfigParam.get_param('openwebui.api_key', False):
+ IrConfigParam.set_param('openwebui.api_key', old_api_key)
+
+ if old_base_url and not base_url:
+ IrConfigParam.set_param('openwebui.base_url', old_base_url)
+
+ if old_model and not model:
+ IrConfigParam.set_param('openwebui.model', old_model)
+
+ # Set defaults if not already set
+ if not base_url:
+ IrConfigParam.set_param('openwebui.base_url', 'https://api.openai.com/v1')
+
+ if not model:
+ IrConfigParam.set_param('openwebui.model', 'anthropic.claude-3-7-sonnet-latest')
+
+ # Ensure there's a default template
+ template_model = self.env['openwebui.prompt.template']
+ template_model._ensure_default_template('helpdesk')
+ default_template = template_model.get_default_template('helpdesk')
+
+ # Set the default template ID in config parameters if not set
+ default_template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id')
+ if not default_template_id and default_template:
+ IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
+
+ @api.model
+ def create(self, vals_list):
+ """Set default values when creating settings"""
+ self._set_default_values()
+ return super(ResConfigSettings, self).create(vals_list)
+
+ def set_values(self):
+ """Set values from the settings form"""
+ _logger.info("Starting set_values in ResConfigSettings")
+ try:
+ # First, explicitly save the API key, base URL, and model to ensure they're saved
+ # even if there's an error with the template handling
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+
+ # Save API key, base URL, and model directly with explicit error handling
+ # API Key - most critical setting
+ if hasattr(self, 'openai_api_key'):
+ _logger.info(f"Saving API key (present: {bool(self.openai_api_key)})")
+ # Always set the parameter, even if None or False, to clear previous value if needed
+ api_key_value = self.openai_api_key if self.openai_api_key else ''
+ IrConfigParam.set_param('openai.api_key', api_key_value)
+ # Verify it was saved
+ saved_key = IrConfigParam.get_param('openai.api_key', '')
+ _logger.info(f"API key saved successfully: {bool(saved_key)}")
+
+ # Base URL
+ if hasattr(self, 'openai_base_url'):
+ _logger.info(f"Saving base URL: {self.openai_base_url}")
+ base_url_value = self.openai_base_url if self.openai_base_url else 'https://ai.bemade.org/api'
+ IrConfigParam.set_param('openai.base_url', base_url_value)
+ # Verify it was saved
+ saved_url = IrConfigParam.get_param('openai.base_url', '')
+ _logger.info(f"Base URL saved: {saved_url}")
+
+ # Model
+ if hasattr(self, 'openai_model'):
+ _logger.info(f"Saving model: {self.openai_model}")
+ model_value = self.openai_model if self.openai_model else 'anthropic.claude-3-7-sonnet-latest'
+ IrConfigParam.set_param('openai.model', model_value)
+ # Verify it was saved
+ saved_model = IrConfigParam.get_param('openai.model', '')
+ _logger.info(f"Model saved: {saved_model}")
+
+ _logger.info(f"Current settings values: openai_api_key={bool(self.openai_api_key)}, openai_base_url={self.openai_base_url}, openai_model={self.openai_model}")
+ _logger.info(f"Template ID: {self.ai_prompt_template_id if self.ai_prompt_template_id else 'None'}")
+
+ # Force a commit to ensure parameters are saved to database
+ self.env.cr.commit()
+ _logger.info("Committed parameter changes to database")
+
+ # Now call the super method
+ res = super(ResConfigSettings, self).set_values()
+ _logger.info("Super set_values completed successfully")
+
+ # Handle the template ID - either save the selected one or ensure a default exists
+ try:
+ if self.ai_prompt_template_id:
+ _logger.info(f"Saving template ID {self.ai_prompt_template_id} to system parameters")
+ IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(self.ai_prompt_template_id))
+ else:
+ # If no template is selected, ensure a default one exists and use that
+ _logger.info("No template selected, getting default template")
+ template_model = self.env['openwebui.prompt.template']
+ try:
+ default_template = template_model.get_default_template('helpdesk')
+ if default_template:
+ _logger.info(f"Using default template ID {default_template.id}")
+ IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
+ else:
+ _logger.warning("No default template found")
+ except Exception as e:
+ _logger.error(f"Error getting default template: {e}", exc_info=True)
+ # Continue execution even if there's an error with the template
+ except Exception as template_error:
+ _logger.error(f"Error handling template: {template_error}", exc_info=True)
+ # Continue execution even if there's an error with the template
+
+ # Force another commit to ensure all changes are saved
+ self.env.cr.commit()
+ _logger.info("Final commit completed")
+
+ # Double-check that the API key was saved
+ final_api_key = IrConfigParam.get_param('openai.api_key', '')
+ _logger.info(f"Final API key check - key exists: {bool(final_api_key)}")
+
+ _logger.info("set_values completed successfully")
+ return res
+ except Exception as e:
+ _logger.error(f"Error in set_values: {e}", exc_info=True)
+ # Try to save API key directly even if there was an error
+ try:
+ if hasattr(self, 'openai_api_key'):
+ api_key_value = self.openai_api_key if self.openai_api_key else ''
+ self.env['ir.config_parameter'].sudo().set_param('openai.api_key', api_key_value)
+ self.env.cr.commit() # Force commit to save the key
+ _logger.info("Saved API key directly after error")
+ except Exception as key_error:
+ _logger.error(f"Failed to save API key after error: {key_error}")
+ raise
+
+ @api.model
+ def get_values(self):
+ """Get values for the settings form"""
+ res = super(ResConfigSettings, self).get_values()
+
+ # Ensure default values are set
+ self._set_default_values()
+
+ # Get all OpenAI settings from system parameters
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+
+ # Get OpenAI API key, base URL, and model
+ _logger.info("Retrieving OpenAI settings from system parameters")
+ api_key = IrConfigParam.get_param('openai.api_key', False)
+ base_url = IrConfigParam.get_param('openai.base_url', 'https://ai.bemade.org/api')
+ model = IrConfigParam.get_param('openai.model', 'anthropic.claude-3-7-sonnet-latest')
+
+ # Set the values in the result dictionary
+ res.update({
+ 'openai_api_key': api_key,
+ 'openai_base_url': base_url,
+ 'openai_model': model,
+ })
+
+ _logger.info(f"Retrieved settings: API key present: {bool(api_key)}, base_url: {base_url}, model: {model}")
+
+ # Get the template ID from the system parameter
+ template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
+
+ if template_id:
+ try:
+ template_id_int = int(template_id) if isinstance(template_id, str) else template_id
+ # Check if the template exists
+ template = self.env['openwebui.prompt.template'].browse(template_id_int)
+ if template.exists():
+ res['ai_prompt_template_id'] = template_id_int
+ else:
+ # If template doesn't exist, get a default one
+ default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
+ if default_template:
+ res['ai_prompt_template_id'] = default_template.id
+ # Update the system parameter
+ IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
+ except (ValueError, TypeError):
+ _logger.error(f"Invalid template ID in system parameter: {template_id}")
+ # Get a default template
+ default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
+ if default_template:
+ res['ai_prompt_template_id'] = default_template.id
+ # Update the system parameter
+ IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
+ else:
+ # No template ID in system parameters, get a default one
+ default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
+ if default_template:
+ res['ai_prompt_template_id'] = default_template.id
+ # Update the system parameter
+ IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
+
+ return res
+
+ def action_open_prompt_templates(self):
+ """Open the prompt templates management view"""
+ self.ensure_one()
+
+ return {
+ 'name': _('AI Prompt Templates'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'openwebui.prompt.template',
+ 'view_mode': 'list,form',
+ }
+
+ @api.model
+ def get_prompt_template(self, template_type='helpdesk'):
+ """Get the prompt template content from the selected template or default"""
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
+
+ if template_id:
+ try:
+ # Make sure the template model exists first
+ self.env['openwebui.prompt.template']._ensure_default_template(template_type)
+ # The template_id is already an integer in the database now
+ template_id_int = int(template_id) if isinstance(template_id, str) else template_id
+ template = self.env['openwebui.prompt.template'].browse(template_id_int)
+ if template.exists():
+ return template.content
+ except (ValueError, TypeError) as e:
+ _logger.error(f"Error retrieving prompt template: {e}")
+ pass
+
+ # Fallback to default template
+ default_template = self.env['openwebui.prompt.template'].get_default_template(template_type)
+ return default_template.content if default_template else ""
diff --git a/openwebui_connector/security/ir.model.access.csv b/openwebui_connector/security/ir.model.access.csv
new file mode 100644
index 0000000..abf7f47
--- /dev/null
+++ b/openwebui_connector/security/ir.model.access.csv
@@ -0,0 +1,5 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_openwebui_prompt_template_user,openwebui.prompt.template.user,model_openwebui_prompt_template,base.group_user,1,0,0,0
+access_openwebui_prompt_template_admin,openwebui.prompt.template.admin,model_openwebui_prompt_template,base.group_system,1,1,1,1
+access_openwebui_client_user,openwebui.client.user,model_openwebui_client,base.group_user,1,0,0,0
+access_openwebui_client_admin,openwebui.client.admin,model_openwebui_client,base.group_system,1,1,1,1
diff --git a/openwebui_connector/views/ai_prompt_template_views.xml b/openwebui_connector/views/ai_prompt_template_views.xml
new file mode 100644
index 0000000..927299c
--- /dev/null
+++ b/openwebui_connector/views/ai_prompt_template_views.xml
@@ -0,0 +1,109 @@
+
+
+
+
+ openwebui.prompt.template.form
+ openwebui.prompt.template
+
+
+
+
+
+
+
+ openwebui.prompt.template.list
+ openwebui.prompt.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+ openwebui.prompt.template.search
+ openwebui.prompt.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AI Prompt Templates
+ openwebui.prompt.template
+ list,form
+
+
+ Create a new AI prompt template
+
+
+ Define templates for AI prompts used in various modules.
+