update
This commit is contained in:
5
fusion_api/__init__.py
Normal file
5
fusion_api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import models
|
||||
73
fusion_api/__manifest__.py
Normal file
73
fusion_api/__manifest__.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
{
|
||||
'name': 'Fusion API',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Productivity',
|
||||
'summary': 'Central API Key Management, Usage Tracking & Rate Limiting for Fusion Products',
|
||||
'description': """
|
||||
Fusion API - Central API Hub for Fusion Products
|
||||
==================================================
|
||||
|
||||
Centralized management of API keys, usage tracking, rate limiting,
|
||||
and access control for all Fusion modules.
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
* **Provider Management** - Configure API keys for OpenAI, Anthropic, Google Maps, Twilio, and more
|
||||
* **Auto-Detection** - Automatically discovers which Fusion modules use the API
|
||||
* **Usage Tracking** - Per-module, per-feature, per-user, per-model granular usage logs
|
||||
* **Rate Limiting** - Requests per minute/day limits per module and per user
|
||||
* **Budget Control** - Monthly and daily cost caps per module and per user
|
||||
* **Access Control** - Enable/disable API access per module, block individual users
|
||||
* **Dashboard** - Real-time overview of API usage, costs, and health
|
||||
* **Daily Reports** - Aggregated usage summaries for trend analysis
|
||||
|
||||
Supported Providers
|
||||
-------------------
|
||||
|
||||
* OpenAI (GPT-4o, GPT-4o-mini, GPT-3.5-turbo)
|
||||
* Anthropic (Claude 3.5 Sonnet, Claude 3.5 Haiku, Claude 3 Opus)
|
||||
* Google Maps (Geocoding, Places, Distance Matrix)
|
||||
* Google OAuth (Calendar, Drive)
|
||||
* Microsoft OAuth (Calendar, Graph)
|
||||
* Twilio (SMS, Voice)
|
||||
* Custom providers
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.io',
|
||||
'license': 'OPL-1',
|
||||
'depends': [
|
||||
'base',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/api_provider_data.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
'views/api_key_views.xml',
|
||||
'views/api_provider_views.xml',
|
||||
'views/api_consumer_views.xml',
|
||||
'views/api_access_views.xml',
|
||||
'views/api_user_limit_views.xml',
|
||||
'views/api_usage_views.xml',
|
||||
'views/api_dashboard_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_api/static/src/scss/fusion_api.scss',
|
||||
'fusion_api/static/src/js/dashboard.js',
|
||||
'fusion_api/static/src/xml/dashboard.xml',
|
||||
],
|
||||
},
|
||||
'images': ['static/description/icon.png'],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': True,
|
||||
}
|
||||
64
fusion_api/data/api_provider_data.xml
Normal file
64
fusion_api/data/api_provider_data.xml
Normal file
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="provider_openai" model="fusion.api.provider">
|
||||
<field name="name">OpenAI</field>
|
||||
<field name="provider_type">openai</field>
|
||||
<field name="status">inactive</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="icon_class">fa-brain</field>
|
||||
<field name="description">OpenAI API for GPT models (GPT-4o, GPT-4o-mini, GPT-3.5-turbo, o1). Used for text generation, chat completion, embeddings, and more.</field>
|
||||
<field name="website_url">https://platform.openai.com/api-keys</field>
|
||||
</record>
|
||||
|
||||
<record id="provider_anthropic" model="fusion.api.provider">
|
||||
<field name="name">Anthropic</field>
|
||||
<field name="provider_type">anthropic</field>
|
||||
<field name="status">inactive</field>
|
||||
<field name="sequence">2</field>
|
||||
<field name="icon_class">fa-comments</field>
|
||||
<field name="description">Anthropic API for Claude models (Claude 3.5 Sonnet, Claude 3.5 Haiku, Claude 3 Opus). Used for text generation and analysis.</field>
|
||||
<field name="website_url">https://console.anthropic.com/settings/keys</field>
|
||||
</record>
|
||||
|
||||
<record id="provider_google_maps" model="fusion.api.provider">
|
||||
<field name="name">Google Maps</field>
|
||||
<field name="provider_type">google_maps</field>
|
||||
<field name="status">inactive</field>
|
||||
<field name="sequence">3</field>
|
||||
<field name="icon_class">fa-map-marker</field>
|
||||
<field name="description">Google Maps Platform API. Used for geocoding, places, distance matrix, maps display, and directions.</field>
|
||||
<field name="website_url">https://console.cloud.google.com/apis/credentials</field>
|
||||
</record>
|
||||
|
||||
<record id="provider_google_oauth" model="fusion.api.provider">
|
||||
<field name="name">Google OAuth</field>
|
||||
<field name="provider_type">google_oauth</field>
|
||||
<field name="status">inactive</field>
|
||||
<field name="sequence">4</field>
|
||||
<field name="icon_class">fa-google</field>
|
||||
<field name="description">Google OAuth 2.0 credentials for Calendar, Drive, and other Google Workspace integrations.</field>
|
||||
<field name="website_url">https://console.cloud.google.com/apis/credentials</field>
|
||||
</record>
|
||||
|
||||
<record id="provider_microsoft_oauth" model="fusion.api.provider">
|
||||
<field name="name">Microsoft OAuth</field>
|
||||
<field name="provider_type">microsoft_oauth</field>
|
||||
<field name="status">inactive</field>
|
||||
<field name="sequence">5</field>
|
||||
<field name="icon_class">fa-windows</field>
|
||||
<field name="description">Microsoft Azure AD OAuth credentials for Calendar, Outlook, and Microsoft 365 integrations.</field>
|
||||
<field name="website_url">https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps</field>
|
||||
</record>
|
||||
|
||||
<record id="provider_twilio" model="fusion.api.provider">
|
||||
<field name="name">Twilio</field>
|
||||
<field name="provider_type">twilio</field>
|
||||
<field name="status">inactive</field>
|
||||
<field name="sequence">6</field>
|
||||
<field name="icon_class">fa-phone</field>
|
||||
<field name="description">Twilio API for SMS, voice calls, and communication services.</field>
|
||||
<field name="website_url">https://console.twilio.com</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
26
fusion_api/data/ir_cron_data.xml
Normal file
26
fusion_api/data/ir_cron_data.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="cron_aggregate_daily_usage" model="ir.cron">
|
||||
<field name="name">Fusion API: Aggregate Daily Usage</field>
|
||||
<field name="model_id" ref="model_fusion_api_usage_daily"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_aggregate_daily()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">90</field>
|
||||
</record>
|
||||
|
||||
<record id="cron_cleanup_old_logs" model="ir.cron">
|
||||
<field name="name">Fusion API: Cleanup Old Usage Logs</field>
|
||||
<field name="model_id" ref="model_fusion_api_usage_daily"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_cleanup_old_logs()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">weeks</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">95</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
13
fusion_api/models/__init__.py
Normal file
13
fusion_api/models/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import api_provider
|
||||
from . import api_key
|
||||
from . import api_consumer
|
||||
from . import api_access
|
||||
from . import api_user_limit
|
||||
from . import api_usage
|
||||
from . import api_usage_daily
|
||||
from . import api_service
|
||||
from . import res_config_settings
|
||||
95
fusion_api/models/api_access.py
Normal file
95
fusion_api/models/api_access.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class FusionApiAccess(models.Model):
|
||||
_name = 'fusion.api.access'
|
||||
_description = 'API Access Rule'
|
||||
_order = 'consumer_id, provider_id'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
consumer_id = fields.Many2one(
|
||||
'fusion.api.consumer', required=True, ondelete='cascade',
|
||||
)
|
||||
provider_id = fields.Many2one(
|
||||
'fusion.api.provider', required=True, ondelete='cascade',
|
||||
)
|
||||
|
||||
is_enabled = fields.Boolean(default=True, string='Enabled', tracking=True)
|
||||
|
||||
monthly_budget_usd = fields.Float(
|
||||
string='Monthly Budget (USD)',
|
||||
help='Maximum monthly spend. 0 = unlimited.',
|
||||
)
|
||||
daily_budget_usd = fields.Float(
|
||||
string='Daily Budget (USD)',
|
||||
help='Maximum daily spend. 0 = unlimited.',
|
||||
)
|
||||
max_rpm = fields.Integer(
|
||||
string='Max Requests/Min',
|
||||
help='Maximum requests per minute. 0 = unlimited.',
|
||||
)
|
||||
max_rpd = fields.Integer(
|
||||
string='Max Requests/Day',
|
||||
help='Maximum requests per day. 0 = unlimited.',
|
||||
)
|
||||
|
||||
current_month_cost = fields.Float(
|
||||
compute='_compute_current_usage', string='Month Spend',
|
||||
)
|
||||
current_day_cost = fields.Float(
|
||||
compute='_compute_current_usage', string='Today Spend',
|
||||
)
|
||||
current_day_requests = fields.Integer(
|
||||
compute='_compute_current_usage', string='Today Requests',
|
||||
)
|
||||
budget_usage_pct = fields.Float(
|
||||
compute='_compute_current_usage', string='Budget Used %',
|
||||
)
|
||||
is_budget_exceeded = fields.Boolean(compute='_compute_current_usage')
|
||||
|
||||
display_name = fields.Char(compute='_compute_display_name', store=True)
|
||||
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
||||
|
||||
_sql_constraints = [
|
||||
('consumer_provider_uniq', 'unique(consumer_id, provider_id, company_id)',
|
||||
'Only one access rule per consumer-provider pair per company.'),
|
||||
]
|
||||
|
||||
@api.depends('consumer_id.name', 'provider_id.name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
consumer = rec.consumer_id.name or ''
|
||||
provider = rec.provider_id.name or ''
|
||||
rec.display_name = f"{consumer} / {provider}"
|
||||
|
||||
def _compute_current_usage(self):
|
||||
today = fields.Date.today()
|
||||
month_start = today.replace(day=1)
|
||||
for rec in self:
|
||||
month_usages = self.env['fusion.api.usage'].sudo().search([
|
||||
('consumer_id', '=', rec.consumer_id.id),
|
||||
('provider_id', '=', rec.provider_id.id),
|
||||
('create_date', '>=', fields.Datetime.to_string(month_start)),
|
||||
('status', '=', 'success'),
|
||||
])
|
||||
day_usages = month_usages.filtered(
|
||||
lambda u: u.create_date.date() == today
|
||||
)
|
||||
rec.current_month_cost = sum(u.estimated_cost_usd for u in month_usages)
|
||||
rec.current_day_cost = sum(u.estimated_cost_usd for u in day_usages)
|
||||
rec.current_day_requests = len(day_usages)
|
||||
|
||||
if rec.monthly_budget_usd > 0:
|
||||
rec.budget_usage_pct = (
|
||||
rec.current_month_cost / rec.monthly_budget_usd
|
||||
) * 100
|
||||
rec.is_budget_exceeded = (
|
||||
rec.current_month_cost >= rec.monthly_budget_usd
|
||||
)
|
||||
else:
|
||||
rec.budget_usage_pct = 0.0
|
||||
rec.is_budget_exceeded = False
|
||||
54
fusion_api/models/api_consumer.py
Normal file
54
fusion_api/models/api_consumer.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class FusionApiConsumer(models.Model):
|
||||
_name = 'fusion.api.consumer'
|
||||
_description = 'API Consumer (Fusion Module)'
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(required=True, string='Display Name')
|
||||
technical_name = fields.Char(required=True, index=True)
|
||||
module_id = fields.Many2one('ir.module.module', string='Odoo Module', readonly=True)
|
||||
module_state = fields.Selection(
|
||||
related='module_id.state', string='Module Status', readonly=True,
|
||||
)
|
||||
|
||||
is_active = fields.Boolean(default=True, string='API Access Enabled', tracking=True)
|
||||
auto_detected = fields.Boolean(default=False, readonly=True)
|
||||
first_seen_at = fields.Datetime(readonly=True)
|
||||
|
||||
access_ids = fields.One2many('fusion.api.access', 'consumer_id', string='Access Rules')
|
||||
|
||||
total_month_cost = fields.Float(
|
||||
compute='_compute_usage_stats', string='Month Cost (USD)',
|
||||
)
|
||||
total_month_requests = fields.Integer(
|
||||
compute='_compute_usage_stats', string='Month Requests',
|
||||
)
|
||||
|
||||
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
||||
|
||||
_sql_constraints = [
|
||||
('technical_name_company_uniq', 'unique(technical_name, company_id)',
|
||||
'Consumer technical name must be unique per company.'),
|
||||
]
|
||||
|
||||
def _compute_usage_stats(self):
|
||||
today = fields.Date.today()
|
||||
month_start = today.replace(day=1)
|
||||
for rec in self:
|
||||
usages = self.env['fusion.api.usage'].sudo().search([
|
||||
('consumer_id', '=', rec.id),
|
||||
('create_date', '>=', fields.Datetime.to_string(month_start)),
|
||||
('status', '=', 'success'),
|
||||
])
|
||||
rec.total_month_cost = sum(u.estimated_cost_usd for u in usages)
|
||||
rec.total_month_requests = len(usages)
|
||||
|
||||
def action_toggle_access(self):
|
||||
for rec in self:
|
||||
rec.is_active = not rec.is_active
|
||||
105
fusion_api/models/api_key.py
Normal file
105
fusion_api/models/api_key.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FusionApiKey(models.Model):
|
||||
_name = 'fusion.api.key'
|
||||
_description = 'API Key'
|
||||
_order = 'provider_id, is_default desc, name'
|
||||
|
||||
provider_id = fields.Many2one(
|
||||
'fusion.api.provider', required=True, ondelete='cascade',
|
||||
)
|
||||
provider_type = fields.Selection(
|
||||
related='provider_id.provider_type', store=True, readonly=True,
|
||||
)
|
||||
name = fields.Char(required=True, string='Label')
|
||||
|
||||
api_key = fields.Char(string='API Key', groups='fusion_api.group_admin')
|
||||
|
||||
client_id = fields.Char(groups='fusion_api.group_admin')
|
||||
client_secret = fields.Char(groups='fusion_api.group_admin')
|
||||
access_token = fields.Char(groups='fusion_api.group_admin')
|
||||
refresh_token = fields.Char(groups='fusion_api.group_admin')
|
||||
token_expiry = fields.Datetime()
|
||||
redirect_uri = fields.Char()
|
||||
|
||||
environment = fields.Selection([
|
||||
('production', 'Production'),
|
||||
('sandbox', 'Sandbox'),
|
||||
], default='production', required=True)
|
||||
is_active = fields.Boolean(default=True)
|
||||
is_default = fields.Boolean(default=False)
|
||||
last_validated_at = fields.Datetime(readonly=True)
|
||||
validation_status = fields.Selection([
|
||||
('unknown', 'Not Validated'),
|
||||
('valid', 'Valid'),
|
||||
('invalid', 'Invalid'),
|
||||
], default='unknown', readonly=True)
|
||||
notes = fields.Text()
|
||||
|
||||
masked_key = fields.Char(compute='_compute_masked_key')
|
||||
|
||||
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
||||
|
||||
@api.depends('api_key')
|
||||
def _compute_masked_key(self):
|
||||
for rec in self:
|
||||
key = rec.api_key
|
||||
if key and len(key) > 8:
|
||||
rec.masked_key = key[:4] + '*' * (len(key) - 8) + key[-4:]
|
||||
elif key:
|
||||
rec.masked_key = '****'
|
||||
else:
|
||||
rec.masked_key = ''
|
||||
|
||||
@api.constrains('is_default', 'provider_id', 'environment')
|
||||
def _check_single_default(self):
|
||||
for rec in self:
|
||||
if rec.is_default:
|
||||
duplicates = self.search([
|
||||
('provider_id', '=', rec.provider_id.id),
|
||||
('environment', '=', rec.environment),
|
||||
('is_default', '=', True),
|
||||
('id', '!=', rec.id),
|
||||
])
|
||||
if duplicates:
|
||||
raise UserError(_(
|
||||
"Only one default key per provider per environment. "
|
||||
"Key '%(other)s' is already the default.",
|
||||
other=duplicates[0].name,
|
||||
))
|
||||
|
||||
def action_validate(self):
|
||||
self.ensure_one()
|
||||
try:
|
||||
self.env['fusion.api.service']._validate_key(self)
|
||||
self.write({
|
||||
'last_validated_at': fields.Datetime.now(),
|
||||
'validation_status': 'valid',
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Validation Successful'),
|
||||
'message': _('API key is valid and working.'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
except UserError as e:
|
||||
self.write({'validation_status': 'invalid'})
|
||||
raise
|
||||
|
||||
def write(self, vals):
|
||||
if 'api_key' in vals and not vals['api_key']:
|
||||
for rec in self:
|
||||
if rec.api_key:
|
||||
vals.pop('api_key')
|
||||
break
|
||||
return super().write(vals)
|
||||
173
fusion_api/models/api_provider.py
Normal file
173
fusion_api/models/api_provider.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class FusionApiProvider(models.Model):
|
||||
_name = 'fusion.api.provider'
|
||||
_description = 'API Service Provider'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
provider_type = fields.Selection([
|
||||
('openai', 'OpenAI'),
|
||||
('anthropic', 'Anthropic'),
|
||||
('google_maps', 'Google Maps'),
|
||||
('google_oauth', 'Google OAuth'),
|
||||
('microsoft_oauth', 'Microsoft OAuth'),
|
||||
('twilio', 'Twilio'),
|
||||
('custom', 'Custom'),
|
||||
], required=True, default='custom')
|
||||
status = fields.Selection([
|
||||
('active', 'Active'),
|
||||
('inactive', 'Inactive'),
|
||||
('error', 'Error'),
|
||||
], default='inactive', required=True, tracking=True)
|
||||
description = fields.Text()
|
||||
website_url = fields.Char(string='API Dashboard URL')
|
||||
sequence = fields.Integer(default=10)
|
||||
color = fields.Integer()
|
||||
icon_class = fields.Char(
|
||||
string='Icon CSS Class',
|
||||
help='Font Awesome class for display, e.g. fa-brain',
|
||||
)
|
||||
|
||||
key_ids = fields.One2many('fusion.api.key', 'provider_id', string='API Keys')
|
||||
access_ids = fields.One2many('fusion.api.access', 'provider_id', string='Access Rules')
|
||||
|
||||
active_key_count = fields.Integer(compute='_compute_key_stats', string='Active Keys')
|
||||
total_month_cost = fields.Float(compute='_compute_usage_stats', string='Month Cost (USD)')
|
||||
total_month_requests = fields.Integer(compute='_compute_usage_stats', string='Month Requests')
|
||||
|
||||
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
||||
|
||||
@api.depends('key_ids', 'key_ids.is_active')
|
||||
def _compute_key_stats(self):
|
||||
for rec in self:
|
||||
rec.active_key_count = len(rec.key_ids.filtered('is_active'))
|
||||
|
||||
def _compute_usage_stats(self):
|
||||
today = fields.Date.today()
|
||||
month_start = today.replace(day=1)
|
||||
for rec in self:
|
||||
usages = self.env['fusion.api.usage'].sudo().search([
|
||||
('provider_id', '=', rec.id),
|
||||
('create_date', '>=', fields.Datetime.to_string(month_start)),
|
||||
('status', '=', 'success'),
|
||||
])
|
||||
rec.total_month_cost = sum(u.estimated_cost_usd for u in usages)
|
||||
rec.total_month_requests = len(usages)
|
||||
|
||||
def action_activate(self):
|
||||
self.ensure_one()
|
||||
if not self.key_ids.filtered('is_active'):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'No Active Keys',
|
||||
'message': 'Add at least one active API key before activating this provider.',
|
||||
'type': 'warning',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
self.status = 'active'
|
||||
|
||||
def action_deactivate(self):
|
||||
self.ensure_one()
|
||||
self.status = 'inactive'
|
||||
|
||||
@api.model
|
||||
def get_dashboard_data(self):
|
||||
today = fields.Date.today()
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
providers = self.search([])
|
||||
consumers = self.env['fusion.api.consumer'].search([])
|
||||
|
||||
month_usages = self.env['fusion.api.usage'].sudo().search([
|
||||
('create_date', '>=', fields.Datetime.to_string(month_start)),
|
||||
('status', '=', 'success'),
|
||||
])
|
||||
today_usages = month_usages.filtered(
|
||||
lambda u: u.create_date.date() == today
|
||||
)
|
||||
|
||||
month_cost = sum(u.estimated_cost_usd for u in month_usages)
|
||||
month_requests = len(month_usages)
|
||||
today_requests = len(today_usages)
|
||||
|
||||
consumer_stats = {}
|
||||
for usage in month_usages:
|
||||
cid = usage.consumer_id.id
|
||||
if cid not in consumer_stats:
|
||||
consumer_stats[cid] = {
|
||||
'name': usage.consumer_id.name,
|
||||
'cost': 0.0,
|
||||
'requests': 0,
|
||||
}
|
||||
consumer_stats[cid]['cost'] += usage.estimated_cost_usd
|
||||
consumer_stats[cid]['requests'] += 1
|
||||
|
||||
top_consumers = sorted(
|
||||
consumer_stats.values(),
|
||||
key=lambda x: x['cost'],
|
||||
reverse=True,
|
||||
)[:5]
|
||||
|
||||
provider_stats = []
|
||||
for prov in providers.filtered(lambda p: p.status == 'active'):
|
||||
prov_usages = month_usages.filtered(lambda u: u.provider_id.id == prov.id)
|
||||
provider_stats.append({
|
||||
'id': prov.id,
|
||||
'name': prov.name,
|
||||
'type': prov.provider_type,
|
||||
'cost': sum(u.estimated_cost_usd for u in prov_usages),
|
||||
'requests': len(prov_usages),
|
||||
'keys': prov.active_key_count,
|
||||
})
|
||||
|
||||
recent = self.env['fusion.api.usage'].sudo().search(
|
||||
[], limit=10, order='create_date desc',
|
||||
)
|
||||
recent_list = [{
|
||||
'consumer': r.consumer_id.name or '',
|
||||
'provider': r.provider_id.name or '',
|
||||
'feature': r.feature or '',
|
||||
'cost': round(r.estimated_cost_usd, 6),
|
||||
'status': r.status,
|
||||
'tokens': r.total_tokens,
|
||||
'time': fields.Datetime.to_string(r.create_date),
|
||||
} for r in recent]
|
||||
|
||||
approaching_limits = []
|
||||
access_rules = self.env['fusion.api.access'].sudo().search([
|
||||
('monthly_budget_usd', '>', 0),
|
||||
('is_enabled', '=', True),
|
||||
])
|
||||
for rule in access_rules:
|
||||
pct = rule.budget_usage_pct
|
||||
if pct >= 80:
|
||||
approaching_limits.append({
|
||||
'consumer': rule.consumer_id.name,
|
||||
'provider': rule.provider_id.name,
|
||||
'pct': round(pct, 1),
|
||||
'budget': rule.monthly_budget_usd,
|
||||
'spent': round(rule.current_month_cost, 2),
|
||||
})
|
||||
|
||||
return {
|
||||
'total_providers': len(providers),
|
||||
'active_providers': len(providers.filtered(lambda p: p.status == 'active')),
|
||||
'total_consumers': len(consumers),
|
||||
'active_consumers': len(consumers.filtered('is_active')),
|
||||
'month_cost': round(month_cost, 2),
|
||||
'month_requests': month_requests,
|
||||
'today_requests': today_requests,
|
||||
'top_consumers': top_consumers,
|
||||
'provider_stats': provider_stats,
|
||||
'recent_usage': recent_list,
|
||||
'approaching_limits': approaching_limits,
|
||||
}
|
||||
499
fusion_api/models/api_service.py
Normal file
499
fusion_api/models/api_service.py
Normal file
@@ -0,0 +1,499 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import time
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
OpenAI = None
|
||||
_logger.info("openai package not installed. OpenAI calls via Fusion API unavailable.")
|
||||
|
||||
try:
|
||||
import anthropic as anthropic_sdk
|
||||
except ImportError:
|
||||
anthropic_sdk = None
|
||||
_logger.info("anthropic package not installed. Anthropic calls via Fusion API unavailable.")
|
||||
|
||||
|
||||
class FusionApiService(models.AbstractModel):
|
||||
_name = 'fusion.api.service'
|
||||
_description = 'Fusion API Service'
|
||||
|
||||
COST_PER_1K = {
|
||||
'openai': {
|
||||
'gpt-4o': {'input': 0.0025, 'output': 0.01},
|
||||
'gpt-4o-mini': {'input': 0.00015, 'output': 0.0006},
|
||||
'gpt-4-turbo': {'input': 0.01, 'output': 0.03},
|
||||
'gpt-3.5-turbo': {'input': 0.0005, 'output': 0.0015},
|
||||
'o1': {'input': 0.015, 'output': 0.06},
|
||||
'o1-mini': {'input': 0.003, 'output': 0.012},
|
||||
},
|
||||
'anthropic': {
|
||||
'claude-sonnet-4-20250514': {'input': 0.003, 'output': 0.015},
|
||||
'claude-3-5-haiku-20241022': {'input': 0.001, 'output': 0.005},
|
||||
'claude-3-opus-20240229': {'input': 0.015, 'output': 0.075},
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Provider / key resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_provider(self, provider_type):
|
||||
provider = self.env['fusion.api.provider'].sudo().search([
|
||||
('provider_type', '=', provider_type),
|
||||
('status', '=', 'active'),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=1)
|
||||
if not provider:
|
||||
provider = self.env['fusion.api.provider'].sudo().search([
|
||||
('provider_type', '=', provider_type),
|
||||
('status', '=', 'active'),
|
||||
], limit=1)
|
||||
if not provider:
|
||||
raise UserError(_(
|
||||
"No active %(type)s provider configured. "
|
||||
"Go to Fusion API > Providers to set one up.",
|
||||
type=provider_type,
|
||||
))
|
||||
return provider
|
||||
|
||||
def _get_default_key(self, provider):
|
||||
domain = [
|
||||
('provider_id', '=', provider.id),
|
||||
('is_active', '=', True),
|
||||
('environment', '=', 'production'),
|
||||
]
|
||||
key = self.env['fusion.api.key'].sudo().search(
|
||||
domain + [('is_default', '=', True)], limit=1,
|
||||
)
|
||||
if not key:
|
||||
key = self.env['fusion.api.key'].sudo().search(domain, limit=1)
|
||||
if not key:
|
||||
raise UserError(_(
|
||||
"No active API key for provider '%(provider)s'. "
|
||||
"Go to Fusion API > Providers to add one.",
|
||||
provider=provider.name,
|
||||
))
|
||||
return key
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Consumer auto-registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _auto_register_consumer(self, consumer_name):
|
||||
consumer = self.env['fusion.api.consumer'].sudo().search([
|
||||
('technical_name', '=', consumer_name),
|
||||
], limit=1)
|
||||
if consumer:
|
||||
return consumer
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if not ICP.get_param('fusion_api.auto_detect_consumers', 'True') == 'True':
|
||||
raise UserError(_(
|
||||
"Consumer '%(name)s' is not registered and auto-detection is disabled.",
|
||||
name=consumer_name,
|
||||
))
|
||||
|
||||
module = self.env['ir.module.module'].sudo().search([
|
||||
('name', '=', consumer_name),
|
||||
], limit=1)
|
||||
|
||||
display = consumer_name.replace('fusion_', 'Fusion ').replace('_', ' ').title()
|
||||
|
||||
consumer = self.env['fusion.api.consumer'].sudo().create({
|
||||
'name': display,
|
||||
'technical_name': consumer_name,
|
||||
'module_id': module.id if module else False,
|
||||
'is_active': True,
|
||||
'auto_detected': True,
|
||||
'first_seen_at': fields.Datetime.now(),
|
||||
})
|
||||
_logger.info("Auto-registered API consumer: %s", consumer_name)
|
||||
return consumer
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Access control checks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_access(self, consumer, provider, user=None):
|
||||
if not consumer.is_active:
|
||||
raise UserError(_(
|
||||
"API access is disabled for '%(module)s'. "
|
||||
"An administrator can re-enable it in Fusion API > Consumers.",
|
||||
module=consumer.name,
|
||||
))
|
||||
|
||||
access = self.env['fusion.api.access'].sudo().search([
|
||||
('consumer_id', '=', consumer.id),
|
||||
('provider_id', '=', provider.id),
|
||||
], limit=1)
|
||||
|
||||
if access and not access.is_enabled:
|
||||
raise UserError(_(
|
||||
"'%(module)s' access to '%(provider)s' is disabled.",
|
||||
module=consumer.name, provider=provider.name,
|
||||
))
|
||||
|
||||
if access:
|
||||
self._check_budget(access, consumer, provider)
|
||||
self._check_rate_limit(access, consumer, provider)
|
||||
|
||||
if user:
|
||||
self._check_user_limit(user, provider)
|
||||
|
||||
return access
|
||||
|
||||
def _check_budget(self, access, consumer, provider):
|
||||
if access.monthly_budget_usd > 0 and access.is_budget_exceeded:
|
||||
raise UserError(_(
|
||||
"Monthly budget of $%(budget).2f exceeded for "
|
||||
"'%(module)s' on '%(provider)s' ($%(spent).2f spent).",
|
||||
budget=access.monthly_budget_usd,
|
||||
module=consumer.name,
|
||||
provider=provider.name,
|
||||
spent=access.current_month_cost,
|
||||
))
|
||||
|
||||
if access.daily_budget_usd > 0:
|
||||
if access.current_day_cost >= access.daily_budget_usd:
|
||||
raise UserError(_(
|
||||
"Daily budget of $%(budget).2f exceeded for "
|
||||
"'%(module)s' on '%(provider)s'.",
|
||||
budget=access.daily_budget_usd,
|
||||
module=consumer.name,
|
||||
provider=provider.name,
|
||||
))
|
||||
|
||||
def _check_rate_limit(self, access, consumer, provider):
|
||||
if access.max_rpm > 0:
|
||||
one_min_ago = fields.Datetime.now() - timedelta(minutes=1)
|
||||
recent_count = self.env['fusion.api.usage'].sudo().search_count([
|
||||
('consumer_id', '=', consumer.id),
|
||||
('provider_id', '=', provider.id),
|
||||
('create_date', '>=', one_min_ago),
|
||||
])
|
||||
if recent_count >= access.max_rpm:
|
||||
raise UserError(_(
|
||||
"Rate limit of %(limit)d requests/minute exceeded for "
|
||||
"'%(module)s' on '%(provider)s'.",
|
||||
limit=access.max_rpm,
|
||||
module=consumer.name,
|
||||
provider=provider.name,
|
||||
))
|
||||
|
||||
if access.max_rpd > 0:
|
||||
today = fields.Date.today()
|
||||
day_count = self.env['fusion.api.usage'].sudo().search_count([
|
||||
('consumer_id', '=', consumer.id),
|
||||
('provider_id', '=', provider.id),
|
||||
('create_date', '>=', fields.Datetime.to_string(today)),
|
||||
])
|
||||
if day_count >= access.max_rpd:
|
||||
raise UserError(_(
|
||||
"Daily limit of %(limit)d requests exceeded for "
|
||||
"'%(module)s' on '%(provider)s'.",
|
||||
limit=access.max_rpd,
|
||||
module=consumer.name,
|
||||
provider=provider.name,
|
||||
))
|
||||
|
||||
def _check_user_limit(self, user, provider):
|
||||
limit = self.env['fusion.api.user.limit'].sudo().search([
|
||||
('user_id', '=', user.id),
|
||||
('provider_id', '=', provider.id),
|
||||
], limit=1)
|
||||
if not limit:
|
||||
return
|
||||
|
||||
if limit.is_blocked:
|
||||
raise UserError(_(
|
||||
"API access to '%(provider)s' is blocked for user '%(user)s'.",
|
||||
provider=provider.name, user=user.name,
|
||||
))
|
||||
|
||||
if limit.monthly_budget_usd > 0:
|
||||
if limit.current_month_cost >= limit.monthly_budget_usd:
|
||||
raise UserError(_(
|
||||
"User monthly budget of $%(budget).2f exceeded for "
|
||||
"'%(provider)s' ($%(spent).2f spent).",
|
||||
budget=limit.monthly_budget_usd,
|
||||
provider=provider.name,
|
||||
spent=limit.current_month_cost,
|
||||
))
|
||||
|
||||
if limit.max_rpd > 0:
|
||||
if limit.current_day_requests >= limit.max_rpd:
|
||||
raise UserError(_(
|
||||
"User daily limit of %(limit)d requests exceeded for "
|
||||
"'%(provider)s'.",
|
||||
limit=limit.max_rpd,
|
||||
provider=provider.name,
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Usage logging
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _log_usage(self, consumer, provider, user, feature, model_name,
|
||||
tokens_in, tokens_out, cost, response_time_ms,
|
||||
status, error_message=None):
|
||||
self.env['fusion.api.usage'].sudo().create({
|
||||
'consumer_id': consumer.id,
|
||||
'provider_id': provider.id,
|
||||
'user_id': user.id if user else False,
|
||||
'feature': feature,
|
||||
'model_name': model_name,
|
||||
'tokens_in': tokens_in,
|
||||
'tokens_out': tokens_out,
|
||||
'estimated_cost_usd': cost,
|
||||
'response_time_ms': response_time_ms,
|
||||
'status': status,
|
||||
'error_message': error_message,
|
||||
})
|
||||
|
||||
def _estimate_cost(self, provider_type, model_name, tokens_in, tokens_out):
|
||||
provider_costs = self.COST_PER_1K.get(provider_type, {})
|
||||
model_costs = provider_costs.get(
|
||||
model_name, {'input': 0.001, 'output': 0.002},
|
||||
)
|
||||
return (
|
||||
(tokens_in / 1000) * model_costs['input']
|
||||
+ (tokens_out / 1000) * model_costs['output']
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API: OpenAI
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def call_openai(self, consumer, feature, messages,
|
||||
model=None, max_tokens=1024, user=None, **kwargs):
|
||||
if OpenAI is None:
|
||||
raise UserError(_(
|
||||
"The 'openai' Python package is not installed. "
|
||||
"Run: pip install openai"
|
||||
))
|
||||
|
||||
provider = self._get_provider('openai')
|
||||
consumer_rec = self._auto_register_consumer(consumer)
|
||||
user_rec = user or self.env.user
|
||||
self._check_access(consumer_rec, provider, user_rec)
|
||||
|
||||
key = self._get_default_key(provider)
|
||||
model_name = model or 'gpt-4o-mini'
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
client = OpenAI(api_key=key.api_key)
|
||||
response = client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
**kwargs,
|
||||
)
|
||||
elapsed_ms = int((time.time() - start) * 1000)
|
||||
|
||||
usage = response.usage
|
||||
cost = self._estimate_cost(
|
||||
'openai', model_name,
|
||||
usage.prompt_tokens, usage.completion_tokens,
|
||||
)
|
||||
self._log_usage(
|
||||
consumer_rec, provider, user_rec, feature, model_name,
|
||||
usage.prompt_tokens, usage.completion_tokens,
|
||||
cost, elapsed_ms, 'success',
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
except UserError:
|
||||
raise
|
||||
except Exception as e:
|
||||
elapsed_ms = int((time.time() - start) * 1000)
|
||||
self._log_usage(
|
||||
consumer_rec, provider, user_rec, feature, model_name,
|
||||
0, 0, 0, elapsed_ms, 'error', str(e),
|
||||
)
|
||||
_logger.error("OpenAI API error for %s: %s", consumer, e)
|
||||
raise UserError(_("OpenAI API error: %s", str(e)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API: Anthropic
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def call_anthropic(self, consumer, feature, messages,
|
||||
model=None, max_tokens=1024, system=None,
|
||||
user=None, **kwargs):
|
||||
if anthropic_sdk is None:
|
||||
raise UserError(_(
|
||||
"The 'anthropic' Python package is not installed. "
|
||||
"Run: pip install anthropic"
|
||||
))
|
||||
|
||||
provider = self._get_provider('anthropic')
|
||||
consumer_rec = self._auto_register_consumer(consumer)
|
||||
user_rec = user or self.env.user
|
||||
self._check_access(consumer_rec, provider, user_rec)
|
||||
|
||||
key = self._get_default_key(provider)
|
||||
model_name = model or 'claude-sonnet-4-20250514'
|
||||
|
||||
call_kwargs = {
|
||||
'model': model_name,
|
||||
'messages': messages,
|
||||
'max_tokens': max_tokens,
|
||||
}
|
||||
if system:
|
||||
call_kwargs['system'] = system
|
||||
call_kwargs.update(kwargs)
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
client = anthropic_sdk.Anthropic(api_key=key.api_key)
|
||||
response = client.messages.create(**call_kwargs)
|
||||
elapsed_ms = int((time.time() - start) * 1000)
|
||||
|
||||
cost = self._estimate_cost(
|
||||
'anthropic', model_name,
|
||||
response.usage.input_tokens, response.usage.output_tokens,
|
||||
)
|
||||
self._log_usage(
|
||||
consumer_rec, provider, user_rec, feature, model_name,
|
||||
response.usage.input_tokens, response.usage.output_tokens,
|
||||
cost, elapsed_ms, 'success',
|
||||
)
|
||||
|
||||
text_blocks = [
|
||||
b.text for b in response.content if b.type == 'text'
|
||||
]
|
||||
return '\n'.join(text_blocks)
|
||||
|
||||
except UserError:
|
||||
raise
|
||||
except Exception as e:
|
||||
elapsed_ms = int((time.time() - start) * 1000)
|
||||
self._log_usage(
|
||||
consumer_rec, provider, user_rec, feature, model_name,
|
||||
0, 0, 0, elapsed_ms, 'error', str(e),
|
||||
)
|
||||
_logger.error("Anthropic API error for %s: %s", consumer, e)
|
||||
raise UserError(_("Anthropic API error: %s", str(e)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API: Raw key access (Google Maps, Twilio, etc.)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_api_key(self, provider_type, consumer,
|
||||
feature=None, user=None):
|
||||
provider = self._get_provider(provider_type)
|
||||
consumer_rec = self._auto_register_consumer(consumer)
|
||||
user_rec = user or self.env.user
|
||||
self._check_access(consumer_rec, provider, user_rec)
|
||||
|
||||
key = self._get_default_key(provider)
|
||||
|
||||
self._log_usage(
|
||||
consumer_rec, provider, user_rec,
|
||||
feature or 'key_access', '', 0, 0, 0, 0, 'success',
|
||||
)
|
||||
return key.api_key
|
||||
|
||||
def get_oauth_credentials(self, provider_type, consumer,
|
||||
feature=None, user=None):
|
||||
provider = self._get_provider(provider_type)
|
||||
consumer_rec = self._auto_register_consumer(consumer)
|
||||
user_rec = user or self.env.user
|
||||
self._check_access(consumer_rec, provider, user_rec)
|
||||
|
||||
key = self._get_default_key(provider)
|
||||
|
||||
self._log_usage(
|
||||
consumer_rec, provider, user_rec,
|
||||
feature or 'oauth_access', '', 0, 0, 0, 0, 'success',
|
||||
)
|
||||
return {
|
||||
'client_id': key.client_id,
|
||||
'client_secret': key.client_secret,
|
||||
'access_token': key.access_token,
|
||||
'refresh_token': key.refresh_token,
|
||||
'token_expiry': key.token_expiry,
|
||||
'redirect_uri': key.redirect_uri,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key validation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _validate_key(self, key_record):
|
||||
ptype = key_record.provider_id.provider_type
|
||||
|
||||
if ptype == 'openai':
|
||||
if OpenAI is None:
|
||||
raise UserError(_("openai package not installed."))
|
||||
try:
|
||||
from openai import AuthenticationError as OpenAIAuthError
|
||||
try:
|
||||
client = OpenAI(api_key=key_record.api_key)
|
||||
client.models.list()
|
||||
except OpenAIAuthError as e:
|
||||
raise UserError(_(
|
||||
"OpenAI key is invalid (authentication failed): %s", str(e),
|
||||
))
|
||||
except Exception as e:
|
||||
err_str = str(e).lower()
|
||||
if 'billing' in err_str or 'quota' in err_str or 'insufficient' in err_str:
|
||||
return
|
||||
raise UserError(_(
|
||||
"OpenAI key validation failed: %s", str(e),
|
||||
))
|
||||
except UserError:
|
||||
raise
|
||||
|
||||
elif ptype == 'anthropic':
|
||||
if anthropic_sdk is None:
|
||||
raise UserError(_("anthropic package not installed."))
|
||||
try:
|
||||
client = anthropic_sdk.Anthropic(api_key=key_record.api_key)
|
||||
client.messages.create(
|
||||
model='claude-3-5-haiku-20241022',
|
||||
max_tokens=1,
|
||||
messages=[{'role': 'user', 'content': 'hi'}],
|
||||
)
|
||||
except anthropic_sdk.AuthenticationError as e:
|
||||
raise UserError(_(
|
||||
"Anthropic key is invalid (authentication failed): %s", str(e),
|
||||
))
|
||||
except anthropic_sdk.BadRequestError as e:
|
||||
if 'credit balance' in str(e) or 'billing' in str(e).lower():
|
||||
return
|
||||
raise UserError(_(
|
||||
"Anthropic key validation failed: %s", str(e),
|
||||
))
|
||||
except anthropic_sdk.PermissionDeniedError:
|
||||
raise UserError(_(
|
||||
"Anthropic key lacks required permissions.",
|
||||
))
|
||||
except Exception as e:
|
||||
raise UserError(_(
|
||||
"Anthropic key validation failed: %s", str(e),
|
||||
))
|
||||
|
||||
elif ptype in ('google_maps', 'twilio'):
|
||||
if not key_record.api_key:
|
||||
raise UserError(_("API key is empty."))
|
||||
|
||||
elif ptype in ('google_oauth', 'microsoft_oauth'):
|
||||
if not key_record.client_id or not key_record.client_secret:
|
||||
raise UserError(_("Client ID and Client Secret are required."))
|
||||
|
||||
else:
|
||||
if not key_record.api_key:
|
||||
raise UserError(_("API key is empty."))
|
||||
53
fusion_api/models/api_usage.py
Normal file
53
fusion_api/models/api_usage.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class FusionApiUsage(models.Model):
|
||||
_name = 'fusion.api.usage'
|
||||
_description = 'API Usage Log'
|
||||
_order = 'create_date desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
consumer_id = fields.Many2one(
|
||||
'fusion.api.consumer', required=True, index=True, ondelete='cascade',
|
||||
)
|
||||
provider_id = fields.Many2one(
|
||||
'fusion.api.provider', required=True, index=True, ondelete='cascade',
|
||||
)
|
||||
user_id = fields.Many2one('res.users', index=True, ondelete='set null')
|
||||
|
||||
feature = fields.Char(index=True, help='Feature label, e.g. invoice_extraction')
|
||||
model_name = fields.Char(string='AI Model', help='e.g. gpt-4o, claude-3-5-sonnet')
|
||||
|
||||
tokens_in = fields.Integer(string='Input Tokens')
|
||||
tokens_out = fields.Integer(string='Output Tokens')
|
||||
total_tokens = fields.Integer(
|
||||
compute='_compute_total_tokens', store=True, string='Total Tokens',
|
||||
)
|
||||
estimated_cost_usd = fields.Float(digits=(10, 6), string='Cost (USD)')
|
||||
response_time_ms = fields.Integer(string='Response Time (ms)')
|
||||
|
||||
status = fields.Selection([
|
||||
('success', 'Success'),
|
||||
('error', 'Error'),
|
||||
('rate_limited', 'Rate Limited'),
|
||||
('budget_exceeded', 'Budget Exceeded'),
|
||||
], default='success', required=True, index=True)
|
||||
error_message = fields.Text()
|
||||
|
||||
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
||||
display_name = fields.Char(compute='_compute_display_name')
|
||||
|
||||
@api.depends('tokens_in', 'tokens_out')
|
||||
def _compute_total_tokens(self):
|
||||
for rec in self:
|
||||
rec.total_tokens = rec.tokens_in + rec.tokens_out
|
||||
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
consumer = rec.consumer_id.name or 'Unknown'
|
||||
provider = rec.provider_id.name or 'Unknown'
|
||||
rec.display_name = f"{consumer} - {provider} ({rec.status})"
|
||||
141
fusion_api/models/api_usage_daily.py
Normal file
141
fusion_api/models/api_usage_daily.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionApiUsageDaily(models.Model):
|
||||
_name = 'fusion.api.usage.daily'
|
||||
_description = 'API Usage Daily Summary'
|
||||
_order = 'date desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
date = fields.Date(required=True, index=True)
|
||||
consumer_id = fields.Many2one(
|
||||
'fusion.api.consumer', required=True, index=True, ondelete='cascade',
|
||||
)
|
||||
provider_id = fields.Many2one(
|
||||
'fusion.api.provider', required=True, index=True, ondelete='cascade',
|
||||
)
|
||||
user_id = fields.Many2one('res.users', index=True, ondelete='set null')
|
||||
feature = fields.Char(index=True)
|
||||
model_name = fields.Char(string='AI Model')
|
||||
|
||||
total_tokens_in = fields.Integer()
|
||||
total_tokens_out = fields.Integer()
|
||||
total_tokens = fields.Integer(
|
||||
compute='_compute_total_tokens', store=True,
|
||||
)
|
||||
total_cost_usd = fields.Float(digits=(10, 4))
|
||||
request_count = fields.Integer()
|
||||
error_count = fields.Integer()
|
||||
avg_response_time_ms = fields.Integer()
|
||||
|
||||
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
||||
display_name = fields.Char(compute='_compute_display_name')
|
||||
|
||||
@api.depends('total_tokens_in', 'total_tokens_out')
|
||||
def _compute_total_tokens(self):
|
||||
for rec in self:
|
||||
rec.total_tokens = rec.total_tokens_in + rec.total_tokens_out
|
||||
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
rec.display_name = (
|
||||
f"{rec.date} - {rec.consumer_id.name or ''} / "
|
||||
f"{rec.provider_id.name or ''}"
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_aggregate_daily(self):
|
||||
yesterday = fields.Date.today() - timedelta(days=1)
|
||||
_logger.info("Aggregating API usage for %s", yesterday)
|
||||
|
||||
usages = self.env['fusion.api.usage'].sudo().search([
|
||||
('create_date', '>=', fields.Datetime.to_string(yesterday)),
|
||||
('create_date', '<', fields.Datetime.to_string(
|
||||
yesterday + timedelta(days=1)
|
||||
)),
|
||||
])
|
||||
|
||||
if not usages:
|
||||
_logger.info("No usage records to aggregate for %s", yesterday)
|
||||
return
|
||||
|
||||
groups = {}
|
||||
for usage in usages:
|
||||
key = (
|
||||
usage.consumer_id.id,
|
||||
usage.provider_id.id,
|
||||
usage.user_id.id or 0,
|
||||
usage.feature or '',
|
||||
usage.model_name or '',
|
||||
)
|
||||
if key not in groups:
|
||||
groups[key] = {
|
||||
'date': yesterday,
|
||||
'consumer_id': usage.consumer_id.id,
|
||||
'provider_id': usage.provider_id.id,
|
||||
'user_id': usage.user_id.id or False,
|
||||
'feature': usage.feature or False,
|
||||
'model_name': usage.model_name or False,
|
||||
'total_tokens_in': 0,
|
||||
'total_tokens_out': 0,
|
||||
'total_cost_usd': 0.0,
|
||||
'request_count': 0,
|
||||
'error_count': 0,
|
||||
'total_response_time': 0,
|
||||
}
|
||||
grp = groups[key]
|
||||
grp['total_tokens_in'] += usage.tokens_in
|
||||
grp['total_tokens_out'] += usage.tokens_out
|
||||
grp['total_cost_usd'] += usage.estimated_cost_usd
|
||||
grp['request_count'] += 1
|
||||
grp['total_response_time'] += usage.response_time_ms
|
||||
if usage.status == 'error':
|
||||
grp['error_count'] += 1
|
||||
|
||||
for grp in groups.values():
|
||||
total_rt = grp.pop('total_response_time')
|
||||
count = grp['request_count']
|
||||
grp['avg_response_time_ms'] = total_rt // count if count else 0
|
||||
|
||||
existing = self.sudo().search([
|
||||
('date', '=', grp['date']),
|
||||
('consumer_id', '=', grp['consumer_id']),
|
||||
('provider_id', '=', grp['provider_id']),
|
||||
('user_id', '=', grp['user_id']),
|
||||
('feature', '=', grp['feature']),
|
||||
('model_name', '=', grp['model_name']),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write(grp)
|
||||
else:
|
||||
self.sudo().create(grp)
|
||||
|
||||
_logger.info(
|
||||
"Aggregated %d usage records into %d daily summaries",
|
||||
len(usages), len(groups),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_cleanup_old_logs(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
retention_days = int(ICP.get_param('fusion_api.log_retention_days', '90'))
|
||||
if retention_days <= 0:
|
||||
return
|
||||
|
||||
cutoff = fields.Date.today() - timedelta(days=retention_days)
|
||||
old_logs = self.env['fusion.api.usage'].sudo().search([
|
||||
('create_date', '<', fields.Datetime.to_string(cutoff)),
|
||||
])
|
||||
count = len(old_logs)
|
||||
if count:
|
||||
old_logs.unlink()
|
||||
_logger.info("Cleaned up %d usage logs older than %s", count, cutoff)
|
||||
65
fusion_api/models/api_user_limit.py
Normal file
65
fusion_api/models/api_user_limit.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class FusionApiUserLimit(models.Model):
|
||||
_name = 'fusion.api.user.limit'
|
||||
_description = 'API User Limit'
|
||||
_order = 'user_id, provider_id'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
user_id = fields.Many2one('res.users', required=True, ondelete='cascade')
|
||||
provider_id = fields.Many2one(
|
||||
'fusion.api.provider', required=True, ondelete='cascade',
|
||||
)
|
||||
|
||||
monthly_budget_usd = fields.Float(
|
||||
string='Monthly Budget (USD)',
|
||||
help='Maximum monthly spend for this user. 0 = unlimited.',
|
||||
)
|
||||
max_rpd = fields.Integer(
|
||||
string='Max Requests/Day',
|
||||
help='Maximum requests per day. 0 = unlimited.',
|
||||
)
|
||||
is_blocked = fields.Boolean(string='Blocked', tracking=True)
|
||||
|
||||
current_month_cost = fields.Float(
|
||||
compute='_compute_current_usage', string='Month Spend',
|
||||
)
|
||||
current_day_requests = fields.Integer(
|
||||
compute='_compute_current_usage', string='Today Requests',
|
||||
)
|
||||
|
||||
display_name = fields.Char(compute='_compute_display_name', store=True)
|
||||
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
||||
|
||||
_sql_constraints = [
|
||||
('user_provider_uniq', 'unique(user_id, provider_id, company_id)',
|
||||
'Only one limit per user-provider pair per company.'),
|
||||
]
|
||||
|
||||
@api.depends('user_id.name', 'provider_id.name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
user = rec.user_id.name or ''
|
||||
provider = rec.provider_id.name or ''
|
||||
rec.display_name = f"{user} - {provider}"
|
||||
|
||||
def _compute_current_usage(self):
|
||||
today = fields.Date.today()
|
||||
month_start = today.replace(day=1)
|
||||
for rec in self:
|
||||
month_usages = self.env['fusion.api.usage'].sudo().search([
|
||||
('user_id', '=', rec.user_id.id),
|
||||
('provider_id', '=', rec.provider_id.id),
|
||||
('create_date', '>=', fields.Datetime.to_string(month_start)),
|
||||
('status', '=', 'success'),
|
||||
])
|
||||
day_usages = month_usages.filtered(
|
||||
lambda u: u.create_date.date() == today
|
||||
)
|
||||
rec.current_month_cost = sum(u.estimated_cost_usd for u in month_usages)
|
||||
rec.current_day_requests = len(day_usages)
|
||||
36
fusion_api/models/res_config_settings.py
Normal file
36
fusion_api/models/res_config_settings.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
fusion_api_global_budget = fields.Float(
|
||||
string='Global Monthly Budget (USD)',
|
||||
config_parameter='fusion_api.global_monthly_budget_usd',
|
||||
default=0.0,
|
||||
help="Global monthly budget across all providers. 0 = unlimited.",
|
||||
)
|
||||
fusion_api_default_environment = fields.Selection([
|
||||
('production', 'Production'),
|
||||
('sandbox', 'Sandbox'),
|
||||
], string='Default Environment',
|
||||
config_parameter='fusion_api.default_environment',
|
||||
default='production',
|
||||
)
|
||||
fusion_api_log_retention_days = fields.Integer(
|
||||
string='Usage Log Retention (days)',
|
||||
config_parameter='fusion_api.log_retention_days',
|
||||
default=90,
|
||||
help="Keep detailed usage logs for this many days. "
|
||||
"Daily summaries are kept indefinitely. 0 = keep forever.",
|
||||
)
|
||||
fusion_api_auto_detect = fields.Boolean(
|
||||
string='Auto-Detect Consumers',
|
||||
config_parameter='fusion_api.auto_detect_consumers',
|
||||
default=True,
|
||||
help="Automatically register new Fusion modules when they first call the API.",
|
||||
)
|
||||
21
fusion_api/security/ir.model.access.csv
Normal file
21
fusion_api/security/ir.model.access.csv
Normal file
@@ -0,0 +1,21 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_provider_user,fusion.api.provider.user,model_fusion_api_provider,fusion_api.group_user,1,0,0,0
|
||||
access_provider_manager,fusion.api.provider.manager,model_fusion_api_provider,fusion_api.group_manager,1,1,1,0
|
||||
access_provider_admin,fusion.api.provider.admin,model_fusion_api_provider,fusion_api.group_admin,1,1,1,1
|
||||
access_key_manager,fusion.api.key.manager,model_fusion_api_key,fusion_api.group_manager,1,0,0,0
|
||||
access_key_admin,fusion.api.key.admin,model_fusion_api_key,fusion_api.group_admin,1,1,1,1
|
||||
access_consumer_user,fusion.api.consumer.user,model_fusion_api_consumer,fusion_api.group_user,1,0,0,0
|
||||
access_consumer_manager,fusion.api.consumer.manager,model_fusion_api_consumer,fusion_api.group_manager,1,1,1,1
|
||||
access_consumer_admin,fusion.api.consumer.admin,model_fusion_api_consumer,fusion_api.group_admin,1,1,1,1
|
||||
access_access_user,fusion.api.access.user,model_fusion_api_access,fusion_api.group_user,1,0,0,0
|
||||
access_access_manager,fusion.api.access.manager,model_fusion_api_access,fusion_api.group_manager,1,1,1,1
|
||||
access_access_admin,fusion.api.access.admin,model_fusion_api_access,fusion_api.group_admin,1,1,1,1
|
||||
access_user_limit_user,fusion.api.user.limit.user,model_fusion_api_user_limit,fusion_api.group_user,1,0,0,0
|
||||
access_user_limit_manager,fusion.api.user.limit.manager,model_fusion_api_user_limit,fusion_api.group_manager,1,1,1,1
|
||||
access_user_limit_admin,fusion.api.user.limit.admin,model_fusion_api_user_limit,fusion_api.group_admin,1,1,1,1
|
||||
access_usage_user,fusion.api.usage.user,model_fusion_api_usage,fusion_api.group_user,1,0,0,0
|
||||
access_usage_manager,fusion.api.usage.manager,model_fusion_api_usage,fusion_api.group_manager,1,0,0,0
|
||||
access_usage_admin,fusion.api.usage.admin,model_fusion_api_usage,fusion_api.group_admin,1,1,1,1
|
||||
access_usage_daily_user,fusion.api.usage.daily.user,model_fusion_api_usage_daily,fusion_api.group_user,1,0,0,0
|
||||
access_usage_daily_manager,fusion.api.usage.daily.manager,model_fusion_api_usage_daily,fusion_api.group_manager,1,0,0,0
|
||||
access_usage_daily_admin,fusion.api.usage.daily.admin,model_fusion_api_usage_daily,fusion_api.group_admin,1,1,1,1
|
||||
|
35
fusion_api/security/security.xml
Normal file
35
fusion_api/security/security.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="module_category_fusion_api" model="ir.module.category">
|
||||
<field name="name">Fusion API</field>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
|
||||
<record id="res_groups_privilege_fusion_api" model="res.groups.privilege">
|
||||
<field name="name">Fusion API</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="category_id" ref="module_category_fusion_api"/>
|
||||
</record>
|
||||
|
||||
<record id="group_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_api"/>
|
||||
</record>
|
||||
|
||||
<record id="group_manager" model="res.groups">
|
||||
<field name="name">Manager</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="implied_ids" eval="[(4, ref('fusion_api.group_user'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_api"/>
|
||||
</record>
|
||||
|
||||
<record id="group_admin" model="res.groups">
|
||||
<field name="name">Administrator</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="implied_ids" eval="[(4, ref('fusion_api.group_manager'))]"/>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_api"/>
|
||||
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
BIN
fusion_api/static/description/icon.png
Normal file
BIN
fusion_api/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
102
fusion_api/static/src/js/dashboard.js
Normal file
102
fusion_api/static/src/js/dashboard.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class FusionApiDashboard extends Component {
|
||||
static template = "fusion_api.Dashboard";
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
|
||||
this.state = useState({
|
||||
totalProviders: 0,
|
||||
activeProviders: 0,
|
||||
totalConsumers: 0,
|
||||
activeConsumers: 0,
|
||||
monthCost: 0,
|
||||
monthRequests: 0,
|
||||
todayRequests: 0,
|
||||
topConsumers: [],
|
||||
providerStats: [],
|
||||
recentUsage: [],
|
||||
approachingLimits: [],
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
onWillStart(() => this.loadData());
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
const data = await this.orm.call(
|
||||
"fusion.api.provider",
|
||||
"get_dashboard_data",
|
||||
[],
|
||||
);
|
||||
Object.assign(this.state, {
|
||||
totalProviders: data.total_providers,
|
||||
activeProviders: data.active_providers,
|
||||
totalConsumers: data.total_consumers,
|
||||
activeConsumers: data.active_consumers,
|
||||
monthCost: data.month_cost,
|
||||
monthRequests: data.month_requests,
|
||||
todayRequests: data.today_requests,
|
||||
topConsumers: data.top_consumers || [],
|
||||
providerStats: data.provider_stats || [],
|
||||
recentUsage: data.recent_usage || [],
|
||||
approachingLimits: data.approaching_limits || [],
|
||||
loaded: true,
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.state.loaded = false;
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
openProviders() {
|
||||
this.actionService.doAction("fusion_api.action_api_provider");
|
||||
}
|
||||
|
||||
openConsumers() {
|
||||
this.actionService.doAction("fusion_api.action_api_consumer");
|
||||
}
|
||||
|
||||
openUsageLog() {
|
||||
this.actionService.doAction("fusion_api.action_api_usage");
|
||||
}
|
||||
|
||||
openAccessRules() {
|
||||
this.actionService.doAction("fusion_api.action_api_access");
|
||||
}
|
||||
|
||||
formatCost(value) {
|
||||
return (value || 0).toFixed(2);
|
||||
}
|
||||
|
||||
formatCostDetailed(value) {
|
||||
return (value || 0).toFixed(6);
|
||||
}
|
||||
|
||||
getStatusClass(status) {
|
||||
const classes = {
|
||||
success: "text-bg-success",
|
||||
error: "text-bg-danger",
|
||||
rate_limited: "text-bg-warning",
|
||||
budget_exceeded: "text-bg-warning",
|
||||
};
|
||||
return classes[status] || "text-bg-secondary";
|
||||
}
|
||||
|
||||
formatTime(isoString) {
|
||||
if (!isoString) return "";
|
||||
const d = new Date(isoString);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("actions")
|
||||
.add("fusion_api_dashboard", FusionApiDashboard);
|
||||
76
fusion_api/static/src/scss/fusion_api.scss
Normal file
76
fusion_api/static/src/scss/fusion_api.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
.o_fusion_api_dashboard {
|
||||
background-color: var(--o-view-background-color);
|
||||
min-height: 100%;
|
||||
|
||||
.o_fusion_api_header {
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fusion_stat_card {
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-body h2 {
|
||||
font-weight: 700;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fusion_icon_circle {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 0.5rem;
|
||||
|
||||
.card-header {
|
||||
padding: 1rem 1.25rem 0.5rem;
|
||||
|
||||
h5 {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
opacity: 0.65;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: middle;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
border-radius: 0.5rem;
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.35em 0.6em;
|
||||
}
|
||||
}
|
||||
228
fusion_api/static/src/xml/dashboard.xml
Normal file
228
fusion_api/static/src/xml/dashboard.xml
Normal file
@@ -0,0 +1,228 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_api.Dashboard">
|
||||
<div class="o_fusion_api_dashboard o_action">
|
||||
<!-- Header -->
|
||||
<div class="o_fusion_api_header d-flex align-items-center justify-content-between p-3 border-bottom bg-view">
|
||||
<h2 class="mb-0">Fusion API Dashboard</h2>
|
||||
<button class="btn btn-outline-primary" t-on-click="refresh">
|
||||
<i class="fa fa-refresh me-1"/> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="o_fusion_api_content p-3" t-if="state.loaded">
|
||||
<!-- Stats Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card h-100 border-0 shadow-sm o_fusion_stat_card"
|
||||
style="cursor:pointer" t-on-click="openProviders">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="text-muted small text-uppercase">Active Providers</div>
|
||||
<h2 class="mb-0 mt-1" t-esc="state.activeProviders"/>
|
||||
</div>
|
||||
<div class="o_fusion_icon_circle bg-primary bg-opacity-25">
|
||||
<i class="fa fa-plug text-primary"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted small mt-2">
|
||||
<t t-esc="state.totalProviders"/> total configured
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card h-100 border-0 shadow-sm o_fusion_stat_card"
|
||||
style="cursor:pointer" t-on-click="openConsumers">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="text-muted small text-uppercase">Active Modules</div>
|
||||
<h2 class="mb-0 mt-1" t-esc="state.activeConsumers"/>
|
||||
</div>
|
||||
<div class="o_fusion_icon_circle bg-success bg-opacity-25">
|
||||
<i class="fa fa-cubes text-success"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted small mt-2">
|
||||
<t t-esc="state.totalConsumers"/> total registered
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card h-100 border-0 shadow-sm o_fusion_stat_card"
|
||||
style="cursor:pointer" t-on-click="openUsageLog">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="text-muted small text-uppercase">Month Cost</div>
|
||||
<h2 class="mb-0 mt-1">$<t t-esc="formatCost(state.monthCost)"/></h2>
|
||||
</div>
|
||||
<div class="o_fusion_icon_circle bg-warning bg-opacity-25">
|
||||
<i class="fa fa-usd text-warning"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted small mt-2">
|
||||
<t t-esc="state.monthRequests"/> requests this month
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card h-100 border-0 shadow-sm o_fusion_stat_card"
|
||||
style="cursor:pointer" t-on-click="openUsageLog">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="text-muted small text-uppercase">Today</div>
|
||||
<h2 class="mb-0 mt-1" t-esc="state.todayRequests"/>
|
||||
</div>
|
||||
<div class="o_fusion_icon_circle bg-info bg-opacity-25">
|
||||
<i class="fa fa-bar-chart text-info"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted small mt-2">
|
||||
requests today
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Budget Alerts -->
|
||||
<div class="mb-4" t-if="state.approachingLimits.length > 0">
|
||||
<div class="alert alert-warning d-flex align-items-start" t-foreach="state.approachingLimits" t-as="alert" t-key="alert_index">
|
||||
<i class="fa fa-exclamation-triangle me-2 mt-1"/>
|
||||
<div>
|
||||
<strong t-esc="alert.consumer"/> on <t t-esc="alert.provider"/>:
|
||||
<t t-esc="alert.pct"/>% of budget used
|
||||
($<t t-esc="alert.spent"/> / $<t t-esc="alert.budget"/>)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Provider Stats -->
|
||||
<div class="col-lg-6" t-if="state.providerStats.length > 0">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-transparent border-bottom-0">
|
||||
<h5 class="mb-0">Provider Activity</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Provider</th>
|
||||
<th class="text-end">Keys</th>
|
||||
<th class="text-end">Requests</th>
|
||||
<th class="text-end">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="state.providerStats" t-as="prov" t-key="prov.id">
|
||||
<td><strong t-esc="prov.name"/></td>
|
||||
<td class="text-end" t-esc="prov.keys"/>
|
||||
<td class="text-end" t-esc="prov.requests"/>
|
||||
<td class="text-end">$<t t-esc="formatCost(prov.cost)"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Consumers -->
|
||||
<div class="col-lg-6" t-if="state.topConsumers.length > 0">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-transparent border-bottom-0">
|
||||
<h5 class="mb-0">Top Consumers (This Month)</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
<th class="text-end">Requests</th>
|
||||
<th class="text-end">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="state.topConsumers" t-as="cons" t-key="cons_index">
|
||||
<td><strong t-esc="cons.name"/></td>
|
||||
<td class="text-end" t-esc="cons.requests"/>
|
||||
<td class="text-end">$<t t-esc="formatCost(cons.cost)"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="mt-3" t-if="state.recentUsage.length > 0">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-transparent border-bottom-0 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Recent Activity</h5>
|
||||
<button class="btn btn-sm btn-outline-secondary" t-on-click="openUsageLog">
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Consumer</th>
|
||||
<th>Provider</th>
|
||||
<th>Feature</th>
|
||||
<th class="text-end">Tokens</th>
|
||||
<th class="text-end">Cost</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="state.recentUsage" t-as="usage" t-key="usage_index">
|
||||
<td class="text-muted small" t-esc="formatTime(usage.time)"/>
|
||||
<td t-esc="usage.consumer"/>
|
||||
<td t-esc="usage.provider"/>
|
||||
<td class="text-muted" t-esc="usage.feature"/>
|
||||
<td class="text-end" t-esc="usage.tokens"/>
|
||||
<td class="text-end">$<t t-esc="formatCostDetailed(usage.cost)"/></td>
|
||||
<td>
|
||||
<span class="badge" t-att-class="getStatusClass(usage.status)"
|
||||
t-esc="usage.status"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-5" t-if="state.monthRequests === 0 and state.activeProviders === 0">
|
||||
<i class="fa fa-rocket fa-3x text-muted mb-3 d-block"/>
|
||||
<h4>Welcome to Fusion API</h4>
|
||||
<p class="text-muted mb-3">
|
||||
Get started by configuring your API providers and adding your keys.
|
||||
</p>
|
||||
<button class="btn btn-primary" t-on-click="openProviders">
|
||||
<i class="fa fa-plus me-1"/> Configure Providers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="text-center py-5" t-if="!state.loaded">
|
||||
<i class="fa fa-spinner fa-spin fa-2x text-muted"/>
|
||||
<p class="text-muted mt-2">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
74
fusion_api/views/api_access_views.xml
Normal file
74
fusion_api/views/api_access_views.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="view_api_access_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.api.access.tree</field>
|
||||
<field name="model">fusion.api.access</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="consumer_id"/>
|
||||
<field name="provider_id"/>
|
||||
<field name="is_enabled" widget="boolean_toggle"/>
|
||||
<field name="monthly_budget_usd"/>
|
||||
<field name="daily_budget_usd" optional="hide"/>
|
||||
<field name="max_rpm"/>
|
||||
<field name="max_rpd" optional="hide"/>
|
||||
<field name="current_month_cost" string="Month Spend ($)"/>
|
||||
<field name="current_day_requests" string="Today Reqs"/>
|
||||
<field name="budget_usage_pct" widget="progressbar" string="Budget Used"/>
|
||||
<field name="is_budget_exceeded" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_api_access_form" model="ir.ui.view">
|
||||
<field name="name">fusion.api.access.form</field>
|
||||
<field name="model">fusion.api.access</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Link">
|
||||
<field name="consumer_id"/>
|
||||
<field name="provider_id"/>
|
||||
<field name="is_enabled"/>
|
||||
</group>
|
||||
<group string="Current Usage">
|
||||
<field name="current_month_cost"/>
|
||||
<field name="current_day_cost"/>
|
||||
<field name="current_day_requests"/>
|
||||
<field name="budget_usage_pct" widget="progressbar"/>
|
||||
<field name="is_budget_exceeded"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Budget Limits">
|
||||
<field name="monthly_budget_usd"/>
|
||||
<field name="daily_budget_usd"/>
|
||||
</group>
|
||||
<group string="Rate Limits">
|
||||
<field name="max_rpm"/>
|
||||
<field name="max_rpd"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_api_access" model="ir.actions.act_window">
|
||||
<field name="name">Access Rules</field>
|
||||
<field name="res_model">fusion.api.access</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No access rules configured
|
||||
</p>
|
||||
<p>Access rules control which Fusion modules can use which API providers, with budget and rate limits.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
91
fusion_api/views/api_consumer_views.xml
Normal file
91
fusion_api/views/api_consumer_views.xml
Normal file
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="view_api_consumer_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.api.consumer.tree</field>
|
||||
<field name="model">fusion.api.consumer</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="technical_name"/>
|
||||
<field name="module_state" widget="badge"
|
||||
decoration-success="module_state == 'installed'"
|
||||
decoration-muted="module_state != 'installed'"
|
||||
optional="show"/>
|
||||
<field name="is_active" widget="boolean_toggle"/>
|
||||
<field name="auto_detected"/>
|
||||
<field name="first_seen_at" optional="hide"/>
|
||||
<field name="total_month_cost" string="Month Cost ($)"/>
|
||||
<field name="total_month_requests" string="Month Requests"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_api_consumer_form" model="ir.ui.view">
|
||||
<field name="name">fusion.api.consumer.form</field>
|
||||
<field name="model">fusion.api.consumer</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_toggle_access" type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-power-off">
|
||||
<field name="is_active" widget="boolean_button"
|
||||
options='{"terminology": {"string_true": "Active", "string_false": "Disabled"}}'/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="technical_name"/>
|
||||
<field name="module_id"/>
|
||||
<field name="module_state"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="auto_detected"/>
|
||||
<field name="first_seen_at"/>
|
||||
<field name="total_month_cost"/>
|
||||
<field name="total_month_requests"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Access Rules" name="access">
|
||||
<field name="access_ids">
|
||||
<list editable="bottom">
|
||||
<field name="provider_id"/>
|
||||
<field name="is_enabled"/>
|
||||
<field name="monthly_budget_usd"/>
|
||||
<field name="daily_budget_usd"/>
|
||||
<field name="max_rpm"/>
|
||||
<field name="max_rpd"/>
|
||||
<field name="current_month_cost" string="Month Spend"/>
|
||||
<field name="current_day_requests" string="Today"/>
|
||||
<field name="budget_usage_pct" widget="progressbar" string="Budget %"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_api_consumer" model="ir.actions.act_window">
|
||||
<field name="name">Consumers</field>
|
||||
<field name="res_model">fusion.api.consumer</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No consumers detected yet
|
||||
</p>
|
||||
<p>Fusion modules will appear here automatically when they make their first API call.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
9
fusion_api/views/api_dashboard_views.xml
Normal file
9
fusion_api/views/api_dashboard_views.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_dashboard" model="ir.actions.client">
|
||||
<field name="name">Fusion API Dashboard</field>
|
||||
<field name="tag">fusion_api_dashboard</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
54
fusion_api/views/api_key_views.xml
Normal file
54
fusion_api/views/api_key_views.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Form View (used as dialog from provider form) -->
|
||||
<record id="view_api_key_form" model="ir.ui.view">
|
||||
<field name="name">fusion.api.key.form</field>
|
||||
<field name="model">fusion.api.key</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_validate" type="object"
|
||||
string="Validate Key" class="btn-primary"
|
||||
icon="fa-check-circle"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name" placeholder="e.g. Production Key"/>
|
||||
<field name="api_key" password="True" placeholder="Enter your API key"/>
|
||||
<field name="environment"/>
|
||||
<field name="is_default"/>
|
||||
<field name="is_active"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="provider_id" invisible="context.get('default_provider_id')"/>
|
||||
<field name="provider_type" invisible="1"/>
|
||||
<field name="validation_status" widget="badge"
|
||||
decoration-success="validation_status == 'valid'"
|
||||
decoration-danger="validation_status == 'invalid'"
|
||||
decoration-muted="validation_status == 'unknown'"/>
|
||||
<field name="last_validated_at"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="OAuth Credentials"
|
||||
invisible="provider_type not in ('google_oauth', 'microsoft_oauth')">
|
||||
<group>
|
||||
<field name="client_id" password="True"/>
|
||||
<field name="client_secret" password="True"/>
|
||||
<field name="redirect_uri"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="access_token" password="True"/>
|
||||
<field name="refresh_token" password="True"/>
|
||||
<field name="token_expiry"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="notes" placeholder="Optional notes about this key..."/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
207
fusion_api/views/api_provider_views.xml
Normal file
207
fusion_api/views/api_provider_views.xml
Normal file
@@ -0,0 +1,207 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Kanban View -->
|
||||
<record id="view_api_provider_kanban" model="ir.ui.view">
|
||||
<field name="name">fusion.api.provider.kanban</field>
|
||||
<field name="model">fusion.api.provider</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_dashboard" create="false">
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="status"/>
|
||||
<field name="active_key_count"/>
|
||||
<field name="total_month_cost"/>
|
||||
<field name="total_month_requests"/>
|
||||
<field name="color"/>
|
||||
<field name="icon_class"/>
|
||||
<field name="website_url"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="d-flex align-items-start mb-2">
|
||||
<div class="me-3">
|
||||
<i t-att-class="'fa fa-2x text-primary ' + (record.icon_class.value or 'fa-plug')"/>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<strong><field name="name"/></strong>
|
||||
<div class="text-muted small">
|
||||
<field name="provider_type"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<field name="status" widget="badge"
|
||||
decoration-success="status == 'active'"
|
||||
decoration-muted="status == 'inactive'"
|
||||
decoration-danger="status == 'error'"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mt-2">
|
||||
<div class="col-4 text-center">
|
||||
<div class="fw-bold"><field name="active_key_count"/></div>
|
||||
<div class="text-muted small">Keys</div>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<div class="fw-bold">$<field name="total_month_cost" widget="float" digits="[10,2]"/></div>
|
||||
<div class="text-muted small">Month</div>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<div class="fw-bold"><field name="total_month_requests"/></div>
|
||||
<div class="text-muted small">Requests</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="view_api_provider_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.api.provider.tree</field>
|
||||
<field name="model">fusion.api.provider</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="status" widget="badge"
|
||||
decoration-success="status == 'active'"
|
||||
decoration-muted="status == 'inactive'"
|
||||
decoration-danger="status == 'error'"/>
|
||||
<field name="active_key_count"/>
|
||||
<field name="total_month_cost" string="Month Cost ($)"/>
|
||||
<field name="total_month_requests" string="Month Requests"/>
|
||||
<field name="website_url" widget="url" string="Dashboard"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_api_provider_form" model="ir.ui.view">
|
||||
<field name="name">fusion.api.provider.form</field>
|
||||
<field name="model">fusion.api.provider</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_activate" type="object" string="Activate"
|
||||
class="btn-primary"
|
||||
invisible="status == 'active'"/>
|
||||
<button name="action_deactivate" type="object" string="Deactivate"
|
||||
class="btn-secondary"
|
||||
invisible="status != 'active'"/>
|
||||
<field name="status" widget="statusbar"
|
||||
statusbar_visible="inactive,active"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Provider Name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="provider_type"/>
|
||||
<field name="website_url" widget="url"/>
|
||||
<field name="icon_class"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active_key_count"/>
|
||||
<field name="total_month_cost"/>
|
||||
<field name="total_month_requests"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="description" placeholder="Description of this API provider..."/>
|
||||
<notebook>
|
||||
<page string="API Keys" name="keys">
|
||||
<field name="key_ids" context="{'default_provider_id': id}">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="masked_key" string="API Key"/>
|
||||
<field name="environment"/>
|
||||
<field name="is_default"/>
|
||||
<field name="is_active"/>
|
||||
<field name="validation_status" widget="badge"
|
||||
decoration-success="validation_status == 'valid'"
|
||||
decoration-danger="validation_status == 'invalid'"
|
||||
decoration-muted="validation_status == 'unknown'"/>
|
||||
<field name="last_validated_at"/>
|
||||
</list>
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_validate" type="object"
|
||||
string="Validate Key" class="btn-primary"
|
||||
icon="fa-check-circle"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name" placeholder="e.g. Production Key"/>
|
||||
<field name="api_key" password="True"
|
||||
placeholder="Enter your API key"/>
|
||||
<field name="environment"/>
|
||||
<field name="is_default"/>
|
||||
<field name="is_active"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="provider_id" invisible="1"/>
|
||||
<field name="provider_type" invisible="1"/>
|
||||
<field name="validation_status" widget="badge"
|
||||
decoration-success="validation_status == 'valid'"
|
||||
decoration-danger="validation_status == 'invalid'"
|
||||
decoration-muted="validation_status == 'unknown'"/>
|
||||
<field name="last_validated_at"/>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="OAuth Credentials"
|
||||
invisible="provider_type not in ('google_oauth', 'microsoft_oauth')">
|
||||
<group>
|
||||
<field name="client_id" password="True"/>
|
||||
<field name="client_secret" password="True"/>
|
||||
<field name="redirect_uri"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="access_token" password="True"/>
|
||||
<field name="refresh_token" password="True"/>
|
||||
<field name="token_expiry"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="notes"
|
||||
placeholder="Optional notes about this key..."/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Access Rules" name="access">
|
||||
<field name="access_ids">
|
||||
<list>
|
||||
<field name="consumer_id"/>
|
||||
<field name="is_enabled"/>
|
||||
<field name="monthly_budget_usd"/>
|
||||
<field name="max_rpm"/>
|
||||
<field name="max_rpd"/>
|
||||
<field name="current_month_cost"/>
|
||||
<field name="budget_usage_pct" widget="progressbar"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_api_provider" model="ir.actions.act_window">
|
||||
<field name="name">API Providers</field>
|
||||
<field name="res_model">fusion.api.provider</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Configure your first API provider
|
||||
</p>
|
||||
<p>Add API keys for OpenAI, Anthropic, Google Maps, and other services used by Fusion modules.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
223
fusion_api/views/api_usage_views.xml
Normal file
223
fusion_api/views/api_usage_views.xml
Normal file
@@ -0,0 +1,223 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Usage Log: Tree View -->
|
||||
<record id="view_api_usage_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.api.usage.tree</field>
|
||||
<field name="model">fusion.api.usage</field>
|
||||
<field name="arch" type="xml">
|
||||
<list create="false" edit="false" default_order="create_date desc">
|
||||
<field name="create_date" string="Timestamp"/>
|
||||
<field name="consumer_id"/>
|
||||
<field name="provider_id"/>
|
||||
<field name="user_id"/>
|
||||
<field name="feature"/>
|
||||
<field name="model_name"/>
|
||||
<field name="tokens_in" optional="show"/>
|
||||
<field name="tokens_out" optional="show"/>
|
||||
<field name="total_tokens"/>
|
||||
<field name="estimated_cost_usd" string="Cost ($)" widget="float" digits="[10,6]"/>
|
||||
<field name="response_time_ms" string="Time (ms)" optional="hide"/>
|
||||
<field name="status" widget="badge"
|
||||
decoration-success="status == 'success'"
|
||||
decoration-danger="status == 'error'"
|
||||
decoration-warning="status in ('rate_limited', 'budget_exceeded')"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Usage Log: Form View -->
|
||||
<record id="view_api_usage_form" model="ir.ui.view">
|
||||
<field name="name">fusion.api.usage.form</field>
|
||||
<field name="model">fusion.api.usage</field>
|
||||
<field name="arch" type="xml">
|
||||
<form create="false" edit="false">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="create_date"/>
|
||||
<field name="consumer_id"/>
|
||||
<field name="provider_id"/>
|
||||
<field name="user_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="feature"/>
|
||||
<field name="model_name"/>
|
||||
<field name="status"/>
|
||||
<field name="response_time_ms"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Token Usage">
|
||||
<field name="tokens_in"/>
|
||||
<field name="tokens_out"/>
|
||||
<field name="total_tokens"/>
|
||||
</group>
|
||||
<group string="Cost">
|
||||
<field name="estimated_cost_usd"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Error Details" invisible="not error_message">
|
||||
<field name="error_message" nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Usage Log: Pivot View -->
|
||||
<record id="view_api_usage_pivot" model="ir.ui.view">
|
||||
<field name="name">fusion.api.usage.pivot</field>
|
||||
<field name="model">fusion.api.usage</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="API Usage Analysis">
|
||||
<field name="consumer_id" type="row"/>
|
||||
<field name="provider_id" type="col"/>
|
||||
<field name="estimated_cost_usd" type="measure"/>
|
||||
<field name="total_tokens" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Usage Log: Graph View -->
|
||||
<record id="view_api_usage_graph" model="ir.ui.view">
|
||||
<field name="name">fusion.api.usage.graph</field>
|
||||
<field name="model">fusion.api.usage</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="API Usage" type="bar">
|
||||
<field name="create_date" interval="day" type="row"/>
|
||||
<field name="estimated_cost_usd" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Usage Log: Search View -->
|
||||
<record id="view_api_usage_search" model="ir.ui.view">
|
||||
<field name="name">fusion.api.usage.search</field>
|
||||
<field name="model">fusion.api.usage</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Usage">
|
||||
<field name="consumer_id"/>
|
||||
<field name="provider_id"/>
|
||||
<field name="user_id"/>
|
||||
<field name="feature"/>
|
||||
<field name="model_name"/>
|
||||
<separator/>
|
||||
<filter name="filter_success" string="Success"
|
||||
domain="[('status', '=', 'success')]"/>
|
||||
<filter name="filter_errors" string="Errors"
|
||||
domain="[('status', '=', 'error')]"/>
|
||||
<filter name="filter_rate_limited" string="Rate Limited"
|
||||
domain="[('status', '=', 'rate_limited')]"/>
|
||||
<filter name="filter_budget_exceeded" string="Budget Exceeded"
|
||||
domain="[('status', '=', 'budget_exceeded')]"/>
|
||||
<separator/>
|
||||
<filter name="group_consumer" string="Consumer" context="{'group_by': 'consumer_id'}"/>
|
||||
<filter name="group_provider" string="Provider" context="{'group_by': 'provider_id'}"/>
|
||||
<filter name="group_user" string="User" context="{'group_by': 'user_id'}"/>
|
||||
<filter name="group_feature" string="Feature" context="{'group_by': 'feature'}"/>
|
||||
<filter name="group_model" string="AI Model" context="{'group_by': 'model_name'}"/>
|
||||
<filter name="group_status" string="Status" context="{'group_by': 'status'}"/>
|
||||
<filter name="group_day" string="Day" context="{'group_by': 'create_date:day'}"/>
|
||||
<filter name="group_month" string="Month" context="{'group_by': 'create_date:month'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Usage Log: Action -->
|
||||
<record id="action_api_usage" model="ir.actions.act_window">
|
||||
<field name="name">Usage Log</field>
|
||||
<field name="res_model">fusion.api.usage</field>
|
||||
<field name="view_mode">list,pivot,graph,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No API calls recorded yet
|
||||
</p>
|
||||
<p>Usage data appears here as Fusion modules make API calls through the Fusion API service.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily Summary: Tree View -->
|
||||
<record id="view_api_usage_daily_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.api.usage.daily.tree</field>
|
||||
<field name="model">fusion.api.usage.daily</field>
|
||||
<field name="arch" type="xml">
|
||||
<list create="false" edit="false" default_order="date desc">
|
||||
<field name="date"/>
|
||||
<field name="consumer_id"/>
|
||||
<field name="provider_id"/>
|
||||
<field name="user_id" optional="hide"/>
|
||||
<field name="feature" optional="hide"/>
|
||||
<field name="model_name" optional="show"/>
|
||||
<field name="request_count"/>
|
||||
<field name="error_count" optional="show"/>
|
||||
<field name="total_tokens"/>
|
||||
<field name="total_cost_usd" string="Cost ($)" widget="float" digits="[10,4]"/>
|
||||
<field name="avg_response_time_ms" string="Avg Time (ms)" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily Summary: Pivot View -->
|
||||
<record id="view_api_usage_daily_pivot" model="ir.ui.view">
|
||||
<field name="name">fusion.api.usage.daily.pivot</field>
|
||||
<field name="model">fusion.api.usage.daily</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Daily Usage Summary">
|
||||
<field name="date" interval="month" type="row"/>
|
||||
<field name="consumer_id" type="row"/>
|
||||
<field name="provider_id" type="col"/>
|
||||
<field name="total_cost_usd" type="measure"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="total_tokens" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily Summary: Graph View -->
|
||||
<record id="view_api_usage_daily_graph" model="ir.ui.view">
|
||||
<field name="name">fusion.api.usage.daily.graph</field>
|
||||
<field name="model">fusion.api.usage.daily</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Daily Usage Trends" type="line">
|
||||
<field name="date" type="row"/>
|
||||
<field name="total_cost_usd" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily Summary: Search View -->
|
||||
<record id="view_api_usage_daily_search" model="ir.ui.view">
|
||||
<field name="name">fusion.api.usage.daily.search</field>
|
||||
<field name="model">fusion.api.usage.daily</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Daily Summary">
|
||||
<field name="consumer_id"/>
|
||||
<field name="provider_id"/>
|
||||
<field name="user_id"/>
|
||||
<field name="feature"/>
|
||||
<field name="model_name"/>
|
||||
<separator/>
|
||||
<filter name="group_consumer" string="Consumer" context="{'group_by': 'consumer_id'}"/>
|
||||
<filter name="group_provider" string="Provider" context="{'group_by': 'provider_id'}"/>
|
||||
<filter name="group_user" string="User" context="{'group_by': 'user_id'}"/>
|
||||
<filter name="group_model" string="AI Model" context="{'group_by': 'model_name'}"/>
|
||||
<filter name="group_month" string="Month" context="{'group_by': 'date:month'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Daily Summary: Action -->
|
||||
<record id="action_api_usage_daily" model="ir.actions.act_window">
|
||||
<field name="name">Daily Summary</field>
|
||||
<field name="res_model">fusion.api.usage.daily</field>
|
||||
<field name="view_mode">list,pivot,graph,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No daily summaries yet
|
||||
</p>
|
||||
<p>Daily usage summaries are aggregated automatically each night by a scheduled action.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
63
fusion_api/views/api_user_limit_views.xml
Normal file
63
fusion_api/views/api_user_limit_views.xml
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="view_api_user_limit_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.api.user.limit.tree</field>
|
||||
<field name="model">fusion.api.user.limit</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="user_id"/>
|
||||
<field name="provider_id"/>
|
||||
<field name="monthly_budget_usd"/>
|
||||
<field name="max_rpd"/>
|
||||
<field name="is_blocked" widget="boolean_toggle"/>
|
||||
<field name="current_month_cost" string="Month Spend ($)"/>
|
||||
<field name="current_day_requests" string="Today Reqs"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_api_user_limit_form" model="ir.ui.view">
|
||||
<field name="name">fusion.api.user.limit.form</field>
|
||||
<field name="model">fusion.api.user.limit</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="user_id"/>
|
||||
<field name="provider_id"/>
|
||||
<field name="is_blocked"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="monthly_budget_usd"/>
|
||||
<field name="max_rpd"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Current Usage">
|
||||
<field name="current_month_cost"/>
|
||||
<field name="current_day_requests"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_api_user_limit" model="ir.actions.act_window">
|
||||
<field name="name">User Limits</field>
|
||||
<field name="res_model">fusion.api.user.limit</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No user limits configured
|
||||
</p>
|
||||
<p>Set per-user budget caps and request limits, or block individual users from API access.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
71
fusion_api/views/menus.xml
Normal file
71
fusion_api/views/menus.xml
Normal file
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Root Menu -->
|
||||
<menuitem id="menu_fusion_api_root"
|
||||
name="Fusion API"
|
||||
web_icon="fusion_api,static/description/icon.png"
|
||||
sequence="90"/>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<menuitem id="menu_dashboard"
|
||||
name="Dashboard"
|
||||
parent="menu_fusion_api_root"
|
||||
action="action_dashboard"
|
||||
sequence="1"/>
|
||||
|
||||
<!-- Providers -->
|
||||
<menuitem id="menu_providers"
|
||||
name="Providers"
|
||||
parent="menu_fusion_api_root"
|
||||
action="action_api_provider"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Consumers -->
|
||||
<menuitem id="menu_consumers"
|
||||
name="Consumers"
|
||||
parent="menu_fusion_api_root"
|
||||
action="action_api_consumer"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- Access Rules -->
|
||||
<menuitem id="menu_access_rules"
|
||||
name="Access Rules"
|
||||
parent="menu_fusion_api_root"
|
||||
action="action_api_access"
|
||||
sequence="30"
|
||||
groups="fusion_api.group_manager"/>
|
||||
|
||||
<!-- Usage Menu -->
|
||||
<menuitem id="menu_usage"
|
||||
name="Usage"
|
||||
parent="menu_fusion_api_root"
|
||||
sequence="40"/>
|
||||
|
||||
<menuitem id="menu_usage_log"
|
||||
name="Usage Log"
|
||||
parent="menu_usage"
|
||||
action="action_api_usage"
|
||||
sequence="1"/>
|
||||
|
||||
<menuitem id="menu_usage_daily"
|
||||
name="Daily Summary"
|
||||
parent="menu_usage"
|
||||
action="action_api_usage_daily"
|
||||
sequence="2"/>
|
||||
|
||||
<!-- Configuration Menu -->
|
||||
<menuitem id="menu_configuration"
|
||||
name="Configuration"
|
||||
parent="menu_fusion_api_root"
|
||||
sequence="90"
|
||||
groups="fusion_api.group_manager"/>
|
||||
|
||||
<menuitem id="menu_user_limits"
|
||||
name="User Limits"
|
||||
parent="menu_configuration"
|
||||
action="action_api_user_limit"
|
||||
sequence="1"/>
|
||||
|
||||
|
||||
</odoo>
|
||||
50
fusion_api/views/res_config_settings_views.xml
Normal file
50
fusion_api/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.fusion.api</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Fusion API" string="Fusion API"
|
||||
name="fusion_api"
|
||||
groups="fusion_api.group_manager">
|
||||
<block title="General Settings">
|
||||
<setting string="Default Environment"
|
||||
help="Choose whether API calls default to production or sandbox keys.">
|
||||
<field name="fusion_api_default_environment"/>
|
||||
</setting>
|
||||
<setting string="Auto-Detect Consumers"
|
||||
help="Automatically register new Fusion modules when they first call the API.">
|
||||
<field name="fusion_api_auto_detect"/>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Budget & Retention">
|
||||
<setting string="Global Monthly Budget"
|
||||
help="Set a global monthly cost cap across all providers. 0 = unlimited.">
|
||||
<div class="content-group">
|
||||
<div class="row mt8">
|
||||
<label class="col-lg-3" for="fusion_api_global_budget"/>
|
||||
<field name="fusion_api_global_budget" class="col-lg-3"/>
|
||||
<span class="col-lg-2">USD</span>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Log Retention"
|
||||
help="Keep detailed usage logs for this many days. Daily summaries are kept indefinitely.">
|
||||
<div class="content-group">
|
||||
<div class="row mt8">
|
||||
<label class="col-lg-3" for="fusion_api_log_retention_days"/>
|
||||
<field name="fusion_api_log_retention_days" class="col-lg-3"/>
|
||||
<span class="col-lg-2">days</span>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user