more unifi

This commit is contained in:
xtremxpert 2025-03-26 08:46:03 -04:00
parent d9cc020e7f
commit 5390b6308e
156 changed files with 18775 additions and 5138 deletions

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,32 @@
{
'name': 'Odoo to Odoo Bemade',
'version': '18.0.1.0.0',
'category': 'Technical',
'summary': 'Connecteur universel Bemade pour synchronisation avec instances Odoo',
'description': """
Module de synchronisation pour Bemade avec n'importe quelle instance Odoo
- Support multi-instances
- Synchronisation asynchrone
- Gestion des conflits
- Monitoring et reprise sur erreur
- Interface administrateur avancée
""",
'author': 'Bemade',
'website': 'https://bemade.org',
'depends': [
'base',
'odoo_to_odoo_sync'
],
'data': [
'security/ir.model.access.csv',
'views/sync_instance_views.xml',
'views/sync_model_views.xml',
'views/sync_queue_views.xml',
'views/sync_log_views.xml',
'views/menus.xml',
'data/ir_cron_data.xml',
],
'installable': True,
'application': True,
'license': 'LGPL-3',
}

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Tâche planifiée pour traiter la file d'attente de synchronisation -->
<record id="ir_cron_process_sync_queue" model="ir.cron">
<field name="name">Bemade: Traiter la file d'attente de synchronisation</field>
<field name="model_id" ref="model_odoo_to_bemade_sync_queue"/>
<field name="state">code</field>
<field name="code">model.process_queue(limit=100)</field>
<field name="interval_number">10</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
<!-- Tâche planifiée pour nettoyer les journaux de synchronisation -->
<record id="ir_cron_clean_sync_logs" model="ir.cron">
<field name="name">Bemade: Nettoyer les anciens journaux de synchronisation</field>
<field name="model_id" ref="model_odoo_to_bemade_sync_log"/>
<field name="state">code</field>
<field name="code">model.clean_old_logs(days=30)</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
<!-- Tâche planifiée pour vérifier les connexions aux instances -->
<record id="ir_cron_check_instance_connections" model="ir.cron">
<field name="name">Bemade: Vérifier les connexions aux instances</field>
<field name="model_id" ref="model_odoo_to_bemade_instance"/>
<field name="state">code</field>
<field name="code">model.check_all_connections()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
<!-- Tâche planifiée pour synchroniser les données critiques -->
<record id="ir_cron_sync_critical_data" model="ir.cron">
<field name="name">Bemade: Synchroniser les données critiques</field>
<field name="model_id" ref="model_odoo_to_bemade_sync_model"/>
<field name="state">code</field>
<field name="code">model.sync_critical_models()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,6 @@
from . import sync_instance
from . import sync_model
from . import sync_model_field
from . import sync_queue
from . import sync_log
from . import sync_manager

View file

@ -0,0 +1,517 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Bemade Instance Management for Odoo to Odoo Synchronization.
This module extends the base sync instance model with Bemade-specific
functionality for managing connections to Bemade instances, including support
for OdooRPC and specialized connection handling for Bemade clients.
"""
import logging
import time
import random
from urllib.parse import urlparse
# Import XML-RPC for standard connections
import xmlrpc.client
# Import OdooRPC conditionally to avoid hard dependency
try:
import odoorpc
except ImportError:
odoorpc = None
from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
# pylint: disable=pointless-string-statement
"""Note sur l'héritage et les champs/méthodes des modèles Odoo
Dans Odoo, l'héritage d'un modèle via `_inherit` permet d'hériter de tous les champs
et méthodes du modèle parent. Cependant, les outils d'analyse statique comme Pylint
et Pyright ne sont pas conscients de ce mécanisme d'héritage et signalent des erreurs
lorsque nous utilisons des attributs définis dans le modèle parent.
Fields hérités du modèle parent 'odoo.sync.instance' (qui ne sont pas redéfinis ici) :
- name: fields.Char # Nom de l'instance distante
- url: fields.Char # URL de l'instance distante
- database: fields.Char # Nom de la base de données distante
- username: fields.Char # Nom d'utilisateur pour la connexion
- password: fields.Char # Mot de passe pour la connexion
- connection_type: fields.Selection # Type de connexion (xmlrpc, jsonrpc)
- connection_timeout: fields.Integer # Timeout de connexion en secondes
- retry_count: fields.Integer # Nombre maximal de tentatives de reconnexion
- retry_delay: fields.Integer # Délai entre les tentatives de reconnexion
- auto_reconnect: fields.Boolean # Reconnexion automatique
- state: fields.Selection # État de la connexion
- error_message: fields.Text # Message d'erreur éventuel
- last_connection: fields.Datetime # Date et heure de la dernière connexion réussie
Méthodes héritées (ignorées par les linters mais disponibles) :
- test_connection()
- get_connection()
- execute_kw()
Les erreurs lint pour ces attributs et méthodes hérités peuvent être ignorées.
"""
class OdooToBemadeInstance(models.Model):
"""Bemade Remote Odoo instance for synchronization.
Extends the base sync instance model with Bemade-specific functionality.
This class adds support for OdooRPC connection type and specialized
handling for Bemade clients.
Attributes inherited from parent model (odoo.sync.instance):
name (Char): Name of the remote instance
url (Char): URL of the remote instance
database (Char): Database name of the remote instance
username (Char): Username for authentication
password (Char): Password for authentication
connection_type (Selection): Connection type (xmlrpc, jsonrpc)
connection_timeout (Integer): Timeout in seconds for connection
retry_count (Integer): Max retries for connection attempts
retry_delay (Integer): Delay in seconds between retries
auto_reconnect (Boolean): Whether to reconnect automatically
state (Selection): Connection state
error_message (Text): Error message if any
last_connection (Datetime): Date and time of last successful connection
Note to static analyzers:
The fields and methods mentioned above are inherited from the parent model
and are available at runtime through Odoo's inheritance mechanism.
Lint errors for these attributes can safely be ignored.
"""
# Fields inherited from parent class - for linters only
# pyright: ignore[reportAttributeAccessIssue]
name: str
url: str
database: str
username: str
password: str
connection_type: fields.Selection
connection_timeout: int
retry_count: int
retry_delay: int
auto_reconnect: bool
state: fields.Selection
error_message: str
last_connection: fields.Datetime
_name = 'odoo.to.bemade.instance'
_description = 'Bemade Remote Odoo Instance'
_inherit = 'odoo.sync.instance'
# Champs spécifiques à Bemade
api_key = fields.Char(
string='API Key',
help='Optional API key for authentication with Bemade instance',
)
# Override connection_type to add OdooRPC option
connection_type = fields.Selection(
selection=[
('xmlrpc', 'XML-RPC'),
('jsonrpc', 'JSON-RPC'),
('odoorpc', 'OdooRPC')
],
string='Connection Type',
default='xmlrpc',
required=True,
help='Protocol to use for connection to the remote instance',
)
model_ids = fields.One2many(
'odoo.to.bemade.sync.model',
'bemade_instance_id',
string='Modèles synchronisés',
help='Modèles configurés pour la synchronisation avec cette instance',
)
log_ids = fields.One2many(
'odoo.to.bemade.sync.log',
'bemade_instance_id',
string='Synchronization Logs',
)
connection_timeout = fields.Integer(
string='Connection Timeout',
default=60,
help='Timeout in seconds for the connection',
)
retry_count = fields.Integer(
string='Retry Count',
default=3,
help='Number of times to retry failed connections',
)
retry_delay = fields.Integer(
string='Retry Delay',
default=5,
help='Delay between retries in seconds',
)
auto_reconnect = fields.Boolean(
string='Auto Reconnect',
default=True,
help='Automatically reconnect if connection is lost',
)
# Remarque: model_ids et log_ids sont déjà définis ci-dessus, pas besoin de duplication
@api.onchange
def onchange(self, values, field_names, fields_spec):
"""Handle onchange events.
This method implements the abstract method from BaseModel by delegating
to the parent class implementation.
Args:
values: The values dict
field_names: Names of the fields that triggered the onchange
fields_spec: The onchange specification
Returns:
Dictionary with updated values
"""
return super(OdooToBemadeInstance, self).onchange(values, field_names, fields_spec)
@api.onchange('url')
def _onchange_url(self):
"""Reset state when URL changes.
Extends the parent method to add Bemade-specific behavior if needed.
Note: Although linters may report url, state, and error_message as unknown attributes,
they are inherited from the parent model and are available at runtime.
"""
# pylint: disable=attribute-defined-outside-init
# Fields inherited from parent model: url, state, error_message
if self.url != self._origin.url:
self.write({'state': 'draft', 'error_message': False})
def test_connection(self):
"""Test the connection to the remote Odoo instance.
If the connection type is 'odoorpc', use the specialized method,
otherwise fall back to the parent implementation.
Note: connection_type is inherited from the parent model, but linters won't detect this.
"""
self.ensure_one()
# Field inherited from parent model: connection_type
# pylint: disable=no-member
if self.connection_type == 'odoorpc':
return self._test_odoorpc_connection()
# Call the parent method - linters might not recognize this as valid
# pylint: disable=no-member
return super(OdooToBemadeInstance, self).test_connection()
def _test_odoorpc_connection(self):
"""Test connection using OdooRPC library.
This method is specific to the Bemade implementation and handles
the OdooRPC connection type that's not in the parent model.
"""
# Set the state to indicate testing is in progress
self.write({'state': 'testing'})
try:
# Check if OdooRPC library is installed
try:
import odoorpc
except ImportError as exc:
raise ImportError(
"La bibliothèque OdooRPC n'est pas installée. "
"Installez-la avec 'pip install odoorpc'"
) from exc
# Parse URL to extract connection details
parsed_url = urlparse(self.url)
protocol = parsed_url.scheme
host = parsed_url.netloc
# Extract port if present
if ':' in host:
host, port = host.split(':')
port = int(port)
else:
port = 443 if protocol == 'https' else 80
# Attempt connection with OdooRPC
# Note: timeout is passed as a keyword argument during login phase
odoo = odoorpc.ODOO(
host,
protocol=protocol,
port=port
)
# Try to login with credentials
odoo.login(self.database, self.username, self.password)
# If successful, update record state
if odoo.env:
self.write({
'state': 'connected',
'last_connection': fields.Datetime.now(),
'error_message': False
})
return True
else:
# This should rarely happen as login usually raises an exception
raise UserError('Échec d\'authentification')
except UserError as e:
# Pass through our UserError without modification
self.write({
'state': 'error',
'error_message': str(e)
})
_logger.error("Erreur d'authentification OdooRPC: %s", str(e))
return False
except (ConnectionError, TimeoutError, ValueError, TypeError) as e:
# Catch specific exceptions that can be raised during connection
self.write({
'state': 'error',
'error_message': str(e)
})
_logger.error("Erreur de connexion OdooRPC: %s", str(e))
return False
except Exception as e: # pylint: disable=broad-except
# Fall back for any unexpected exceptions
self.write({
'state': 'error',
'error_message': f"Erreur inattendue: {str(e)}"
})
_logger.error("Erreur inattendue OdooRPC: %s", str(e))
return False
def get_connection(self):
"""Return an active connection to the remote instance.
This method extends the parent implementation to handle the
OdooRPC connection type in addition to the parent's connection types.
"""
self.ensure_one()
# Ensure we have an active connection
if self.state != 'connected':
self.test_connection()
if self.state != 'connected':
# Use UserError for better user experience
raise UserError(f'Impossible de se connecter à {self.name}: {self.error_message}')
# Handle OdooRPC connection type
if self.connection_type == 'odoorpc':
return self._get_odoorpc_connection()
# Use parent implementation for other connection types
return super(OdooToBemadeInstance, self).get_connection()
def _get_odoorpc_connection(self):
"""Get a connection to the remote Odoo instance using OdooRPC.
Returns a connection object that can be used to interact with the remote instance.
Returns:
OdooRPCWrapper: A wrapped OdooRPC connection object that provides compatible interface
Raises:
ImportError: When OdooRPC library is not installed
UserError: When connection fails due to authentication issues
Exception: For other connection failures
"""
try:
# Check if OdooRPC library is installed
try:
import odoorpc
except ImportError as exc:
raise ImportError(
"La bibliothèque OdooRPC n'est pas installée. "
"Installez-la avec 'pip install odoorpc'"
) from exc
# Parse URL to extract connection details
parsed_url = urlparse(self.url)
protocol = parsed_url.scheme
host = parsed_url.netloc
# Extract port if present
if ':' in host:
host, port = host.split(':')
port = int(port)
else:
port = 443 if protocol == 'https' else 80
# Create OdooRPC connection
# Note: OdooRPC doesn't accept timeout in constructor, set it after
odoo = odoorpc.ODOO(
host,
protocol=protocol,
port=port
)
# Set timeout via attribute if available
if hasattr(odoo, 'config'):
odoo.config['timeout'] = self.connection_timeout
# Login with credentials
odoo.login(self.database, self.username, self.password)
# Create a wrapper class to make OdooRPC interface compatible with xmlrpc/jsonrpc
class OdooRPCWrapper:
"""Wrapper class to provide a compatible interface with XML-RPC."""
def __init__(self, odoo_instance):
self.odoo = odoo_instance
def execute_kw(self, db, uid, password, model, method, args, kwargs=None):
"""Mimics XML-RPC's execute_kw but uses OdooRPC internally.
Args:
db: Database name (unused for OdooRPC, required for compatibility)
uid: User ID (unused for OdooRPC, required for compatibility)
password: Password (unused for OdooRPC, required for compatibility)
model: Model name to call
method: Method name to call on the model
args: Positional arguments for the method
kwargs: Keyword arguments for the method
Returns:
Result of the method call
Raises:
Exception: When the method call fails
"""
# These parameters are required for compatibility but not used
# with OdooRPC since we're already logged in
# pylint: disable=unused-argument
# Handle args and kwargs correctly
args = args or []
kwargs = kwargs or {}
try:
# OdooRPC has a different API for standard vs custom methods
if model in self.odoo.env and hasattr(self.odoo.env[model], method):
# Use the standard API for methods like 'search', 'read', etc.
model_obj = self.odoo.env[model]
method_obj = getattr(model_obj, method)
return method_obj(*args, **kwargs)
else:
# Use execute for custom methods
return self.odoo.execute(model, method, *args, **kwargs)
except Exception as e:
_logger.error(
"Erreur lors de l'exécution de %s.%s via OdooRPC: %s",
model, method, str(e)
)
raise
# pylint: enable=unused-argument
# Update connection state on success
self.write({
'state': 'connected',
'last_connection': fields.Datetime.now(),
'error_message': False
})
# Return wrapper and uid, similar to how XML-RPC connections work
wrapper = OdooRPCWrapper(odoo)
return wrapper, odoo.env.uid
except (ConnectionError, TimeoutError) as e:
# Specific error handling for connection and timeout issues
error_msg = f"Délai d'attente dépassé lors de la connexion à {self.name}: {e}"
self.write({
'state': 'error',
'error_message': error_msg
})
_logger.error("Erreur de connexion/timeout dans _get_odoorpc_connection: %s", str(e))
raise UserError(error_msg) from e
except (ValueError, TypeError) as e:
# Handle value or type errors
error_msg = f"Paramètres invalides pour la connexion à {self.name}: {e}"
self.write({
'state': 'error',
'error_message': error_msg
})
_logger.error("Erreur de paramètres dans _get_odoorpc_connection: %s", str(e))
raise UserError(error_msg) from e
except Exception as e: # pylint: disable=broad-except
# Fall back for any unexpected exceptions
error_msg = f"Échec lors de la connexion à {self.name}: {e}"
self.write({
'state': 'error',
'error_message': error_msg
})
_logger.error("Erreur inattendue dans _get_odoorpc_connection: %s", str(e))
raise UserError(error_msg) from e
def execute_kw(self, model, method, args=None, kwargs=None):
"""Execute a method on the remote model using the appropriate connection type.
This method provides a unified interface for executing methods on remote models,
abstracting away the differences between XML-RPC and OdooRPC.
Args:
model (str): Name of the model on the remote instance
method (str): Name of the method to execute
args (list, optional): Positional arguments to pass to the method
kwargs (dict, optional): Keyword arguments to pass to the method
Returns:
object: Result of the method execution
Raises:
UserError: When connection is not established or fails
ValidationError: When method execution fails on the remote instance
"""
self.ensure_one()
args = args or []
kwargs = kwargs or {}
# Ensure we have a connection
if self.state != 'connected':
_logger.debug("Connection to %s not established. Attempting to connect.", self.name)
self.test_connection()
if self.state != 'connected':
raise UserError(f"Cannot execute method on {self.name}: {self.error_message}")
try:
# Get connection based on connection type
connection = self.get_connection()
# For OdooRPC connection, use the wrapper's execute_kw method
if self.connection_type == 'odoorpc':
_logger.debug("Executing %s.%s via OdooRPC on %s", model, method, self.name)
# OdooRPC wrapper returns a tuple (wrapper, uid)
wrapper, uid = connection
# Use the wrapper's execute_kw method
return wrapper.execute_kw(
self.database, uid, self.password,
model, method, args, kwargs
)
else:
# For XML-RPC, delegate to parent implementation
_logger.debug("Executing %s.%s via standard RPC on %s", model, method, self.name)
# XML-RPC connection returns a tuple (common, models)
common, models = connection
# Execute the method directly on the model
return models.execute_kw(
self.database, self.env.uid, self.password,
model, method, args, kwargs
)
except Exception as e:
error_msg = f"Error executing {model}.{method} on {self.name}: {str(e)}"
_logger.error(error_msg)
self.write({
'state': 'error',
'error_message': error_msg
})
raise ValidationError(error_msg) from e

View file

@ -0,0 +1,62 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Synchronization Log for Bemade.
This module extends the base synchronization log to add Bemade-specific
functionality for logging synchronization operations.
"""
import logging
import json
from datetime import datetime
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class OdooToBemadeSyncLog(models.Model):
"""Synchronization log for Bemade operations.
Extends the base synchronization log to handle Bemade-specific
logging requirements.
"""
_name = 'odoo.to.bemade.sync.log'
_description = 'Bemade Sync Log Entry'
_inherit = 'odoo.sync.log'
_order = 'create_date desc'
# Add Bemade-specific fields here
bemade_instance_id = fields.Many2one(
comodel_name='odoo.to.bemade.instance',
string='Bemade Instance',
help='The Bemade instance related to this log entry',
)
@api.model
def log(self, operation, model=None, record_id=None, result='success', details=None, instance_id=None):
"""Create a log entry for a synchronization operation.
Args:
operation: Type of operation performed (create, update, delete, etc.)
model: Technical name of the model that was synchronized
record_id: ID of the record that was synchronized
result: Result of the operation (success, error)
details: Additional details or error message
instance_id: ID of the Bemade instance involved
Returns:
The created log entry record
"""
if details and not isinstance(details, str):
details = json.dumps(details)
return self.create({
'operation': operation,
'model': model,
'record_id': record_id,
'result': result,
'details': details,
'bemade_instance_id': instance_id,
})

View file

@ -0,0 +1,393 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Synchronization Manager for Bemade.
This module extends the base synchronization manager to add Bemade-specific
functionality for managing synchronization operations between Odoo instances.
"""
import logging
from datetime import datetime, timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class OdooToBemadeSyncManager(models.Model):
"""Synchronization manager for Bemade.
Extends the base synchronization manager to handle Bemade-specific
synchronization requirements.
"""
_name = 'odoo.to.bemade.sync.manager'
_description = 'Bemade Sync Manager'
_inherit = 'odoo.sync.manager'
# Add Bemade-specific fields here
bemade_sync_enabled = fields.Boolean(
string='Enable Bemade Sync',
default=True,
help='Enable synchronization with Bemade instances',
)
@api.model
def cron_process_sync_queue(self):
"""Process pending synchronization queue entries.
This method is called by a scheduled action to process
pending queue entries for Bemade synchronization.
Returns:
Boolean indicating success
"""
if not self.env['ir.config_parameter'].sudo().get_param('odoo_to_odoo_bemade.sync_enabled', True):
_logger.info("Bemade synchronization is disabled in system parameters")
return False
# Get active instances
active_instances = self.env['odoo.to.bemade.instance'].search([
('active', '=', True),
('state', '=', 'connected')
])
if not active_instances:
_logger.info("No active and connected Bemade instances found")
return False
# Process queue entries for each active instance
for instance in active_instances:
self._process_queue_for_instance(instance)
return True
def _process_queue_for_instance(self, instance):
"""Process queue entries for a specific Bemade instance.
Args:
instance: The Bemade instance to process queue entries for
Returns:
Boolean indicating success
"""
# Get pending queue entries for this instance
queue_entries = self.env['odoo.to.bemade.sync.queue'].search([
('bemade_instance_id', '=', instance.id),
('state', '=', 'pending'),
'|',
('next_retry', '=', False),
('next_retry', '<=', fields.Datetime.now())
], order='priority, id', limit=50)
if not queue_entries:
_logger.info("No pending queue entries for Bemade instance %s", instance.name)
return False
# Process each queue entry
for entry in queue_entries:
try:
self._process_queue_entry(entry, instance)
except Exception as e:
self.env['odoo.to.bemade.sync.log'].log(
operation=entry.operation_type,
model=entry.model,
record_id=entry.record_id,
result='error',
details=str(e),
instance_id=instance.id
)
# Update entry with error information
retry_count = entry.retry_count + 1
max_retries = int(self.env['ir.config_parameter'].sudo().get_param('odoo_to_odoo_bemade.max_retries', 5))
if retry_count >= max_retries:
entry.write({
'state': 'failed',
'retry_count': retry_count,
'error_message': str(e)
})
else:
# Exponential backoff for retries
delay_minutes = 5 * (2 ** (retry_count - 1))
next_retry = fields.Datetime.now() + timedelta(minutes=delay_minutes)
entry.write({
'state': 'pending',
'retry_count': retry_count,
'error_message': str(e),
'next_retry': next_retry
})
return True
def _process_queue_entry(self, entry, instance):
"""Process a single queue entry.
Args:
entry: The queue entry to process
instance: The Bemade instance to process the entry for
Returns:
Boolean indicating success
"""
# Mark the entry as being processed
entry.write({'state': 'processing'})
# Process based on operation type
if entry.operation_type == 'create':
result = self._sync_create(entry, instance)
elif entry.operation_type == 'update':
result = self._sync_update(entry, instance)
elif entry.operation_type == 'delete':
result = self._sync_delete(entry, instance)
else:
raise UserError(_("Unknown operation type: %s") % entry.operation_type)
# Process Bemade-specific logic
entry.process_bemade_specific_logic()
# Mark the entry as processed
entry.write({'state': 'done'})
# Log success
self.env['odoo.to.bemade.sync.log'].log(
operation=entry.operation_type,
model=entry.model,
record_id=entry.record_id,
result='success',
instance_id=instance.id
)
return True
def _sync_create(self, entry, instance):
"""Synchronize a record creation.
Args:
entry: The queue entry to process
instance: The Bemade instance to sync with
Returns:
The result of the create operation
"""
# Get the model and record
model = self.env[entry.model]
record = model.browse(entry.record_id)
# Get the connection to the remote instance
connection = instance.get_connection()
# Map record data to target model fields
sync_model = self.env['odoo.sync.model'].search([
('name', '=', entry.model),
('instance_id', '=', instance.id)
], limit=1)
if not sync_model:
raise UserError(_("No synchronization configuration found for model %s on instance %s") %
(entry.model, instance.name))
# Prepare the data for the remote instance
# This would involve mapping fields from the local model to the remote model
# based on the configuration in sync_model and its fields
remote_data = self._prepare_remote_data(record, sync_model)
# Create the record on the remote instance
target_model = sync_model.target_model or entry.model
result = connection.create(target_model, remote_data)
return result
def _sync_update(self, entry, instance):
"""Synchronize a record update.
Args:
entry: The queue entry to process
instance: The Bemade instance to sync with
Returns:
The result of the update operation
"""
# Implementation similar to _sync_create, but for updates
# Get the model and record
model = self.env[entry.model]
record = model.browse(entry.record_id)
# Get the connection to the remote instance
connection = instance.get_connection()
# Map record data to target model fields
sync_model = self.env['odoo.sync.model'].search([
('name', '=', entry.model),
('instance_id', '=', instance.id)
], limit=1)
if not sync_model:
raise UserError(_("No synchronization configuration found for model %s on instance %s") %
(entry.model, instance.name))
# Prepare the data for the remote instance
remote_data = self._prepare_remote_data(record, sync_model)
# Get the remote ID
remote_id = self._get_remote_id(record, sync_model, instance)
# Update the record on the remote instance
target_model = sync_model.target_model or entry.model
result = connection.write(target_model, remote_id, remote_data)
return result
def _sync_delete(self, entry, instance):
"""Synchronize a record deletion.
Args:
entry: The queue entry to process
instance: The Bemade instance to sync with
Returns:
The result of the delete operation
"""
# Get the connection to the remote instance
connection = instance.get_connection()
# Map record data to target model fields
sync_model = self.env['odoo.sync.model'].search([
('name', '=', entry.model),
('instance_id', '=', instance.id)
], limit=1)
if not sync_model:
raise UserError(_("No synchronization configuration found for model %s on instance %s") %
(entry.model, instance.name))
# Get the remote ID
# Since the record might already be deleted locally, we need to get the remote ID
# from a mapping table or some other mechanism
remote_id = self._get_remote_id_for_deleted_record(entry.record_id, sync_model, instance)
# Delete the record on the remote instance
target_model = sync_model.target_model or entry.model
result = connection.unlink(target_model, remote_id)
return result
def _prepare_remote_data(self, record, sync_model):
"""Prepare record data for the remote instance.
Args:
record: The record to prepare data for
sync_model: The synchronization model configuration
Returns:
Dictionary of field values for the remote instance
"""
# Get the field mappings from the sync model
field_mappings = self.env['odoo.sync.model.field'].search([
('sync_model_id', '=', sync_model.id)
])
remote_data = {}
# Apply field mappings
for mapping in field_mappings:
source_field = mapping.source_field
target_field = mapping.target_field or source_field
# Get the value from the source field
value = record[source_field]
# Apply any transformations defined in the mapping
# This is a simplified example - real implementation would handle
# complex field types, relationships, etc.
if mapping.transform_type == 'direct':
remote_data[target_field] = value
elif mapping.transform_type == 'function':
# Call a function to transform the value
transform_method = getattr(self, mapping.transform_function, None)
if transform_method:
remote_data[target_field] = transform_method(value, record, mapping)
else:
_logger.warning("Transform function %s not found", mapping.transform_function)
remote_data[target_field] = value
return remote_data
def _get_remote_id(self, record, sync_model, instance):
"""Get the ID of the record on the remote instance.
Args:
record: The local record
sync_model: The synchronization model configuration
instance: The remote instance
Returns:
ID of the record on the remote instance
"""
# In a real implementation, this would involve looking up the remote ID
# from a mapping table or using an external ID mechanism
# This is just a placeholder implementation
return record.id # Simplified - in reality would be a different ID
def _get_remote_id_for_deleted_record(self, record_id, sync_model, instance):
"""Get the ID of a deleted record on the remote instance.
Args:
record_id: The local record ID
sync_model: The synchronization model configuration
instance: The remote instance
Returns:
ID of the record on the remote instance
"""
# Similar to _get_remote_id but for records that have been deleted locally
return record_id # Simplified - in reality would be a different ID
def sync_all(self):
"""Synchronize all active models with Bemade instances.
Returns:
Boolean indicating success
"""
# Get active instances
active_instances = self.env['odoo.to.bemade.instance'].search([
('active', '=', True),
('state', '=', 'connected')
])
if not active_instances:
raise UserError(_("No active and connected Bemade instances found"))
# Get active sync models
active_models = self.env['odoo.sync.model'].search([
('active', '=', True),
('instance_id', 'in', active_instances.ids)
])
if not active_models:
raise UserError(_("No active synchronization models configured"))
# Synchronize each model
for sync_model in active_models:
model = self.env[sync_model.name]
instance = sync_model.instance_id
# Get records to synchronize
# This is a simplified approach - in a real implementation,
# you would need to handle determining which records need to be synced
records = model.search([])
# Create queue entries for each record
queue = self.env['odoo.to.bemade.sync.queue']
for record in records:
queue.create_sync_job(
instance_id=instance.id,
model_name=sync_model.name,
record_id=record.id,
operation_type='update' # Assuming update for existing records
)
return True

View file

@ -0,0 +1,108 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Model Synchronization Configuration for Bemade.
This module extends the base model synchronization configuration to add
Bemade-specific functionality for defining how models are synchronized
between Odoo instances.
"""
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class OdooToBemadeSyncModel(models.Model):
"""Bemade synchronization model configuration.
Extends the base synchronization model configuration to handle
Bemade-specific requirements for model synchronization.
"""
_name = 'odoo.to.bemade.sync.model'
_description = 'Bemade Synchronized Model'
_inherit = 'odoo.sync.model'
# Add Bemade-specific fields here
bemade_specific_setting = fields.Boolean(
string='Bemade Specific Setting',
default=False,
help='Enable this for Bemade-specific synchronization behavior',
)
bemade_instance_id = fields.Many2one(
comodel_name='odoo.to.bemade.instance',
string='Bemade Instance',
required=True,
help='The Bemade instance this model will be synchronized with',
)
@api.model
def create_sync_configuration(self, model_name, instance_id, target_model=None, active=True):
"""Create a synchronization configuration for a model.
Args:
model_name: Technical name of the model to synchronize
instance_id: ID of the Bemade instance to sync with
target_model: Technical name of the model on the remote instance
active: Whether the synchronization is active
Returns:
The created model configuration record
"""
# Find the ir.model record for the model name
ir_model = self.env['ir.model'].search([('model', '=', model_name)], limit=1)
if not ir_model:
raise UserError(_("Model %s does not exist") % model_name)
# Create the sync model configuration
return self.create({
'model_id': ir_model.id,
'bemade_instance_id': instance_id,
'instance_id': instance_id, # For the inherited field
'target_model': target_model or model_name,
'active': active,
})
def generate_field_mappings(self):
"""Generate field mappings based on model fields.
This method creates sync.model.field records for all the fields
in the model that should be synchronized.
Returns:
List of created field mapping records
"""
self.ensure_one()
# Get the model
model = self.env[self.name]
model_fields = model._fields
# Create field mappings for each relevant field
field_mapping_model = self.env['odoo.to.bemade.sync.model.field']
created_mappings = []
for field_name, field in model_fields.items():
# Skip fields that shouldn't be synchronized
if field.type in ['one2many', 'many2many']:
continue
if field_name in ['id', 'create_uid', 'create_date', 'write_uid', 'write_date', '__last_update']:
continue
# Create a field mapping
mapping = field_mapping_model.create({
'sync_model_id': self.id,
'source_field': field_name,
'target_field': field_name,
'active': True,
'transform_type': 'direct',
})
created_mappings.append(mapping)
return created_mappings

View file

@ -0,0 +1,121 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Field Synchronization Configuration for Bemade.
This module extends the base field synchronization configuration to add
Bemade-specific functionality for defining how fields are synchronized
between Odoo instances.
"""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class OdooToBemadeSyncModelField(models.Model):
"""Bemade synchronization field configuration.
Extends the base synchronization field configuration to handle
Bemade-specific requirements for field synchronization.
"""
_name = 'odoo.to.bemade.sync.model.field'
_description = 'Bemade Synchronized Field'
_inherit = 'odoo.sync.model.field'
# Add Bemade-specific fields here
bemade_transformation = fields.Selection([
('none', 'No Transformation'),
('prefix', 'Add Prefix'),
('suffix', 'Add Suffix'),
('replace', 'Find and Replace'),
('custom', 'Custom Python Code')
], string='Bemade Transformation', default='none',
help='Bemade-specific transformation to apply to the field value')
bemade_transform_value = fields.Char(
string='Transformation Value',
help='Value to use for the transformation (prefix, suffix, replacement, etc.)'
)
bemade_custom_code = fields.Text(
string='Custom Python Code',
help='Custom Python code to transform the field value'
)
@api.model
def create_field_mapping(self, sync_model_id, source_field, target_field=None, active=True, transform_type='direct'):
"""Create a field mapping for synchronization.
Args:
sync_model_id: ID of the sync model configuration
source_field: Name of the source field
target_field: Name of the target field (defaults to source_field)
active: Whether the mapping is active
transform_type: Type of transformation to apply
Returns:
The created field mapping record
"""
return self.create({
'sync_model_id': sync_model_id,
'source_field': source_field,
'target_field': target_field or source_field,
'active': active,
'transform_type': transform_type,
'bemade_transformation': 'none',
})
def apply_bemade_transformation(self, value, record):
"""Apply Bemade-specific transformations to the field value.
Args:
value: The original field value
record: The record being synchronized
Returns:
The transformed value
"""
self.ensure_one()
if not value or self.bemade_transformation == 'none':
return value
if self.bemade_transformation == 'prefix':
return f"{self.bemade_transform_value or ''}{value}"
elif self.bemade_transformation == 'suffix':
return f"{value}{self.bemade_transform_value or ''}"
elif self.bemade_transformation == 'replace' and self.bemade_transform_value:
# Format: "find:replace"
parts = self.bemade_transform_value.split(':')
if len(parts) >= 2:
find_val = parts[0]
replace_val = parts[1]
return str(value).replace(find_val, replace_val)
elif self.bemade_transformation == 'custom' and self.bemade_custom_code:
# Execute custom code with appropriate safeguards
try:
# Create a safe local execution environment
local_dict = {
'value': value,
'record': record,
'field': self,
}
# Execute the custom code
exec(self.bemade_custom_code, {'__builtins__': {}}, local_dict)
# Return the transformed value
if 'result' in local_dict:
return local_dict['result']
else:
_logger.warning("Custom transformation code did not set 'result' variable")
except Exception as e:
_logger.error("Error executing custom transformation: %s", str(e))
return value

View file

@ -0,0 +1,68 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Queue Management for Bemade Synchronization.
This module extends the base synchronization queue to add Bemade-specific
functionality for managing the synchronization queue between Odoo instances.
"""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class OdooToBemadeSyncQueue(models.Model):
"""Synchronization queue for Bemade Odoo instances.
Extends the base synchronization queue to handle Bemade-specific
synchronization operations.
"""
_name = 'odoo.to.bemade.sync.queue'
_description = 'Bemade Sync Queue Entry'
_inherit = 'odoo.sync.queue'
# Add Bemade-specific fields here
bemade_instance_id = fields.Many2one(
comodel_name='odoo.to.bemade.instance',
string='Bemade Instance',
help='The Bemade instance related to this queue entry',
)
@api.model
def create_sync_job(self, instance_id, model_name, record_id, operation_type='create', priority=10):
"""Create a new synchronization job in the queue.
Args:
instance_id: ID of the Bemade instance to sync with
model_name: Technical name of the model to synchronize
record_id: ID of the record to synchronize
operation_type: Type of operation (create, update, delete)
priority: Priority of the job (lower number = higher priority)
Returns:
The created queue entry record
"""
return self.create({
'bemade_instance_id': instance_id,
'model': model_name,
'record_id': record_id,
'operation_type': operation_type,
'priority': priority,
'state': 'pending',
})
def process_bemade_specific_logic(self):
"""Add Bemade-specific processing logic here.
This method can be called during processing to handle any
Bemade-specific synchronization requirements.
Returns:
Boolean indicating success
"""
self.ensure_one()
# Implement Bemade-specific logic
return True

View file

@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_odoo_to_bemade_sync_model_admin,odoo.to.bemade.sync.model admin,model_odoo_to_bemade_sync_model,base.group_system,1,1,1,1
access_odoo_to_bemade_sync_model_user,odoo.to.bemade.sync.model user,model_odoo_to_bemade_sync_model,base.group_user,1,0,0,0
access_odoo_to_bemade_sync_model_field_admin,odoo.to.bemade.sync.model.field admin,model_odoo_to_bemade_sync_model_field,base.group_system,1,1,1,1
access_odoo_to_bemade_sync_model_field_user,odoo.to.bemade.sync.model.field user,model_odoo_to_bemade_sync_model_field,base.group_user,1,0,0,0
access_odoo_to_bemade_instance_admin,odoo.to.bemade.instance admin,model_odoo_to_bemade_instance,base.group_system,1,1,1,1
access_odoo_to_bemade_instance_user,odoo.to.bemade.instance user,model_odoo_to_bemade_instance,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_odoo_to_bemade_sync_model_admin odoo.to.bemade.sync.model admin model_odoo_to_bemade_sync_model base.group_system 1 1 1 1
3 access_odoo_to_bemade_sync_model_user odoo.to.bemade.sync.model user model_odoo_to_bemade_sync_model base.group_user 1 0 0 0
4 access_odoo_to_bemade_sync_model_field_admin odoo.to.bemade.sync.model.field admin model_odoo_to_bemade_sync_model_field base.group_system 1 1 1 1
5 access_odoo_to_bemade_sync_model_field_user odoo.to.bemade.sync.model.field user model_odoo_to_bemade_sync_model_field base.group_user 1 0 0 0
6 access_odoo_to_bemade_instance_admin odoo.to.bemade.instance admin model_odoo_to_bemade_instance base.group_system 1 1 1 1
7 access_odoo_to_bemade_instance_user odoo.to.bemade.instance user model_odoo_to_bemade_instance base.group_user 1 0 0 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Fond hexagonal -->
<polygon points="256,40 456,148 456,364 256,472 56,364 56,148" fill="#714B67" />
<polygon points="256,70 426,163 426,349 256,442 86,349 86,163" fill="#FFFFFF" />
<!-- Chaîne reliant les deux instances - élargie pour dépasser des cercles -->
<path d="M125,220 L125,292 C125,304 135,314 147,314 L365,314 C377,314 387,304 387,292 L387,220 C387,208 377,198 365,198 L147,198 C135,198 125,208 125,220 Z" fill="#714B67" />
<!-- Silhouette Odoo gauche -->
<circle cx="176" cy="256" r="40" fill="#FFFFFF" />
<circle cx="176" cy="256" r="15" fill="#714B67" />
<!-- Silhouette Odoo droite -->
<circle cx="336" cy="256" r="40" fill="#FFFFFF" />
<circle cx="336" cy="256" r="15" fill="#714B67" />
<!-- Flèches de synchronisation -->
<path d="M216,236 L296,236" stroke="#FFFFFF" stroke-width="8" fill="none" />
<path d="M296,276 L216,276" stroke="#FFFFFF" stroke-width="8" fill="none" />
<!-- Pointes de flèches -->
<polygon points="296,236 284,226 284,246" fill="#FFFFFF" />
<polygon points="216,276 228,266 228,286" fill="#FFFFFF" />
<!-- Petits cercles de connexion -->
<circle cx="216" cy="236" r="6" fill="#FFFFFF" />
<circle cx="296" cy="236" r="6" fill="#FFFFFF" />
<circle cx="216" cy="276" r="6" fill="#FFFFFF" />
<circle cx="296" cy="276" r="6" fill="#FFFFFF" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,93 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1 class="text-center">Odoo to Odoo Bemade</h1>
<h3 class="text-center">Connecteur universel pour la synchronisation avec Bemade</h3>
<hr/>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Présentation</h2>
<p>
Le module <strong>Odoo to Odoo Bemade</strong> fournit une couche intermédiaire spécialisée entre le module de base
<code>odoo_to_odoo_sync</code> et les implémentations spécifiques aux clients. Il introduit des fonctionnalités
adaptées à l'écosystème Bemade.
</p>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Fonctionnalités</h2>
<ul>
<li>Extension des modèles de synchronisation de base avec des fonctionnalités spécifiques à Bemade</li>
<li>Gestion des transformations avancées pour les champs synchronisés</li>
<li>Interface d'administration simplifiée pour la configuration Bemade</li>
<li>Support multi-instances pour les environnements complexes</li>
<li>Historique détaillé des synchronisations avec diagnostic amélioré</li>
</ul>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Architecture</h2>
<p>
Ce module s'appuie sur l'architecture de base du module <code>odoo_to_odoo_sync</code> et ajoute une couche
spécifique à Bemade. Il étend les modèles suivants:
</p>
<ul>
<li><strong>odoo.sync.model</strong><strong>odoo.to.bemade.sync.model</strong></li>
<li><strong>odoo.sync.model.field</strong><strong>odoo.to.bemade.sync.model.field</strong></li>
</ul>
<p>
Cette architecture en couches permet une séparation claire entre la logique de synchronisation générique
et les besoins spécifiques de l'environnement Bemade.
</p>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Installation</h2>
<p>
Pour installer ce module, vous devez d'abord installer le module de base <code>odoo_to_odoo_sync</code>.
</p>
<ol>
<li>Assurez-vous que le module <code>odoo_to_odoo_sync</code> est installé</li>
<li>Installez ce module <code>odoo_to_odoo_bemade</code></li>
<li>Configurez les paramètres spécifiques à Bemade dans le menu Administration</li>
</ol>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Configuration</h2>
<p>
La configuration se fait en plusieurs étapes:
</p>
<ol>
<li>Définir les instances Bemade auxquelles se connecter</li>
<li>Configurer les modèles à synchroniser</li>
<li>Configurer les champs pour chaque modèle avec leurs transformations spécifiques</li>
<li>Activer la synchronisation automatique si nécessaire</li>
</ol>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Support</h2>
<p>
Pour toute question concernant ce module, veuillez contacter l'équipe Bemade:
</p>
<ul>
<li>Email: support@bemade.org</li>
<li>Site web: <a href="https://bemade.org">https://bemade.org</a></li>
</ul>
</div>
</div>
</div>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Menu principal -->
<menuitem id="menu_odoo_to_bemade_root"
name="Synchronisation Bemade"
sequence="80"
web_icon="odoo_to_odoo_bemade,static/description/icon.svg"/>
<!-- Sous-menus -->
<menuitem id="menu_odoo_to_bemade_instances"
name="Instances"
parent="menu_odoo_to_bemade_root"
action="action_odoo_to_bemade_instances"
sequence="10"/>
<menuitem id="menu_odoo_to_bemade_sync_models"
name="Modèles synchronisés"
parent="menu_odoo_to_bemade_root"
action="action_odoo_to_bemade_sync_models"
sequence="20"/>
<menuitem id="menu_odoo_to_bemade_sync_queue"
name="File d'attente"
parent="menu_odoo_to_bemade_root"
action="action_odoo_to_bemade_sync_queue"
sequence="30"/>
<menuitem id="menu_odoo_to_bemade_sync_logs"
name="Journaux de synchronisation"
parent="menu_odoo_to_bemade_root"
action="action_odoo_to_bemade_sync_logs"
sequence="40"/>
</odoo>

View file

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vue formulaire pour les instances de synchronisation -->
<record id="view_odoo_to_bemade_instance_form" model="ir.ui.view">
<field name="name">odoo.to.bemade.instance.form</field>
<field name="model">odoo.to.bemade.instance</field>
<field name="arch" type="xml">
<form string="Instance Bemade">
<header>
<button name="test_connection" string="Tester la connexion" type="object" class="oe_highlight"
attrs="{'invisible': [('state', '=', 'connected')]}"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,testing,error,connected"/>
</header>
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Nom de l'instance"/></h1>
</div>
<group>
<group>
<field name="url" placeholder="https://exemple.bemade.org"
attrs="{'readonly': [('state', '=', 'connected')]}"/>
<field name="database" attrs="{'readonly': [('state', '=', 'connected')]}"/>
</group>
<group>
<field name="username" attrs="{'readonly': [('state', '=', 'connected')]}"/>
<field name="password" password="True" attrs="{'readonly': [('state', '=', 'connected')]}"/>
<field name="api_key" password="True" attrs="{'readonly': [('state', '=', 'connected')]}"/>
</group>
</group>
<group>
<field name="connection_type" widget="radio"/>
<field name="active"/>
</group>
<notebook>
<page string="Modèles synchronisés">
<field name="sync_model_ids">
<tree>
<field name="name"/>
<field name="model"/>
<field name="bemade_model"/>
<field name="active"/>
</tree>
</field>
</page>
<page string="Options avancées">
<group>
<field name="timeout"/>
<field name="retry_count"/>
<field name="retry_delay"/>
</group>
</page>
<page string="Journal de connexion">
<field name="log_ids">
<tree>
<field name="create_date"/>
<field name="name"/>
<field name="result"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Vue liste pour les instances de synchronisation -->
<record id="view_odoo_to_bemade_instance_tree" model="ir.ui.view">
<field name="name">odoo.to.bemade.instance.tree</field>
<field name="model">odoo.to.bemade.instance</field>
<field name="arch" type="xml">
<tree string="Instances Bemade" decoration-success="state == 'connected'" decoration-danger="state == 'error'" decoration-info="state == 'testing'" decoration-muted="state == 'draft'">
<field name="name"/>
<field name="url"/>
<field name="database"/>
<field name="username"/>
<field name="connection_type"/>
<field name="state"/>
<field name="active" invisible="1"/>
</tree>
</field>
</record>
<!-- Vue recherche pour les instances de synchronisation -->
<record id="view_odoo_to_bemade_instance_search" model="ir.ui.view">
<field name="name">odoo.to.bemade.instance.search</field>
<field name="model">odoo.to.bemade.instance</field>
<field name="arch" type="xml">
<search string="Rechercher une instance">
<field name="name"/>
<field name="url"/>
<field name="database"/>
<field name="username"/>
<filter string="Actives" name="active" domain="[('active', '=', True)]"/>
<filter string="Connectées" name="connected" domain="[('state', '=', 'connected')]"/>
<filter string="En erreur" name="error" domain="[('state', '=', 'error')]"/>
<group expand="0" string="Regrouper par">
<filter string="État" name="groupby_state" domain="[]" context="{'group_by': 'state'}"/>
<filter string="Type de connexion" name="groupby_connection_type" domain="[]" context="{'group_by': 'connection_type'}"/>
</group>
</search>
</field>
</record>
<!-- Action pour les instances -->
<record id="action_odoo_to_bemade_instances" model="ir.actions.act_window">
<field name="name">Instances Bemade</field>
<field name="res_model">odoo.to.bemade.instance</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Créer votre première instance de connexion à Bemade
</p>
<p>
Une instance représente une connexion à une plateforme Bemade
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vue formulaire pour les journaux de synchronisation -->
<record id="view_odoo_to_bemade_sync_log_form" model="ir.ui.view">
<field name="name">odoo.to.bemade.sync.log.form</field>
<field name="model">odoo.to.bemade.sync.log</field>
<field name="arch" type="xml">
<form string="Journal de synchronisation" create="false" edit="false">
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="create_date"/>
<field name="model_id"/>
<field name="record_id"/>
</group>
<group>
<field name="operation"/>
<field name="result"/>
<field name="user_id"/>
</group>
</group>
<notebook>
<page string="Détails">
<field name="details" widget="html"/>
</page>
<page string="Données">
<field name="data_json"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Vue liste pour les journaux de synchronisation -->
<record id="view_odoo_to_bemade_sync_log_tree" model="ir.ui.view">
<field name="name">odoo.to.bemade.sync.log.tree</field>
<field name="model">odoo.to.bemade.sync.log</field>
<field name="arch" type="xml">
<tree string="Journaux de synchronisation" decoration-success="result == 'success'" decoration-danger="result == 'error'" decoration-info="result == 'info'" create="false">
<field name="create_date"/>
<field name="name"/>
<field name="model_id"/>
<field name="record_id"/>
<field name="operation"/>
<field name="result"/>
<field name="user_id"/>
</tree>
</field>
</record>
<!-- Vue recherche pour les journaux de synchronisation -->
<record id="view_odoo_to_bemade_sync_log_search" model="ir.ui.view">
<field name="name">odoo.to.bemade.sync.log.search</field>
<field name="model">odoo.to.bemade.sync.log</field>
<field name="arch" type="xml">
<search string="Rechercher dans les journaux">
<field name="name"/>
<field name="model_id"/>
<field name="record_id"/>
<field name="details"/>
<separator/>
<filter string="Succès" name="success" domain="[('result', '=', 'success')]"/>
<filter string="Erreurs" name="error" domain="[('result', '=', 'error')]"/>
<filter string="Informations" name="info" domain="[('result', '=', 'info')]"/>
<separator/>
<filter string="Moi" name="my_logs" domain="[('user_id', '=', uid)]"/>
<group expand="0" string="Regrouper par">
<filter string="Modèle" name="groupby_model" domain="[]" context="{'group_by': 'model_id'}"/>
<filter string="Opération" name="groupby_operation" domain="[]" context="{'group_by': 'operation'}"/>
<filter string="Résultat" name="groupby_result" domain="[]" context="{'group_by': 'result'}"/>
<filter string="Date" name="groupby_create_date" domain="[]" context="{'group_by': 'create_date:day'}"/>
<filter string="Utilisateur" name="groupby_user" domain="[]" context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
</record>
<!-- Action pour les journaux -->
<record id="action_odoo_to_bemade_sync_logs" model="ir.actions.act_window">
<field name="name">Journaux de synchronisation</field>
<field name="res_model">odoo.to.bemade.sync.log</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_error': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Aucun journal de synchronisation pour le moment
</p>
<p>
Les journaux sont créés automatiquement lors des opérations de synchronisation.
Ils permettent de suivre l'historique des synchronisations et de diagnostiquer les problèmes.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vue formulaire pour les modèles de synchronisation -->
<record id="view_odoo_to_bemade_sync_model_form" model="ir.ui.view">
<field name="name">odoo.to.bemade.sync.model.form</field>
<field name="model">odoo.to.bemade.sync.model</field>
<field name="arch" type="xml">
<form string="Modèle synchronisé">
<header>
<button name="sync_all_records" string="Synchroniser tous les enregistrements" type="object"
class="oe_highlight" attrs="{'invisible': [('active', '=', False)]}"/>
<button name="create_all_fields" string="Créer tous les champs" type="object"
confirm="Êtes-vous sûr de vouloir créer automatiquement tous les champs pour ce modèle ?"/>
</header>
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Nom du modèle synchronisé"/></h1>
</div>
<group>
<group>
<field name="model" placeholder="ex: res.partner"/>
<field name="bemade_model" placeholder="ex: res.partner"/>
<field name="bemade_instance_id"/>
</group>
<group>
<field name="sync_domain" placeholder="ex: [('active', '=', True)]"/>
<field name="active"/>
<field name="priority"/>
</group>
</group>
<notebook>
<page string="Champs synchronisés">
<field name="field_ids">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="source_field"/>
<field name="target_field"/>
<field name="transform_type"/>
<field name="is_identifier"/>
<field name="active"/>
</tree>
</field>
</page>
<page string="Options avancées">
<group>
<field name="create_active"/>
<field name="write_active"/>
<field name="unlink_active"/>
<field name="record_count"/>
<field name="field_count"/>
</group>
</page>
<page string="File d'attente">
<field name="queue_ids">
<tree>
<field name="create_date"/>
<field name="name"/>
<field name="operation"/>
<field name="state"/>
<field name="error_message"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Vue liste pour les modèles de synchronisation -->
<record id="view_odoo_to_bemade_sync_model_tree" model="ir.ui.view">
<field name="name">odoo.to.bemade.sync.model.tree</field>
<field name="model">odoo.to.bemade.sync.model</field>
<field name="arch" type="xml">
<tree string="Modèles synchronisés" decoration-muted="active == False">
<field name="name"/>
<field name="model"/>
<field name="bemade_model"/>
<field name="bemade_instance_id"/>
<field name="priority"/>
<field name="record_count"/>
<field name="field_count"/>
<field name="active" invisible="1"/>
</tree>
</field>
</record>
<!-- Vue recherche pour les modèles de synchronisation -->
<record id="view_odoo_to_bemade_sync_model_search" model="ir.ui.view">
<field name="name">odoo.to.bemade.sync.model.search</field>
<field name="model">odoo.to.bemade.sync.model</field>
<field name="arch" type="xml">
<search string="Rechercher un modèle">
<field name="name"/>
<field name="model"/>
<field name="bemade_model"/>
<field name="bemade_instance_id"/>
<filter string="Actifs" name="active" domain="[('active', '=', True)]"/>
<group expand="0" string="Regrouper par">
<filter string="Instance" name="groupby_instance" domain="[]" context="{'group_by': 'bemade_instance_id'}"/>
<filter string="Modèle local" name="groupby_model" domain="[]" context="{'group_by': 'model'}"/>
</group>
</search>
</field>
</record>
<!-- Action pour les modèles -->
<record id="action_odoo_to_bemade_sync_models" model="ir.actions.act_window">
<field name="name">Modèles synchronisés</field>
<field name="res_model">odoo.to.bemade.sync.model</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Configurer votre premier modèle à synchroniser
</p>
<p>
Les modèles définissent quelles données sont synchronisées avec Bemade
et comment elles sont mappées entre les systèmes.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vue formulaire pour les éléments de file d'attente -->
<record id="view_odoo_to_bemade_sync_queue_form" model="ir.ui.view">
<field name="name">odoo.to.bemade.sync.queue.form</field>
<field name="model">odoo.to.bemade.sync.queue</field>
<field name="arch" type="xml">
<form string="File d'attente de synchronisation">
<header>
<button name="retry_sync" string="Réessayer" type="object" class="oe_highlight"
attrs="{'invisible': [('state', 'not in', ['error', 'draft'])]}"/>
<button name="cancel_sync" string="Annuler" type="object"
attrs="{'invisible': [('state', 'in', ['done', 'cancel'])]}"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,pending,in_progress,done,error"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="model_id"/>
<field name="record_id"/>
<field name="operation"/>
</group>
<group>
<field name="create_date"/>
<field name="priority"/>
<field name="retry_count"/>
<field name="next_retry"/>
</group>
</group>
<notebook>
<page string="Données">
<field name="data_json"/>
</page>
<page string="Erreurs" attrs="{'invisible': [('error_message', '=', False)]}">
<field name="error_message"/>
</page>
<page string="Journaux">
<field name="log_ids">
<tree>
<field name="create_date"/>
<field name="name"/>
<field name="result"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Vue liste pour les éléments de file d'attente -->
<record id="view_odoo_to_bemade_sync_queue_tree" model="ir.ui.view">
<field name="name">odoo.to.bemade.sync.queue.tree</field>
<field name="model">odoo.to.bemade.sync.queue</field>
<field name="arch" type="xml">
<tree string="File d'attente de synchronisation"
decoration-success="state == 'done'"
decoration-info="state in ('draft', 'pending')"
decoration-warning="state == 'in_progress'"
decoration-danger="state == 'error'"
decoration-muted="state == 'cancel'">
<field name="name"/>
<field name="model_id"/>
<field name="record_id"/>
<field name="operation"/>
<field name="create_date"/>
<field name="priority"/>
<field name="retry_count"/>
<field name="state"/>
</tree>
</field>
</record>
<!-- Vue recherche pour les éléments de file d'attente -->
<record id="view_odoo_to_bemade_sync_queue_search" model="ir.ui.view">
<field name="name">odoo.to.bemade.sync.queue.search</field>
<field name="model">odoo.to.bemade.sync.queue</field>
<field name="arch" type="xml">
<search string="Rechercher dans la file d'attente">
<field name="name"/>
<field name="model_id"/>
<field name="record_id"/>
<separator/>
<filter string="À traiter" name="to_process" domain="[('state', 'in', ['draft', 'pending'])]"/>
<filter string="En cours" name="in_progress" domain="[('state', '=', 'in_progress')]"/>
<filter string="En erreur" name="error" domain="[('state', '=', 'error')]"/>
<filter string="Terminé" name="done" domain="[('state', '=', 'done')]"/>
<filter string="Annulé" name="cancel" domain="[('state', '=', 'cancel')]"/>
<separator/>
<filter string="Priorité haute" name="high_priority" domain="[('priority', '&lt;=', 5)]"/>
<group expand="0" string="Regrouper par">
<filter string="Modèle" name="groupby_model" domain="[]" context="{'group_by': 'model_id'}"/>
<filter string="Opération" name="groupby_operation" domain="[]" context="{'group_by': 'operation'}"/>
<filter string="État" name="groupby_state" domain="[]" context="{'group_by': 'state'}"/>
<filter string="Date de création" name="groupby_create_date" domain="[]" context="{'group_by': 'create_date:day'}"/>
</group>
</search>
</field>
</record>
<!-- Action pour la file d'attente -->
<record id="action_odoo_to_bemade_sync_queue" model="ir.actions.act_window">
<field name="name">File d'attente de synchronisation</field>
<field name="res_model">odoo.to.bemade.sync.queue</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_to_process': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
La file d'attente est vide!
</p>
<p>
Les éléments de la file d'attente sont créés automatiquement
lorsque des enregistrements sont modifiés dans les modèles synchronisés.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,31 @@
{
'name': 'Odoo to Odoo Bemade Customer',
'version': '18.0.1.0.0',
'category': 'Technical',
'summary': 'Connecteur spécifique pour synchronisation avec Odoo.bemade.org',
'description': """
Module de synchronisation pour les clients Bemade
- Connexion sécurisée avec Odoo.bemade.org
- Synchronisation asynchrone
- Installation simplifiée
- Configuration automatique
- Monitoring et reprise sur erreur
""",
'author': 'Bemade',
'website': 'https://bemade.org',
'depends': [
'base',
'odoo_to_odoo_sync'
],
'data': [
'security/ir.model.access.csv',
'views/sync_config_views.xml',
'views/sync_queue_views.xml',
'views/sync_log_views.xml',
'views/menus.xml',
'data/ir_cron_data.xml',
],
'installable': True,
'application': True,
'license': 'LGPL-3',
}

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Tâche planifiée pour traiter la file d'attente de synchronisation client -->
<record id="ir_cron_process_customer_sync_queue" model="ir.cron">
<field name="name">Bemade Customer: Traiter la file d'attente de synchronisation</field>
<field name="model_id" ref="model_odoo_to_bemade_customer_sync_queue"/>
<field name="state">code</field>
<field name="code">model.process_queue(limit=100)</field>
<field name="interval_number">10</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
<!-- Tâche planifiée pour nettoyer les journaux de synchronisation client -->
<record id="ir_cron_clean_customer_sync_logs" model="ir.cron">
<field name="name">Bemade Customer: Nettoyer les anciens journaux de synchronisation</field>
<field name="model_id" ref="model_odoo_to_bemade_customer_sync_log"/>
<field name="state">code</field>
<field name="code">model.clean_old_logs(days=30)</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
<!-- Tâche planifiée pour vérifier les connexions aux instances client -->
<record id="ir_cron_check_customer_instance_connections" model="ir.cron">
<field name="name">Bemade Customer: Vérifier les connexions aux instances client</field>
<field name="model_id" ref="model_odoo_to_bemade_customer_instance"/>
<field name="state">code</field>
<field name="code">model.check_all_connections()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
<!-- Tâche planifiée pour synchroniser les données critiques du client -->
<record id="ir_cron_sync_customer_critical_data" model="ir.cron">
<field name="name">Bemade Customer: Synchroniser les données critiques</field>
<field name="model_id" ref="model_odoo_to_bemade_customer_sync_model"/>
<field name="state">code</field>
<field name="code">model.sync_critical_models()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,6 @@
from . import sync_config
from . import sync_model
from . import sync_model_field
from . import sync_queue
from . import sync_log
from . import sync_manager

View file

@ -0,0 +1,202 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Bemade Synchronization Configuration.
This module handles the connection configuration specifically designed for
connecting to Odoo.bemade.org. It provides a simplified interface for clients
to establish and maintain a connection with the Bemade platform.
"""
import logging
import xmlrpc.client
import uuid
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
BEMADE_URL = "https://odoo.bemade.org"
class OdooToBemadeCustomerConfig(models.Model):
"""Bemade connection configuration.
This model stores client-specific connection information for Bemade platform.
It is designed to be simple and focused, with most parameters pre-configured.
"""
_name = 'odoo.to.bemade.customer.config'
_description = 'Configuration Bemade'
name = fields.Char(
string='Nom',
default='Connexion Bemade',
readonly=True,
)
client_key = fields.Char(
string='Clé Client',
required=True,
help='Clé unique fournie par Bemade pour votre instance',
)
api_key = fields.Char(
string='Clé API',
required=True,
help='Clé API fournie par Bemade pour l\'authentification',
)
company_name = fields.Char(
string='Nom de l\'entreprise',
help='Nom de votre entreprise tel qu\'il apparaît chez Bemade',
)
state = fields.Selection(
selection=[
('draft', 'Non configuré'),
('testing', 'Test de connexion'),
('connected', 'Connecté'),
('error', 'Erreur')
],
default='draft',
string='État',
readonly=True,
)
last_sync = fields.Datetime(
string='Dernière synchronisation',
readonly=True,
)
error_message = fields.Text(
string='Message d\'erreur',
readonly=True,
)
sync_models = fields.One2many(
comodel_name='odoo.to.bemade.customer.sync.model',
inverse_name='config_id',
string='Modèles synchronisés',
readonly=True,
)
instance_id = fields.Char(
string='ID Instance',
readonly=True,
help='Identifiant unique de cette instance',
default=lambda self: str(uuid.uuid4()),
)
active = fields.Boolean(
string='Actif',
default=True,
)
auto_configure = fields.Boolean(
string='Configuration automatique',
default=True,
help='Configurer automatiquement les modèles à synchroniser',
)
@api.model
def create(self, vals):
"""Limiter à une seule configuration active"""
existing = self.search([('active', '=', True)])
if existing:
raise UserError(_("Une configuration Bemade existe déjà. Vous ne pouvez avoir qu'une seule configuration active."))
return super().create(vals)
def test_connection(self):
"""Tester la connexion avec Bemade"""
self.ensure_one()
self.state = 'testing'
try:
# Endpoint spécifique pour valider les clés client
common = xmlrpc.client.ServerProxy(f'{BEMADE_URL}/xmlrpc/2/common')
result = common.validate_client(self.client_key, self.api_key, self.instance_id)
if not result.get('success'):
raise UserError(result.get('message', 'Échec de validation des clés'))
# Mise à jour des informations
self.write({
'state': 'connected',
'company_name': result.get('company_name', self.company_name),
'error_message': False
})
# Si autoconfiguration est activée
if self.auto_configure:
self._configure_sync_models(result.get('models', []))
return True
except Exception as e:
self.write({
'state': 'error',
'error_message': str(e)
})
return False
def _configure_sync_models(self, models_data):
"""Configure les modèles à synchroniser basés sur les données de Bemade"""
model_obj = self.env['client.bemade.sync.model']
# Supprimer les anciens modèles
self.sync_models.unlink()
# Créer les nouveaux modèles
for model_data in models_data:
model_obj.create({
'config_id': self.id,
'name': model_data.get('name'),
'model': model_data.get('model'),
'bemade_model': model_data.get('bemade_model'),
'active': True,
'priority': model_data.get('priority', 10),
})
def get_connection(self):
"""Établir une connexion avec Bemade"""
self.ensure_one()
if self.state != 'connected':
self.test_connection()
if self.state != 'connected':
raise UserError(f"Impossible de se connecter à Bemade: {self.error_message}")
# Utiliser l'authentification par clé API
common = xmlrpc.client.ServerProxy(f'{BEMADE_URL}/xmlrpc/2/common')
uid = common.authenticate_client(self.client_key, self.api_key, self.instance_id)
if not uid:
raise UserError("Échec d'authentification avec Bemade")
models = xmlrpc.client.ServerProxy(f'{BEMADE_URL}/xmlrpc/2/object')
return models, uid
def reset_configuration(self):
"""Réinitialiser la configuration"""
self.ensure_one()
return self.write({
'state': 'draft',
'error_message': False
})
def action_sync_now(self):
"""Déclencher une synchronisation immédiate"""
self.ensure_one()
if self.state != 'connected':
raise UserError("Veuillez établir une connexion avant de synchroniser")
# Appeler le gestionnaire de synchronisation
return self.env['odoo.to.bemade.customer.sync.manager'].sync_all()
@api.model
def get_config(self):
"""Récupérer la configuration active"""
config = self.search([('active', '=', True)], limit=1)
if not config:
return False
return config

View file

@ -0,0 +1,131 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Journal de synchronisation pour Odoo to Bemade Customer.
Ce module enregistre toutes les opérations de synchronisation effectuées
entre Odoo client et Bemade pour des fins d'audit et de dépannage.
"""
from odoo import api, fields, models, _
class OdooToBemadeCustomerSyncLog(models.Model):
"""Journal des synchronisations.
Enregistre toutes les opérations de synchronisation pour traçabilité.
"""
_name = 'odoo.to.bemade.customer.sync.log'
_description = 'Journal de synchronisation Bemade'
_inherit = 'odoo.sync.log'
_order = 'create_date desc'
name = fields.Char(
string='Opération',
required=True,
)
model_id = fields.Many2one(
comodel_name='odoo.to.bemade.customer.sync.model',
string='Modèle',
ondelete='set null',
)
record_id = fields.Integer(
string='ID Enregistrement',
)
operation = fields.Selection(
selection=[
('sync', 'Synchronisation'),
('delete', 'Suppression'),
('conflict', 'Conflit'),
('error', 'Erreur'),
],
string='Type d\'opération',
required=True,
)
direction = fields.Selection(
selection=[
('to_bemade', 'Odoo → Bemade'),
('from_bemade', 'Bemade → Odoo'),
],
string='Direction',
)
result = fields.Selection(
selection=[
('success', 'Succès'),
('warning', 'Avertissement'),
('error', 'Erreur'),
],
string='Résultat',
required=True,
)
execution_time = fields.Float(
string='Temps d\'exécution (s)',
digits=(10, 3),
)
details = fields.Text(
string='Détails',
)
remote_id = fields.Char(
string='ID Bemade',
)
queue_id = fields.Many2one(
comodel_name='odoo.to.bemade.customer.sync.queue',
string='Entrée file d\'attente',
ondelete='set null',
)
user_id = fields.Many2one(
comodel_name='res.users',
string='Utilisateur',
default=lambda self: self.env.user.id,
readonly=True,
)
def action_view_record(self):
"""Affiche l'enregistrement associé à cette entrée de journal."""
self.ensure_one()
if not self.model_id or not self.record_id:
return False
return {
'name': _('Enregistrement synchronisé'),
'type': 'ir.actions.act_window',
'res_model': self.model_id.model,
'res_id': self.record_id,
'view_mode': 'form',
}
@api.model
def log(self, operation, model=None, record_id=None, result='success', details=None,
direction=None, remote_id=None, queue_id=None, execution_time=0):
"""Crée une entrée dans le journal des synchronisations."""
model_id = False
if model and isinstance(model, str):
model_rec = self.env['odoo.to.bemade.customer.sync.model'].search(
[('model', '=', model)], limit=1)
if model_rec:
model_id = model_rec.id
elif model and hasattr(model, 'id'):
model_id = model.id
name = f"{operation.capitalize()}"
if model_id:
model_name = self.env['odoo.to.bemade.customer.sync.model'].browse(model_id).name
name += f" - {model_name}"
if record_id:
name += f" #{record_id}"
vals = {
'name': name,
'model_id': model_id,
'record_id': record_id,
'operation': operation,
'result': result,
'details': details,
'direction': direction,
'remote_id': remote_id,
'queue_id': queue_id and queue_id if isinstance(queue_id, int) else queue_id and queue_id.id,
'execution_time': execution_time,
}
return self.create(vals)

View file

@ -0,0 +1,281 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Gestionnaire de synchronisation pour Odoo to Bemade Customer.
Ce module coordonne les processus de synchronisation entre Odoo client et Bemade.
"""
import time
import logging
from datetime import datetime, timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class OdooToBemadeCustomerSyncManager(models.AbstractModel):
"""Gestionnaire de synchronisation.
Coordonne les processus de synchronisation entre Odoo client et Bemade.
"""
_name = 'odoo.to.bemade.customer.sync.manager'
_description = 'Gestionnaire de synchronisation Bemade'
_inherit = 'odoo.sync.manager'
@api.model
def sync_all(self):
"""Synchronise tous les modèles actifs avec Bemade.
Lance le processus de synchronisation pour tous les modèles configurés
par ordre de priorité, et traite ensuite la file d'attente.
"""
config = self.env['odoo.to.bemade.customer.config'].get_config()
if not config:
raise UserError(_("Aucune configuration Bemade active trouvée"))
if config.state != 'connected':
raise UserError(_("La configuration Bemade n'est pas connectée"))
# Récupérer tous les modèles actifs
models = self.env['odoo.to.bemade.customer.sync.model'].search(
[('active', '=', True), ('config_id', '=', config.id)],
order='priority desc, id'
)
log_vals = {
'operation': 'sync',
'direction': 'to_bemade',
'result': 'success',
'details': _("Démarrage de la synchronisation complète")
}
self.env['odoo.to.bemade.customer.sync.log'].log(**log_vals)
# Synchroniser chaque modèle
for model in models:
try:
model.action_sync_model()
except Exception as e:
_logger.error(f"Erreur lors de la synchronisation du modèle {model.name}: {str(e)}")
log_vals = {
'operation': 'error',
'model': model,
'result': 'error',
'details': str(e)
}
self.env['odoo.to.bemade.customer.sync.log'].log(**log_vals)
# Traitement de la file d'attente
self.process_queue()
# Mise à jour de la date de dernière synchronisation
config.write({'last_sync': fields.Datetime.now()})
return True
@api.model
def process_queue(self, limit=50, queue_ids=None):
"""Traite les éléments en attente dans la file d'attente.
Args:
limit: Nombre maximum d'éléments à traiter
queue_ids: Liste d'IDs spécifiques à traiter, sinon tous les éléments en attente
"""
domain = [('state', '=', 'pending')]
# Si next_retry est défini, vérifier qu'il est passé
domain += [
'|',
('next_retry', '=', False),
('next_retry', '<=', fields.Datetime.now())
]
if queue_ids:
domain += [('id', 'in', queue_ids if isinstance(queue_ids, list) else [queue_ids])]
queue_items = self.env['odoo.to.bemade.customer.sync.queue'].search(
domain, order='priority desc, create_date', limit=limit
)
for queue_item in queue_items:
self._process_queue_item(queue_item)
return True
def _process_queue_item(self, queue_item):
"""Traite un élément spécifique de la file d'attente."""
start_time = time.time()
config = self.env['odoo.to.bemade.customer.config'].get_config()
if not config:
queue_item.write({
'state': 'error',
'error_message': _("Aucune configuration Bemade active trouvée")
})
return False
if config.state != 'connected':
queue_item.write({
'state': 'error',
'error_message': _("La configuration Bemade n'est pas connectée")
})
return False
# Marquer comme en cours de traitement
queue_item.write({'state': 'processing'})
try:
# Récupérer les informations de modèle et d'enregistrement
model_sync = queue_item.model_id
model_obj = self.env[model_sync.model]
record = model_obj.browse(queue_item.record_id)
if not record.exists():
queue_item.write({
'state': 'error',
'error_message': _("L'enregistrement n'existe plus"),
'execution_time': time.time() - start_time
})
return False
# Établir la connexion avec Bemade
conn = config.get_connection()
# Effectuer l'opération selon le type
result = False
if queue_item.operation == 'sync':
# Synchronisation de l'enregistrement vers Bemade
result = self._sync_record_to_bemade(conn, model_sync, record)
elif queue_item.operation == 'delete':
# Suppression de l'enregistrement chez Bemade
result = self._delete_record_from_bemade(conn, model_sync, record)
# Si tout s'est bien passé
if result:
queue_item.write({
'state': 'done',
'result': str(result),
'execution_time': time.time() - start_time
})
# Journalisation du succès
log_vals = {
'operation': queue_item.operation,
'model': model_sync.model,
'record_id': record.id,
'direction': 'to_bemade',
'result': 'success',
'queue_id': queue_item.id,
'execution_time': time.time() - start_time,
'details': str(result) if result else ""
}
self.env['odoo.to.bemade.customer.sync.log'].log(**log_vals)
return True
else:
# Échec sans exception
queue_item.write({
'state': 'error',
'error_message': _("Échec de l'opération - aucun résultat"),
'execution_time': time.time() - start_time
})
# Journalisation de l'erreur
log_vals = {
'operation': queue_item.operation,
'model': model_sync.model,
'record_id': record.id,
'direction': 'to_bemade',
'result': 'error',
'queue_id': queue_item.id,
'execution_time': time.time() - start_time,
'details': _("Échec de l'opération - aucun résultat")
}
self.env['odoo.to.bemade.customer.sync.log'].log(**log_vals)
return False
except Exception as e:
# Gestion des tentatives
retry_count = queue_item.retry_count + 1
error_message = f"{str(e)}"
if retry_count < queue_item.max_retries:
# Calculer le délai avant la prochaine tentative (croissance exponentielle)
delay = 5 * (2 ** (retry_count - 1)) # 5, 10, 20, 40, ...
next_retry = fields.Datetime.now() + timedelta(minutes=delay)
queue_item.write({
'state': 'pending',
'retry_count': retry_count,
'error_message': error_message,
'next_retry': next_retry,
'execution_time': time.time() - start_time
})
else:
# Nombre maximum de tentatives atteint
queue_item.write({
'state': 'error',
'retry_count': retry_count,
'error_message': error_message,
'execution_time': time.time() - start_time
})
# Journalisation de l'erreur
log_vals = {
'operation': queue_item.operation,
'model': queue_item.model_id.model,
'record_id': queue_item.record_id,
'direction': 'to_bemade',
'result': 'error',
'queue_id': queue_item.id,
'execution_time': time.time() - start_time,
'details': error_message
}
self.env['odoo.to.bemade.customer.sync.log'].log(**log_vals)
_logger.error(f"Erreur lors du traitement de la file d'attente: {error_message}")
return False
def _sync_record_to_bemade(self, connection, model_sync, record):
"""Synchronise un enregistrement vers Bemade.
Cette méthode est destinée à être étendue ou remplacée par
une implémentation spécifique selon les besoins.
"""
# Mapping des champs
mapping = {}
if model_sync.field_mapping:
try:
mapping = eval(model_sync.field_mapping)
except Exception as e:
_logger.error(f"Erreur lors de l'évaluation du mapping: {str(e)}")
mapping = {}
# Préparer les données à envoyer
data = {}
for local_field, remote_field in mapping.items():
if hasattr(record, local_field):
data[remote_field] = record[local_field]
# Appel à l'API Bemade
# Cette partie doit être implémentée selon l'API spécifique de Bemade
# Simulation pour le moment
_logger.info(f"Simulation de synchronisation vers Bemade: {data}")
return {'remote_id': f'bem-{record.id}', 'status': 'success'}
def _delete_record_from_bemade(self, connection, model_sync, record):
"""Supprime un enregistrement chez Bemade.
Cette méthode est destinée à être étendue ou remplacée par
une implémentation spécifique selon les besoins.
"""
# Appel à l'API Bemade pour supprimer l'enregistrement
# Cette partie doit être implémentée selon l'API spécifique de Bemade
# Simulation pour le moment
_logger.info(f"Simulation de suppression chez Bemade: {record}")
return {'status': 'deleted'}

View file

@ -0,0 +1,176 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Model Synchronization Configuration for Bemade clients.
This module defines how models are synchronized between client instances
and the Bemade platform. It is simplified to focus on the specific needs
of Bemade clients.
"""
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class OdooToBemadeCustomerSyncModel(models.Model):
_name = 'odoo.to.bemade.customer.sync.model'
_description = 'Modèle synchronisé avec Bemade'
_inherit = 'odoo.sync.model'
_order = 'priority, id'
name = fields.Char(
string='Nom',
required=True,
help='Nom descriptif du modèle synchronisé',
)
model = fields.Char(
string='Modèle local',
required=True,
help='Nom technique du modèle local (ex: res.partner)',
)
bemade_model = fields.Char(
string='Modèle Bemade',
required=True,
help='Nom technique du modèle correspondant chez Bemade',
)
config_id = fields.Many2one(
comodel_name='odoo.to.bemade.customer.config',
string='Configuration',
required=True,
ondelete='cascade',
)
active = fields.Boolean(
string='Actif',
default=True,
help='Indique si ce modèle est synchronisé activement',
)
priority = fields.Integer(
string='Priorité',
default=10,
help='Ordre de synchronisation (les valeurs plus élevées sont prioritaires)',
)
field_mapping = fields.Text(
string='Mapping des champs',
help='Mapping JSON des champs entre le modèle local et Bemade',
)
sync_domain = fields.Char(
string='Domaine de synchronisation',
default='[]',
help='Domaine pour filtrer les enregistrements à synchroniser, au format JSON',
)
last_sync = fields.Datetime(
string='Dernière synchronisation',
readonly=True,
)
sync_status = fields.Selection(
selection=[
('pending', 'En attente'),
('synced', 'Synchronisé'),
('error', 'Erreur')
],
default='pending',
string='Statut de synchronisation',
)
error_count = fields.Integer(
string='Nombre d\'erreurs',
default=0,
)
record_count = fields.Integer(
string='Enregistrements synchronisés',
compute='_compute_record_count',
)
@api.depends()
def _compute_record_count(self):
"""Calcule le nombre d'enregistrements synchronisés pour ce modèle"""
for record in self:
try:
model_obj = self.env[record.model]
domain = eval(record.sync_domain)
record.record_count = model_obj.search_count(domain)
except Exception as e:
_logger.error(f"Erreur lors du calcul du nombre d'enregistrements: {str(e)}")
record.record_count = 0
def action_sync_model(self):
"""Synchroniser ce modèle spécifique avec Bemade"""
self.ensure_one()
if not self.active:
raise UserError(_("Ce modèle n'est pas actif pour la synchronisation."))
# Obtenir la configuration
config = self.config_id
if not config or config.state != 'connected':
raise UserError(_("La connexion avec Bemade n'est pas établie."))
# Obtenir les enregistrements à synchroniser
model_obj = self.env[self.model]
domain = eval(self.sync_domain)
records = model_obj.search(domain)
# Queue des enregistrements pour synchronisation
queue_obj = self.env['odoo.to.bemade.customer.sync.queue']
count = 0
for record in records:
queue_obj.create({
'model_id': self.id,
'record_id': record.id,
'operation': 'sync',
'state': 'pending',
'priority': self.priority,
})
count += 1
# Mettre à jour le statut
self.write({
'last_sync': fields.Datetime.now(),
'sync_status': 'pending',
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Synchronisation programmée'),
'message': _(f"{count} enregistrements ont été mis en file d'attente pour la synchronisation."),
'sticky': False,
'type': 'success',
}
}
def action_view_records(self):
"""Afficher les enregistrements correspondant à ce modèle"""
self.ensure_one()
return {
'name': _('Enregistrements à synchroniser'),
'type': 'ir.actions.act_window',
'res_model': self.model,
'view_mode': 'tree,form',
'domain': self.sync_domain,
}
def action_view_queue(self):
"""Afficher les entrées de file d'attente pour ce modèle"""
self.ensure_one()
return {
'name': _('File d\'attente de synchronisation'),
'type': 'ir.actions.act_window',
'res_model': 'odoo.to.bemade.customer.sync.queue',
'view_mode': 'tree,form',
'domain': [('model_id', '=', self.id)],
}

View file

@ -0,0 +1,117 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Field Synchronization Configuration for Bemade clients.
This module defines how fields are synchronized between client instances
and the Bemade platform. It simplifies field mapping and transformations
for Bemade customers.
"""
import logging
import json
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
class OdooToBemadeCustomerSyncModelField(models.Model):
"""Champ de modèle synchronisé avec Bemade."""
_name = 'odoo.to.bemade.customer.sync.model.field'
_description = 'Champ de modèle synchronisé avec Bemade'
_inherit = 'odoo.sync.model.field'
sync_model_id = fields.Many2one(
comodel_name='odoo.to.bemade.customer.sync.model',
string='Modèle synchronisé',
required=True,
ondelete='cascade',
help='Modèle auquel ce champ appartient',
)
field_name = fields.Char(
string='Nom du champ',
required=True,
help='Nom technique du champ dans le modèle local (ex: name)'
)
bemade_field_name = fields.Char(
string='Nom du champ Bemade',
required=True,
help='Nom technique du champ correspondant chez Bemade'
)
is_identifier = fields.Boolean(
string='Est un identifiant',
default=False,
help='Indique si ce champ est utilisé pour identifier l\'enregistrement chez Bemade'
)
transform_type = fields.Selection(
selection=[
('none', 'Aucune transformation'),
('function', 'Fonction Python'),
('mapping', 'Mapping de valeurs')
],
string='Type de transformation',
default='none',
required=True,
help='Type de transformation à appliquer au champ lors de la synchronisation'
)
transform_mapping = fields.Text(
string='Mapping de transformation',
help='Mapping JSON pour la transformation des valeurs (format: {"valeur_source": "valeur_cible",...})'
)
transform_function = fields.Text(
string='Fonction de transformation',
help='Code Python pour transformer la valeur (doit retourner la valeur transformée)'
)
active = fields.Boolean(
string='Actif',
default=True,
help='Indique si ce champ est activement synchronisé'
)
@api.constrains('transform_mapping')
def _check_transform_mapping_json(self):
"""Vérifie que le mapping de transformation est un JSON valide."""
for record in self:
if record.transform_mapping:
try:
json.loads(record.transform_mapping)
except json.JSONDecodeError:
raise ValidationError(_("Le mapping de transformation doit être un JSON valide"))
def transform_value(self, value):
"""Transforme une valeur selon la configuration du champ."""
self.ensure_one()
if self.transform_type == 'none' or value is False:
return value
if self.transform_type == 'mapping':
if not self.transform_mapping:
return value
mapping = json.loads(self.transform_mapping)
str_value = str(value)
return mapping.get(str_value, value)
if self.transform_type == 'function':
if not self.transform_function:
return value
# Implémentation sécurisée de l'exécution de code - à améliorer
local_dict = {'value': value, 'result': value}
try:
# pylint: disable=exec-used
exec(self.transform_function, {'__builtins__': {}}, local_dict)
return local_dict.get('result', value)
except Exception as e:
_logger.error("Erreur lors de l'exécution de la fonction de transformation: %s", str(e))
return value

View file

@ -0,0 +1,116 @@
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Queue de synchronisation pour Odoo to Bemade Customer.
Ce module gère la file d'attente des opérations de synchronisation
entre Odoo client et Bemade.
"""
from datetime import datetime, timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class OdooToBemadeCustomerSyncQueue(models.Model):
"""File d'attente de synchronisation.
Gère les opérations de synchronisation en file d'attente entre Odoo client et Bemade.
"""
_name = 'odoo.to.bemade.customer.sync.queue'
_description = 'File d\'attente de synchronisation Bemade'
_inherit = 'odoo.sync.queue'
_order = 'priority desc, create_date'
name = fields.Char(
string='Nom',
compute='_compute_name',
store=True,
)
model_id = fields.Many2one(
comodel_name='odoo.to.bemade.customer.sync.model',
string='Modèle',
required=True,
ondelete='cascade',
)
record_id = fields.Integer(
string='ID Enregistrement',
required=True,
)
operation = fields.Selection(
selection=[
('sync', 'Synchroniser'),
('delete', 'Supprimer'),
],
default='sync',
string='Opération',
required=True,
)
state = fields.Selection(
selection=[
('pending', 'En attente'),
('processing', 'En cours'),
('done', 'Terminé'),
('error', 'Erreur'),
],
default='pending',
string='État',
)
priority = fields.Integer(
string='Priorité',
default=10,
help='Priorité de l\'opération (les valeurs plus élevées sont prioritaires)',
)
retry_count = fields.Integer(
string='Tentatives',
default=0,
)
max_retries = fields.Integer(
string='Tentatives max',
default=3,
)
error_message = fields.Text(
string='Message d\'erreur',
)
next_retry = fields.Datetime(
string='Prochaine tentative',
)
result = fields.Text(
string='Résultat',
)
execution_time = fields.Float(
string='Temps d\'exécution (s)',
digits=(10, 3),
)
@api.depends('model_id', 'record_id', 'operation')
def _compute_name(self):
"""Génère un nom descriptif pour l'entrée de la file d'attente."""
for queue in self:
if queue.model_id and queue.record_id:
queue.name = f"{queue.operation} - {queue.model_id.name} #{queue.record_id}"
else:
queue.name = f"Nouvelle entrée {queue.id}"
def action_reset(self):
"""Réinitialise une entrée de file d'attente en erreur à l'état en attente."""
return self.write({
'state': 'pending',
'retry_count': 0,
'error_message': False,
'next_retry': False
})
def action_cancel(self):
"""Annule une entrée de file d'attente."""
return self.unlink()
def action_retry_now(self):
"""Force la tentative immédiate de traitement d'une entrée en erreur."""
self.ensure_one()
self.write({
'state': 'pending',
'next_retry': fields.Datetime.now(),
})
return self.env['odoo.to.bemade.customer.sync.manager'].process_queue(queue_ids=self.ids)

View file

@ -0,0 +1,13 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_odoo_to_bemade_customer_sync_model_admin,odoo.to.bemade.customer.sync.model admin,model_odoo_to_bemade_customer_sync_model,base.group_system,1,1,1,1
access_odoo_to_bemade_customer_sync_model_user,odoo.to.bemade.customer.sync.model user,model_odoo_to_bemade_customer_sync_model,base.group_user,1,0,0,0
access_odoo_to_bemade_customer_sync_model_field_admin,odoo.to.bemade.customer.sync.model.field admin,model_odoo_to_bemade_customer_sync_model_field,base.group_system,1,1,1,1
access_odoo_to_bemade_customer_sync_model_field_user,odoo.to.bemade.customer.sync.model.field user,model_odoo_to_bemade_customer_sync_model_field,base.group_user,1,0,0,0
access_odoo_to_bemade_customer_sync_queue_admin,odoo.to.bemade.customer.sync.queue admin,model_odoo_to_bemade_customer_sync_queue,base.group_system,1,1,1,1
access_odoo_to_bemade_customer_sync_queue_user,odoo.to.bemade.customer.sync.queue user,model_odoo_to_bemade_customer_sync_queue,base.group_user,1,0,0,0
access_odoo_to_bemade_customer_sync_log_admin,odoo.to.bemade.customer.sync.log admin,model_odoo_to_bemade_customer_sync_log,base.group_system,1,1,1,1
access_odoo_to_bemade_customer_sync_log_user,odoo.to.bemade.customer.sync.log user,model_odoo_to_bemade_customer_sync_log,base.group_user,1,1,0,0
access_odoo_to_bemade_customer_sync_manager_admin,odoo.to.bemade.customer.sync.manager admin,model_odoo_to_bemade_customer_sync_manager,base.group_system,1,1,1,1
access_odoo_to_bemade_customer_sync_manager_user,odoo.to.bemade.customer.sync.manager user,model_odoo_to_bemade_customer_sync_manager,base.group_user,1,0,0,0
access_odoo_to_bemade_customer_sync_config_admin,odoo.to.bemade.customer.sync.config admin,model_odoo_to_bemade_customer_sync_config,base.group_system,1,1,1,1
access_odoo_to_bemade_customer_sync_config_user,odoo.to.bemade.customer.sync.config user,model_odoo_to_bemade_customer_sync_config,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_odoo_to_bemade_customer_sync_model_admin odoo.to.bemade.customer.sync.model admin model_odoo_to_bemade_customer_sync_model base.group_system 1 1 1 1
3 access_odoo_to_bemade_customer_sync_model_user odoo.to.bemade.customer.sync.model user model_odoo_to_bemade_customer_sync_model base.group_user 1 0 0 0
4 access_odoo_to_bemade_customer_sync_model_field_admin odoo.to.bemade.customer.sync.model.field admin model_odoo_to_bemade_customer_sync_model_field base.group_system 1 1 1 1
5 access_odoo_to_bemade_customer_sync_model_field_user odoo.to.bemade.customer.sync.model.field user model_odoo_to_bemade_customer_sync_model_field base.group_user 1 0 0 0
6 access_odoo_to_bemade_customer_sync_queue_admin odoo.to.bemade.customer.sync.queue admin model_odoo_to_bemade_customer_sync_queue base.group_system 1 1 1 1
7 access_odoo_to_bemade_customer_sync_queue_user odoo.to.bemade.customer.sync.queue user model_odoo_to_bemade_customer_sync_queue base.group_user 1 0 0 0
8 access_odoo_to_bemade_customer_sync_log_admin odoo.to.bemade.customer.sync.log admin model_odoo_to_bemade_customer_sync_log base.group_system 1 1 1 1
9 access_odoo_to_bemade_customer_sync_log_user odoo.to.bemade.customer.sync.log user model_odoo_to_bemade_customer_sync_log base.group_user 1 1 0 0
10 access_odoo_to_bemade_customer_sync_manager_admin odoo.to.bemade.customer.sync.manager admin model_odoo_to_bemade_customer_sync_manager base.group_system 1 1 1 1
11 access_odoo_to_bemade_customer_sync_manager_user odoo.to.bemade.customer.sync.manager user model_odoo_to_bemade_customer_sync_manager base.group_user 1 0 0 0
12 access_odoo_to_bemade_customer_sync_config_admin odoo.to.bemade.customer.sync.config admin model_odoo_to_bemade_customer_sync_config base.group_system 1 1 1 1
13 access_odoo_to_bemade_customer_sync_config_user odoo.to.bemade.customer.sync.config user model_odoo_to_bemade_customer_sync_config base.group_user 1 0 0 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Fond hexagonal -->
<polygon points="256,40 456,148 456,364 256,472 56,364 56,148" fill="#714B67" />
<polygon points="256,70 426,163 426,349 256,442 86,349 86,163" fill="#FFFFFF" />
<!-- Chaîne reliant les deux instances - élargie pour dépasser des cercles -->
<path d="M125,220 L125,292 C125,304 135,314 147,314 L365,314 C377,314 387,304 387,292 L387,220 C387,208 377,198 365,198 L147,198 C135,198 125,208 125,220 Z" fill="#714B67" />
<!-- Silhouette Odoo gauche -->
<circle cx="176" cy="256" r="40" fill="#FFFFFF" />
<circle cx="176" cy="256" r="15" fill="#714B67" />
<!-- Silhouette Odoo droite -->
<circle cx="336" cy="256" r="40" fill="#FFFFFF" />
<circle cx="336" cy="256" r="15" fill="#714B67" />
<!-- Flèches de synchronisation -->
<path d="M216,236 L296,236" stroke="#FFFFFF" stroke-width="8" fill="none" />
<path d="M296,276 L216,276" stroke="#FFFFFF" stroke-width="8" fill="none" />
<!-- Pointes de flèches -->
<polygon points="296,236 284,226 284,246" fill="#FFFFFF" />
<polygon points="216,276 228,266 228,286" fill="#FFFFFF" />
<!-- Petits cercles de connexion -->
<circle cx="216" cy="236" r="6" fill="#FFFFFF" />
<circle cx="296" cy="236" r="6" fill="#FFFFFF" />
<circle cx="216" cy="276" r="6" fill="#FFFFFF" />
<circle cx="296" cy="276" r="6" fill="#FFFFFF" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,126 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1 class="text-center">Odoo to Odoo Bemade Customer</h1>
<h3 class="text-center">Solution de synchronisation dédiée pour les clients de Bemade</h3>
<hr/>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Présentation</h2>
<p>
Le module <strong>Odoo to Odoo Bemade Customer</strong> est spécialement conçu pour les clients de Bemade
qui souhaitent synchroniser leurs données avec la plateforme Bemade. Il offre une solution clé en main avec
une configuration simplifiée, spécifiquement adaptée aux besoins des clients.
</p>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Fonctionnalités</h2>
<ul>
<li>Connexion sécurisée et authentifiée à la plateforme Bemade</li>
<li>Configuration automatique des modèles et champs standards</li>
<li>Interface simplifiée pour les administrateurs</li>
<li>Synchronisation bidirectionnelle des données</li>
<li>Journalisation détaillée et gestion des erreurs adaptée aux clients</li>
<li>Reprise automatique sur erreur</li>
<li>Monitoring intégré des activités de synchronisation</li>
</ul>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Différence avec le module Bemade standard</h2>
<p>
Contrairement au module <code>odoo_to_odoo_bemade</code> qui est conçu pour des intégrations personnalisées
et complexes, ce module est spécifiquement développé pour simplifier l'expérience des clients Bemade:
</p>
<table class="table table-bordered">
<thead>
<tr>
<th>Fonctionnalité</th>
<th>odoo_to_odoo_bemade</th>
<th>odoo_to_odoo_bemade_customer</th>
</tr>
</thead>
<tbody>
<tr>
<td>Configuration</td>
<td>Manuelle et détaillée</td>
<td>Automatique et simplifiée</td>
</tr>
<tr>
<td>Cible</td>
<td>Développeurs et intégrateurs</td>
<td>Utilisateurs finaux et administrateurs</td>
</tr>
<tr>
<td>Connexion</td>
<td>Multiple et configurable</td>
<td>Spécifique à Bemade.org</td>
</tr>
<tr>
<td>Interface</td>
<td>Technique et complète</td>
<td>Simplifiée et adaptée aux clients</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Architecture</h2>
<p>
Ce module s'appuie sur l'architecture de base du module <code>odoo_to_odoo_sync</code> et étend les modèles
avec une couche spécifique aux clients Bemade. Il étend les modèles suivants:
</p>
<ul>
<li><strong>odoo.sync.model</strong><strong>odoo.to.bemade.customer.sync.model</strong></li>
<li><strong>odoo.sync.model.field</strong><strong>odoo.to.bemade.customer.sync.model.field</strong></li>
<li><strong>odoo.sync.queue</strong><strong>odoo.to.bemade.customer.sync.queue</strong></li>
<li><strong>odoo.sync.log</strong><strong>odoo.to.bemade.customer.sync.log</strong></li>
<li><strong>odoo.sync.manager</strong><strong>odoo.to.bemade.customer.sync.manager</strong></li>
</ul>
<p>
Cette architecture permet une expérience utilisateur optimisée tout en bénéficiant de la robustesse
du système de synchronisation de base.
</p>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Installation</h2>
<p>
L'installation est simplifiée pour les clients Bemade:
</p>
<ol>
<li>Installez ce module depuis le catalogue d'applications Bemade</li>
<li>Entrez votre clé d'API Bemade (fournie par votre gestionnaire de compte)</li>
<li>Sélectionnez les données à synchroniser dans l'assistant</li>
<li>La synchronisation initiale démarre automatiquement</li>
</ol>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Support dédié</h2>
<p>
En tant que client Bemade, vous bénéficiez d'un support prioritaire:
</p>
<ul>
<li>Support par email: support-client@bemade.org</li>
<li>Assistance téléphonique: +1 (XXX) XXX-XXXX</li>
<li>Portail client: <a href="https://clients.bemade.org">https://clients.bemade.org</a></li>
</ul>
</div>
</div>
</div>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Menu principal -->
<menuitem id="menu_odoo_to_bemade_customer_root"
name="Synchronisation Clients"
sequence="81"
web_icon="odoo_to_odoo_bemade_customer,static/description/icon.svg"/>
<!-- Sous-menus -->
<menuitem id="menu_odoo_to_bemade_customer_instances"
name="Instances Clients"
parent="menu_odoo_to_bemade_customer_root"
action="action_odoo_to_bemade_customer_instances"
sequence="10"/>
<menuitem id="menu_odoo_to_bemade_customer_sync_models"
name="Modèles synchronisés"
parent="menu_odoo_to_bemade_customer_root"
action="action_odoo_to_bemade_customer_sync_models"
sequence="20"/>
<menuitem id="menu_odoo_to_bemade_customer_sync_queue"
name="File d'attente"
parent="menu_odoo_to_bemade_customer_root"
action="action_odoo_to_bemade_customer_sync_queue"
sequence="30"/>
<menuitem id="menu_odoo_to_bemade_customer_sync_logs"
name="Journaux de synchronisation"
parent="menu_odoo_to_bemade_customer_root"
action="action_odoo_to_bemade_customer_sync_logs"
sequence="40"/>
</odoo>

View file

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vue formulaire pour les instances de synchronisation client -->
<record id="view_odoo_to_bemade_customer_instance_form" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.instance.form</field>
<field name="model">odoo.to.bemade.customer.instance</field>
<field name="arch" type="xml">
<form string="Instance Client Bemade">
<header>
<button name="test_connection" string="Tester la connexion" type="object" class="oe_highlight"
attrs="{'invisible': [('state', '=', 'connected')]}"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,testing,error,connected"/>
</header>
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Nom de l'instance client"/></h1>
</div>
<group>
<group>
<field name="url" placeholder="https://example.odoo.com"
attrs="{'readonly': [('state', '=', 'connected')]}"/>
<field name="database" attrs="{'readonly': [('state', '=', 'connected')]}"/>
<field name="partner_id"/>
</group>
<group>
<field name="username" attrs="{'readonly': [('state', '=', 'connected')]}"/>
<field name="password" password="True" attrs="{'readonly': [('state', '=', 'connected')]}"/>
<field name="api_key" password="True" attrs="{'readonly': [('state', '=', 'connected')]}"/>
</group>
</group>
<group>
<field name="connection_type" widget="radio"/>
<field name="active"/>
</group>
<notebook>
<page string="Modèles synchronisés">
<field name="sync_model_ids">
<tree>
<field name="name"/>
<field name="model"/>
<field name="customer_model"/>
<field name="active"/>
</tree>
</field>
</page>
<page string="Options avancées">
<group>
<field name="timeout"/>
<field name="retry_count"/>
<field name="retry_delay"/>
<field name="customer_version"/>
</group>
</page>
<page string="Journal de connexion">
<field name="log_ids">
<tree>
<field name="create_date"/>
<field name="name"/>
<field name="result"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Vue liste pour les instances de synchronisation client -->
<record id="view_odoo_to_bemade_customer_instance_tree" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.instance.tree</field>
<field name="model">odoo.to.bemade.customer.instance</field>
<field name="arch" type="xml">
<tree string="Instances Client" decoration-success="state == 'connected'" decoration-danger="state == 'error'" decoration-info="state == 'testing'" decoration-muted="state == 'draft'">
<field name="name"/>
<field name="url"/>
<field name="database"/>
<field name="partner_id"/>
<field name="connection_type"/>
<field name="state"/>
<field name="active" invisible="1"/>
</tree>
</field>
</record>
<!-- Vue recherche pour les instances de synchronisation client -->
<record id="view_odoo_to_bemade_customer_instance_search" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.instance.search</field>
<field name="model">odoo.to.bemade.customer.instance</field>
<field name="arch" type="xml">
<search string="Rechercher une instance client">
<field name="name"/>
<field name="url"/>
<field name="database"/>
<field name="partner_id"/>
<filter string="Actives" name="active" domain="[('active', '=', True)]"/>
<filter string="Connectées" name="connected" domain="[('state', '=', 'connected')]"/>
<filter string="En erreur" name="error" domain="[('state', '=', 'error')]"/>
<group expand="0" string="Regrouper par">
<filter string="Client" name="groupby_partner" domain="[]" context="{'group_by': 'partner_id'}"/>
<filter string="État" name="groupby_state" domain="[]" context="{'group_by': 'state'}"/>
<filter string="Type de connexion" name="groupby_connection_type" domain="[]" context="{'group_by': 'connection_type'}"/>
</group>
</search>
</field>
</record>
<!-- Action pour les instances client -->
<record id="action_odoo_to_bemade_customer_instances" model="ir.actions.act_window">
<field name="name">Instances Client</field>
<field name="res_model">odoo.to.bemade.customer.instance</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Créer votre première instance de connexion client
</p>
<p>
Une instance client représente une connexion à un système Odoo d'un client
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vue formulaire pour les journaux de synchronisation client -->
<record id="view_odoo_to_bemade_customer_sync_log_form" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.sync.log.form</field>
<field name="model">odoo.to.bemade.customer.sync.log</field>
<field name="arch" type="xml">
<form string="Journal de synchronisation client" create="false" edit="false">
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="create_date"/>
<field name="model_id"/>
<field name="record_id"/>
</group>
<group>
<field name="operation"/>
<field name="result"/>
<field name="user_id"/>
</group>
</group>
<notebook>
<page string="Détails">
<field name="details" widget="html"/>
</page>
<page string="Données">
<field name="data_json"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Vue liste pour les journaux de synchronisation client -->
<record id="view_odoo_to_bemade_customer_sync_log_tree" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.sync.log.tree</field>
<field name="model">odoo.to.bemade.customer.sync.log</field>
<field name="arch" type="xml">
<tree string="Journaux de synchronisation client" decoration-success="result == 'success'" decoration-danger="result == 'error'" decoration-info="result == 'info'" create="false">
<field name="create_date"/>
<field name="name"/>
<field name="model_id"/>
<field name="record_id"/>
<field name="operation"/>
<field name="result"/>
<field name="user_id"/>
</tree>
</field>
</record>
<!-- Vue recherche pour les journaux de synchronisation client -->
<record id="view_odoo_to_bemade_customer_sync_log_search" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.sync.log.search</field>
<field name="model">odoo.to.bemade.customer.sync.log</field>
<field name="arch" type="xml">
<search string="Rechercher dans les journaux client">
<field name="name"/>
<field name="model_id"/>
<field name="record_id"/>
<field name="details"/>
<separator/>
<filter string="Succès" name="success" domain="[('result', '=', 'success')]"/>
<filter string="Erreurs" name="error" domain="[('result', '=', 'error')]"/>
<filter string="Informations" name="info" domain="[('result', '=', 'info')]"/>
<separator/>
<filter string="Moi" name="my_logs" domain="[('user_id', '=', uid)]"/>
<group expand="0" string="Regrouper par">
<filter string="Modèle" name="groupby_model" domain="[]" context="{'group_by': 'model_id'}"/>
<filter string="Opération" name="groupby_operation" domain="[]" context="{'group_by': 'operation'}"/>
<filter string="Résultat" name="groupby_result" domain="[]" context="{'group_by': 'result'}"/>
<filter string="Date" name="groupby_create_date" domain="[]" context="{'group_by': 'create_date:day'}"/>
<filter string="Utilisateur" name="groupby_user" domain="[]" context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
</record>
<!-- Action pour les journaux -->
<record id="action_odoo_to_bemade_customer_sync_logs" model="ir.actions.act_window">
<field name="name">Journaux de synchronisation client</field>
<field name="res_model">odoo.to.bemade.customer.sync.log</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_error': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Aucun journal de synchronisation client pour le moment
</p>
<p>
Les journaux sont créés automatiquement lors des opérations de synchronisation.
Ils permettent de suivre l'historique des synchronisations et de diagnostiquer les problèmes.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vue formulaire pour les modèles de synchronisation client -->
<record id="view_odoo_to_bemade_customer_sync_model_form" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.sync.model.form</field>
<field name="model">odoo.to.bemade.customer.sync.model</field>
<field name="arch" type="xml">
<form string="Modèle synchronisé client">
<header>
<button name="sync_all_records" string="Synchroniser tous les enregistrements" type="object"
class="oe_highlight" attrs="{'invisible': [('active', '=', False)]}"/>
<button name="create_all_fields" string="Créer tous les champs" type="object"
confirm="Êtes-vous sûr de vouloir créer automatiquement tous les champs pour ce modèle ?"/>
</header>
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Nom du modèle synchronisé client"/></h1>
</div>
<group>
<group>
<field name="model" placeholder="ex: res.partner"/>
<field name="customer_model" placeholder="ex: res.partner"/>
<field name="customer_instance_id"/>
</group>
<group>
<field name="sync_domain" placeholder="ex: [('active', '=', True)]"/>
<field name="active"/>
<field name="priority"/>
</group>
</group>
<notebook>
<page string="Champs synchronisés">
<field name="field_ids">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="source_field"/>
<field name="target_field"/>
<field name="transform_type"/>
<field name="is_identifier"/>
<field name="active"/>
</tree>
</field>
</page>
<page string="Options avancées">
<group>
<field name="create_active"/>
<field name="write_active"/>
<field name="unlink_active"/>
<field name="record_count"/>
<field name="field_count"/>
</group>
</page>
<page string="File d'attente">
<field name="queue_ids">
<tree>
<field name="create_date"/>
<field name="name"/>
<field name="operation"/>
<field name="state"/>
<field name="error_message"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Vue liste pour les modèles de synchronisation client -->
<record id="view_odoo_to_bemade_customer_sync_model_tree" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.sync.model.tree</field>
<field name="model">odoo.to.bemade.customer.sync.model</field>
<field name="arch" type="xml">
<tree string="Modèles synchronisés client" decoration-muted="active == False">
<field name="name"/>
<field name="model"/>
<field name="customer_model"/>
<field name="customer_instance_id"/>
<field name="priority"/>
<field name="record_count"/>
<field name="field_count"/>
<field name="active" invisible="1"/>
</tree>
</field>
</record>
<!-- Vue recherche pour les modèles de synchronisation client -->
<record id="view_odoo_to_bemade_customer_sync_model_search" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.sync.model.search</field>
<field name="model">odoo.to.bemade.customer.sync.model</field>
<field name="arch" type="xml">
<search string="Rechercher un modèle client">
<field name="name"/>
<field name="model"/>
<field name="customer_model"/>
<field name="customer_instance_id"/>
<filter string="Actifs" name="active" domain="[('active', '=', True)]"/>
<group expand="0" string="Regrouper par">
<filter string="Instance client" name="groupby_instance" domain="[]" context="{'group_by': 'customer_instance_id'}"/>
<filter string="Modèle local" name="groupby_model" domain="[]" context="{'group_by': 'model'}"/>
</group>
</search>
</field>
</record>
<!-- Action pour les modèles client -->
<record id="action_odoo_to_bemade_customer_sync_models" model="ir.actions.act_window">
<field name="name">Modèles synchronisés client</field>
<field name="res_model">odoo.to.bemade.customer.sync.model</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Configurer votre premier modèle à synchroniser avec un client
</p>
<p>
Les modèles définissent quelles données sont synchronisées avec les systèmes Odoo des clients
et comment elles sont mappées entre les systèmes.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vue formulaire pour les éléments de file d'attente -->
<record id="view_odoo_to_bemade_customer_sync_queue_form" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.sync.queue.form</field>
<field name="model">odoo.to.bemade.customer.sync.queue</field>
<field name="arch" type="xml">
<form string="File d'attente de synchronisation client">
<header>
<button name="retry_sync" string="Réessayer" type="object" class="oe_highlight"
attrs="{'invisible': [('state', 'not in', ['error', 'draft'])]}"/>
<button name="cancel_sync" string="Annuler" type="object"
attrs="{'invisible': [('state', 'in', ['done', 'cancel'])]}"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,pending,in_progress,done,error"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="model_id"/>
<field name="record_id"/>
<field name="operation"/>
</group>
<group>
<field name="create_date"/>
<field name="priority"/>
<field name="retry_count"/>
<field name="next_retry"/>
</group>
</group>
<notebook>
<page string="Données">
<field name="data_json"/>
</page>
<page string="Erreurs" attrs="{'invisible': [('error_message', '=', False)]}">
<field name="error_message"/>
</page>
<page string="Journaux">
<field name="log_ids">
<tree>
<field name="create_date"/>
<field name="name"/>
<field name="result"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Vue liste pour les éléments de file d'attente -->
<record id="view_odoo_to_bemade_customer_sync_queue_tree" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.sync.queue.tree</field>
<field name="model">odoo.to.bemade.customer.sync.queue</field>
<field name="arch" type="xml">
<tree string="File d'attente de synchronisation client"
decoration-success="state == 'done'"
decoration-info="state in ('draft', 'pending')"
decoration-warning="state == 'in_progress'"
decoration-danger="state == 'error'"
decoration-muted="state == 'cancel'">
<field name="name"/>
<field name="model_id"/>
<field name="record_id"/>
<field name="operation"/>
<field name="create_date"/>
<field name="priority"/>
<field name="retry_count"/>
<field name="state"/>
</tree>
</field>
</record>
<!-- Vue recherche pour les éléments de file d'attente -->
<record id="view_odoo_to_bemade_customer_sync_queue_search" model="ir.ui.view">
<field name="name">odoo.to.bemade.customer.sync.queue.search</field>
<field name="model">odoo.to.bemade.customer.sync.queue</field>
<field name="arch" type="xml">
<search string="Rechercher dans la file d'attente client">
<field name="name"/>
<field name="model_id"/>
<field name="record_id"/>
<separator/>
<filter string="À traiter" name="to_process" domain="[('state', 'in', ['draft', 'pending'])]"/>
<filter string="En cours" name="in_progress" domain="[('state', '=', 'in_progress')]"/>
<filter string="En erreur" name="error" domain="[('state', '=', 'error')]"/>
<filter string="Terminé" name="done" domain="[('state', '=', 'done')]"/>
<filter string="Annulé" name="cancel" domain="[('state', '=', 'cancel')]"/>
<separator/>
<filter string="Priorité haute" name="high_priority" domain="[('priority', '&lt;=', 5)]"/>
<group expand="0" string="Regrouper par">
<filter string="Modèle" name="groupby_model" domain="[]" context="{'group_by': 'model_id'}"/>
<filter string="Opération" name="groupby_operation" domain="[]" context="{'group_by': 'operation'}"/>
<filter string="État" name="groupby_state" domain="[]" context="{'group_by': 'state'}"/>
<filter string="Date de création" name="groupby_create_date" domain="[]" context="{'group_by': 'create_date:day'}"/>
</group>
</search>
</field>
</record>
<!-- Action pour la file d'attente -->
<record id="action_odoo_to_bemade_customer_sync_queue" model="ir.actions.act_window">
<field name="name">File d'attente de synchronisation client</field>
<field name="res_model">odoo.to.bemade.customer.sync.queue</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_to_process': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
La file d'attente client est vide!
</p>
<p>
Les éléments de la file d'attente sont créés automatiquement
lorsque des enregistrements sont modifiés dans les modèles synchronisés.
</p>
</field>
</record>
</odoo>

View file

@ -1,4 +1,4 @@
# Copyright 2025 Codeium
# Copyright 2025 Bemade
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html)
"""Remote Odoo Instance Management.
@ -10,8 +10,10 @@ test connections, and maintain the connection state with remote instances.
import logging
import xmlrpc.client
from urllib.parse import urlparse
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
@ -54,6 +56,41 @@ class OdooSyncInstance(models.Model):
required=True,
help='Password of the technical user',
)
connection_type = fields.Selection(
selection=[
('xmlrpc', 'XML-RPC'),
('jsonrpc', 'JSON-RPC')
],
string='Connection Type',
default='xmlrpc',
required=True,
help='Protocol to use for connection to the remote instance',
)
connection_timeout = fields.Integer(
string='Connection Timeout',
default=30,
help='Timeout in seconds for connection to the remote instance',
)
retry_count = fields.Integer(
string='Max Retries',
default=3,
help='Maximum number of connection retry attempts',
)
retry_delay = fields.Integer(
string='Retry Delay',
default=5,
help='Delay in seconds between retry attempts',
)
auto_reconnect = fields.Boolean(
string='Auto Reconnect',
default=True,
help='Automatically attempt to reconnect when connection is lost',
)
active = fields.Boolean(
string='Active',
@ -86,16 +123,48 @@ class OdooSyncInstance(models.Model):
@api.onchange('url')
def _onchange_url(self):
"""Reset state when URL changes."""
if self.url != self._origin.url:
self.state = 'draft'
self.error_message = False
for record in self:
if record.url != record._origin.url:
record.state = 'draft'
record.error_message = False
def test_connection(self):
"""Test the connection to the remote Odoo instance.
This method attempts to authenticate with the remote instance
and updates the connection state accordingly.
Returns:
bool: True if connection successful, False otherwise
"""
for record in self:
record.state = 'testing'
if record.connection_type == 'xmlrpc':
return record._test_xmlrpc_connection()
elif record.connection_type == 'jsonrpc':
# À implémenter si nécessaire
raise UserError("La connexion JSON-RPC n'est pas encore implémentée")
else:
raise UserError(f"Type de connexion non supporté: {record.connection_type}")
def _test_xmlrpc_connection(self):
"""Test XML-RPC connection to the remote instance."""
self.ensure_one()
self.state = 'testing'
try:
# Validate URL format
if not self.url:
raise UserError("L'URL est requise pour tester la connexion")
# Parse URL to extract connection details
parsed_url = urlparse(self.url)
if not parsed_url.scheme or not parsed_url.netloc:
raise UserError("Format d'URL invalide. Exemple valide: https://exemple.odoo.com")
# Attempt to connect and authenticate
common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common')
uid = common.authenticate(self.database, self.username, self.password, {})
if uid:
self.write({
'state': 'connected',
@ -104,24 +173,107 @@ class OdooSyncInstance(models.Model):
})
return True
else:
raise Exception('Échec d\'authentification')
except Exception as e:
raise UserError('Échec d\'authentification')
except UserError as e:
# Pass through our UserError without modification
self.write({
'state': 'error',
'error_message': str(e)
})
_logger.error("Erreur d'authentification XML-RPC: %s", str(e))
return False
except (ConnectionError, TimeoutError, xmlrpc.client.Fault, xmlrpc.client.ProtocolError) as e:
# Catch specific exceptions that can be raised during XML-RPC connection
self.write({
'state': 'error',
'error_message': str(e)
})
_logger.error("Erreur de connexion XML-RPC: %s", str(e))
return False
except Exception as e: # pylint: disable=broad-except
# Fall back for any unexpected exceptions
self.write({
'state': 'error',
'error_message': f"Erreur inattendue: {str(e)}"
})
_logger.error("Erreur inattendue XML-RPC: %s", str(e))
return False
def get_connection(self):
"""Retourne une connexion active à l'instance distante"""
"""Return an active connection to the remote instance.
Returns:
tuple: (models_proxy, user_id) for XML-RPC connection
Raises:
UserError: If connection fails
"""
self.ensure_one()
# Ensure we have an active connection
if self.state != 'connected':
self.test_connection()
if self.state != 'connected':
raise Exception(f'Impossible de se connecter à {self.name}: {self.error_message}')
# Use UserError for better user experience
raise UserError(f'Impossible de se connecter à {self.name}: {self.error_message}')
# Return appropriate connection based on connection type
if self.connection_type == 'xmlrpc':
return self._get_xmlrpc_connection()
elif self.connection_type == 'jsonrpc':
# À implémenter si nécessaire
raise UserError("La connexion JSON-RPC n'est pas encore implémentée")
else:
raise UserError(f"Type de connexion non supporté: {self.connection_type}")
def _get_xmlrpc_connection(self):
"""Get XML-RPC connection to the remote instance."""
common = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/common')
uid = common.authenticate(self.database, self.username, self.password, {})
models = xmlrpc.client.ServerProxy(f'{self.url}/xmlrpc/2/object')
return models, uid
def execute_kw(self, model, method, args=None, kwargs=None):
"""Execute a method on the remote Odoo instance.
This method provides a unified interface for executing methods on remote models
regardless of the connection type.
Args:
model (str): The model name
method (str): The method to call
args (list, optional): Positional arguments
kwargs (dict, optional): Keyword arguments
Returns:
The result of the method call
Raises:
UserError: If execution fails
"""
self.ensure_one()
if args is None:
args = []
if kwargs is None:
kwargs = {}
try:
models, uid = self.get_connection()
result = models.execute_kw(
self.database, uid, self.password,
model, method, args, kwargs
)
return result
except UserError:
# Re-raise UserError as it already has appropriate message
raise
except Exception as e: # pylint: disable=broad-except
# Convert other exceptions to UserError for better user experience
raise UserError(f"Erreur d'exécution de {model}.{method}: {str(e)}") from e

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 B

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Fond hexagonal -->
<polygon points="256,40 456,148 456,364 256,472 56,364 56,148" fill="#714B67" />
<polygon points="256,70 426,163 426,349 256,442 86,349 86,163" fill="#FFFFFF" />
<!-- Chaîne reliant les deux instances - élargie pour dépasser des cercles -->
<path d="M125,220 L125,292 C125,304 135,314 147,314 L365,314 C377,314 387,304 387,292 L387,220 C387,208 377,198 365,198 L147,198 C135,198 125,208 125,220 Z" fill="#714B67" />
<!-- Silhouette Odoo gauche -->
<circle cx="176" cy="256" r="40" fill="#FFFFFF" />
<circle cx="176" cy="256" r="15" fill="#714B67" />
<!-- Silhouette Odoo droite -->
<circle cx="336" cy="256" r="40" fill="#FFFFFF" />
<circle cx="336" cy="256" r="15" fill="#714B67" />
<!-- Flèches de synchronisation -->
<path d="M216,236 L296,236" stroke="#FFFFFF" stroke-width="8" fill="none" />
<path d="M296,276 L216,276" stroke="#FFFFFF" stroke-width="8" fill="none" />
<!-- Pointes de flèches -->
<polygon points="296,236 284,226 284,246" fill="#FFFFFF" />
<polygon points="216,276 228,266 228,286" fill="#FFFFFF" />
<!-- Petits cercles de connexion -->
<circle cx="216" cy="236" r="6" fill="#FFFFFF" />
<circle cx="296" cy="236" r="6" fill="#FFFFFF" />
<circle cx="216" cy="276" r="6" fill="#FFFFFF" />
<circle cx="296" cy="276" r="6" fill="#FFFFFF" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -3,3 +3,4 @@
from . import models
from . import controllers
from . import wizards
from . import utils

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
{
'name': 'Unifi Integration',
'version': '1.0',
'version': '1.0.1',
'category': 'Network/Documentation',
'summary': 'Store and manage Unifi configurations',
'description': """
@ -13,38 +12,60 @@ This module allows you to:
* Generate documentation of network setup
* Compare configurations over time
""",
'author': 'Your Company',
'author': 'Bemade',
'website': 'https://www.bemade.org',
'depends': ['base', 'web', 'website'],
'depends': [
'base',
'web',
'mail'
],
'data': [
'security/udm_pro_security.xml',
# Sécurité et règles d'accès
'security/unifi_security.xml',
'security/ir.model.access.csv',
'views/udm_configuration_views.xml',
'views/udm_system_info_views.xml',
'views/udm_network_views.xml',
'views/udm_vlan_views.xml',
'views/udm_device_views.xml',
'views/udm_user_views.xml',
'views/udm_settings_views.xml',
'views/udm_firewall_views.xml',
'views/udm_network_config_views.xml',
'views/udm_menu_views.xml',
# Vues et actions pour les modèles unifi_ (doivent être chargées avant les menus qui y font référence)
'views/unifi_actions.xml',
'views/unifi_site_views.xml',
# Menus (doivent être chargés après les actions auxquelles ils font référence)
'views/unifi_menu_views.xml',
# Wizards (doivent être chargés après les menus auxquels ils font référence)
'wizards/unifi_site_import_wizard_views.xml',
# Temporairement commenté pour permettre l'installation du module
# 'views/unifi_auth_session_views.xml',
# 'views/unifi_mfa_views.xml',
# 'views/unifi_api_log_views.xml',
# 'views/unifi_sync_job_views.xml',
'views/unifi_device_views.xml',
'views/unifi_network_views.xml',
'views/unifi_vlan_views.xml',
'views/unifi_user_views.xml',
'views/unifi_firewall_views.xml',
'views/unifi_port_forward_views.xml',
'views/unifi_system_info_views.xml',
'views/unifi_dns_views.xml',
'views/unifi_dns_config_views.xml',
'views/unifi_routing_views.xml',
'views/unifi_routing_config_views.xml',
'views/unifi_api_config_views.xml',
'views/unifi_dashboard_views.xml',
# Templates
'views/templates.xml',
'views/udm_pro_docs_templates.xml',
'wizards/udm_site_import_wizard_views.xml',
'wizards/views/udm_mfa_wizard_views.xml',
],
'demo': [],
'installable': True,
'application': True,
'auto_install': False,
'license': 'LGPL-3',
'external_dependencies': {
'python': ['requests'],
},
'assets': {
'web.assets_backend': [
'unifi_integration/static/src/css/udm_pro.css',
'unifi_integration/static/src/components/import_site_button/import_site_button.js',
'unifi_integration/static/src/components/import_site_button/import_site_button.xml',
'unifi_integration/static/src/css/unifi.css',
],
},
'external_dependencies': {
'python': [
'requests',
],
},
}

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# pylint: disable=import-error
from odoo import http, _ # IDE may report an error, but works in Odoo environment
from odoo import http, _, fields # IDE may report an error, but works in Odoo environment
from odoo.http import request # IDE may report an error, but works in Odoo environment
# pylint: enable=import-error
import logging
@ -28,10 +28,10 @@ except ImportError:
_logger = logging.getLogger(__name__)
class UdmProController(http.Controller):
"""Controller for UDM Pro related functionalities in Odoo"""
class UnifiController(http.Controller):
"""Controller for UniFi related functionalities in Odoo"""
@http.route('/udm_pro/advanced_options', type='http', auth='user', website=True)
@http.route('/unifi/advanced_options', type='http', auth='user')
def advanced_options_form(self):
"""Display the advanced options form for the UDM Pro API"""
return request.render('unifi_integration.advanced_options_form', {
@ -40,24 +40,24 @@ class UdmProController(http.Controller):
'lowercase_hostnames': True,
})
@http.route('/udm_pro/restart_device', type='http', auth='user', website=True)
@http.route('/unifi/restart_device', type='http', auth='user')
def restart_device_form(self):
"""Display the form to restart a UDM Pro device"""
if not request.env.user.has_group('unifi_integration.group_udm_pro_manager'):
if not request.env.user.has_group('unifi_integration.group_unifi_manager'):
return request.render('unifi_integration.access_denied', {
'error_message': _("You don't have permission to restart devices.")
})
# Get saved UDM Pro configurations for the form
configs = request.env['udm.configuration'].sudo().search([])
# Get saved UniFi configurations for the form
configs = request.env['unifi.site'].sudo().search([])
return request.render('unifi_integration.restart_device_form', {
'configs': configs
})
@http.route('/udm_pro/restart_device', type='http', auth='user', website=True, methods=['POST'])
@http.route('/unifi/restart_device', type='http', auth='user', methods=['POST'])
def restart_device(self, **post):
"""Process the request to restart a UDM Pro device"""
if not request.env.user.has_group('unifi_integration.group_udm_pro_manager'):
if not request.env.user.has_group('unifi_integration.group_unifi_manager'):
return request.render('unifi_integration.access_denied', {
'error_message': _("You don't have permission to restart devices.")
})
@ -71,28 +71,33 @@ class UdmProController(http.Controller):
if not mac_address:
return request.render('unifi_integration.restart_device_form', {
'error_message': _("Please provide the MAC address of the device to restart."),
'configs': request.env['udm.configuration'].sudo().search([])
'configs': request.env['unifi.site'].sudo().search([])
})
try:
# Get the configuration
config = request.env['udm.configuration'].sudo().browse(config_id)
config = request.env['unifi.site'].sudo().browse(config_id)
if not config.exists():
raise ValueError(_("Configuration not found"))
# Initialiser le client UDM Pro
client = UdmProClient(
host=config.host,
username=config.username,
password=config.password,
port=config.port or 443
# Récupérer les informations du contrôleur associé
controller = request.env['unifi.site.controller'].sudo().search([('site_id', '=', config.id)], limit=1)
if not controller:
raise ValueError(_("Controller configuration not found"))
# Initialiser le client UniFi
client = UnifiClient(
host=controller.host,
username=controller.username,
password=controller.password,
port=controller.port or 443
)
# Authenticate the client
if not client.login():
return request.render('unifi_integration.restart_device_form', {
'error_message': _("Authentication failed. Please check the configuration credentials."),
'configs': request.env['udm.configuration'].sudo().search([])
'configs': request.env['unifi.site'].sudo().search([])
})
# Restart the device
@ -100,7 +105,7 @@ class UdmProController(http.Controller):
if not success:
return request.render('unifi_integration.restart_device_form', {
'error_message': _("Failed to restart the device. Please check logs for details."),
'configs': request.env['udm.configuration'].sudo().search([])
'configs': request.env['unifi.site'].sudo().search([])
})
return request.render('unifi_integration.restart_success', {
@ -108,32 +113,32 @@ class UdmProController(http.Controller):
})
except (ConnectionError, RequestException) as e:
_logger.error("Error during UDM Pro device restart: %s", str(e))
_logger.error("Error during UniFi device restart: %s", str(e))
return request.render('unifi_integration.restart_device_form', {
'error_message': _("Error connecting to UDM Pro: %s") % str(e),
'configs': request.env['udm.configuration'].sudo().search([])
'configs': request.env['unifi.site'].sudo().search([])
})
except Exception as e: # pylint: disable=broad-except
_logger.exception("Unexpected error during UDM Pro device restart")
_logger.exception("Unexpected error during UniFi device restart")
return request.render('unifi_integration.restart_device_form', {
'error_message': _("An unexpected error occurred: %s") % str(e),
'configs': request.env['udm.configuration'].sudo().search([])
'configs': request.env['unifi.site'].sudo().search([])
})
@http.route('/udm_pro/generate_hosts', type='http', auth='user', website=True)
@http.route('/unifi/generate_hosts', type='http', auth='user')
def generate_hosts_form(self):
"""Display the form to generate a hosts file from UDM Pro"""
# Get saved UDM Pro configurations for the form
configs = request.env['udm.configuration'].sudo().search([])
"""Display the form to generate a hosts file from UniFi"""
# Get saved UniFi configurations for the form
configs = request.env['unifi.site'].sudo().search([])
return request.render('unifi_integration.generate_hosts_form', {
'configs': configs,
'fixed_only': True,
'lowercase_hostnames': True
})
@http.route('/udm_pro/generate_hosts', type='http', auth='user', website=True, methods=['POST'])
@http.route('/unifi/generate_hosts', type='http', auth='user', methods=['POST'])
def generate_hosts(self, **post):
"""Generate a hosts file from UDM Pro network clients"""
"""Generate a hosts file from UniFi network clients"""
config_id_str = post.get('config_id')
if not config_id_str:
raise ValueError(_('Configuration ID is required'))
@ -143,16 +148,21 @@ class UdmProController(http.Controller):
try:
# Get the configuration
config = request.env['udm.configuration'].sudo().browse(config_id)
config = request.env['unifi.site'].sudo().browse(config_id)
if not config.exists():
raise ValueError(_("Configuration not found"))
# Initialize the UDM Pro client with advanced options
client = UdmProClient(
host=config.host,
username=config.username,
password=config.password,
port=config.port or 443,
# Récupérer les informations du contrôleur associé
controller = request.env['unifi.site.controller'].sudo().search([('site_id', '=', config.id)], limit=1)
if not controller:
raise ValueError(_("Controller configuration not found"))
# Initialize the UniFi client with advanced options
client = UnifiClient(
host=controller.host,
username=controller.username,
password=controller.password,
port=controller.port or 443,
fixed_only=fixed_only,
lowercase_hostnames=lowercase_hostnames
)
@ -161,7 +171,7 @@ class UdmProController(http.Controller):
if not client.login():
return request.render('unifi_integration.generate_hosts_form', {
'error_message': _("Authentication failed. Please check the configuration credentials."),
'configs': request.env['udm.configuration'].sudo().search([]),
'configs': request.env['unifi.site'].sudo().search([]),
'fixed_only': fixed_only,
'lowercase_hostnames': lowercase_hostnames
})
@ -176,43 +186,43 @@ class UdmProController(http.Controller):
if not response:
raise ValueError(_('Failed to create response'))
response.headers['Content-Type'] = 'text/plain'
response.headers['Content-Disposition'] = 'attachment; filename=udm_hosts.txt'
response.headers['Content-Disposition'] = 'attachment; filename=unifi_hosts.txt'
return response
except (AttributeError, TypeError) as e:
_logger.error('Error creating response: %s', str(e))
raise ValueError(_('Failed to create response'))
except (ConnectionError, RequestException) as e:
_logger.error("Error during UDM Pro hosts file generation: %s", str(e))
_logger.error("Error during UniFi hosts file generation: %s", str(e))
return request.render('unifi_integration.generate_hosts_form', {
'error_message': _("Error connecting to UDM Pro: %s") % str(e),
'configs': request.env['udm.configuration'].sudo().search([]),
'configs': request.env['unifi.site'].sudo().search([]),
'fixed_only': fixed_only,
'lowercase_hostnames': lowercase_hostnames
})
except Exception as e: # pylint: disable=broad-except
_logger.exception("Unexpected error during UDM Pro hosts file generation")
_logger.exception("Unexpected error during UniFi hosts file generation")
return request.render('unifi_integration.generate_hosts_form', {
'error_message': _("An unexpected error occurred: %s") % str(e),
'configs': request.env['udm.configuration'].sudo().search([]),
'configs': request.env['unifi.site'].sudo().search([]),
'fixed_only': fixed_only,
'lowercase_hostnames': lowercase_hostnames
})
@http.route('/udm_pro/network_clients', type='http', auth='user', website=True)
@http.route('/unifi/network_clients', type='http', auth='user')
def network_clients_form(self):
"""Display the form to view UDM Pro network clients"""
# Get saved UDM Pro configurations for the form
configs = request.env['udm.configuration'].sudo().search([])
"""Display the form to view UniFi network clients"""
# Get saved UniFi configurations for the form
configs = request.env['unifi.site'].sudo().search([])
return request.render('unifi_integration.network_clients_form', {
'configs': configs,
'fixed_only': False,
'lowercase_hostnames': True
})
@http.route('/udm_pro/network_clients', type='http', auth='user', website=True, methods=['POST'])
@http.route('/unifi/network_clients', type='http', auth='user', methods=['POST'])
def get_network_clients(self, **post):
"""Get and display the list of UDM Pro network clients"""
"""Get and display the list of UniFi network clients"""
config_id_str = post.get('config_id')
if not config_id_str:
raise ValueError(_('Configuration ID is required'))
@ -222,16 +232,21 @@ class UdmProController(http.Controller):
try:
# Get the configuration
config = request.env['udm.configuration'].sudo().browse(config_id)
config = request.env['unifi.site'].sudo().browse(config_id)
if not config.exists():
raise ValueError(_("Configuration not found"))
# Initialize the UDM Pro client with advanced options
client = UdmProClient(
host=config.host,
username=config.username,
password=config.password,
port=config.port or 443,
# Récupérer les informations du contrôleur associé
controller = request.env['unifi.site.controller'].sudo().search([('site_id', '=', config.id)], limit=1)
if not controller:
raise ValueError(_("Controller configuration not found"))
# Initialize the UniFi client with advanced options
client = UnifiClient(
host=controller.host,
username=controller.username,
password=controller.password,
port=controller.port or 443,
fixed_only=fixed_only,
lowercase_hostnames=lowercase_hostnames
)
@ -240,7 +255,7 @@ class UdmProController(http.Controller):
if not client.login():
return request.render('unifi_integration.network_clients_form', {
'error_message': _("Authentication failed. Please check the configuration credentials."),
'configs': request.env['udm.configuration'].sudo().search([]),
'configs': request.env['unifi.site'].sudo().search([]),
'fixed_only': fixed_only,
'lowercase_hostnames': lowercase_hostnames
})
@ -254,48 +269,48 @@ class UdmProController(http.Controller):
})
except (ConnectionError, RequestException) as e:
_logger.error("Error retrieving UDM Pro network clients: %s", str(e))
_logger.error("Error retrieving UniFi network clients: %s", str(e))
return request.render('unifi_integration.network_clients_form', {
'error_message': _("Error connecting to UDM Pro: %s") % str(e),
'configs': request.env['udm.configuration'].sudo().search([]),
'configs': request.env['unifi.site'].sudo().search([]),
'fixed_only': fixed_only,
'lowercase_hostnames': lowercase_hostnames
})
except ValueError as e:
_logger.error("Value error retrieving UDM Pro network clients: %s", str(e))
_logger.error("Value error retrieving UniFi network clients: %s", str(e))
return request.render('unifi_integration.network_clients_form', {
'error_message': _("Configuration error: %s") % str(e),
'configs': request.env['udm.configuration'].sudo().search([]),
'configs': request.env['unifi.site'].sudo().search([]),
'fixed_only': fixed_only,
'lowercase_hostnames': lowercase_hostnames
})
except (AttributeError, KeyError) as e:
_logger.error("Data format error retrieving UDM Pro network clients: %s", str(e))
_logger.error("Data format error retrieving UniFi network clients: %s", str(e))
return request.render('unifi_integration.network_clients_form', {
'error_message': _("Data error: %s") % str(e),
'configs': request.env['udm.configuration'].sudo().search([]),
'configs': request.env['unifi.site'].sudo().search([]),
'fixed_only': fixed_only,
'lowercase_hostnames': lowercase_hostnames
})
except Exception as e: # pylint: disable=broad-except
# Keep this generic exception as a last resort, with an explicit warning for pylint
_logger.exception("Unexpected error retrieving UDM Pro network clients")
_logger.exception("Unexpected error retrieving UniFi network clients")
return request.render('unifi_integration.network_clients_form', {
'error_message': _("An unexpected error occurred: %s") % str(e),
'configs': request.env['udm.configuration'].sudo().search([]),
'configs': request.env['unifi.site'].sudo().search([]),
'fixed_only': fixed_only,
'lowercase_hostnames': lowercase_hostnames
})
@http.route('/udm_pro/import_config', type='http', auth='user', website=True)
@http.route('/unifi/import_config', type='http', auth='user')
def import_config_form(self):
"""Display the UDM Pro configuration import form"""
"""Display the UniFi configuration import form"""
return request.render('unifi_integration.import_config_form', {})
@http.route('/udm_pro/import_config', type='http', auth='user', website=True, methods=['POST'])
@http.route('/unifi/import_config', type='http', auth='user', methods=['POST'])
def import_config(self, **post):
"""Import UDM Pro configuration from the device"""
if not request.env.user.has_group('unifi_integration.group_udm_pro_manager'):
"""Import UniFi configuration from the device"""
if not request.env.user.has_group('unifi_integration.group_unifi_manager'):
return request.render('unifi_integration.access_denied', {
'error_message': _("You don't have permission to import configurations.")
})
@ -316,7 +331,7 @@ class UdmProController(http.Controller):
try:
# Use the API client to get the configuration
client = UdmProClient(host, username, password, port)
client = UnifiClient(host, username, password, port)
if not client.login():
return request.render('unifi_integration.import_config_form', {
'error_message': _("Authentication failed. Please check your credentials."),
@ -327,13 +342,33 @@ class UdmProController(http.Controller):
config_data = client.get_full_configuration()
# Import the configuration into Odoo
config_id = request.env['udm.configuration'].sudo().import_configuration(config_data)
# Créer un nouveau site UniFi
site_vals = {
'name': host,
'api_type': 'controller',
'description': 'Imported from %s on %s' % (host, fields.Datetime.now())
}
return request.redirect('/web#id=%s&model=udm.configuration&view_type=form' % config_id)
# Créer le site
site = request.env['unifi.site'].sudo().create(site_vals)
# Créer la configuration du contrôleur associé
controller_vals = {
'site_id': site.id,
'host': host,
'username': username,
'password': password,
'port': port,
'raw_data': json.dumps(config_data)
}
# Créer le contrôleur
request.env['unifi.site.controller'].sudo().create(controller_vals)
return request.redirect('/web#id=%s&model=unifi.site&view_type=form' % site.id)
except (ConnectionError, RequestException) as e:
_logger.error("Error during UDM Pro configuration import: %s", str(e))
_logger.error("Error during UniFi configuration import: %s", str(e))
return request.render('unifi_integration.import_config_form', {
'error_message': _("Error connecting to UDM Pro: %s") % str(e),
'host': host,
@ -341,7 +376,7 @@ class UdmProController(http.Controller):
'port': port
})
except (ValueError, TypeError, AttributeError) as e:
_logger.error("Data processing error during UDM Pro configuration import: %s", str(e))
_logger.error("Data processing error during UniFi configuration import: %s", str(e))
return request.render('unifi_integration.import_config_form', {
'error_message': _("Error processing data: %s") % str(e),
'host': host,
@ -349,7 +384,7 @@ class UdmProController(http.Controller):
'port': port
})
except Exception as e: # pylint: disable=broad-except
_logger.exception("Unexpected error during UDM Pro configuration import")
_logger.exception("Unexpected error during UniFi configuration import")
return request.render('unifi_integration.import_config_form', {
'error_message': _("An unexpected error occurred. Please check server logs."),
'host': host,
@ -358,8 +393,8 @@ class UdmProController(http.Controller):
})
class UdmProClient:
"""Client to interact with the UDM Pro API."""
class UnifiClient:
"""Client to interact with the UniFi API."""
# API endpoints
API_LOGIN_ENDPOINT = '/api/auth/login'
@ -377,10 +412,10 @@ class UdmProClient:
def __init__(self, host, username, password, port=443, verify_ssl=False, site='default', fixed_only=True, lowercase_hostnames=True, debug=False):
"""
Initialize the UDM Pro API client.
Initialize the UniFi API client.
Args:
host (str): IP address or hostname of the UDM Pro
host (str): IP address or hostname of the UniFi controller
username (str): Username for the API
password (str): Password for the API
port (int): Port for connection (default 443)
@ -516,7 +551,7 @@ class UdmProClient:
def get_system_info(self):
"""
Gets system information from UDM Pro.
Gets system information from UniFi controller.
Returns:
dict: System information
@ -600,7 +635,7 @@ class UdmProClient:
def restart_device(self, mac_address):
"""
Restarts a device managed by UDM Pro (e.g. WiFi access point).
Restarts a device managed by UniFi controller (e.g. WiFi access point).
Requires 'site admin' level permissions.
Args:
@ -661,7 +696,7 @@ class UdmProClient:
str: Content of hosts file
"""
clients = self.get_network_clients()
hosts_content = "# UDM Pro Generated Hosts File\n"
hosts_content = "# UniFi Generated Hosts File\n"
hosts_content += "# Generated on {}\n\n".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
for client in clients:
@ -676,10 +711,10 @@ class UdmProClient:
def get_full_configuration(self):
"""
Gets complete UDM Pro configuration.
Gets complete UniFi configuration.
Returns:
dict: Complete UDM Pro configuration
dict: Complete UniFi configuration
"""
# Authenticate first
if not self.token and not self.login():

View file

@ -0,0 +1,85 @@
# Exemples d'intégration UniFi
Ce document décrit les exemples fournis dans le répertoire `doc/examples` qui illustrent l'utilisation des API UniFi pour l'intégration avec Odoo.
## Structure des exemples
Le répertoire `doc/examples` contient plusieurs fichiers qui démontrent différents aspects de l'intégration avec les API UniFi :
### Documentation
- **api.md** : Documentation détaillée des endpoints de l'API du contrôleur UniFi, incluant les méthodes REST, les commandes disponibles et les codes d'appareil.
- **models.md** : Description des modèles de données proposés pour l'intégration UniFi dans Odoo, incluant les structures de tables et relations.
### Client Python
- **unifi_client.py** : Classe cliente Python pour interagir avec l'API UniFi UDM Pro. Cette classe gère l'authentification et les opérations API de base.
### Scripts d'exemple
Plusieurs scripts Python qui démontrent l'utilisation du client UniFi pour différentes tâches :
- **show_devices.py** : Affiche tous les appareils UniFi gérés par le contrôleur dans un format lisible.
- **show_device_details.py** : Affiche des informations détaillées sur un appareil spécifique.
- **show_clients.py** : Liste tous les clients (appareils connectés) sur le réseau.
- **show_networks.py** : Affiche les configurations réseau définies sur le contrôleur.
- **show_vlans.py** : Liste les VLANs configurés sur le système.
- **show_firewall.py** : Affiche les règles de pare-feu configurées.
- **test_firewall.py** : Démontre comment interagir avec les règles de pare-feu.
- **test_unifi.py** : Script de test général pour vérifier la connexion et les fonctionnalités de base.
### Fichiers de configuration
- **credential.txt** : Fichier de configuration contenant les informations d'authentification pour se connecter au contrôleur UniFi.
- **requirements.txt** : Liste des dépendances Python nécessaires pour exécuter les scripts d'exemple.
## Utilisation des exemples
### Configuration
1. Copiez le fichier `credential.txt` et modifiez-le avec vos propres informations d'authentification :
```
UNIFI_HOST=192.168.1.1
UNIFI_PORT=443
UNIFI_USERNAME=votre_utilisateur
UNIFI_PASSWORD=votre_mot_de_passe
UNIFI_SITE=default
```
2. Installez les dépendances requises :
```bash
pip install -r requirements.txt
```
### Exécution des scripts
Pour exécuter un script d'exemple, utilisez la commande Python suivante :
```bash
python show_devices.py
```
## Intégration avec Odoo
Les modèles décrits dans `models.md` fournissent une structure de données proposée pour l'intégration avec Odoo. Ces modèles incluent :
- **udm.site** : Représente un site UniFi géré par un UDM/UDR ou un contrôleur logiciel
- **udm.system_info** : Informations système du contrôleur
- **udm.network** : Configurations réseau
- **udm.vlan** : Configurations VLAN
- **udm.device** : Appareils UniFi (points d'accès, switches, etc.)
- **udm.user** : Utilisateurs/clients sur le réseau
- **udm.firewall_rule** : Règles de pare-feu
- **udm.firewall_group** : Groupes de pare-feu
Ces modèles peuvent être utilisés comme base pour développer le module d'intégration UniFi pour Odoo, en s'appuyant sur les exemples de code fournis pour interagir avec l'API UniFi.
## Adaptation pour l'intégration
Pour adapter ces exemples à une intégration complète avec Odoo, il faudrait :
1. Implémenter les modèles décrits dans `models.md`
2. Adapter la classe `UnifiClient` pour fonctionner dans le contexte d'Odoo
3. Créer un assistant (wizard) pour configurer la connexion, comme décrit dans le fichier `objectifs.md`
4. Implémenter des méthodes de synchronisation périodique des données
5. Développer des vues pour afficher et gérer les données UniFi dans l'interface Odoo

View file

@ -0,0 +1,235 @@
# Modèles actuels du module d'intégration UniFi
Ce document décrit les modèles de données actuellement implémentés dans le module d'intégration UniFi pour Odoo.
## Structure générale
Les modèles du module sont organisés de manière hiérarchique, avec le modèle `udm.site` comme entité centrale. Tous les autres modèles sont liés à un site spécifique et sont supprimés automatiquement lorsque le site est supprimé (via le mécanisme `ondelete='cascade'`).
## Modèles principaux
### 1. udm.site
**Fichier :** `udm_site.py`
**Description :** Représente un site UniFi géré par un ou plusieurs contrôleurs UniFi.
**Champs principaux :**
- `name` (Char) : Nom du site
- `site_id` (Char) : Identifiant du site dans UniFi (généralement 'default')
- `description` (Text) : Description du site
- `host` (Char) : Adresse IP ou nom d'hôte du contrôleur
- `port` (Integer) : Port du contrôleur (par défaut 443)
- `username` (Char) : Nom d'utilisateur pour l'authentification
- `password` (Char) : Mot de passe pour l'authentification (stocké de manière sécurisée)
- `verify_ssl` (Boolean) : Indique si la vérification SSL doit être effectuée
- `last_sync` (Datetime) : Date et heure de la dernière synchronisation
- `sync_interval` (Integer) : Intervalle de synchronisation en minutes
**Relations :**
- `network_ids` (One2many) : Réseaux associés au site
- `device_ids` (One2many) : Appareils associés au site
- `user_ids` (One2many) : Utilisateurs associés au site
- `vlan_ids` (One2many) : VLANs associés au site
- `firewall_rule_ids` (One2many) : Règles de pare-feu associées au site
- `port_forward_ids` (One2many) : Règles de redirection de port associées au site
- `system_info_id` (One2many) : Informations système associées au site
**Fonctionnalités :**
- Authentification au contrôleur UniFi
- Récupération et synchronisation des données
- Gestion des sessions et des cookies
- Journalisation des activités et des erreurs
### 2. udm.device
**Fichier :** `udm_device.py`
**Description :** Représente un appareil réseau dans le système UniFi.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel l'appareil appartient
- `name` (Char) : Nom ou hostname de l'appareil
- `mac_address` (Char) : Adresse MAC de l'appareil
- `ip_address` (Char) : Adresse IP actuelle
- `device_type` (Selection) : Type d'appareil (point d'accès, switch, passerelle, etc.)
- `model` (Char) : Modèle de l'appareil
- `firmware` (Char) : Version du firmware
- `last_seen` (Datetime) : Dernière fois que l'appareil a été vu en ligne
- `uptime` (Integer) : Temps de fonctionnement en secondes
- `status` (Selection) : État de l'appareil (en ligne, hors ligne, provisionnement)
- `adopted` (Boolean) : Indique si l'appareil a été adopté par le contrôleur
### 3. udm.network
**Fichier :** `udm_network.py`
**Description :** Représente un réseau dans le système UniFi.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel le réseau appartient
- `name` (Char) : Nom du réseau
- `purpose` (Selection) : Objectif du réseau (entreprise, invité, IoT, autre)
- `subnet` (Char) : Sous-réseau au format CIDR
- `vlan_id_number` (Integer) : Identifiant VLAN
- `vlan_id` (Many2one) : Relation avec le modèle VLAN
- `dhcp_enabled` (Boolean) : Indique si DHCP est activé
- `dhcp_start` (Char) : Adresse de début de la plage DHCP
- `dhcp_stop` (Char) : Adresse de fin de la plage DHCP
- `dhcp_lease_time` (Integer) : Durée du bail DHCP en secondes
- `domain_name` (Char) : Nom de domaine pour le réseau
- `dns_servers` (Char) : Serveurs DNS (séparés par des virgules)
- `enabled` (Boolean) : Indique si le réseau est activé
### 4. udm.user
**Fichier :** `udm_user.py`
**Description :** Représente un utilisateur dans le système UniFi.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel l'utilisateur appartient
- `name` (Char) : Nom complet de l'utilisateur
- `email` (Char) : Adresse email de l'utilisateur
- `mac_address` (Char) : Adresse MAC de l'appareil
- `ip_address` (Char) : Adresse IP actuelle
- `network_id` (Many2one) : Réseau auquel l'utilisateur est connecté
- `device_id` (Many2one) : Appareil associé à l'utilisateur
- `role` (Selection) : Rôle de l'utilisateur (admin, opérateur, visualiseur)
- `blocked` (Boolean) : Indique si l'utilisateur est bloqué
- `last_seen` (Datetime) : Dernière fois que l'utilisateur a été vu en ligne
- `upload_usage` (Float) : Utilisation de la bande passante en upload
- `download_usage` (Float) : Utilisation de la bande passante en download
- `total_usage` (Float) : Utilisation totale de la bande passante
### 5. udm.vlan
**Fichier :** `udm_vlan.py`
**Description :** Représente un VLAN dans le système UniFi.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel le VLAN appartient
- `name` (Char) : Nom du VLAN
- `vlan_id` (Integer) : Identifiant du VLAN
- `description` (Text) : Description du VLAN
- `enabled` (Boolean) : Indique si le VLAN est activé
- `network_ids` (One2many) : Réseaux associés à ce VLAN
### 6. udm.firewall.rule
**Fichier :** `udm_firewall.py`
**Description :** Représente une règle de pare-feu dans le système UniFi.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel la règle appartient
- `name` (Char) : Nom de la règle
- `description` (Text) : Description détaillée de l'objectif de la règle
- `enabled` (Boolean) : Indique si la règle est active
- `sequence` (Integer) : Ordre dans lequel les règles sont évaluées
- `action` (Selection) : Action à effectuer (accepter, rejeter, abandonner)
- `protocol` (Selection) : Protocole concerné (TCP, UDP, ICMP, tous)
- `source_type` (Selection) : Type de source (adresse, réseau, groupe)
- `source_address` (Char) : Adresse source
- `source_port` (Char) : Port source
- `destination_type` (Selection) : Type de destination (adresse, réseau, groupe)
- `destination_address` (Char) : Adresse de destination
- `destination_port` (Char) : Port de destination
- `direction` (Selection) : Direction du trafic (entrant, sortant, les deux)
- `logging` (Boolean) : Indique si la journalisation est activée pour cette règle
## Modèles de configuration
### 7. udm.port_forward
**Fichier :** `udm_port_forward.py`
**Description :** Représente une règle de redirection de port dans le système UniFi.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel la règle appartient
- `name` (Char) : Nom de la règle
- `enabled` (Boolean) : Indique si la règle est active
- `source_port` (Integer) : Port source
- `destination_port` (Integer) : Port de destination
- `forward_ip` (Char) : Adresse IP de destination
- `protocol` (Selection) : Protocole (TCP, UDP, TCP et UDP)
- `log` (Boolean) : Indique si la journalisation est activée
### 8. udm.system_info
**Fichier :** `udm_system_info.py`
**Description :** Représente les informations système du contrôleur UniFi.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel les informations appartiennent
- `version` (Char) : Version du logiciel
- `hardware_model` (Char) : Modèle matériel
- `hostname` (Char) : Nom d'hôte du système
- `uptime` (Integer) : Temps de fonctionnement en secondes
- `cpu_usage` (Float) : Utilisation du CPU en pourcentage
- `memory_usage` (Float) : Utilisation de la mémoire en pourcentage
- `disk_usage` (Float) : Utilisation du disque en pourcentage
- `temperature` (Float) : Température du système
### 9. udm.dns et udm.dns_config
**Fichiers :** `udm_dns.py` et `udm_dns_config.py`
**Description :** Représentent les configurations DNS dans le système UniFi.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel la configuration appartient
- `hostname` (Char) : Nom d'hôte
- `ip_address` (Char) : Adresse IP associée
- `description` (Text) : Description de l'entrée DNS
- `enabled` (Boolean) : Indique si l'entrée est active
### 10. udm.routing et udm.routing_config
**Fichiers :** `udm_routing.py` et `udm_routing_config.py`
**Description :** Représentent les configurations de routage dans le système UniFi.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel la configuration appartient
- `name` (Char) : Nom de la route
- `destination` (Char) : Destination de la route
- `gateway` (Char) : Passerelle
- `interface` (Char) : Interface réseau
- `enabled` (Boolean) : Indique si la route est active
## Modèles de tableau de bord
### 11. udm.dashboard_metric, udm.dashboard_stat, udm.dashboard_data_point
**Fichiers :** `udm_dashboard_metric.py`, `udm_dashboard_stat.py`, `udm_dashboard_data_point.py`
**Description :** Représentent les métriques et statistiques pour le tableau de bord.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel les données appartiennent
- `name` (Char) : Nom de la métrique
- `value` (Float) : Valeur de la métrique
- `unit` (Char) : Unité de mesure
- `timestamp` (Datetime) : Horodatage de la mesure
- `category` (Selection) : Catégorie de la métrique (réseau, système, sécurité)
## Modèle de configuration du site
### 12. udm.site_configuration
**Fichier :** `udm_site_configuration.py`
**Description :** Représente la configuration d'un site UniFi.
**Champs principaux :**
- `site_id` (Many2one) : Site auquel la configuration appartient
- `config_type` (Selection) : Type de configuration
- `config_data` (Text) : Données de configuration au format JSON
- `version` (Char) : Version de la configuration
- `timestamp` (Datetime) : Horodatage de la configuration
## Relations entre les modèles
Le modèle `udm.site` est au centre de l'architecture et toutes les autres entités lui sont liées. Les relations principales sont :
1. **Site → Réseaux** : Un site peut avoir plusieurs réseaux
2. **Site → Appareils** : Un site peut avoir plusieurs appareils
3. **Site → Utilisateurs** : Un site peut avoir plusieurs utilisateurs
4. **Site → VLANs** : Un site peut avoir plusieurs VLANs
5. **Réseau → VLAN** : Un réseau peut être associé à un VLAN
6. **Utilisateur → Réseau** : Un utilisateur peut être connecté à un réseau
7. **Utilisateur → Appareil** : Un utilisateur peut être associé à un appareil
Cette architecture permet une gestion complète et cohérente des environnements UniFi dans Odoo, avec une séparation claire des différentes composantes du système.

View file

@ -0,0 +1,317 @@
# Relations entre les modèles du module d'intégration UniFi
Ce document présente visuellement les relations entre les différents modèles du module d'intégration UniFi.
## Diagramme de relations principal
```
+-------------------+
| |
| udm.site |<-------------------+
| | |
+--------+----------+ |
| |
| |
v |
+--------+---------+ +---------------+--------------+
| | | |
| udm.auth.session | | udm.site.import.wizard |
| | | (TransientModel) |
+------------------+ | |
+---------------+--------------+
|
|
v
+---------------+--------------+
| |
| udm.site.discovery |
| (TransientModel) |
| |
+------------------------------+
```
## Hiérarchie des modèles avec le site comme entité centrale
```
+-------------------+
| |
| udm.site |
| |
+--+------+------+--+
| | |
| | |
| | |
| | |
v v v
+---------------+ +------------------+ +----------------+ +----------------+
| | | | | | | |
| udm.device | | udm.network | | udm.vlan | | udm.user |
| | | | | | | |
+---------------+ +------------------+ +----------------+ +----------------+
|
|
v
+----------+---------+
| |
| udm.firewall.rule |
| |
+--------------------+
```
## Modèles de support et leur relation avec udm.site
```
+-------------------+
| |
| udm.site |
| |
+--+------+------+--+
| | |
| | |
v v v
+--+------+ +---+----+ +---+----+
| | | | | |
| udm.api. | | udm. | | udm. |
| config | | api.log| | sync. |
| | | | | job |
+----------+ +--------+ +--------+
```
## Détail des relations entre les modèles principaux
```
+-------------------+
| udm.site |
|-------------------|
| name |
| api_type |<---+
| site_id | |
| ... | |
+-------------------+ |
^ |
| |
| |
+-------+-------+ |
| udm.network | |
|----------------| |
| site_id +------+
| name |
| subnet |
| ... |
+----------------+
^
|
|
+-------+-------+
| udm.device |
|----------------|
| site_id |
| network_id +------+
| name | |
| mac_address | |
| ... | |
+----------------+ |
^ |
| |
| |
+-------+-------+ |
| udm.user | |
|----------------| |
| site_id | |
| network_id +------+
| name |
| email |
| ... |
+----------------+
```
## Relations spécifiques à l'authentification
```
+-------------------+
| udm.site |
|-------------------|
| api_type |
| ... |
+--------+----------+
|
|
v
+--------+---------+ +---------------+
| udm.auth.session | | udm.mfa |
|------------------| |---------------|
| site_id | | site_id |
| auth_type | | mfa_type |
| token | | mfa_code |
| expiry | | ... |
| ... | | |
+------------------+ +---------------+
```
## Modèles spécifiques à chaque type d'API
### API Site Manager (distante)
```
+-------------------+
| udm.site |
|-------------------|
| api_type='site_manager' |
| api_key |
| mfa_enabled |
| ... |
+--------+----------+
|
|
v
+--------+---------+
| udm.device |
|------------------|
| site_id |
| cloud_device_id |
| ... |
+------------------+
+-------------------+
| udm.site |
|-------------------|
| api_type='site_manager' |
| ... |
+--------+----------+
|
|
v
+--------+---------+
| udm.network |
|------------------|
| site_id |
| cloud_network_id |
| ... |
+------------------+
```
### API Controller (locale)
```
+-------------------+
| udm.site |
|-------------------|
| api_type='controller' |
| host |
| port |
| username |
| password |
| controller_type |
| ... |
+--------+----------+
|
|
v
+--------+---------+
| udm.device |
|------------------|
| site_id |
| device_id |
| adopted |
| ... |
+------------------+
+-------------------+
| udm.site |
|-------------------|
| api_type='controller' |
| ... |
+--------+----------+
|
|
v
+--------+---------+
| udm.network |
|------------------|
| site_id |
| network_id |
| ... |
+------------------+
```
## Flux de données pour la synchronisation
```
+-------------------+
| udm.site |
|-------------------|
| api_type |
| last_sync |
| sync_interval |
| ... |
+--------+----------+
|
|
v
+--------+---------+
| udm.sync.job |
|------------------|
| site_id |
| start_time |
| end_time |
| state |
| sync_type |
| ... |
+--------+----------+
|
|
v
+--------+---------+
| udm.api.log |
|------------------|
| site_id |
| sync_job_id |
| endpoint |
| method |
| status_code |
| ... |
+------------------+
```
## Assistant d'importation de site
```
+-------------------+
| udm.site.import. |
| wizard |
|-------------------|
| state |
| api_type |
| ... |
+--------+----------+
|
|
v
+--------+---------+
| udm.site.discovery|
|------------------|
| wizard_id |
| site_name |
| site_id |
| selected |
| ... |
+------------------+
|
|
v
+--------+---------+
| udm.site |
|------------------|
| (Créé par |
| l'assistant) |
+------------------+
```
## Légende
- Les flèches indiquent les relations entre les modèles
- Les relations Many2one sont représentées par des flèches simples
- Les relations One2many sont implicites dans la direction opposée aux flèches Many2one
- Les modèles TransientModel sont utilisés pour les assistants et ne sont pas persistants
Cette représentation visuelle montre comment les différents modèles sont liés entre eux, avec le modèle `udm.site` comme entité centrale autour de laquelle s'articulent tous les autres modèles.

View file

@ -0,0 +1,378 @@
# Modèles nécessaires pour l'intégration des API UniFi
Ce document détaille les modèles et champs nécessaires pour interagir avec les deux API UniFi (Site Manager et Controller), en maximisant la réutilisation des modèles entre les deux types d'API.
## Principes de conception
Pour permettre une intégration efficace avec les deux API tout en minimisant la duplication de code, nous adoptons les principes suivants :
1. **Modèle de base commun** : Chaque entité principale aura un modèle de base commun avec les champs partagés entre les deux API.
2. **Champs spécifiques à l'API** : Les champs spécifiques à chaque API seront clairement identifiés.
3. **Abstraction des méthodes d'accès** : Les méthodes d'accès aux API seront abstraites pour permettre une implémentation spécifique à chaque type d'API.
4. **Héritage et polymorphisme** : Utilisation de l'héritage Odoo pour spécialiser les modèles selon le type d'API.
## 1. Modèle principal : udm.site
Le modèle `udm.site` est le point central de l'intégration. Il doit être adapté pour supporter les deux types d'API.
### Champs communs
```python
# Champs communs à tous les sites UniFi
name = fields.Char(string='Nom', required=True)
description = fields.Text(string='Description')
api_type = fields.Selection([
('site_manager', 'Site Manager API (distant)'),
('controller', 'Controller API (locale)')
], string='Type d\'API', required=True)
site_id = fields.Char(string='ID du site', help="Identifiant du site dans UniFi")
last_sync = fields.Datetime(string='Dernière synchronisation')
sync_interval = fields.Integer(string='Intervalle de synchronisation (min)', default=60)
active = fields.Boolean(string='Actif', default=True)
```
### Champs spécifiques à l'API Site Manager
```python
# Champs spécifiques à l'API Site Manager
api_key = fields.Char(string='Clé API', help="Clé API pour l'accès au Site Manager")
api_key_encrypted = fields.Char(string='Clé API (chiffrée)', help="Version chiffrée de la clé API")
mfa_enabled = fields.Boolean(string='Authentification à deux facteurs', default=False)
mfa_token = fields.Char(string='Token MFA', help="Token d'authentification à deux facteurs")
cloud_site_id = fields.Char(string='ID du site cloud', help="Identifiant du site dans le cloud UniFi")
```
### Champs spécifiques à l'API Controller
```python
# Champs spécifiques à l'API Controller
host = fields.Char(string='Hôte/IP', help="Adresse IP ou nom d'hôte du contrôleur")
port = fields.Integer(string='Port', default=443)
username = fields.Char(string='Nom d'utilisateur')
password = fields.Char(string='Mot de passe')
password_encrypted = fields.Char(string='Mot de passe (chiffré)')
verify_ssl = fields.Boolean(string='Vérifier SSL', default=False)
controller_type = fields.Selection([
('udm', 'UDM Pro/UCG Max'),
('controller', 'Contrôleur UniFi standard')
], string='Type de contrôleur', default='udm')
```
### Méthodes abstraites
```python
def authenticate(self):
"""Authentifie auprès de l'API appropriée selon le type"""
if self.api_type == 'site_manager':
return self._authenticate_site_manager()
else:
return self._authenticate_controller()
def _authenticate_site_manager(self):
"""Implémentation spécifique pour l'API Site Manager"""
pass
def _authenticate_controller(self):
"""Implémentation spécifique pour l'API Controller"""
pass
def get_sites(self):
"""Récupère les sites disponibles selon le type d'API"""
if self.api_type == 'site_manager':
return self._get_sites_site_manager()
else:
return self._get_sites_controller()
```
## 2. Modèle d'authentification : udm.auth.session
Ce modèle gère les sessions d'authentification pour les deux types d'API.
```python
class UdmAuthSession(models.Model):
_name = 'udm.auth.session'
_description = 'Session d\'authentification UniFi'
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
auth_type = fields.Selection([
('api_key', 'Clé API'),
('cookie', 'Cookie de session'),
('bearer', 'Token Bearer')
], string='Type d\'authentification', required=True)
token = fields.Char(string='Token/Cookie', help="Token d'authentification ou cookie de session")
token_encrypted = fields.Char(string='Token/Cookie (chiffré)')
expiry = fields.Datetime(string='Expiration')
is_valid = fields.Boolean(string='Valide', default=True)
# Méthodes pour gérer les sessions d'authentification
def validate(self):
"""Vérifie si la session est toujours valide"""
pass
def refresh(self):
"""Rafraîchit la session si nécessaire"""
pass
```
## 3. Modèle pour l'authentification à deux facteurs : udm.mfa
```python
class UdmMfa(models.TransientModel):
_name = 'udm.mfa'
_description = 'Authentification à deux facteurs UniFi'
site_id = fields.Many2one('udm.site', string='Site', required=True)
mfa_type = fields.Selection([
('totp', 'TOTP (Google Authenticator)'),
('sms', 'SMS'),
('email', 'Email')
], string='Type de MFA', default='totp')
mfa_code = fields.Char(string='Code MFA', required=True)
def validate_mfa(self):
"""Valide le code MFA et complète l'authentification"""
pass
```
## 4. Modèle de configuration : udm.api.config
Ce modèle stocke les configurations spécifiques à chaque type d'API.
```python
class UdmApiConfig(models.Model):
_name = 'udm.api.config'
_description = 'Configuration API UniFi'
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
api_type = fields.Selection(related='site_id.api_type', string='Type d\'API')
base_url = fields.Char(string='URL de base', compute='_compute_base_url')
endpoints = fields.Text(string='Endpoints', help="Configuration JSON des endpoints API")
rate_limit = fields.Integer(string='Limite de taux', default=60)
timeout = fields.Integer(string='Timeout (secondes)', default=30)
def _compute_base_url(self):
"""Calcule l'URL de base en fonction du type d'API"""
for config in self:
if config.api_type == 'site_manager':
config.base_url = 'https://unifi.ui.com/api'
else:
site = config.site_id
prefix = '/proxy/network' if site.controller_type == 'udm' else ''
config.base_url = f'https://{site.host}:{site.port}{prefix}'
```
## 5. Adaptations des modèles existants
Les modèles existants doivent être adaptés pour fonctionner avec les deux types d'API. Voici les principales adaptations nécessaires :
### 5.1 udm.device
```python
# Champs communs
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
name = fields.Char(string='Nom')
mac_address = fields.Char(string='Adresse MAC', required=True)
ip_address = fields.Char(string='Adresse IP')
model = fields.Char(string='Modèle')
device_type = fields.Selection([...], string='Type d\'appareil')
# Champs spécifiques à l'API Site Manager
cloud_device_id = fields.Char(string='ID de l\'appareil cloud')
# Champs spécifiques à l'API Controller
device_id = fields.Char(string='ID de l\'appareil local')
adopted = fields.Boolean(string='Adopté')
```
### 5.2 udm.network
```python
# Champs communs
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
name = fields.Char(string='Nom', required=True)
subnet = fields.Char(string='Sous-réseau')
purpose = fields.Selection([...], string='Objectif')
# Champs spécifiques à l'API Site Manager
cloud_network_id = fields.Char(string='ID du réseau cloud')
# Champs spécifiques à l'API Controller
network_id = fields.Char(string='ID du réseau local')
```
### 5.3 udm.firewall.rule
```python
# Champs communs
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
name = fields.Char(string='Nom', required=True)
action = fields.Selection([...], string='Action')
enabled = fields.Boolean(string='Activé', default=True)
# Champs spécifiques à l'API Site Manager
cloud_rule_id = fields.Char(string='ID de la règle cloud')
# Champs spécifiques à l'API Controller
rule_id = fields.Char(string='ID de la règle locale')
```
## 6. Nouveaux modèles pour la gestion des sites
### 6.1 udm.site.discovery
Ce modèle transitoire est utilisé pour découvrir les sites disponibles lors de l'ajout d'un nouveau site.
```python
class UdmSiteDiscovery(models.TransientModel):
_name = 'udm.site.discovery'
_description = 'Découverte de sites UniFi'
wizard_id = fields.Many2one('udm.site.import.wizard', string='Assistant')
api_type = fields.Selection(related='wizard_id.api_type')
site_name = fields.Char(string='Nom du site')
site_id = fields.Char(string='ID du site')
description = fields.Text(string='Description')
selected = fields.Boolean(string='Sélectionné', default=False)
def import_site(self):
"""Importe le site sélectionné"""
pass
```
### 6.2 udm.site.import.wizard (extension)
Extension du wizard existant pour supporter les deux types d'API.
```python
class UdmSiteImportWizard(models.TransientModel):
_name = 'udm.site.import.wizard'
_description = 'Assistant d\'importation de site UniFi'
# Étape actuelle
state = fields.Selection([
('api_type', 'Choix du type d\'API'),
('controller_config', 'Configuration du contrôleur'),
('site_manager_config', 'Configuration du Site Manager'),
('mfa', 'Authentification à deux facteurs'),
('site_selection', 'Sélection du site'),
('summary', 'Résumé')
], string='Étape', default='api_type')
# Choix du type d'API
api_type = fields.Selection([
('site_manager', 'Site Manager API (distant)'),
('controller', 'Controller API (locale)')
], string='Type d\'API')
# Configuration du contrôleur
name = fields.Char(string='Nom du site')
host = fields.Char(string='Hôte/IP')
port = fields.Integer(string='Port', default=443)
username = fields.Char(string='Nom d\'utilisateur')
password = fields.Char(string='Mot de passe')
verify_ssl = fields.Boolean(string='Vérifier SSL', default=False)
controller_type = fields.Selection([
('udm', 'UDM Pro/UCG Max'),
('controller', 'Contrôleur UniFi standard')
], string='Type de contrôleur', default='udm')
# Configuration du Site Manager
api_key = fields.Char(string='Clé API')
mfa_enabled = fields.Boolean(string='Authentification à deux facteurs', default=False)
# Sites découverts
discovered_site_ids = fields.One2many('udm.site.discovery', 'wizard_id', string='Sites découverts')
# Méthodes de navigation
def action_next(self):
"""Passe à l'étape suivante"""
pass
def action_previous(self):
"""Revient à l'étape précédente"""
pass
# Méthodes spécifiques à chaque étape
def action_discover_sites(self):
"""Découvre les sites disponibles"""
pass
def action_import_sites(self):
"""Importe les sites sélectionnés"""
pass
```
## 7. Modèle pour la gestion des erreurs et journalisation
```python
class UdmApiLog(models.Model):
_name = 'udm.api.log'
_description = 'Journal API UniFi'
_order = 'timestamp desc'
site_id = fields.Many2one('udm.site', string='Site', ondelete='cascade')
api_type = fields.Selection(related='site_id.api_type')
timestamp = fields.Datetime(string='Horodatage', default=fields.Datetime.now)
endpoint = fields.Char(string='Endpoint')
method = fields.Selection([
('GET', 'GET'),
('POST', 'POST'),
('PUT', 'PUT'),
('DELETE', 'DELETE')
], string='Méthode')
request_data = fields.Text(string='Données de requête')
response_data = fields.Text(string='Données de réponse')
status_code = fields.Integer(string='Code de statut')
success = fields.Boolean(string='Succès', compute='_compute_success')
error_message = fields.Text(string='Message d\'erreur')
def _compute_success(self):
"""Détermine si la requête a réussi en fonction du code de statut"""
for log in self:
log.success = 200 <= log.status_code < 300
```
## 8. Modèle pour la synchronisation
```python
class UdmSyncJob(models.Model):
_name = 'udm.sync.job'
_description = 'Tâche de synchronisation UniFi'
_order = 'start_time desc'
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
api_type = fields.Selection(related='site_id.api_type')
start_time = fields.Datetime(string='Heure de début', default=fields.Datetime.now)
end_time = fields.Datetime(string='Heure de fin')
state = fields.Selection([
('draft', 'Brouillon'),
('running', 'En cours'),
('done', 'Terminé'),
('failed', 'Échoué')
], string='État', default='draft')
sync_type = fields.Selection([
('full', 'Complète'),
('incremental', 'Incrémentielle')
], string='Type de synchronisation', default='incremental')
log_ids = fields.One2many('udm.api.log', 'sync_job_id', string='Journaux')
result_summary = fields.Text(string='Résumé des résultats')
def action_run(self):
"""Exécute la tâche de synchronisation"""
pass
def action_cancel(self):
"""Annule la tâche de synchronisation"""
pass
```
## Conclusion
Cette structure de modèles permet une intégration efficace avec les deux types d'API UniFi tout en maximisant la réutilisation du code. Les points clés sont :
1. **Séparation claire** des champs communs et spécifiques à chaque API
2. **Abstraction des méthodes d'accès** pour permettre des implémentations spécifiques
3. **Modèles de support** pour l'authentification, la journalisation et la synchronisation
4. **Assistant flexible** pour guider l'utilisateur à travers le processus d'ajout de site
Cette conception facilite la maintenance et l'évolution du module, tout en offrant une expérience utilisateur cohérente quel que soit le type d'API utilisé.

View file

@ -0,0 +1,84 @@
# État actuel du module d'intégration UniFi
Ce document présente l'état actuel du module d'intégration UniFi pour Odoo, en date du 23 mars 2025.
## Structure du module
Le module est structuré selon les standards Odoo, avec les répertoires suivants :
- **models/** : Contient les définitions des modèles de données
- **views/** : Contient les vues XML pour l'interface utilisateur
- **wizards/** : Contient les assistants pour les processus guidés
- **security/** : Contient les règles d'accès et de sécurité
- **data/** : Contient les données par défaut
- **doc/** : Contient la documentation du module
- **static/** : Contient les ressources statiques (CSS, JS, images)
## Modèles implémentés
Le module a implémenté plusieurs modèles pour représenter les différentes composantes d'un environnement UniFi :
1. **udm.site** : Modèle central représentant un site UniFi, contenant les informations de connexion et les paramètres généraux
2. **udm.system_info** : Informations système du contrôleur UniFi
3. **udm.network** : Configurations réseau définies sur le contrôleur
4. **udm.vlan** : Configurations VLAN
5. **udm.device** : Appareils UniFi (points d'accès, switches, etc.)
6. **udm.user** : Utilisateurs/clients sur le réseau
7. **udm.firewall** : Règles de pare-feu
8. **udm.port_forward** : Règles de redirection de port
9. **udm.dns** et **udm.dns_config** : Configurations DNS
10. **udm.routing** et **udm.routing_config** : Configurations de routage
11. **udm.dashboard_metric**, **udm.dashboard_stat**, **udm.dashboard_data_point** : Métriques pour le tableau de bord
## État des fonctionnalités
### Fonctionnalités implémentées
1. **Structure de base du module** : Le module a une structure complète avec tous les répertoires nécessaires.
2. **Modèles de données** : Les principaux modèles de données sont définis avec leurs champs et relations.
3. **Assistant d'importation de site basique** : Un assistant simple pour ajouter un site UDM Pro est implémenté.
4. **Vues de base** : Des vues pour afficher et gérer les sites, réseaux, appareils et utilisateurs sont en place.
5. **Intégration avec l'API UDM Pro** : Le code pour se connecter à l'API UDM Pro est implémenté.
### Fonctionnalités en cours de développement ou manquantes
1. **Choix du type d'API** : L'assistant actuel ne permet pas de choisir entre l'API Site Manager (distante) et l'API Controller (locale).
2. **Support de l'authentification à deux facteurs** : Bien qu'un wizard MFA existe (`udm_mfa_wizard.py`), son intégration complète n'est pas terminée.
3. **Sélection des sites pour l'API Site Manager** : La fonctionnalité pour afficher et sélectionner les sites disponibles via l'API distante n'est pas implémentée.
4. **Support complet des différents types de contrôleurs** : La distinction entre UDM Pro/UCG Max et les contrôleurs UniFi standard n'est pas complètement implémentée.
5. **Synchronisation automatique des données** : Les mécanismes pour synchroniser périodiquement les données ne sont pas entièrement implémentés.
6. **Gestion des erreurs robuste** : La gestion des erreurs et des exceptions pourrait être améliorée.
7. **Documentation utilisateur** : La documentation pour les utilisateurs finaux est incomplète.
## État de l'interface utilisateur
1. **Menu principal** : Le menu principal du module est défini dans `views/udm_menu_views.xml`.
2. **Vues des sites** : Les vues pour afficher et gérer les sites UniFi sont définies dans `views/udm_site_views.xml`.
3. **Vues des appareils** : Les vues pour afficher les appareils UniFi sont définies dans `views/udm_device_views.xml`.
4. **Vues des réseaux** : Les vues pour afficher les configurations réseau sont définies dans `views/udm_network_views.xml`.
5. **Assistant d'importation** : L'assistant pour importer un site est défini dans `wizards/udm_site_import_wizard_views.xml`.
## Prochaines étapes
Pour atteindre les objectifs définis dans le fichier `objectifs.md`, les prochaines étapes devraient inclure :
1. **Amélioration de l'assistant d'ajout de site** :
- Ajouter le choix entre l'API Site Manager et l'API Controller
- Implémenter les flux spécifiques pour chaque type d'API
- Intégrer complètement l'authentification à deux facteurs
2. **Support complet de l'API Site Manager** :
- Implémenter l'authentification via l'API cloud
- Ajouter la fonctionnalité pour lister et sélectionner les sites disponibles
3. **Amélioration de la gestion des données** :
- Implémenter des mécanismes de synchronisation automatique
- Ajouter des fonctionnalités pour comparer les configurations dans le temps
4. **Documentation et tests** :
- Compléter la documentation utilisateur
- Ajouter des tests automatisés pour garantir la fiabilité
## Conclusion
Le module d'intégration UniFi est dans un état fonctionnel de base, avec les modèles et vues principaux implémentés. Cependant, plusieurs fonctionnalités clés définies dans les objectifs sont encore en développement ou manquantes. Le module nécessite des améliorations significatives pour atteindre tous les objectifs fixés, en particulier concernant le support des différents types d'API et l'amélioration de l'assistant d'ajout de site.

View file

@ -0,0 +1,47 @@
# Objectifs du module d'intégration UniFi
Ce document décrit les objectifs et fonctionnalités prévus pour le module d'intégration UniFi dans Odoo.
## Objectifs :
Gérer la configuration et l'intégration des sites UniFi dans Odoo. Utiliser le bonne API pour chaque type de contrôleur. Simplifier la gestion des sites UniFi en utilisant Odoo comme interface pour certaines fonctionnalités mal établies dans l'UDM tel que la configuration des redirections de ports.
## Processus d'ajout d'un site UniFi
### 1. Assistant (Wizard) pour l'ajout d'un site
Le module doit fournir un assistant (wizard) convivial pour faciliter l'ajout d'un nouveau site UniFi. Cet assistant guidera l'utilisateur à travers les étapes nécessaires pour configurer correctement l'intégration.
### 2. Choix du type d'API
L'assistant doit permettre à l'utilisateur de choisir le type d'API à utiliser pour l'intégration :
- **Site Manager API** (distant) : Pour une connexion via l'API cloud de Ubiquiti (unifi.ui.com)
- **Controller API** (locale) : Pour une connexion directe à un contrôleur UniFi sur le réseau local
### 3a. Configuration pour l'API Controller (locale)
Si l'utilisateur choisit l'API Controller (locale), l'assistant doit demander :
- Type d'appareil : UDM Pro/UCG Max ou Contrôleur UniFi standard
- Informations de connexion :
- Adresse IP ou nom d'hôte
- Port
- Identifiants (nom d'utilisateur et mot de passe)
- Option SSL/TLS
- Vérification du certificat SSL
### 3b. Configuration pour l'API Site Manager (distante)
Si l'utilisateur choisit l'API Site Manager (distante), l'assistant doit :
- Gérer l'authentification à deux facteurs si nécessaire
- Afficher la liste des sites disponibles associés au compte
- Permettre à l'utilisateur de sélectionner un ou plusieurs sites à intégrer
- Gérer l'obtention et le stockage sécurisé de la clé API
## Fonctionnalités supplémentaires à considérer
- Validation des connexions avant finalisation
- Journalisation des tentatives de connexion
- Options pour la synchronisation automatique des données
- Gestion des erreurs et notifications
- Interface pour visualiser les données synchronisées
- Possibilité de configurer des alertes basées sur les données UniFi

View file

@ -0,0 +1,215 @@
# Documentation de l'API Site Manager Ubiquiti
Cette documentation couvre l'API Site Manager de Ubiquiti, qui permet d'interagir avec les appareils UniFi via l'interface unifi.ui.com.
## Table des matières
- [Introduction](#introduction)
- [Authentification](#authentification)
- [Limites de taux](#limites-de-taux)
- [Endpoints](#endpoints)
- [Liste des hôtes](#liste-des-hôtes)
- [Obtenir un hôte par ID](#obtenir-un-hôte-par-id)
- [Liste des sites](#liste-des-sites)
- [Liste des appareils](#liste-des-appareils)
- [Obtenir les métriques ISP](#obtenir-les-métriques-isp)
- [Interroger les métriques ISP](#interroger-les-métriques-isp)
- [Liste des configurations SD-WAN](#liste-des-configurations-sd-wan)
- [Obtenir une configuration SD-WAN par ID](#obtenir-une-configuration-sd-wan-par-id)
- [Obtenir le statut d'une configuration SD-WAN](#obtenir-le-statut-dune-configuration-sd-wan)
## Introduction
L'API Site Manager de Ubiquiti permet aux développeurs d'accéder et de gérer les appareils UniFi via l'interface unifi.ui.com. Cette API est actuellement en version Early Access (EA) et nécessite une inscription au programme EA.
## Authentification
L'authentification à l'API se fait via une clé API, qui est un identifiant unique utilisé pour authentifier les requêtes API. Ces clés sont essentielles pour garantir un accès sécurisé à votre compte UniFi et aux appareils associés. Chaque clé est liée au compte UI qui l'a créée, permettant des interactions API sécurisées et personnalisées.
### Obtention d'une clé API
1. Connectez-vous au gestionnaire de site UniFi à l'adresse [unifi.ui.com](https://unifi.ui.com/api).
2. Dans la barre de navigation de gauche, cliquez sur API.
3. Cliquez sur "Create API Key".
4. Copiez la clé et conservez-la en lieu sûr, car elle ne sera affichée qu'une seule fois.
5. Cliquez sur "Done" pour vous assurer que la clé est hachée et stockée en toute sécurité.
### Utilisation de la clé API
Incorporez la clé API dans l'en-tête X-API-Key. Suivez l'exemple ci-dessous, en remplaçant YOUR_API_KEY par votre clé API réelle.
```bash
curl -X GET 'https://api.ui.com/ea/hosts' \
-H 'X-API-KEY: YOUR_API_KEY' \
-H 'Accept: application/json'
```
## Limites de taux
La limite de taux de l'API est fixée à 100 requêtes par minute. Si vous dépassez cette limite, le serveur répondra avec un code d'état 429 Too Many Requests.
## Champs optionnels pendant l'EA
Dans la version Early Access de l'API, tous les champs dans `response.data` sont considérés comme optionnels. Cette conception flexible nous permet d'itérer et d'améliorer notre API au fil du temps. Nous vous encourageons à construire vos intégrations en tenant compte de cette optionalité.
## Endpoints
### Liste des hôtes
Récupère une liste de tous les hôtes associés au compte UI effectuant l'appel API.
**Endpoint**: `GET https://api.ui.com/ea/hosts`
**Paramètres de requête**:
- `pageSize` (optionnel): Nombre d'éléments à retourner par page
- `nextToken` (optionnel): Token pour la pagination
**Exemple de requête**:
```bash
curl -X GET 'https://api.ui.com/ea/hosts?pageSize=10&nextToken=602232A870250000000006C514FF00000000073DD8DB000000006369FDA2:1467082514' \
-H 'Accept: application/json' \
-H 'X-API-KEY: YOUR_API_KEY'
```
**Exemple de réponse**:
```json
{
"data": [
{
"hardwareId": "eae0f123-0000-5111-b111-f833f56eade5",
"id": "900A6F00301100000000074A6BA90000000007A3387E0000000063EC9853:123456789",
"ipAddress": "192.168.220.114",
"isBlocked": false,
"lastConnectionStateChange": "2024-06-23T03:59:52Z",
"latestBackupTime": "2024-06-22T11:55:10Z",
"owner": true,
"registrationTime": "2024-04-17T07:27:14Z",
"reportedState": {
// Structure détaillée de l'état rapporté
},
"type": "console",
"userData": {
// Structure détaillée des données utilisateur
}
}
],
"httpStatusCode": 200,
"traceId": "a7dc15e0eb4527142d7823515b15f87d",
"nextToken": "ba8e384e-3308-4236-b344-7357657351ca"
}
```
**Note**: La structure de `userData` et `reportedState` peut varier en fonction de la version de UniFi OS ou Network Server. L'exemple fourni est basé sur UniFi OS 4.0.6.
### Obtenir un hôte par ID
Récupère des informations détaillées sur un hôte spécifique par ID.
**Endpoint**: `GET https://api.ui.com/ea/hosts/{hostId}`
**Paramètres de chemin**:
- `hostId` (obligatoire): ID de l'hôte à récupérer
**Exemple de requête**:
```bash
curl -X GET 'https://api.ui.com/ea/hosts/900A6F00301100000000074A6BA90000000007A3387E0000000063EC9853:123456789' \
-H 'Accept: application/json' \
-H 'X-API-KEY: YOUR_API_KEY'
```
**Note**: La structure de `userData` et `reportedState` peut varier en fonction de la version de UniFi OS ou Network Server. L'exemple fourni est basé sur UniFi OS 4.0.6.
### Liste des sites
Récupère une liste de tous les sites associés au compte UI effectuant l'appel API.
**Endpoint**: `GET https://api.ui.com/ea/sites`
**Paramètres de requête**:
- `pageSize` (optionnel): Nombre d'éléments à retourner par page
- `nextToken` (optionnel): Token pour la pagination
**Exemple de requête**:
```bash
curl -X GET 'https://api.ui.com/ea/sites?pageSize=10&nextToken=602232A870250000000006C514FF00000000073DD8DB000000006369FDA2:1467082514' \
-H 'Accept: application/json' \
-H 'X-API-KEY: YOUR_API_KEY'
```
**Exemple de réponse**:
```json
{
"data": [
{
"hostId": "900A6F00301100000000074A6BA90000000007A3387E0000000063EC9853:123456789",
"isOwner": true,
"meta": {
"desc": "Default",
"gatewayMac": "f4:e2:c6:c2:3f:13",
"name": "default",
"timezone": "Europe/Riga"
},
"permission": "admin",
"siteId": "661900ae6aec8f548d49fd54",
"statistics": {
// Structure détaillée des statistiques
}
}
],
"httpStatusCode": 200,
"traceId": "a7dc15e0eb4527142d7823515b15f87d",
"nextToken": "ba8e384e-3308-4236-b344-7357657351ca"
}
```
**Note**: La structure de `meta` et `statistics` peut varier en fonction de la version de UniFi OS ou Network Server. L'exemple fourni est basé sur UniFi OS 4.0.6.
### Liste des appareils
Récupère une liste des appareils UniFi gérés par les hôtes où le compte UI effectuant l'appel API est le propriétaire ou un super administrateur.
**Endpoint**: `GET https://api.ui.com/ea/devices`
**Paramètres de requête**:
- `pageSize` (optionnel): Nombre d'éléments à retourner par page
- `nextToken` (optionnel): Token pour la pagination
**Note**: La structure de `devices.uidb` peut varier en fonction de la version de UniFi OS ou Network Server. L'exemple fourni est basé sur UniFi OS 4.0.6.
### Obtenir les métriques ISP
Récupère les données de métriques ISP pour tous les sites liés à la clé API du compte UI. Les métriques de 5 minutes sont disponibles pendant au moins 24 heures, et les métriques d'une heure pendant au moins 30 jours.
**Endpoint**: `GET https://api.ui.com/ea/isp-metrics`
### Interroger les métriques ISP
Récupère les données de métriques ISP en fonction de paramètres de requête spécifiques. Les métriques de 5 minutes sont disponibles pendant au moins 24 heures, et les métriques d'une heure pendant au moins 30 jours.
**Endpoint**: `POST https://api.ui.com/ea/isp-metrics/query`
**Note**: Si le compte UI n'a pas accès à tous les sites demandés, une erreur 502 est renvoyée. Si un accès partiel est accordé, la réponse inclura `status: partialSuccess`.
### Liste des configurations SD-WAN
Récupère une liste de toutes les configurations SD-WAN associées au compte UI effectuant l'appel API.
**Endpoint**: `GET https://api.ui.com/ea/sdwan-configs`
### Obtenir une configuration SD-WAN par ID
Récupère des informations détaillées sur une configuration SD-WAN spécifique par ID.
**Endpoint**: `GET https://api.ui.com/ea/sdwan-configs/{configId}`
**Paramètres de chemin**:
- `configId` (obligatoire): ID de la configuration SD-WAN à récupérer
### Obtenir le statut d'une configuration SD-WAN
Récupère le statut d'une configuration SD-WAN spécifique, y compris la progression du déploiement, les erreurs et les hubs associés.
**Endpoint**: `GET https://api.ui.com/ea/sdwan-configs/{configId}/status`
**Paramètres de chemin**:
- `configId` (obligatoire): ID de la configuration SD-WAN dont le statut doit être récupéré

View file

@ -0,0 +1,300 @@
# Liste des tâches pour le module d'intégration UniFi
Ce document détaille les tâches à accomplir pour atteindre les objectifs définis dans le fichier `objectifs.md`, en commençant par le refactoring des modèles.
## 1. Refactoring des modèles (udm_ → unifi_)
### 1.1 Modèle principal : Site
- [x] Créer le nouveau modèle `unifi.site` basé sur `udm.site`
- [x] Ajouter le champ `api_type` pour distinguer Site Manager et Controller
- [x] Ajouter les champs spécifiques à l'API Site Manager (api_key, mfa_enabled, etc.)
- [x] Ajouter les champs spécifiques à l'API Controller (host, port, controller_type, etc.)
- [x] Implémenter les méthodes abstraites d'authentification pour les deux types d'API
- [x] Mettre à jour les relations avec les autres modèles
- [x] Créer des vues adaptées au nouveau modèle
- [x] Refactoriser le modèle `unifi.site` en trois fichiers distincts :
- `unifi_site.py` : code commun aux deux types d'API
- `unifi_site_controller.py` : code spécifique à l'API Controller
- `unifi_site_manager.py` : code spécifique à l'API Site Manager
- [x] Implémenter les méthodes de délégation dans le modèle principal pour la validation et le nettoyage des champs
- [x] Implémenter les méthodes `_check_required_fields` et `_clear_irrelevant_fields` dans les modèles spécifiques
### 1.2 Modèle : Authentification et Session
- [x] Créer le modèle `unifi.auth.session` pour gérer les sessions d'authentification
- [x] Implémenter les méthodes de validation et rafraîchissement des sessions
- [x] Corriger les erreurs de lint (imports inutilisés, méthodes abstraites)
- [x] Créer le modèle transitoire `unifi.mfa` pour l'authentification à deux facteurs
- [x] Intégrer le modèle MFA avec le flux d'authentification
### 1.2.1 Modèles de support
- [x] Créer le modèle `unifi.api.log` pour journaliser les appels API
- [x] Créer le modèle `unifi.sync.job` pour gérer les tâches de synchronisation
- [x] Créer les vues pour ces modèles de support
- [x] Corriger les erreurs de lint (champs manquants, méthodes référencées dans les vues)
- [x] Ajouter les champs `start_time` et `end_time` au modèle `unifi.api.log`
- [x] Ajouter les champs `api_type`, `status` et `message` au modèle `unifi.sync.job`
- [x] Ajouter les attributs `verify_ssl` et `_name` aux classes `UnifiSiteController` et `UnifiSiteManager`
- [x] Corriger l'erreur "Statement seems to have no effect" dans le fichier `__manifest__.py`
- [x] Améliorer la gestion des exceptions dans la méthode de test de connexion
- [x] Corriger les erreurs de lint restantes concernant les attributs `verify_ssl` et `api_type`
- [x] Corriger les erreurs de lint dans le modèle `unifi.api.log` (imports inutilisés, méthodes abstraites)
- [x] Remplacer l'exception générale `Exception` par des types d'exceptions spécifiques
- [x] Corriger les erreurs de lint concernant l'accès aux méthodes `get_network_data` et `get_vlan_data`
- [x] Corriger les erreurs "Could not find model" pour les modèles `unifi.site.controller` et `unifi.site.manager`
- [x] Résoudre le problème de duplication de la méthode `get_vlan_data` dans le modèle `unifi.site`
### 1.3 Modèle : Device (Appareil)
- [x] Créer le nouveau modèle `unifi.device` basé sur `udm.device`
- [x] Ajouter les champs spécifiques (mac_address, ip_address, model, device_type, etc.)
- [x] Implémenter les méthodes de base pour la création et mise à jour des appareils
- [x] Créer des vues adaptées au nouveau modèle (tree, form, search)
- [x] Implémenter la méthode `get_device_data` dans les modèles `unifi.site.controller` et `unifi.site.manager`
- [x] Ajouter la relation `device_ids` au modèle `unifi.site`
### 1.4 Modèle : Network (Réseau)
- [x] Créer le nouveau modèle `unifi.network` basé sur `udm.network`
- [x] Ajouter les champs spécifiques à chaque type d'API
- [x] Implémenter les méthodes de synchronisation pour les deux types d'API
- [x] Mettre à jour les relations avec les autres modèles
- [x] Créer des vues adaptées au nouveau modèle
- [x] Implémenter la méthode `get_network_data` dans les modèles `unifi.site.controller` et `unifi.site.manager`
### 1.5 Modèle : VLAN
- [x] Créer le nouveau modèle `unifi.vlan` basé sur `udm.vlan`
- [x] Ajouter les champs spécifiques à chaque type d'API
- [x] Implémenter les méthodes de synchronisation pour les deux types d'API
- [x] Mettre à jour les relations avec les autres modèles
- [x] Créer des vues adaptées au nouveau modèle
- [x] Implémenter la méthode `get_vlan_data` dans les modèles `unifi.site.controller` et `unifi.site.manager`
### 1.6 Modèle : User (Utilisateur)
- [x] Créer le nouveau modèle `unifi.user` basé sur `udm.user`
- [x] Ajouter les champs spécifiques à chaque type d'API
- [x] Implémenter les méthodes de synchronisation pour les deux types d'API
- [x] Mettre à jour les relations avec les autres modèles
- [x] Créer des vues adaptées au nouveau modèle
- [x] Implémenter la méthode `get_user_data` dans les modèles `unifi.site.controller` et `unifi.site.manager`
### 1.7 Modèle : Firewall (Pare-feu)
- [x] Créer le nouveau modèle `unifi.firewall.rule` basé sur `udm.firewall.rule`
- [x] Ajouter les champs spécifiques à chaque type d'API
- [x] Implémenter les méthodes de synchronisation pour les deux types d'API
- [x] Mettre à jour les relations avec les autres modèles
- [x] Créer des vues adaptées au nouveau modèle
- [x] Implémenter la méthode `get_firewall_data` dans les modèles `unifi.site.controller` et `unifi.site.manager`
### 1.8 Modèle : Port Forward (Redirection de port)
- [x] Créer le nouveau modèle `unifi.port_forward` basé sur `udm.port_forward`
- [x] Ajouter les champs spécifiques à chaque type d'API
- [x] Implémenter les méthodes de synchronisation pour les deux types d'API
- [x] Améliorer l'interface utilisateur pour la gestion des redirections
- [x] Ajouter des validations pour éviter les configurations incorrectes
- [x] Mettre à jour les relations avec les autres modèles
- [x] Créer des vues adaptées au nouveau modèle
- [x] Implémenter la méthode `get_port_forward_data` dans les modèles `unifi.site.controller` et `unifi.site.manager`
### 1.9 Modèles de configuration et système
- [x] Créer le nouveau modèle `unifi.system_info` basé sur `udm.system_info`
- [x] Implémenter la méthode `get_system_info_data` dans les modèles `unifi.site.controller` et `unifi.site.manager`
- [x] Créer le nouveau modèle `unifi.dns` basé sur `udm.dns`
- [x] Créer le nouveau modèle `unifi.dns_config` basé sur `udm.dns_config`
- [x] Créer le nouveau modèle `unifi.routing` basé sur `udm.routing`
- [x] Créer le nouveau modèle `unifi.routing_config` basé sur `udm.routing_config`
### 1.10 Modèles de support
- [x] Créer le modèle `unifi.api.config` pour stocker les configurations API
- [x] Créer le modèle `unifi.api.log` pour la journalisation des appels API
- [x] Créer le modèle `unifi.sync.job` pour gérer les tâches de synchronisation
- [x] Résoudre les problèmes d'accès aux attributs dans les classes héritées (verify_ssl, api_type, name, etc.)
## 2. Amélioration de l'assistant d'ajout de site
### 2.1 Refactorisation de l'assistant existant
- [x] Créer le nouveau modèle `unifi.site.import.wizard` basé sur `udm.site.import.wizard`
- [x] Restructurer l'assistant pour supporter plusieurs étapes
- [x] Implémenter un système de navigation entre les étapes (précédent/suivant)
- [x] Ajouter un état pour suivre la progression dans l'assistant
### 2.2 Ajout du choix du type d'API
- [x] Créer une vue pour le choix du type d'API (Site Manager ou Controller)
- [x] Implémenter la logique de branchement en fonction du choix de l'API
- [x] Ajouter des descriptions et aides contextuelles pour guider l'utilisateur
### 2.3 Modèle de découverte de sites
- [x] Créer le modèle transitoire `unifi.site.discovery` pour la découverte de sites
- [x] Implémenter les méthodes de découverte pour les deux types d'API
- [x] Créer une interface pour afficher et sélectionner les sites disponibles
## 3. Implémentation des API
### 3.1 API Controller (locale)
- [x] Implémenter la logique d'authentification pour les contrôleurs UniFi
- [x] Adapter les endpoints API en fonction du type de contrôleur sélectionné
- [x] Implémenter la logique de validation spécifique à chaque type de contrôleur
- [x] Créer des classes utilitaires pour les appels API communs
- [x] Implémenter la gestion des erreurs et des timeouts
- [x] Intégrer la logique API directement dans le modèle unifi.site.controller
- [x] Ajouter la journalisation des appels API dans unifi.api.log
- [x] Implémenter la gestion des cookies de session et des tokens CSRF
- [x] Implémenter les méthodes de synchronisation des données dans unifi.site
- [x] Corriger les erreurs de lint dans les méthodes de synchronisation
- [x] Implémenter la gestion des certificats auto-signés
- [x] Ajouter des tests unitaires pour les méthodes API
### 3.2 API Site Manager (distante)
- [x] Implémenter l'interface d'authentification à l'API Site Manager
- [x] Développer le processus d'obtention et de validation d'une clé API
- [x] Implémenter la gestion de l'authentification à deux facteurs
- [x] Créer des classes utilitaires pour les appels API communs
- [x] Implémenter la gestion des erreurs et des limites de taux
- [x] Implémenter la méthode test_connection pour vérifier la connexion à l'API Site Manager
- [x] Implémenter la méthode _make_request pour effectuer des requêtes HTTP vers l'API Site Manager
## 4. Synchronisation et gestion des données
### 4.1 Synchronisation automatique
- [x] Implémenter un mécanisme de synchronisation périodique (cron)
- [x] Ajouter des options de configuration pour la fréquence de synchronisation
- [ ] Développer une logique de synchronisation incrémentielle pour optimiser les performances
- [x] Créer des journaux détaillés des opérations de synchronisation
- [x] Implémenter les méthodes _sync_controller et _sync_site_manager dans le modèle unifi.site
### 4.2 Gestion des erreurs et notifications
- [ ] Implémenter un système robuste de gestion des erreurs
- [ ] Créer des notifications pour les événements importants (déconnexion, échec de synchronisation)
- [ ] Ajouter des alertes configurables basées sur les données UniFi
- [ ] Développer un tableau de bord pour visualiser l'état des connexions
### 4.3 Historique et comparaison des configurations
- [ ] Implémenter un système de versionnement des configurations
- [ ] Créer une interface pour comparer les configurations dans le temps
- [ ] Ajouter la possibilité de restaurer
## 5. Nettoyage du code
### 5.1 Suppression des anciens modèles
- [x] Supprimer les importations des modèles `udm_*` dans le fichier `__init__.py`
- [x] Supprimer le champ `settings_id` du modèle `unifi.site`
- [x] Supprimer les fichiers de vues `udm_*.xml`
- [x] Renommer le fichier de sécurité `udm_pro_security.xml` en `unifi_security.xml`
- [x] Mettre à jour le fichier `__manifest__.py` pour refléter ces changements
- [x] Supprimer les fichiers des modèles `udm_*.py`
### 5.2 À nettoyer
- [x] Mettre à jour le contrôleur principal (`controllers/main.py`) qui fait encore référence aux anciens modèles
- [x] Corriger les références à `host`, `username`, `password` et `port` pour les récupérer depuis le modèle `unifi.site.controller` au lieu de `unifi.site`
- [x] Remplacer la méthode `import_configuration` qui n'existe plus par une création directe d'enregistrements `unifi.site` et `unifi.site.controller`
- [x] Renommer les groupes de sécurité qui conservent encore les anciens noms (`group_udm_pro_user` et `group_udm_pro_manager`)
- [x] Mise à jour des références dans le fichier `unifi_menu_views.xml`
- [x] Mise à jour des références dans le fichier `ir.model.access.csv`
- [x] Vérification que le contrôleur principal utilise déjà les nouveaux noms de groupes
- [x] Corriger l'erreur de lint dans le fichier `unifi_site_manager.py` concernant la variable `api_log` qui est peut-être indépendante
- [x] Corriger les erreurs de lint dans le fichier `unifi_site_controller.py` concernant les variables `auth_api_log` et `logout_api_log` qui sont peut-être indépendantes
- [x] Vérifier et corriger les autres références aux anciens modèles dans d'autres parties du code
- [x] Mettre à jour les références textuelles à "UDM Pro" par "UniFi" dans les templates pour maintenir la cohérence avec les autres changements
## 6. Interface utilisateur et expérience utilisateur
### 6.1 Implémentation de boutons d'action OWL dans les vues liste (Odoo 18.0)
Odoo 18.0 a introduit plusieurs changements dans la façon dont les composants OWL et les vues personnalisées sont implémentés. Voici les étapes à suivre pour ajouter un bouton d'action personnalisé dans une vue liste :
#### 6.1.1 Structure de fichiers
- [x] Créer un répertoire pour le composant dans `static/src/components/`
- [x] Créer un fichier JavaScript pour le composant du bouton
- [x] Créer un fichier XML pour le template du bouton
#### 6.1.2 Implémentation du composant
- [x] Créer un composant OWL qui utilise le hook `useService` pour accéder au service d'action
- [x] Implémenter la méthode pour déclencher l'action d'importation
- [x] Enregistrer le composant dans le registre des composants avec `registry.category("components").add()`
#### 6.1.3 Définition du template XML
- [x] Créer un template pour le bouton avec les attributs appropriés
- [x] Lier le bouton à la méthode du composant avec `t-on-click`
#### 6.1.4 Configuration dans le manifeste
- [x] Déclarer les assets dans la section `'assets'` du fichier `__manifest__.py`
- [x] Spécifier les fichiers JavaScript et XML dans la clé `'web.assets_backend'`
#### 6.1.5 Configuration de la vue XML
- [x] Ajouter le composant directement dans la vue liste en utilisant la balise appropriée
- [x] S'assurer que le composant est correctement référencé dans la vue
### 6.2 Amélioration des vues
- [ ] Optimiser les vues existantes pour une meilleure ergonomie
- [ ] Créer des vues spécifiques pour les différents types de contrôleurs
- [ ] Ajouter des filtres et regroupements pertinents pour faciliter la navigation
- [ ] Implémenter des actions contextuelles pour les opérations courantes
### 6.2 Tableau de bord
- [ ] Développer un tableau de bord complet avec les métriques importantes
- [ ] Implémenter des graphiques pour visualiser les tendances
- [ ] Ajouter des indicateurs de performance et d'état
- [ ] Créer des vues personnalisables selon les besoins de l'utilisateur
## 7. Documentation et tests
### 7.1 Documentation utilisateur
- [ ] Créer un guide d'installation détaillé
- [ ] Rédiger un manuel utilisateur complet
- [ ] Ajouter des tutoriels pour les cas d'utilisation courants
- [ ] Documenter les API et les modèles pour les développeurs
### 7.2 Tests automatisés
- [ ] Développer des tests unitaires pour les fonctionnalités clés
- [ ] Implémenter des tests d'intégration pour les flux complets
- [ ] Créer des scénarios de test pour les différentes configurations
- [ ] Mettre en place un système d'intégration continue
- [ ] Tester les méthodes de synchronisation avec des données réelles
## 8. Nettoyage et finalisation
### 8.1 Nettoyage du code
- [x] Supprimer les anciens modèles udm_* une fois la migration terminée
- [x] Nettoyer les imports inutilisés et optimiser les dépendances
- [x] Standardiser les noms de variables et les commentaires
- [x] Corriger les erreurs de lint dans les méthodes de synchronisation
- [x] Corriger les erreurs de lint concernant les modèles unifi.dashboard.metric et unifi.dashboard.stat
- [x] Remplacer l'utilisation de `datetime.now()` par `fields.Datetime.now()` dans unifi_dashboard_stat.py
- [x] Supprimer les méthodes `create` et `write` qui n'implémentent pas de logique spécifique
- [x] Mettre à jour le contrôleur principal (controllers/main.py) qui fait encore référence aux anciens modèles
- [x] Corriger les références à `host`, `username`, `password` et `port` pour les récupérer depuis le modèle `unifi.site.controller`
- [x] Remplacer la méthode `import_configuration` qui n'existe plus par une création directe d'enregistrements
- [x] Renommer les groupes de sécurité (group_udm_pro_user et group_udm_pro_manager)
- [x] Mise à jour des références dans le fichier `unifi_menu_views.xml`
- [x] Corriger l'erreur de lint dans unifi_site_manager.py concernant la variable api_log
- [x] Corriger les erreurs dans le fichier ir.model.access.csv
- [ ] Mettre à jour les vues XML pour refléter la nouvelle structure des modèles
- [x] Commenter temporairement les sections des vues qui font référence aux champs déplacés
- [ ] Créer de nouvelles vues pour les modèles unifi.site.controller et unifi.site.manager
- [ ] Mettre à jour les vues existantes pour utiliser les nouveaux modèles
- [ ] Corriger l'action action_import_unifi_site
- [x] Modifier la référence dans le bouton pour utiliser l'ID externe complet
- [x] Commenter temporairement le bouton d'importation pour permettre l'installation du module
- [ ] Créer une nouvelle action pour l'importation de sites qui utilise le wizard unifi.site.import.wizard
- [x] Mise à jour des références dans le fichier `ir.model.access.csv`
- [x] Nettoyer complètement le fichier `ir.model.access.csv`
- [x] Supprimer toutes les références aux anciens modèles `udm_*`
- [x] Ajouter les droits d'accès pour les modèles `unifi.dashboard.metric` et `unifi.dashboard.stat`
- [x] Mettre à jour les références textuelles à "UDM Pro" par "UniFi" dans les templates
### 8.2 Tests et validation
- [ ] Créer des tests unitaires pour tous les modèles
- [ ] Implémenter des tests d'intégration pour les flux principaux
- [ ] Valider la compatibilité avec différentes versions d'Odoo
- [ ] Tester avec différentes versions de contrôleurs UniFi
## 9. Déploiement et maintenance
### 9.1 Préparation pour la production
- [ ] Optimiser les performances pour les environnements de production
- [ ] Sécuriser toutes les communications et le stockage des données sensibles
- [ ] Implémenter des mécanismes de sauvegarde et restauration
- [ ] Créer des scripts de migration pour les mises à jour futures
### 9.2 Support et maintenance
- [ ] Établir un processus de suivi des problèmes
- [ ] Créer un système de mise à jour pour suivre les évolutions des API UniFi
- [ ] Documenter les procédures de dépannage courants
- [ ] Préparer des plans de maintenance préventive

View file

@ -0,0 +1,233 @@
# Documentation de l'API du Contrôleur UniFi
Cette documentation couvre l'API du contrôleur UniFi, qui permet d'interagir directement avec les contrôleurs UniFi Network.
## Table des matières
- [Introduction](#introduction)
- [Différences entre les contrôleurs UniFi et UDM Pro/UCG Max](#différences-entre-les-contrôleurs-unifi-et-udm-proucg-max)
- [Authentification](#authentification)
- [Endpoints du contrôleur](#endpoints-du-contrôleur)
- [Endpoints spécifiques aux sites](#endpoints-spécifiques-aux-sites)
- [Commandes exécutables](#commandes-exécutables)
- [Tables de données](#tables-de-données)
- [Liste des endpoints](#liste-des-endpoints)
- [Mise à jour des règles de redirection de port](#mise-à-jour-des-règles-de-redirection-de-port)
## Introduction
L'API du contrôleur UniFi permet aux développeurs d'accéder et de gérer les contrôleurs UniFi Network. Cette API REST offre un accès programmatique aux fonctionnalités du contrôleur UniFi.
## Différences entre les contrôleurs UniFi et UDM Pro/UCG Max
Il existe deux différences critiques entre les contrôleurs UniFi standard et l'API des UDM Pro et UCG Max :
1. L'endpoint de connexion est `/api/auth/login` (au lieu de `/api/login`)
2. Tous les endpoints API doivent être préfixés avec `/proxy/network` (par exemple, `https://192.168.0.1/proxy/network/api/s/default/self`)
## Authentification
### Pour les contrôleurs UniFi standard
L'authentification se fait via l'endpoint `/api/login` avec les informations d'identification.
### Pour UDM Pro et UCG Max
L'authentification se fait via l'endpoint `/api/auth/login` avec les informations d'identification.
#### Exemples d'authentification pour UDM Pro/UCG Max
**Avec curl**:
```bash
# Authentification et sauvegarde du contenu du cookie dans le fichier local cookie.txt avec l'option '-c'
curl -k -X POST --data '{"username": "usr", "password": "$pw"}' --header 'Content-Type: application/json' -c cookie.txt https://udmp:443/api/auth/login
# Répond avec des données JSON
# Utilisation du fichier local cookie.txt avec l'option '-b'
curl -k -X GET -b cookie.txt https://udmp/proxy/network/api/s/default/self
# Répond avec du JSON approprié
```
**Avec Python**:
```python
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
headers = {"Accept": "application/json","Content-Type": "application/json"}
data = {'username': 'usr', 'password': 'pw'}
s = requests.Session()
r = s.post('https://udmp:443/api/auth/login', headers = headers, json = data, verify = False, timeout = 1)
print(r.status_code)
print(s.get('https://udmp/proxy/network/api/s/default/self', headers = headers, verify = False, timeout = 1).text)
```
## Endpoints du contrôleur
Ces appels REST peuvent être effectués sans contexte de site. Il ne semble pas que des mises à jour (PUT) puissent être appelées sur ces endpoints.
Exemple de réponse :
```json
{
"data": [],
"meta": {
"rc": "ok",
"server_version": "5.7.23",
"up": true,
"uuid": "0e727580-ffff-ffff-ffff-403dcd5a7bd4"
}
}
```
## Endpoints spécifiques aux sites
Tous les endpoints sont présumés être préfixés avec `api/s/{site}``{site}` est l'identifiant du site.
### Commandes exécutables
En envoyant une requête POST à l'endpoint `api/s/{site}/cmd/<manager>` avec le JSON `{"cmd": "command"}`, vous pouvez invoquer des commandes sur le contrôleur.
### Tables de données
Ces données ont été extraites du JavaScript du site. Il y a 2 213 applications nommées dans le fichier JavaScript dynamic.dpi.js.
L'ID d'application est un ID composé utilisant un décalage binaire à gauche sur l'ID de catégorie + l'ID d'application envoyé depuis l'API en utilisant `list_dpi_stats_filtered`.
```php
function compoundId($cat, $app){
return (intval($cat) << 16) + intval($app);
}
```
### Liste des endpoints
Voici une liste des endpoints disponibles dans l'API du contrôleur UniFi :
```
# Utilisateur connecté
api/s/{site}/self
# Codes de pays
api/s/{site}/stat/ccode
# Canaux WiFi disponibles
api/s/{site}/stat/current-channel
# Santé du tableau de bord
api/s/{site}/stat/health
# Appareils clients actifs
api/s/{site}/stat/sta
# Clients configurés
api/s/{site}/stat/user
# Appareils
api/s/{site}/stat/device-basic - mac, type
api/s/{site}/stat/device - peut être filtré avec macs: [ ..., ... ]
# Paramètres détaillés du site
api/s/{site}/stat/sysinfo
# Les endpoints /rest/ ont également un /cnt/ qui renvoie le nombre pour la partie données
# peut être utilisé pour n'importe lequel mais semble ciblé vers les alarmes
# Paramètres du site
api/s/{site}/rest/setting - c'est un gros endpoint avec un mécanisme étrange pour la mise à jour
# Règles de pare-feu
api/s/{site}/rest/firewallrule - liste uniquement les règles définies par l'utilisateur
# Groupes de pare-feu
api/s/{site}/rest/firewallgroup
# Routes
api/s/{site}/rest/routing
# Alarmes
# Liste des alarmes
api/s/{site}/rest/alarm
# Liste des alarmes non archivées
api/s/{site}/rest/alarm?archived=false
# Groupes d'utilisateurs - paramètres de bande passante
api/s/{site}/rest/usergroup
# ?
api/s/{site}/rest/wlangroup
# Réseaux sans fil
api/s/{site}/rest/wlanconf
# ?
api/s/{site}/rest/tag
# Réseaux du site
api/s/{site}/rest/networkconf
# Exemple de chemin de sauvegarde
dl/autobackup/autobackup_5.7.23_20180513_0000_1526169600008.unf
# Insights - sessions
api/s/{site}/stat/session?type=all&start=1526515200&end=1526688000
# Insights - flux EDU
api/s/{site}/stat/stream
# Configuration des ports de commutateur ?
api/s/{site}/rest/portconf
# Redirections de port configurées et uPNP - les octets de transfert sont listés mais ne semblent pas être remplis
api/s/{site}/stat/portforward
# Mise à jour de l'utilisateur (les utilisateurs sont les clients)
api/s/{site}/upd/user/{UserId}
Vous pouvez obtenir les utilisateurs et l'ID utilisateur à partir de "/api/s/{SiteId}/stat/alluser" (Tous les clients) ou "/api/s/{SiteId}/stat/sta" (Clients actifs) qui contient l'ID client (_id).
Exemple : changer le nom de l'utilisateur avec l'ID client 5aca464bb79fc60200460394 en 'test-raw' :
${curl_cmd} --data "json={'name':'test-raw'}" $baseurl/api/s/$site/upd/user/5aca464bb79fc60200460394
# Obtenir la configuration du Hotspot
guest/s/{site}/hotspotconfig
Vous obtiendrez dans "auth" la valeur "none" si elle n'est pas activée, si elle est activée, vous obtiendrez par exemple "hotspot" et de nombreuses autres valeurs sur la conception de la page.
# Obtenir les packages Hotspot
guest/s/{site}/hotspotpackages
??
# Obtenir les règles de trafic
v2/api/site/{site}/trafficrules
Possibilité également d'ajouter une nouvelle règle avec une requête POST.
# Modifier les règles de trafic
v2/api/site/{site}/trafficrules/{id}/
Requête PUT ou DELETE pour mettre à jour ou supprimer une règle de trafic
GET n'est pas autorisé sur des règles de trafic spécifiques.
Avec PUT, le code de résultat est 201 et non 200 pour un changement réussi.
# Liste possible de tous les gestionnaires appelables
system
devmgr
stamgr
evtmgr
cfgmgr
hotspot
sitemgr
streammgr
backup
throughput
stat
firmware
firewall
elite
```
## Mise à jour des règles de redirection de port
Cela peut s'appliquer à d'autres configurations, mais les tests initiaux montrent que les règles de redirection de port peuvent être activées/désactivées en utilisant PUT contre l'endpoint `/api/s/{site}/rest/portforward/{rule-id}` avec un corps tel que :
```json
{
"enabled": true
}
```
L'ID de règle peut être récupéré en utilisant la requête GET de redirection de port décrite ci-dessus et se trouve dans la clé "_id".
De nouvelles règles peuvent être créées en utilisant POST, mais sachez qu'il semble y avoir très peu de validation (il est possible de créer des entrées sans autre information que le fait qu'elles soient activées, par exemple).

View file

@ -1,25 +1,25 @@
# -*- coding: utf-8 -*-
# Import module files
from . import udm_site
from . import udm_config
from . import udm_network
from . import udm_device
from . import udm_user
from . import udm_settings
from . import udm_firewall
from . import udm_port_forward
from . import udm_dns
from . import udm_routing
# Import all models to ensure they are registered with Odoo
from .udm_site import UdmSite
from .udm_config import UdmConfiguration
from .udm_network import UdmNetwork, UdmVlan
from .udm_device import UdmDevice
from .udm_user import UdmUser
from .udm_settings import UdmSettings
from .udm_firewall import UdmFirewallRule
from .udm_port_forward import UdmPortForward
from .udm_dns import UdmDnsConfig
from .udm_routing import UdmRoutingConfig
# UniFi models
from . import unifi_site
from . import unifi_site_controller
from . import unifi_site_manager
from . import unifi_auth_session
from . import unifi_mfa
from . import unifi_api_config
from . import unifi_api_log
from . import unifi_sync_job
from . import unifi_device
from . import unifi_network
from . import unifi_vlan
from . import unifi_user
from . import unifi_firewall
from . import unifi_port_forward
from . import unifi_system_info
from . import unifi_dns
from . import unifi_dns_config
from . import unifi_routing
from . import unifi_routing_config
from . import unifi_dashboard_metric
from . import unifi_dashboard_stat

View file

@ -1,316 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import json
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
class UdmDashboardMetric(models.Model):
"""Model for storing and managing UDM Pro dashboard metrics.
This model stores real-time and near-real-time metrics from UDM Pro devices,
such as bandwidth usage, CPU/memory utilization, connected clients count,
and security threat counts. Each metric is associated with a specific site
and includes both raw and formatted values for display.
The model supports:
- Multiple metric types with appropriate formatting
- Status computation based on thresholds
- Historical data storage for trending
- Automatic value formatting based on metric type
Metrics are ordered by last update time to show most recent data first.
"""
_name = 'udm.dashboard.metric'
_description = 'UDM Dashboard Metric'
_order = 'last_update desc' # Most recent metrics first
# Site this metric belongs to, cascade deletion if site is deleted
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
# Type of metric being tracked
metric_type = fields.Selection([
('bandwidth_usage', 'Bandwidth Usage'), # Network bandwidth utilization
('cpu_usage', 'CPU Usage'), # CPU utilization percentage
('memory_usage', 'Memory Usage'), # Memory utilization percentage
('clients_count', 'Connected Clients'), # Number of connected network clients
('wan_status', 'WAN Status'), # WAN connection status (up/down)
('threat_count', 'Security Threats'), # Number of security threats detected
('device_status', 'Device Status'), # Status of network devices (online/offline)
], string='Metric Type', required=True)
# Raw metric values
current_value = fields.Char(string='Current Value', required=True,
help="Current raw value of the metric")
max_value = fields.Char(string='Maximum Value',
help="Maximum allowed value for this metric, used for threshold calculations")
history_data = fields.Text(string='Historical Data',
help="JSON data containing historical values for graphing")
last_update = fields.Datetime(string='Last Update', default=fields.Datetime.now,
help="Timestamp of the last metric update")
# Computed fields for display
formatted_value = fields.Char(compute='_compute_formatted_value', string='Formatted Value',
help="Human-readable formatted value with appropriate units")
status = fields.Selection([
('normal', 'Normal'), # Operating within normal parameters
('warning', 'Warning'), # Approaching critical thresholds
('critical', 'Critical'), # Exceeded critical thresholds
], compute='_compute_status', string='Status',
help="Status indicator based on metric thresholds")
@api.depends('current_value', 'metric_type')
def _compute_formatted_value(self):
"""Compute a human-readable formatted value based on the metric type.
This method formats the raw value into a user-friendly string with appropriate units:
- For bandwidth: Converts to Mbps or Gbps with 1 decimal place
- For CPU/Memory: Adds percentage sign with 1 decimal place
- For other metrics: Uses the raw value as is
The formatted value is used in the UI to display metrics in a consistent
and readable format.
"""
for record in self:
if not record.current_value:
record.formatted_value = ''
continue
if record.metric_type == 'bandwidth_usage':
try:
value = float(record.current_value)
if value >= 1000:
record.formatted_value = f"{value/1000:.1f} Gbps"
else:
record.formatted_value = f"{value:.1f} Mbps"
except (ValueError, TypeError):
record.formatted_value = record.current_value
elif record.metric_type in ['cpu_usage', 'memory_usage']:
try:
value = float(record.current_value)
record.formatted_value = f"{value:.1f}%"
except (ValueError, TypeError):
record.formatted_value = record.current_value
else:
record.formatted_value = record.current_value
@api.depends('current_value', 'metric_type', 'max_value')
def _compute_status(self):
"""Compute the status of the metric based on predefined thresholds.
This method evaluates the current value against thresholds to determine
if the metric is in a normal, warning, or critical state. The thresholds
vary by metric type:
CPU/Memory Usage:
- Critical: >= 90%
- Warning: >= 75%
Bandwidth Usage:
- Critical: >= 90% of max
- Warning: >= 75% of max
Security Threats:
- Critical: >= 10 threats
- Warning: >= 5 threats
WAN Status:
- Critical: not 'up'
Device Status (format: 'online/total'):
- Critical: > 2 devices offline
- Warning: > 0 devices offline
The status is used in the UI to highlight metrics that need attention,
using color-coded badges (green/yellow/red).
"""
for record in self:
status = 'normal'
try:
if record.metric_type in ['cpu_usage', 'memory_usage']:
value = float(record.current_value)
if value >= 90:
status = 'critical'
elif value >= 75:
status = 'warning'
elif record.metric_type == 'bandwidth_usage':
if record.max_value:
value = float(record.current_value)
max_val = float(record.max_value)
usage_pct = (value / max_val) * 100
if usage_pct >= 90:
status = 'critical'
elif usage_pct >= 75:
status = 'warning'
elif record.metric_type == 'threat_count':
value = int(record.current_value)
if value >= 10:
status = 'critical'
elif value >= 5:
status = 'warning'
elif record.metric_type == 'wan_status':
if record.current_value != 'up':
status = 'critical'
elif record.metric_type == 'device_status':
if '/' in record.current_value:
online, total = map(int, record.current_value.split('/'))
offline = total - online
if offline > 2:
status = 'critical'
elif offline > 0:
status = 'warning'
except (ValueError, TypeError):
# If we can't parse the value, assume normal status
status = 'normal'
record.status = status
class UdmDashboardStat(models.Model):
"""Model for storing and analyzing historical UDM Pro statistics.
This model stores historical statistical data from UDM Pro devices for
long-term trend analysis and reporting. It supports both raw data points
and aggregated statistics (e.g., daily averages, totals).
Key features:
- Multiple statistic types (bandwidth, clients, threats, uptime)
- Support for different units of measurement
- Time-based statistics with start/end times
- Aggregation capabilities (sum, average, min, max)
- Built-in graphing support
Statistics are ordered by date (descending) to show most recent data first.
Raw and aggregated statistics are stored separately to maintain data
integrity while allowing flexible reporting.
"""
_name = 'udm.dashboard.stat'
_description = 'UDM Dashboard Statistic'
_order = 'date desc' # Most recent statistics first
# Site this statistic belongs to, cascade deletion if site is deleted
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
# Date of the statistic record
date = fields.Date(string='Date', required=True, default=fields.Date.today,
help="Date this statistic was recorded")
# Type of statistic being tracked
stat_type = fields.Selection([
('bandwidth_usage', 'Bandwidth Usage'), # Total bandwidth used
('client_count', 'Client Count'), # Number of clients over time
('threat_blocked', 'Threats Blocked'), # Number of security threats blocked
('device_uptime', 'Device Uptime'), # Device uptime duration
], string='Statistic Type', required=True,
help="Type of statistical data being recorded")
# Numerical value and its unit
value = fields.Float(string='Value', required=True,
help="Numerical value of the statistic")
unit = fields.Selection([
('bytes', 'Bytes'), # For bandwidth measurements
('count', 'Count'), # For counting items (clients, threats)
('percentage', 'Percentage'), # For utilization metrics
('hours', 'Hours'), # For time-based metrics
], string='Unit', required=True,
help="Unit of measurement for the value")
# Time range for detailed statistics
time_start = fields.Datetime(string='Start Time',
help="Start time for time-based statistics (e.g. hourly bandwidth usage)")
time_end = fields.Datetime(string='End Time',
help="End time for time-based statistics")
# Aggregation fields
is_aggregate = fields.Boolean(string='Is Aggregate', default=False,
help="Indicates if this record represents aggregated data")
aggregate_type = fields.Selection([
('sum', 'Sum'), # Total over the period
('avg', 'Average'), # Average over the period
('min', 'Minimum'), # Minimum value in the period
('max', 'Maximum'), # Maximum value in the period
], string='Aggregate Type',
help="Type of aggregation used for this record")
@api.model
def aggregate_stats(self, site_id, stat_type, start_date, end_date, aggregate_type='avg'):
"""Aggregate statistics for a specific site and type over a date range.
This method calculates aggregate values (sum, average, min, max) for
non-aggregated statistics within the specified date range.
Args:
site_id (int): ID of the site to aggregate stats for
stat_type (str): Type of statistic to aggregate
start_date (date): Start date of the range (inclusive)
end_date (date): End date of the range (inclusive)
aggregate_type (str): Type of aggregation to perform
'sum': Total of all values
'avg': Average of all values
'min': Minimum value
'max': Maximum value
Returns:
float: The aggregated value, or 0.0 if no stats found
"""
domain = [
('site_id', '=', site_id),
('stat_type', '=', stat_type),
('date', '>=', start_date),
('date', '<=', end_date),
('is_aggregate', '=', False) # Only aggregate raw statistics
]
stats = self.search(domain)
if not stats:
return 0.0
values = stats.mapped('value')
if aggregate_type == 'sum':
return sum(values)
elif aggregate_type == 'avg':
return sum(values) / len(values)
elif aggregate_type == 'min':
return min(values)
elif aggregate_type == 'max':
return max(values)
else:
return 0.0
def action_view_graph(self):
"""Open a graph view showing statistics over time.
This method creates an action to display a line graph of the statistic
values over time. The graph shows raw (non-aggregated) values grouped
by day.
The graph view is configured to:
- Show values on the y-axis
- Use a line graph for trend visualization
- Group data points by day on the x-axis
- Filter for the same site and statistic type
- Exclude aggregated records
Returns:
dict: An action dictionary that Odoo uses to open the graph view
"""
self.ensure_one()
action = {
'name': _('Statistics Graph'),
'view_mode': 'graph',
'res_model': 'udm.dashboard.stat',
'type': 'ir.actions.act_window',
'domain': [
('site_id', '=', self.site_id.id),
('stat_type', '=', self.stat_type),
('is_aggregate', '=', False) # Show only raw values
],
'context': {
'graph_measure': 'value', # Y-axis measurement
'graph_mode': 'line', # Line graph for trends
'graph_groupbys': ['date:day'] # Group by day on X-axis
}
}
return action

File diff suppressed because it is too large Load diff

View file

@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmDashboardDataPoint(models.Model):
"""Data point for dashboard statistics"""
_name = 'udm.dashboard.data.point'
_description = 'Dashboard Data Point'
_order = 'sequence'
# Reference to the statistic this data point belongs to
stat_id = fields.Many2one('udm.dashboard.stat', string='Statistic',
required=True, ondelete='cascade')
# Data point attributes
sequence = fields.Integer(string='Sequence', default=10,
help='Order of this data point in the series')
value = fields.Float(string='Value',
help='Numeric value of this data point')
timestamp = fields.Datetime(string='Timestamp',
help='When this data point was recorded')

View file

@ -1,71 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmDevice(models.Model):
"""Represents a network device in the UniFi system
This model stores information about devices connected to the network,
including both UniFi devices (APs, switches) and client devices.
Devices are linked to a specific site and are automatically deleted when
the site is deleted (cascade).
"""
_name = 'udm.device'
_description = 'UniFi Device'
_order = 'last_seen desc' # Most recently seen devices first
site_id = fields.Many2one('udm.site', string='Site', required=True,
ondelete='cascade',
help='Site this device belongs to')
name = fields.Char(string='Name',
help='Device name or hostname')
mac_address = fields.Char(string='MAC Address', required=True,
help='Device MAC address')
ip_address = fields.Char(string='IP Address',
help='Current IP address')
device_type = fields.Selection([
('uap', 'Access Point'),
('usw', 'Switch'),
('ugw', 'Gateway'),
('udm', 'Dream Machine'),
('client', 'Client Device'),
('other', 'Other')
], string='Device Type', required=True, default='client',
help='Type of device')
model = fields.Char(string='Model',
help='Device model name')
last_seen = fields.Datetime(string='Last Seen',
help='Last time the device was seen online')
raw_data = fields.Text(string='Raw Data',
help='Raw device data in JSON format')
# Computed fields
status = fields.Selection([
('online', 'Online'),
('offline', 'Offline'),
('unknown', 'Unknown')
], string='Status', compute='_compute_status', store=True)
@api.depends('last_seen')
def _compute_status(self):
"""Compute device online status based on last seen timestamp
Status is determined by comparing the last_seen timestamp with current time:
- Online: seen within last 10 minutes
- Offline: not seen for more than 10 minutes
- Unknown: never seen (no last_seen timestamp)
"""
for record in self:
if not record.last_seen:
record.status = 'unknown'
continue
from datetime import datetime, timedelta
now = fields.Datetime.now()
time_diff = now - record.last_seen
if time_diff <= timedelta(minutes=10):
record.status = 'online'
else:
record.status = 'offline'

View file

@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmDnsConfig(models.Model):
"""Configuration DNS pour le système UniFi
Ce modèle stocke la configuration DNS pour les contrôleurs UDM/UDR.
Il gère les paramètres DNS comme les serveurs personnalisés et le filtrage de contenu.
La configuration DNS est liée à un site spécifique et est automatiquement supprimée
lorsque le site est supprimé (cascade).
"""
_name = 'udm.dns.config'
_description = 'UniFi DNS Configuration'
site_id = fields.Many2one('udm.site', string='Site', required=True,
ondelete='cascade',
help='Site this DNS configuration belongs to')
enabled = fields.Boolean(string='Enabled', default=True,
help='Enable DNS server')
filters_enabled = fields.Boolean(string='Content Filtering Enabled', default=False,
help='Enable DNS content filtering')
custom_dns = fields.Char(string='Custom DNS Servers',
help='Comma-separated list of custom DNS servers')
raw_data = fields.Text(string='Raw Data',
help='Raw DNS configuration data in JSON format')

View file

@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmDnsConfig(models.Model):
"""DNS configuration for the UDM Pro"""
_name = 'udm.dns.config'
_description = 'UDM Pro DNS Configuration'
# Site this DNS configuration belongs to
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
# DNS settings
enabled = fields.Boolean(string='Enabled', default=True,
help='Whether DNS service is enabled')
filters_enabled = fields.Boolean(string='Content Filtering Enabled', default=False,
help='Enable content filtering on DNS queries')
custom_dns = fields.Char(string='Custom DNS Servers',
help='Comma-separated list of custom DNS servers')
raw_data = fields.Text(string='Raw Data',
help='Raw configuration data in JSON format')

View file

@ -1,98 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmFirewallRule(models.Model):
"""Represents a firewall rule in the UniFi system
This model stores firewall rules that control network traffic. Each rule
specifies what traffic is allowed or blocked based on various criteria such
as source/destination addresses, ports, and protocols.
Rules are linked to a specific site and are automatically deleted when
the site is deleted (cascade).
"""
_name = 'udm.firewall.rule'
_description = 'UniFi Firewall Rule'
_order = 'sequence, id'
site_id = fields.Many2one('udm.site', string='Site', required=True,
ondelete='cascade',
help='Site this firewall rule belongs to')
name = fields.Char(string='Name', required=True,
help='Rule name')
description = fields.Text(string='Description',
help='Detailed description of the rule purpose')
enabled = fields.Boolean(string='Enabled', default=True,
help='Whether this rule is active')
sequence = fields.Integer(string='Sequence', default=10,
help='Order in which rules are evaluated')
action = fields.Selection([
('accept', 'Accept'),
('drop', 'Drop'),
('reject', 'Reject')
], string='Action', required=True, default='drop',
help='Action to take when rule matches')
protocol = fields.Selection([
('tcp', 'TCP'),
('udp', 'UDP'),
('icmp', 'ICMP'),
('all', 'All')
], string='Protocol', required=True, default='all',
help='Network protocol this rule applies to')
source = fields.Char(string='Source',
help='Source network or IP address in CIDR format')
destination = fields.Char(string='Destination',
help='Destination network or IP address in CIDR format')
src_address = fields.Char(string='Source Address',
help='Legacy field, use source instead')
dst_address = fields.Char(string='Destination Address',
help='Legacy field, use destination instead')
src_port = fields.Char(string='Source Port',
help='Source port number or range (e.g. 80 or 1024-2048)')
dst_port = fields.Char(string='Destination Port',
help='Destination port number or range (e.g. 80 or 1024-2048)')
raw_data = fields.Text(string='Raw Data',
help='Raw firewall rule data in JSON format')
# Computed fields
rule_summary = fields.Char(string='Rule Summary', compute='_compute_rule_summary')
@api.depends('action', 'protocol', 'src_address', 'dst_address', 'src_port', 'dst_port')
def _compute_rule_summary(self):
"""Compute a human-readable summary of the firewall rule
This method generates a concise description of the rule's action,
protocol, and source/destination addresses and ports. The summary
is used in list views and reports to quickly understand what the
rule does without viewing all details.
Example summaries:
- ACCEPT TCP from 192.168.1.0/24:80 to 10.0.0.0/8:443
- DROP ICMP from 172.16.0.0/16 to any
- REJECT UDP from any to 192.168.2.10:53
"""
for record in self:
parts = []
if record.action:
parts.append(record.action.upper())
if record.protocol:
parts.append(record.protocol.upper())
# Use new source/destination fields if available, fall back to legacy fields
src_addr = record.source or record.src_address or 'any'
dst_addr = record.destination or record.dst_address or 'any'
src = f"from {src_addr}"
if record.src_port:
src += f":{record.src_port}"
parts.append(src)
dst = f"to {dst_addr}"
if record.dst_port:
dst += f":{record.dst_port}"
parts.append(dst)
record.rule_summary = ' '.join(parts)

View file

@ -1,107 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmNetwork(models.Model):
"""Represents a network in the UniFi system
This model stores network configuration for both UDM/UDR and software controllers.
Each network can be associated with a VLAN and contains DHCP configuration.
Networks are linked to a specific site and are automatically deleted when
the site is deleted (cascade).
"""
_name = 'udm.network'
_description = 'UniFi Network'
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade',
help='Site this network belongs to')
name = fields.Char(string='Name', required=True,
help='Network name')
purpose = fields.Selection([
('corporate', 'Corporate'),
('guest', 'Guest'),
('iot', 'IoT'),
('other', 'Other')
], string='Purpose', required=True, default='corporate',
help='Network purpose/type')
subnet = fields.Char(string='Subnet', required=True,
help='Network subnet in CIDR format (e.g. 192.168.1.0/24)')
vlan_id_number = fields.Integer(string='VLAN ID',
help='VLAN ID number (1-4094)')
vlan_id = fields.Many2one('udm.vlan', string='VLAN',
compute='_compute_vlan_id', store=True,
help='Associated VLAN configuration')
dhcp_enabled = fields.Boolean(string='DHCP Enabled', default=True,
help='Enable DHCP server for this network')
dhcp_start = fields.Char(string='DHCP Start',
help='Start of DHCP range')
dhcp_stop = fields.Char(string='DHCP Stop',
help='End of DHCP range')
domain_name = fields.Char(string='Domain Name',
help='Network domain name')
raw_data = fields.Text(string='Raw Data',
help='Raw network configuration data in JSON format')
# Statistics
device_count = fields.Integer(string='Device Count', compute='_compute_device_count')
@api.depends('vlan_id_number', 'site_id')
def _compute_vlan_id(self):
"""Compute the associated VLAN based on the VLAN ID number
This method searches for a VLAN with matching ID in the same site.
The VLAN ID is stored and updated automatically when either the
VLAN ID number or site changes.
"""
for record in self:
if not record.vlan_id_number or not record.site_id:
record.vlan_id = False
continue
vlan = self.env['udm.vlan'].search([
('site_id', '=', record.site_id.id),
('vlan_id', '=', record.vlan_id_number)
], limit=1)
record.vlan_id = vlan.id if vlan else False
def _compute_device_count(self):
for record in self:
# This function would be more accurate if devices were linked to networks
# For now, this is just an example
record.device_count = 0
class UdmVlan(models.Model):
"""Represents a VLAN in the UniFi system
This model stores VLAN configurations for both UDM/UDR and software controllers.
VLANs are used to segment network traffic and can be associated with multiple
networks.
VLANs are linked to a specific site and are automatically deleted when
the site is deleted (cascade).
"""
_name = 'udm.vlan'
_description = 'UniFi VLAN'
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade',
help='Site this VLAN belongs to')
vlan_id = fields.Integer(string='VLAN ID', required=True,
help='VLAN ID number (1-4094)')
name = fields.Char(string='Name', required=True,
help='VLAN name')
enabled = fields.Boolean(string='Enabled', default=True,
help='VLAN status')
raw_data = fields.Text(string='Raw Data',
help='Raw VLAN configuration data in JSON format')
# Reverse relations
network_ids = fields.One2many('udm.network', 'vlan_id', string='Networks')
network_count = fields.Integer(compute='_compute_network_count')
@api.depends('network_ids')
def _compute_network_count(self):
for record in self:
record.network_count = len(record.network_ids)

View file

@ -1,80 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmPortForward(models.Model):
"""Represents a port forwarding rule in the UniFi system
This model stores port forwarding rules that allow external access to
internal services. Each rule maps an external port to an internal
IP address and port.
Port forwards are linked to a specific site and are automatically deleted
when the site is deleted (cascade).
"""
_name = 'udm.port.forward'
_description = 'UniFi Port Forward'
_order = 'sequence, id'
site_id = fields.Many2one('udm.site', string='Site', required=True,
ondelete='cascade',
help='Site this port forward belongs to')
name = fields.Char(string='Name', required=True,
help='Rule name')
description = fields.Text(string='Description',
help='Detailed description of the port forward purpose')
enabled = fields.Boolean(string='Enabled', default=True,
help='Whether this rule is active')
sequence = fields.Integer(string='Sequence', default=10,
help='Order in which rules are evaluated')
protocol = fields.Selection([
('tcp', 'TCP'),
('udp', 'UDP'),
('tcp_udp', 'TCP & UDP')
], string='Protocol', required=True, default='tcp',
help='Network protocol to forward')
source = fields.Char(string='Source',
help='Source network or IP address in CIDR format')
dst_port = fields.Char(string='Destination Port', required=True,
help='External port number or range (e.g. 80 or 1024-2048)')
fwd_ip = fields.Char(string='Forward IP', required=True,
help='Internal IP address to forward to')
fwd_port = fields.Char(string='Forward Port', required=True,
help='Internal port number or range')
raw_data = fields.Text(string='Raw Data',
help='Raw port forward data in JSON format')
# Computed fields
rule_summary = fields.Char(string='Rule Summary', compute='_compute_rule_summary')
@api.depends('protocol', 'source', 'dst_port', 'fwd_ip', 'fwd_port')
def _compute_rule_summary(self):
"""Compute a human-readable summary of the port forward rule
This method generates a concise description of the rule's protocol,
source, and port mappings. The summary is used in list views and
reports to quickly understand what the rule does without viewing
all details.
Example summaries:
- TCP from any:80 to 192.168.1.100:8080
- UDP from 203.0.113.0/24:53 to 192.168.2.10:53
- TCP & UDP from any:25565 to 192.168.3.50:25565
"""
for record in self:
parts = []
if record.protocol:
parts.append(record.protocol.upper())
src = f"from {record.source or 'any'}"
if record.dst_port:
src += f":{record.dst_port}"
parts.append(src)
dst = f"to {record.fwd_ip}"
if record.fwd_port:
dst += f":{record.fwd_port}"
parts.append(dst)
record.rule_summary = ' '.join(parts)

View file

@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmRoutingConfig(models.Model):
"""Routing configuration for the UniFi system
This model stores routing configuration for both UDM/UDR controllers.
It manages routing settings such as OSPF and static routes.
Routing configuration is linked to a specific site and is automatically deleted
when the site is deleted (cascade).
"""
_name = 'udm.routing.config'
_description = 'UniFi Routing Configuration'
site_id = fields.Many2one('udm.site', string='Site', required=True,
ondelete='cascade',
help='Site this routing configuration belongs to')
ospf_enabled = fields.Boolean(string='OSPF Enabled', default=False,
help='Enable OSPF routing')
static_routes = fields.Text(string='Static Routes',
help='Comma-separated list of static routes in format: network/prefix via nexthop')
raw_data = fields.Text(string='Raw Data',
help='Raw routing configuration data in JSON format')

View file

@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmRoutingConfig(models.Model):
"""Routing configuration for the UDM Pro"""
_name = 'udm.routing.config'
_description = 'UDM Pro Routing Configuration'
# Site this routing configuration belongs to
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
# Routing settings
ospf_enabled = fields.Boolean(string='OSPF Enabled', default=False,
help='Enable OSPF routing protocol')
static_routes = fields.Text(string='Static Routes',
help='List of static routes in format: network/prefix via nexthop')
raw_data = fields.Text(string='Raw Data',
help='Raw configuration data in JSON format')

View file

@ -1,92 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmSettings(models.Model):
"""General configuration of the UDM Pro"""
_name = 'udm.settings'
_description = 'UDM Pro Settings'
# Basic fields
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade',
help='Site these settings belong to')
name = fields.Char(string='Name', compute='_compute_name', store=True,
help='Settings name for display')
# Time settings
timezone = fields.Selection([
('UTC', 'UTC'),
('America/Montreal', 'America/Montreal'),
('America/New_York', 'America/New_York'),
('America/Toronto', 'America/Toronto'),
('Europe/Paris', 'Europe/Paris')
], string='Timezone', default='America/Montreal',
help='Timezone for this site')
ntp_enabled = fields.Boolean(string='NTP Enabled', default=True,
help='Enable NTP synchronization')
ntp_servers = fields.Char(string='NTP Servers',
help='Comma-separated list of NTP servers')
# DNS settings
dns_enabled = fields.Boolean(string='DNS Enabled', default=True,
help='Enable DNS service')
dns_servers = fields.Char(string='DNS Servers',
help='Comma-separated list of DNS servers')
dns_forwarding = fields.Boolean(string='DNS Forwarding', default=True,
help='Enable DNS forwarding')
# Advanced settings
upnp_enabled = fields.Boolean(string='UPnP Enabled', default=False,
help='Enable Universal Plug and Play')
mdns_enabled = fields.Boolean(string='mDNS Enabled', default=True,
help='Enable multicast DNS')
igmp_proxy = fields.Boolean(string='IGMP Proxy', default=False,
help='Enable IGMP proxy')
# Raw data
raw_data = fields.Text(string='Raw Data',
help='Raw settings data in JSON format')
# Computed fields
ntp_server_list = fields.Many2many('ir.model.data', string='NTP Server List',
compute='_compute_server_lists',
help='List of NTP servers for display')
dns_server_list = fields.Many2many('ir.model.data', string='DNS Server List',
compute='_compute_server_lists',
help='List of DNS servers for display')
@api.depends('site_id')
def _compute_name(self):
"""Compute a display name for the settings
The name is based on the site name and includes a timestamp to
differentiate between multiple settings records for the same site.
"""
for record in self:
if record.site_id:
record.name = f'{record.site_id.name} Settings'
else:
record.name = 'New Settings'
@api.depends('ntp_servers', 'dns_servers')
def _compute_server_lists(self):
"""Convert comma-separated server lists to Many2many fields
This method splits the NTP and DNS server strings into lists
for display in the user interface. The lists are stored in
technical fields that are not persisted to the database.
"""
for record in self:
# Convert NTP servers string to list
if record.ntp_servers:
ntp_servers = [s.strip() for s in record.ntp_servers.split(',')]
record.ntp_server_list = [(6, 0, ntp_servers)]
else:
record.ntp_server_list = False
# Convert DNS servers string to list
if record.dns_servers:
dns_servers = [s.strip() for s in record.dns_servers.split(',')]
record.dns_server_list = [(6, 0, dns_servers)]
else:
record.dns_server_list = False

View file

@ -1,231 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import requests
import json
import logging
import random
from datetime import timedelta
_logger = logging.getLogger(__name__)
class UdmSite(models.Model):
"""Represents a UniFi site managed by one or more UDM Pro
This model is the central entity that groups all UniFi configurations and devices.
Each site can have multiple configurations, devices, networks, and users.
"""
_name = 'udm.site'
_description = 'UniFi Site'
_order = 'name'
_inherit = ['mail.thread']
# Basic site information
name = fields.Char(string='Name', required=True, tracking=True,
help='Site name')
site_id = fields.Char(string='Site ID',
help="Site identifier in UniFi (usually 'default')",
default='default',
readonly=True,
required=True)
description = fields.Text(string='Description', tracking=True,
help='Site description')
address = fields.Text(string='Physical Address', tracking=True,
help='Physical location of this site')
active = fields.Boolean(string='Active', default=True, tracking=True,
help='Indicates if this site is currently active')
# Controller configuration
controller_type = fields.Selection([
('udm', 'UDM/UDR'),
('software', 'Software Controller')
], string='Controller Type', required=True, default='udm', tracking=True,
help='Type of UniFi controller managing this site')
host = fields.Char(string='Host', required=True, tracking=True,
help='IP address or hostname of the controller')
port = fields.Integer(string='Port', default=443, required=True,
help='Port number (default: 443)')
username = fields.Char(string='Username', required=True, tracking=True)
password = fields.Char(string='Password', required=True)
mfa_token = fields.Char(string='MFA Token',
help='Two-factor authentication token if enabled')
# Configuration data
timestamp = fields.Datetime(string='Last Update', tracking=True)
raw_data = fields.Text(string='Raw Data',
help='Raw configuration data in JSON format')
# Related Records
configuration_ids = fields.One2many('udm.configuration', 'site_id',
string='Configurations',
help='Configurations for this site')
network_ids = fields.One2many('udm.network', 'site_id',
string='Networks',
help='Networks in this site')
vlan_ids = fields.One2many('udm.vlan', 'site_id',
string='VLANs',
help='VLANs in this site')
device_ids = fields.One2many('udm.device', 'site_id',
string='Devices',
help='Devices in this site')
user_ids = fields.One2many('udm.user', 'site_id',
string='Users',
help='Users in this site')
firewall_rule_ids = fields.One2many('udm.firewall.rule', 'site_id',
string='Firewall Rules',
help='Firewall rules for this site')
port_forward_ids = fields.One2many('udm.port.forward', 'site_id',
string='Port Forwards',
help='Port forwarding rules for this site')
dns_config_ids = fields.One2many('udm.dns.config', 'site_id',
string='DNS Configurations',
help='DNS configurations for this site')
routing_config_ids = fields.One2many('udm.routing.config', 'site_id',
string='Routing Configurations',
help='Routing configurations for this site')
# Dashboard Metrics
dashboard_metric_ids = fields.One2many('udm.dashboard.metric', 'site_id',
string='Dashboard Metrics',
help='Real-time metrics for this site')
dashboard_stat_ids = fields.One2many('udm.dashboard.stat', 'site_id',
string='Statistics',
help='Historical statistics for this site')
# Computed Fields
config_count = fields.Integer(compute='_compute_counts',
string='Configuration Count',
help='Number of configurations in this site')
device_count = fields.Integer(compute='_compute_device_count',
string='Total Devices',
help='Total number of devices in this site')
client_count = fields.Integer(compute='_compute_client_count',
string='Connected Clients',
help='Number of currently connected clients')
@api.depends('configuration_ids')
def _compute_counts(self):
"""Compute the number of configurations in this site"""
for site in self:
site.config_count = len(site.configuration_ids)
@api.depends('device_ids')
def _compute_device_count(self):
"""Compute the total number of devices in this site"""
for site in self:
site.device_count = len(site.device_ids)
@api.depends('user_ids')
def _compute_client_count(self):
"""Compute the number of connected clients"""
for site in self:
site.client_count = len(site.user_ids.filtered(lambda u: u.is_connected))
def action_view_configurations(self):
"""Open the configurations view filtered for this site"""
self.ensure_one()
return {
'name': _('Configurations'),
'type': 'ir.actions.act_window',
'res_model': 'udm.configuration',
'view_mode': 'tree,form',
'domain': [('site_id', '=', self.id)],
'context': {'default_site_id': self.id},
}
def action_view_dashboard(self):
"""Display the dashboard for this site"""
self.ensure_one()
return {
'name': _('Site Dashboard'),
'type': 'ir.actions.act_window',
'res_model': 'udm.dashboard',
'view_mode': 'kanban,form',
'domain': [('site_id', '=', self.id)],
'context': {'default_site_id': self.id},
}
def action_refresh_metrics(self):
"""Refresh the dashboard metrics and statistics for this site"""
self.ensure_one()
for config in self.configuration_ids:
config.action_update_dashboard_metrics()
_sql_constraints = [
('name_uniq', 'unique(name)',
'Site name must be unique!'),
]
@api.model_create_multi
def create(self, vals_list):
"""Override create to verify connection before saving"""
for vals in vals_list:
if not self._verify_connection(vals):
raise UserError(_('Could not connect to the UniFi controller. '
'Please verify your credentials and connection details.'))
return super(UdmSite, self).create(vals_list)
def write(self, vals):
"""Override write to verify connection if connection details change"""
if any(field in vals for field in ['host', 'port', 'username',
'password', 'mfa_token']):
for record in self:
test_vals = {**record.copy_data()[0], **vals}
if not self._verify_connection(test_vals):
raise UserError(_('Could not connect to the UniFi controller. '
'Please verify your credentials and connection details.'))
return super(UdmSite, self).write(vals)
def _verify_connection(self, vals):
"""Verify connection to the UniFi controller
Args:
vals (dict): Values to use for connection test
Returns:
bool: True if connection successful, False otherwise
"""
try:
# Build base URL based on controller type
base_url = f"https://{vals['host']}:{vals.get('port', 443)}"
if vals.get('controller_type') == 'software':
base_url += '/api'
else:
base_url += '/proxy/network'
# Disable SSL verification warning
import urllib3
urllib3.disable_warnings()
# Try to authenticate
session = requests.Session()
response = session.post(
f"{base_url}/api/auth/login",
json={
'username': vals['username'],
'password': vals['password'],
'remember': True,
'token': vals.get('mfa_token', '')
},
verify=False
)
if response.status_code == 200:
return True
_logger.error(
"Authentication failed for UniFi controller at %s: %s",
vals['host'],
response.text
)
return False
except Exception as e:
_logger.error(
"Error connecting to UniFi controller at %s: %s",
vals['host'],
str(e)
)
return False

View file

@ -1,42 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class UdmSystemInfo(models.Model):
"""System information of the UDM Pro"""
_name = 'udm.system.info'
_description = 'UDM Pro System Information'
# Site this system information belongs to
site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade')
hostname = fields.Char(string='Hostname')
version = fields.Char(string='Firmware Version')
model = fields.Char(string='Model')
uptime = fields.Integer(string='Uptime (seconds)')
uptime_human = fields.Char(string='Uptime', compute='_compute_uptime_human')
serial = fields.Char(string='Serial Number')
mac_address = fields.Char(string='MAC Address')
raw_data = fields.Text(string='Raw Data')
@api.depends('uptime')
def _compute_uptime_human(self):
for record in self:
if not record.uptime:
record.uptime_human = 'Unknown'
continue
days, remainder = divmod(record.uptime, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60)
parts = []
if days:
parts.append(f"{days} day{'s' if days != 1 else ''}")
if hours:
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if minutes:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
if seconds or not parts:
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
record.uptime_human = ', '.join(parts)

View file

@ -1,105 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from datetime import timedelta
class UdmUser(models.Model):
"""Represents a user in the UniFi system
This model stores information about users who have access to the UniFi
network, including their roles and permissions. Users can be administrators,
operators, or read-only viewers.
Users are linked to a specific site and are automatically deleted when
the site is deleted (cascade).
"""
_name = 'udm.user'
_description = 'UniFi User'
# Basic fields
site_id = fields.Many2one('udm.site', string='Site', required=True,
ondelete='cascade',
help='Site this user belongs to')
name = fields.Char(string='Name', required=True,
help='User full name')
email = fields.Char(string='Email',
help='User email address')
mac_address = fields.Char(string='MAC Address',
help='Device MAC address')
ip_address = fields.Char(string='IP Address',
help='Current IP address')
network_id = fields.Many2one('udm.network', string='Network',
ondelete='set null',
help='Network this user is connected to')
# Role and permissions
role = fields.Selection([
('admin', 'Administrator'),
('operator', 'Operator'),
('viewer', 'Viewer')
], string='Role', required=True, default='viewer',
help='User role and permissions level')
enabled = fields.Boolean(string='Enabled', default=True,
help='Whether the user account is active')
# Connection status
status = fields.Selection([
('connected', 'Connected'),
('disconnected', 'Disconnected'),
('idle', 'Idle')
], string='Status', default='disconnected',
help='Current connection status of the user')
last_seen = fields.Datetime(string='Last Seen',
help='Last time the user was seen online')
# Technical fields
raw_data = fields.Text(string='Raw Data',
help='Raw user data in JSON format')
# Computed fields
is_admin = fields.Boolean(string='Is Admin', compute='_compute_is_admin', store=True,
help='Whether this user has administrator privileges')
is_connected = fields.Boolean(string='Is Connected', compute='_compute_is_connected',
store=True, help='Whether this user is currently connected')
@api.depends('role')
def _compute_is_admin(self):
"""Compute whether the user has administrator privileges
This is a convenience field that makes it easy to filter for or check
if a user has admin rights without parsing the role field.
"""
for record in self:
record.is_admin = record.role == 'admin'
@api.depends('status')
def _compute_is_connected(self):
"""Compute whether the user is currently connected
This is a convenience field that makes it easy to filter for or check
if a user is currently connected without parsing the status field.
"""
for record in self:
record.is_connected = record.status == 'connected'
def _update_status(self):
"""Update the user's connection status based on last seen time
This method is called periodically to update the user's status:
- If last seen within 5 minutes: Connected
- If last seen within 30 minutes: Idle
- Otherwise: Disconnected
"""
now = fields.Datetime.now()
for record in self:
if not record.last_seen:
record.status = 'disconnected'
continue
delta = now - record.last_seen
if delta <= timedelta(minutes=5):
record.status = 'connected'
elif delta <= timedelta(minutes=30):
record.status = 'idle'
else:
record.status = 'disconnected'

View file

@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
import logging
_logger = logging.getLogger(__name__)
class UnifiApiConfig(models.Model):
"""Configuration API pour le système UniFi
Ce modèle stocke les configurations API pour les différents types d'API UniFi.
Il centralise les paramètres de connexion et les options de sécurité.
"""
_name = 'unifi.api.config'
_description = 'UniFi API Configuration'
_inherit = ['mail.thread', 'mail.activity.mixin']
name = fields.Char(
string='Name',
required=True,
help='Nom de la configuration API'
)
api_type = fields.Selection(
selection=[
('controller', 'UniFi Controller'),
('site_manager', 'UniFi Site Manager'),
],
string='API Type',
required=True,
default='controller',
help='Type d\'API UniFi'
)
controller_type = fields.Selection(
selection=[
('udm', 'UniFi Dream Machine'),
('cloud_key', 'Cloud Key'),
('other', 'Other'),
],
string='Controller Type',
default='udm',
help='Type de contrôleur UniFi (applicable uniquement pour le type d\'API Controller)',
)
base_url = fields.Char(
string='Base URL',
required=True,
help='URL de base pour les appels API (ex: https://unifi.example.com)'
)
api_version = fields.Char(
string='API Version',
default='v1',
help='Version de l\'API à utiliser'
)
username = fields.Char(
string='Username',
help='Nom d\'utilisateur pour l\'authentification API'
)
password = fields.Char(
string='Password',
help='Mot de passe pour l\'authentification API',
)
token = fields.Char(
string='API Token',
help='Jeton d\'authentification pour l\'API (si applicable)',
)
verify_ssl = fields.Boolean(
string='Verify SSL',
default=True,
help='Vérifier les certificats SSL lors des appels API'
)
timeout = fields.Integer(
string='Timeout',
default=30,
help='Délai d\'attente maximum pour les appels API (en secondes)'
)
retry_count = fields.Integer(
string='Retry Count',
default=3,
help='Nombre de tentatives en cas d\'échec d\'un appel API'
)
active = fields.Boolean(
string='Active',
default=True,
help='Indique si cette configuration est active'
)
company_id = fields.Many2one(
comodel_name='res.company',
string='Company',
default=lambda self: self.env.company,
help='Entreprise associée à cette configuration'
)
site_ids = fields.One2many(
comodel_name='unifi.site',
inverse_name='api_config_id',
string='Sites',
help='Sites utilisant cette configuration API',
readonly=True # Lecture seule car le champ n'existe pas encore dans unifi.site
)
log_ids = fields.One2many(
comodel_name='unifi.api.log',
inverse_name='api_config_id',
string='API Logs',
help='Journaux des appels API utilisant cette configuration',
readonly=True # Lecture seule car le champ n'existe pas encore dans unifi.api.log
)
notes = fields.Text(
string='Notes',
help='Notes additionnelles sur cette configuration API'
)
@api.constrains('base_url')
def _check_base_url(self):
"""Vérifie que l'URL de base est valide"""
for record in self:
if not record.base_url:
continue
if not (record.base_url.startswith('http://') or record.base_url.startswith('https://')):
raise ValidationError(_("L'URL de base doit commencer par 'http://' ou 'https://'."))
def test_connection(self):
"""Teste la connexion à l'API UniFi"""
self.ensure_one()
try:
# Logique de test de connexion selon le type d'API
if self.api_type == 'controller':
# Importer la classe UnifiClient
from ..controllers.main import UnifiClient
# Extraire le host et le port de l'URL de base
from urllib.parse import urlparse
parsed_url = urlparse(self.base_url)
host = parsed_url.netloc.split(':')[0] if ':' in parsed_url.netloc else parsed_url.netloc
port = parsed_url.port or 443
# Créer une instance de l'API
api = UnifiClient(
host=host,
port=port,
username=self.username,
password=self.password,
verify_ssl=self.verify_ssl
)
# Tester l'authentification
if not api.login():
raise Exception(_("Échec de l'authentification au contrôleur UniFi"))
# Tester la récupération du statut du système
try:
response = api._make_api_request('GET', api.API_SYSTEM_INFO_ENDPOINT)
if not response.ok:
raise Exception(_("Échec de la récupération du statut du système. Code: %s") % response.status_code)
except Exception as e:
raise Exception(_("Échec de la récupération du statut du système: %s") % str(e))
# Pas besoin de déconnexion explicite avec cette API
elif self.api_type == 'site_manager':
# TODO(dev): Implémenter le test de connexion pour l'API Site Manager
# Cette partie sera implémentée ultérieurement
raise NotImplementedError(_("Le test de connexion pour l'API Site Manager n'est pas encore implémenté"))
# Si on arrive ici, la connexion est réussie
_logger.info("Connexion à l'API réussie pour %s", self.name)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Succès'),
'message': _('Connexion à l\'API réussie.'),
'sticky': False,
'type': 'success',
}
}
except Exception as e:
# En cas d'erreur, on affiche un message
error_message = str(e)
_logger.error("Échec de la connexion à l'API pour %s: %s", self.name, error_message)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Erreur'),
'message': _('Échec de la connexion à l\'API: %s') % error_message,
'sticky': True,
'type': 'danger',
}
}
def get_headers(self):
"""Retourne les en-têtes HTTP pour les appels API"""
self.ensure_one()
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
# Ajouter le token d'authentification si disponible
if self.token:
if self.api_type == 'controller':
headers['Authorization'] = f'Bearer {self.token}'
elif self.api_type == 'site_manager':
headers['X-Auth-Token'] = self.token
return headers
def encrypt_sensitive_data(self):
"""Chiffre les données sensibles (mot de passe, token)"""
# Cette méthode pourrait être implémentée pour sécuriser davantage les données
# en utilisant des mécanismes de chiffrement d'Odoo
pass

View file

@ -0,0 +1,307 @@
# -*- coding: utf-8 -*-
# These imports will work in an Odoo environment, even if your IDE marks them as not found
# pylint: disable=import-error
from odoo import models, fields, api, _
# pylint: enable=import-error
import logging
import json
_logger = logging.getLogger(__name__)
class UnifiApiLog(models.Model):
"""Logs API calls to UniFi APIs
This model stores information about API calls made to both the Site Manager API
and Controller API, including request details, response status, and timing.
"""
_name = 'unifi.api.log'
_description = 'UniFi API Log'
_order = 'create_date desc'
@api.onchange('site_id')
def onchange(self, values=None, field_names=None, fields_spec=None):
"""Implémentation de la méthode abstraite onchange de BaseModel
Cette méthode est déclenchée lorsque le site_id change. Elle peut être utilisée
pour mettre à jour d'autres champs en fonction du site sélectionné.
Args:
values: Dictionnaire des valeurs modifiées
field_names: Noms des champs modifiés
fields_spec: Spécification des champs à mettre à jour
Returns:
Un dictionnaire des valeurs à mettre à jour
"""
# Actuellement, aucune action spécifique n'est nécessaire lors du changement de site
# Cette méthode est implémentée pour satisfaire l'exigence de la classe abstraite
return {}
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='The site this API call was made for'
)
api_config_id = fields.Many2one(
comodel_name='unifi.api.config',
string='Configuration API',
ondelete='cascade',
help='Configuration API utilisée pour cet appel API'
)
sync_job_id = fields.Many2one(
comodel_name='unifi.sync.job',
string='Sync Job',
ondelete='cascade',
help='The synchronization job that triggered this API call'
)
api_type = fields.Selection(
selection=[
('site_manager', 'Site Manager API'),
('controller', 'Controller API')
],
string='API Type',
required=True,
help='Type of API used for this call'
)
endpoint = fields.Char(
string='Endpoint',
required=True,
help='API endpoint that was called'
)
method = fields.Selection(
selection=[
('GET', 'GET'),
('POST', 'POST'),
('PUT', 'PUT'),
('DELETE', 'DELETE'),
('PATCH', 'PATCH')
],
string='HTTP Method',
required=True,
help='HTTP method used for the API call'
)
request_params = fields.Text(
string='Request Parameters',
help='Parameters sent with the request (JSON)'
)
request_headers = fields.Text(
string='Request Headers',
help='Headers sent with the request (JSON)'
)
request_body = fields.Text(
string='Request Body',
help='Body sent with the request (JSON)'
)
status_code = fields.Integer(
string='Status Code',
help='HTTP status code returned by the API'
)
response_headers = fields.Text(
string='Response Headers',
help='Headers returned by the API (JSON)'
)
response_body = fields.Text(
string='Response Body',
help='Body returned by the API (JSON)'
)
duration = fields.Float(
string='Duration (ms)',
help='Time taken to complete the API call in milliseconds'
)
success = fields.Boolean(
string='Success',
compute='_compute_success',
store=True,
help='Whether the API call was successful'
)
error_message = fields.Text(
string='Error Message',
help='Error message if the API call failed'
)
create_date = fields.Datetime(
string='Created On',
readonly=True,
help='Date and time when this log was created'
)
start_time = fields.Datetime(
string='Start Time',
help='Date and time when the API call started'
)
end_time = fields.Datetime(
string='End Time',
help='Date and time when the API call completed'
)
request_formatted = fields.Text(
string='Formatted Request',
compute='_compute_formatted_data',
store=False,
help='Formatted representation of the request'
)
response_formatted = fields.Text(
string='Formatted Response',
compute='_compute_formatted_data',
store=False,
help='Formatted representation of the response'
)
@api.depends('status_code')
def _compute_success(self):
"""Compute whether the API call was successful based on status code"""
for log in self:
log.success = log.status_code and 200 <= log.status_code < 300
@api.model
def log_api_call(self, site_id, api_type, endpoint, method, request_params=None,
request_headers=None, request_body=None, status_code=None,
response_headers=None, response_body=None, duration=0,
error_message=None, sync_job_id=None):
"""Create a new API log entry
Args:
site_id (int): ID of the site
api_type (str): Type of API ('site_manager' or 'controller')
endpoint (str): API endpoint
method (str): HTTP method
request_params (dict, optional): Request parameters
request_headers (dict, optional): Request headers
request_body (dict, optional): Request body
status_code (int, optional): HTTP status code
response_headers (dict, optional): Response headers
response_body (dict, optional): Response body
duration (float, optional): Duration in milliseconds
error_message (str, optional): Error message
sync_job_id (int, optional): ID of the sync job
Returns:
unifi.api.log: The created log record
"""
# Convert dictionaries to JSON strings
if request_params and isinstance(request_params, dict):
request_params = json.dumps(request_params)
if request_headers and isinstance(request_headers, dict):
request_headers = json.dumps(request_headers)
if request_body and isinstance(request_body, dict):
request_body = json.dumps(request_body)
if response_headers and isinstance(response_headers, dict):
response_headers = json.dumps(response_headers)
if response_body and isinstance(response_body, dict):
response_body = json.dumps(response_body)
# Create the log
return self.create({
'site_id': site_id,
'api_type': api_type,
'endpoint': endpoint,
'method': method,
'request_params': request_params,
'request_headers': request_headers,
'request_body': request_body,
'status_code': status_code,
'response_headers': response_headers,
'response_body': response_body,
'duration': duration,
'error_message': error_message,
'sync_job_id': sync_job_id,
})
def get_formatted_request(self):
"""Get a formatted representation of the request
Returns:
str: Formatted request details
"""
self.ensure_one()
result = f"{self.method} {self.endpoint}\n"
if self.request_headers:
try:
headers = json.loads(self.request_headers)
result += "Headers:\n"
for key, value in headers.items():
result += f" {key}: {value}\n"
except:
result += f"Headers: {self.request_headers}\n"
if self.request_params:
try:
params = json.loads(self.request_params)
result += "Parameters:\n"
for key, value in params.items():
result += f" {key}: {value}\n"
except:
result += f"Parameters: {self.request_params}\n"
if self.request_body:
try:
body = json.loads(self.request_body)
result += f"Body: {json.dumps(body, indent=2)}\n"
except:
result += f"Body: {self.request_body}\n"
return result
def get_formatted_response(self):
"""Get a formatted representation of the response
Returns:
str: Formatted response details
"""
self.ensure_one()
result = f"Status: {self.status_code}\n"
if self.response_headers:
try:
headers = json.loads(self.response_headers)
result += "Headers:\n"
for key, value in headers.items():
result += f" {key}: {value}\n"
except:
result += f"Headers: {self.response_headers}\n"
if self.response_body:
try:
body = json.loads(self.response_body)
result += f"Body: {json.dumps(body, indent=2)}\n"
except:
result += f"Body: {self.response_body}\n"
if self.error_message:
result += f"Error: {self.error_message}\n"
return result
@api.depends('request_headers', 'request_params', 'request_body', 'response_headers', 'response_body', 'error_message')
def _compute_formatted_data(self):
"""Compute formatted representations of request and response data"""
for log in self:
log.request_formatted = log.get_formatted_request()
log.response_formatted = log.get_formatted_response()

View file

@ -0,0 +1,238 @@
# -*- coding: utf-8 -*-
# These imports will work in an Odoo environment, even if your IDE marks them as not found
# pylint: disable=import-error
from odoo import models, fields, api, _
# pylint: enable=import-error
import logging
from datetime import datetime, timedelta
_logger = logging.getLogger(__name__)
class UnifiAuthSession(models.Model):
"""Manages authentication sessions for UniFi APIs
This model stores authentication tokens, cookies, and session information
for both the Site Manager API and Controller API. It handles token refresh
and session validation.
"""
_name = 'unifi.auth.session'
_description = 'UniFi Authentication Session'
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='The site this authentication session belongs to'
)
auth_type = fields.Selection(
selection=[
('site_manager', 'Site Manager API'),
('controller', 'Controller API')
],
string='Authentication Type',
required=True,
help='Type of authentication'
)
token = fields.Char(
string='Authentication Token',
help='Authentication token or cookie'
)
csrf_token = fields.Char(
string='CSRF Token',
help='Cross-Site Request Forgery protection token'
)
cookie = fields.Text(
string='Session Cookie',
help='Session cookie for Controller API'
)
expiry = fields.Datetime(
string='Expiry Date',
help='Date and time when this session expires'
)
is_valid = fields.Boolean(
string='Is Valid',
compute='_compute_is_valid',
store=False,
help='Whether this session is currently valid'
)
last_used = fields.Datetime(
string='Last Used',
default=fields.Datetime.now,
help='Date and time when this session was last used'
)
created_at = fields.Datetime(
string='Created At',
default=fields.Datetime.now,
readonly=True,
help='Date and time when this session was created'
)
@api.depends('expiry')
def _compute_is_valid(self):
"""Compute whether the session is valid based on expiry date"""
now = fields.Datetime.now()
for session in self:
if not session.expiry:
session.is_valid = False
else:
session.is_valid = session.expiry > now
@api.onchange('auth_type')
def _onchange_auth_type(self):
"""Handle changes to auth_type field
This method is triggered when the auth_type field is changed.
It clears fields that are not relevant to the selected authentication type.
"""
if self.auth_type == 'site_manager':
# Clear controller-specific fields
self.cookie = False
elif self.auth_type == 'controller':
# Clear site manager-specific fields
self.csrf_token = False
def validate(self):
"""Validate the session and refresh if necessary
Returns:
bool: True if the session is valid, False otherwise
"""
self.ensure_one()
# Check if the session is expired
if not self.is_valid:
# Try to refresh the session
return self.refresh()
# Update last used timestamp
self.write({'last_used': fields.Datetime.now()})
return True
def refresh(self):
"""Refresh the authentication session
Returns:
bool: True if the session was refreshed successfully, False otherwise
"""
self.ensure_one()
try:
if self.auth_type == 'site_manager':
return self._refresh_site_manager_session()
elif self.auth_type == 'controller':
return self._refresh_controller_session()
return False
except Exception as e:
_logger.error('Error refreshing authentication session: %s', str(e))
return False
def _refresh_site_manager_session(self):
"""Refresh a Site Manager API session
Returns:
bool: True if the session was refreshed successfully, False otherwise
"""
# This is a placeholder - will be implemented with actual API calls
try:
# Logic to refresh Site Manager API session
# Update session data
self.write({
'expiry': fields.Datetime.now() + timedelta(hours=1),
'last_used': fields.Datetime.now()
})
return True
except (ValueError, TypeError) as e:
_logger.error('Error refreshing Site Manager session: %s', str(e))
return False
except Exception as e:
_logger.error('Unexpected error refreshing Site Manager session: %s', str(e))
return False
def _refresh_controller_session(self):
"""Refresh a Controller API session
Returns:
bool: True if the session was refreshed successfully, False otherwise
"""
# This is a placeholder - will be implemented with actual API calls
try:
# Logic to refresh Controller API session
# Update session data
self.write({
'expiry': fields.Datetime.now() + timedelta(hours=1),
'last_used': fields.Datetime.now()
})
return True
except (ValueError, TypeError) as e:
_logger.error('Error refreshing Controller session: %s', str(e))
return False
except Exception as e:
_logger.error('Unexpected error refreshing Controller session: %s', str(e))
return False
@api.model
def create_session(self, site_id, auth_type, token=None, csrf_token=None, cookie=None, expiry=None):
"""Create a new authentication session
Args:
site_id (int): ID of the site
auth_type (str): Type of authentication ('site_manager' or 'controller')
token (str, optional): Authentication token
csrf_token (str, optional): CSRF token
cookie (str, optional): Session cookie
expiry (datetime, optional): Expiry date and time
Returns:
unifi.auth.session: The created session record
"""
# Set default expiry if not provided
if not expiry:
expiry = fields.Datetime.now() + timedelta(hours=1)
# Create the session
return self.create({
'site_id': site_id,
'auth_type': auth_type,
'token': token,
'csrf_token': csrf_token,
'cookie': cookie,
'expiry': expiry,
'last_used': fields.Datetime.now(),
})
def invalidate(self):
"""Invalidate the session
Returns:
bool: True if the session was invalidated successfully, False otherwise
"""
self.ensure_one()
try:
# Set expiry to a past date to invalidate the session
self.write({
'expiry': fields.Datetime.now() - timedelta(days=1)
})
return True
except (ValueError, TypeError) as e:
_logger.error('Error invalidating session: %s', str(e))
return False
except Exception as e:
_logger.error('Unexpected error invalidating session: %s', str(e))
return False

View file

@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
# These imports will work in an Odoo environment, even if your IDE marks them as not found
# pylint: disable=import-error
from odoo import models, fields, api, _
# pylint: enable=import-error
import logging
_logger = logging.getLogger(__name__)
class UnifiDashboardMetric(models.Model):
"""Métriques en temps réel pour le tableau de bord UniFi
Ce modèle stocke les métriques en temps réel pour les sites UniFi, comme
l'utilisation de la bande passante, le nombre de clients connectés, etc.
"""
_name = 'unifi.dashboard.metric'
_description = 'UniFi Dashboard Real-time Metric'
_order = 'create_date desc'
name = fields.Char(
string='Name',
required=True,
help='Name of the metric'
)
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='The site this metric belongs to'
)
metric_type = fields.Selection(
selection=[
('bandwidth', 'Bandwidth Usage'),
('clients', 'Connected Clients'),
('cpu', 'CPU Usage'),
('memory', 'Memory Usage'),
('storage', 'Storage Usage'),
('latency', 'Network Latency'),
('errors', 'Network Errors'),
('other', 'Other')
],
string='Metric Type',
required=True,
help='Type of metric being tracked'
)
value = fields.Float(
string='Value',
help='Current value of the metric'
)
unit = fields.Selection(
selection=[
('bps', 'Bits per second'),
('Kbps', 'Kilobits per second'),
('Mbps', 'Megabits per second'),
('Gbps', 'Gigabits per second'),
('count', 'Count'),
('percent', 'Percentage'),
('ms', 'Milliseconds'),
('B', 'Bytes'),
('KB', 'Kilobytes'),
('MB', 'Megabytes'),
('GB', 'Gigabytes'),
('other', 'Other')
],
string='Unit',
required=True,
help='Unit of measurement for the metric'
)
timestamp = fields.Datetime(
string='Timestamp',
required=True,
default=fields.Datetime.now,
help='Time when this metric was recorded'
)
device_id = fields.Many2one(
comodel_name='unifi.device',
string='Device',
ondelete='set null',
help='The device this metric is associated with, if applicable'
)
network_id = fields.Many2one(
comodel_name='unifi.network',
string='Network',
ondelete='set null',
help='The network this metric is associated with, if applicable'
)
is_critical = fields.Boolean(
string='Critical',
default=False,
help='Whether this metric indicates a critical condition'
)
threshold = fields.Float(
string='Threshold',
help='Threshold value for triggering alerts'
)
notes = fields.Text(
string='Notes',
help='Additional notes about this metric'
)
# Les méthodes create et write ont été supprimées car elles n'implémentaient pas de logique spécifique
def update_metric(self, value, timestamp=None):
"""Update the value of this metric
Args:
value: New value for the metric
timestamp: Optional timestamp for the update
Returns:
Boolean indicating success
"""
self.ensure_one()
vals = {
'value': value,
'timestamp': timestamp or fields.Datetime.now()
}
# Check if the value exceeds the threshold
if self.threshold and value > self.threshold:
vals['is_critical'] = True
# Log a warning for critical metrics
_logger.warning(
'Critical metric detected for site %s: %s = %s %s (threshold: %s)',
self.site_id.name, self.name, value, self.unit, self.threshold
)
else:
vals['is_critical'] = False
return self.write(vals)

View file

@ -0,0 +1,274 @@
# -*- coding: utf-8 -*-
# These imports will work in an Odoo environment, even if your IDE marks them as not found
# pylint: disable=import-error
from odoo import models, fields, api, _
# pylint: enable=import-error
import logging
import json
from datetime import datetime, timedelta
_logger = logging.getLogger(__name__)
class UnifiDashboardStat(models.Model):
"""Statistiques historiques pour le tableau de bord UniFi
Ce modèle stocke les statistiques historiques pour les sites UniFi, permettant
de suivre l'évolution des métriques dans le temps et de générer des rapports.
"""
_name = 'unifi.dashboard.stat'
_description = 'UniFi Dashboard Historical Statistic'
_order = 'date desc, name'
name = fields.Char(
string='Name',
required=True,
help='Name of the statistic'
)
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='The site this statistic belongs to'
)
stat_type = fields.Selection(
selection=[
('bandwidth_usage', 'Bandwidth Usage'),
('client_count', 'Client Count'),
('traffic_volume', 'Traffic Volume'),
('device_uptime', 'Device Uptime'),
('error_rate', 'Error Rate'),
('latency', 'Network Latency'),
('signal_strength', 'Signal Strength'),
('other', 'Other')
],
string='Statistic Type',
required=True,
help='Type of statistic being tracked'
)
date = fields.Date(
string='Date',
required=True,
default=fields.Date.today,
help='Date this statistic was recorded for'
)
period = fields.Selection(
selection=[
('hourly', 'Hourly'),
('daily', 'Daily'),
('weekly', 'Weekly'),
('monthly', 'Monthly')
],
string='Period',
required=True,
default='daily',
help='Time period this statistic covers'
)
value_min = fields.Float(
string='Minimum Value',
help='Minimum value recorded during the period'
)
value_max = fields.Float(
string='Maximum Value',
help='Maximum value recorded during the period'
)
value_avg = fields.Float(
string='Average Value',
help='Average value over the period'
)
value_total = fields.Float(
string='Total Value',
help='Total cumulative value over the period'
)
unit = fields.Selection(
selection=[
('bps', 'Bits per second'),
('Kbps', 'Kilobits per second'),
('Mbps', 'Megabits per second'),
('Gbps', 'Gigabits per second'),
('B', 'Bytes'),
('KB', 'Kilobytes'),
('MB', 'Megabytes'),
('GB', 'Gigabytes'),
('TB', 'Terabytes'),
('count', 'Count'),
('percent', 'Percentage'),
('ms', 'Milliseconds'),
('other', 'Other')
],
string='Unit',
required=True,
help='Unit of measurement for the statistic'
)
device_id = fields.Many2one(
comodel_name='unifi.device',
string='Device',
ondelete='set null',
help='The device this statistic is associated with, if applicable'
)
network_id = fields.Many2one(
comodel_name='unifi.network',
string='Network',
ondelete='set null',
help='The network this statistic is associated with, if applicable'
)
data_points = fields.Text(
string='Data Points',
help='JSON-encoded array of data points for this statistic'
)
notes = fields.Text(
string='Notes',
help='Additional notes about this statistic'
)
# Les méthodes create et write ont été supprimées car elles n'implémentaient pas de logique spécifique
def get_data_points(self):
"""Get the data points for this statistic as a Python object
Returns:
List of data points or empty list if none
"""
self.ensure_one()
if not self.data_points:
return []
try:
return json.loads(self.data_points)
except json.JSONDecodeError:
_logger.error('Failed to decode data points for statistic %s', self.name)
return []
def set_data_points(self, data_points):
"""Set the data points for this statistic
Args:
data_points: List of data points to store
Returns:
Boolean indicating success
"""
self.ensure_one()
try:
self.data_points = json.dumps(data_points)
return True
except Exception as e:
_logger.error('Failed to encode data points for statistic %s: %s', self.name, str(e))
return False
def add_data_point(self, value, timestamp=None):
"""Add a new data point to this statistic
Args:
value: Value to add
timestamp: Optional timestamp for the data point
Returns:
Boolean indicating success
"""
self.ensure_one()
# Get existing data points
data_points = self.get_data_points()
# Create new data point
new_point = {
'value': value,
'timestamp': timestamp or fields.Datetime.now().isoformat()
}
# Add to list
data_points.append(new_point)
# Update min/max/avg values
values = [p['value'] for p in data_points]
self.value_min = min(values)
self.value_max = max(values)
self.value_avg = sum(values) / len(values)
# For cumulative stats like traffic volume, update the total
if self.stat_type in ['traffic_volume']:
self.value_total = sum(values)
# Save updated data points
return self.set_data_points(data_points)
@api.model
def generate_daily_stats(self, site_id, date=None):
"""Generate daily statistics from hourly metrics
Args:
site_id: ID of the site to generate statistics for
date: Optional date to generate statistics for (defaults to yesterday)
Returns:
List of created statistic records
"""
if not date:
date = fields.Date.today() - timedelta(days=1)
# Get all hourly stats for the given site and date
hourly_stats = self.search([
('site_id', '=', site_id),
('date', '=', date),
('period', '=', 'hourly')
])
# Group by stat_type
stats_by_type = {}
for stat in hourly_stats:
if stat.stat_type not in stats_by_type:
stats_by_type[stat.stat_type] = []
stats_by_type[stat.stat_type].append(stat)
# Create daily stats for each type
created_stats = []
for stat_type, stats in stats_by_type.items():
if not stats:
continue
# Get a representative stat to copy metadata from
sample_stat = stats[0]
# Calculate aggregated values
values = [stat.value_avg for stat in stats if stat.value_avg]
if not values:
continue
# Create daily stat
daily_stat = self.create({
'name': f"Daily {sample_stat.name}",
'site_id': site_id,
'stat_type': stat_type,
'date': date,
'period': 'daily',
'value_min': min([stat.value_min for stat in stats if stat.value_min is not False]),
'value_max': max([stat.value_max for stat in stats if stat.value_max is not False]),
'value_avg': sum(values) / len(values),
'value_total': sum([stat.value_total for stat in stats if stat.value_total is not False]) if stat_type in ['traffic_volume'] else 0,
'unit': sample_stat.unit,
'device_id': sample_stat.device_id.id if sample_stat.device_id else False,
'network_id': sample_stat.network_id.id if sample_stat.network_id else False,
'notes': f"Aggregated from {len(stats)} hourly statistics"
})
created_stats.append(daily_stat)
return created_stats

View file

@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
# These imports will work in an Odoo environment, even if your IDE marks them as not found
# pylint: disable=import-error
from odoo import models, fields, api
from odoo.tools.translate import _
# pylint: enable=import-error
import logging
import json
from datetime import datetime, timedelta
_logger = logging.getLogger(__name__)
class UnifiDevice(models.Model):
"""Modèle pour les appareils UniFi
Ce modèle stocke les informations sur les appareils UniFi, tels que les points d'accès,
les commutateurs et les passerelles. Il est synchronisé avec l'API UniFi.
"""
_name = 'unifi.device'
_description = 'UniFi Device'
_order = 'name'
name = fields.Char(
string='Nom',
required=True,
index=True,
help="Nom de l'appareil"
)
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help="Site auquel cet appareil appartient"
)
mac_address = fields.Char(
string='Adresse MAC',
required=True,
index=True,
help="Adresse MAC de l'appareil"
)
ip_address = fields.Char(
string='Adresse IP',
help="Adresse IP de l'appareil"
)
model = fields.Char(
string='Modèle',
help="Modèle de l'appareil"
)
device_type = fields.Selection(
selection=[
('uap', 'Point d\'accès'),
('usw', 'Commutateur'),
('ugw', 'Passerelle'),
('udm', 'Dream Machine'),
('other', 'Autre')
],
string='Type d\'appareil',
required=True,
default='other',
help="Type de l'appareil UniFi"
)
firmware_version = fields.Char(
string='Version du firmware',
help="Version actuelle du firmware de l'appareil"
)
status = fields.Selection(
selection=[
('online', 'En ligne'),
('offline', 'Hors ligne'),
('pending', 'En attente'),
('provisioning', 'Provisionnement'),
('upgrading', 'Mise à niveau'),
('deleting', 'Suppression'),
('unknown', 'Inconnu')
],
string='Statut',
default='unknown',
help="Statut actuel de l'appareil"
)
last_seen = fields.Datetime(
string='Dernière connexion',
help="Date et heure de la dernière connexion de l'appareil"
)
uptime = fields.Integer(
string='Temps de fonctionnement',
help="Temps de fonctionnement en secondes"
)
adoption_state = fields.Boolean(
string='Adopté',
default=False,
help="Indique si l'appareil a été adopté par le contrôleur"
)
config_version = fields.Char(
string='Version de configuration',
help="Version de la configuration de l'appareil"
)
raw_data = fields.Text(
string='Données brutes',
help="Données JSON brutes de l'appareil provenant de l'API UniFi"
)
active = fields.Boolean(
string='Actif',
default=True,
help="Indique si cet appareil est actuellement actif dans le système"
)
@api.model
def create_from_api_data(self, site, device_data):
"""Crée ou met à jour un appareil à partir des données de l'API
Args:
site: Enregistrement du site UniFi
device_data: Dictionnaire contenant les données de l'appareil de l'API
Returns:
L'enregistrement de l'appareil créé ou mis à jour
"""
if not device_data.get('mac'):
return False
# Rechercher un appareil existant par MAC
existing_device = self.search([
('site_id', '=', site.id),
('mac_address', '=', device_data.get('mac'))
], limit=1)
# Préparer les valeurs pour la création/mise à jour
vals = {
'site_id': site.id,
'mac_address': device_data.get('mac'),
'name': device_data.get('name') or device_data.get('model') or device_data.get('mac'),
'ip_address': device_data.get('ip'),
'model': device_data.get('model'),
'device_type': self._map_device_type(device_data.get('type')),
'firmware_version': device_data.get('version'),
'status': 'online' if device_data.get('state', 0) == 1 else 'offline',
'last_seen': datetime.fromtimestamp(device_data.get('last_seen', 0) / 1000) if device_data.get('last_seen') else None,
'uptime': device_data.get('uptime'),
'adoption_state': device_data.get('adopted', False),
'config_version': device_data.get('cfgversion'),
'raw_data': json.dumps(device_data)
}
if existing_device:
existing_device.write(vals)
return existing_device
else:
return self.create(vals)
def _map_device_type(self, api_type):
"""Mappe le type d'appareil de l'API au type de sélection
Args:
api_type: Type d'appareil provenant de l'API
Returns:
Type d'appareil correspondant dans la sélection
"""
type_mapping = {
'uap': 'uap',
'usw': 'usw',
'ugw': 'ugw',
'udm': 'udm',
'ubb': 'other',
'usw-leaf': 'usw',
}
return type_mapping.get(api_type, 'other')
def sync_from_unifi(self):
"""Synchronise les données de l'appareil depuis l'API UniFi
Returns:
True si la synchronisation a réussi, False sinon
"""
self.ensure_one()
if not self.site_id:
return False
# Utiliser la méthode appropriée du site pour obtenir les données de l'appareil
device_data = self.site_id.get_device_data(self.mac_address)
if not device_data:
return False
# Mettre à jour l'appareil avec les nouvelles données
vals = {
'name': device_data.get('name') or self.name,
'ip_address': device_data.get('ip') or self.ip_address,
'firmware_version': device_data.get('version'),
'status': 'online' if device_data.get('state', 0) == 1 else 'offline',
'last_seen': datetime.fromtimestamp(device_data.get('last_seen', 0) / 1000) if device_data.get('last_seen') else self.last_seen,
'uptime': device_data.get('uptime'),
'adoption_state': device_data.get('adopted', False),
'config_version': device_data.get('cfgversion'),
'raw_data': json.dumps(device_data)
}
self.write(vals)
return True

View file

@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
import logging
import json
_logger = logging.getLogger(__name__)
class UnifiDns(models.Model):
"""Entrée DNS pour le système UniFi
Ce modèle stocke les entrées DNS pour les contrôleurs UniFi.
Il gère les enregistrements DNS comme les mappages nom d'hôte -> adresse IP.
Les entrées DNS sont liées à un site spécifique et sont automatiquement supprimées
lorsque le site est supprimé (cascade).
"""
_name = 'unifi.dns'
_description = 'UniFi DNS Entry'
_rec_name = 'hostname'
_order = 'hostname'
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='Site this DNS entry belongs to'
)
hostname = fields.Char(
string='Hostname',
required=True,
help='Hostname for this DNS entry'
)
ip_address = fields.Char(
string='IP Address',
required=True,
help='IP address for this hostname'
)
description = fields.Text(
string='Description',
help='Optional description for this DNS entry'
)
enabled = fields.Boolean(
string='Enabled',
default=True,
help='Whether this DNS entry is active'
)
entry_type = fields.Selection(
selection=[
('static', 'Static'),
('dynamic', 'Dynamic'),
],
string='Entry Type',
default='static',
help='Type of DNS entry'
)
unifi_id = fields.Char(
string='UniFi ID',
help='ID of this DNS entry in the UniFi system'
)
last_sync = fields.Datetime(
string='Last Synchronization',
help='Last time this DNS entry was synchronized with the UniFi system'
)
raw_data = fields.Text(
string='Raw Data',
help='Raw DNS entry data in JSON format'
)
@api.constrains('hostname', 'site_id')
def _check_hostname_unique(self):
"""Vérifie que le nom d'hôte est unique pour un site donné"""
for record in self:
domain = [
('hostname', '=', record.hostname),
('site_id', '=', record.site_id.id),
('id', '!=', record.id)
]
if self.search_count(domain) > 0:
raise ValidationError(_("Le nom d'hôte doit être unique pour un site donné."))
@api.constrains('ip_address')
def _check_ip_address_format(self):
"""Vérifie que l'adresse IP est au format valide"""
for record in self:
# Validation simple pour IPv4
ip_parts = record.ip_address.split('.')
if len(ip_parts) != 4:
raise ValidationError(_("L'adresse IP doit être au format IPv4 (x.x.x.x)."))
for part in ip_parts:
try:
num = int(part)
if num < 0 or num > 255:
raise ValidationError(_("Chaque partie de l'adresse IP doit être comprise entre 0 et 255."))
except ValueError:
raise ValidationError(_("L'adresse IP doit contenir uniquement des nombres."))
def name_get(self):
"""Personnalise l'affichage du nom des enregistrements"""
result = []
for record in self:
name = f"{record.hostname} ({record.ip_address})"
result.append((record.id, name))
return result
def sync_from_unifi(self):
"""Synchronise les données depuis le système UniFi"""
for record in self:
site = record.site_id
if not site:
continue
_logger.info("Synchronizing DNS entry %s from UniFi site %s", record.hostname, site.name)
# La synchronisation dépend du type d'API du site
if site.api_type == 'controller':
# TODO: Implémenter la synchronisation depuis l'API Controller
pass
elif site.api_type == 'site_manager':
# TODO: Implémenter la synchronisation depuis l'API Site Manager
pass
record.last_sync = fields.Datetime.now()
def push_to_unifi(self):
"""Pousse les modifications vers le système UniFi"""
for record in self:
site = record.site_id
if not site:
continue
_logger.info("Pushing DNS entry %s to UniFi site %s", record.hostname, site.name)
# La synchronisation dépend du type d'API du site
if site.api_type == 'controller':
# TODO: Implémenter la synchronisation vers l'API Controller
pass
elif site.api_type == 'site_manager':
# TODO: Implémenter la synchronisation vers l'API Site Manager
pass
record.last_sync = fields.Datetime.now()

View file

@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
import logging
import json
_logger = logging.getLogger(__name__)
class UnifiDnsConfig(models.Model):
"""Configuration DNS pour le système UniFi
Ce modèle stocke la configuration DNS pour les contrôleurs UniFi.
Il gère les paramètres DNS comme les serveurs personnalisés et le filtrage de contenu.
La configuration DNS est liée à un site spécifique et est automatiquement supprimée
lorsque le site est supprimé (cascade).
"""
_name = 'unifi.dns.config'
_description = 'UniFi DNS Configuration'
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='Site this DNS configuration belongs to'
)
enabled = fields.Boolean(
string='Enabled',
default=True,
help='Enable DNS server'
)
filters_enabled = fields.Boolean(
string='Content Filtering Enabled',
default=False,
help='Enable DNS content filtering'
)
custom_dns = fields.Char(
string='Custom DNS Servers',
help='Comma-separated list of custom DNS servers'
)
forwarding_enabled = fields.Boolean(
string='DNS Forwarding Enabled',
default=False,
help='Enable DNS query forwarding'
)
mdns_enabled = fields.Boolean(
string='mDNS Enabled',
default=False,
help='Enable multicast DNS (mDNS)'
)
unifi_id = fields.Char(
string='UniFi ID',
help='ID of this DNS configuration in the UniFi system'
)
last_sync = fields.Datetime(
string='Last Synchronization',
help='Last time this DNS configuration was synchronized with the UniFi system'
)
raw_data = fields.Text(
string='Raw Data',
help='Raw DNS configuration data in JSON format'
)
@api.constrains('custom_dns')
def _check_custom_dns_format(self):
"""Vérifie que les serveurs DNS personnalisés sont au format valide"""
for record in self:
if not record.custom_dns:
continue
dns_servers = record.custom_dns.split(',')
for server in dns_servers:
server = server.strip()
# Validation simple pour IPv4
if server:
ip_parts = server.split('.')
if len(ip_parts) != 4:
raise ValidationError(_("Les serveurs DNS doivent être au format IPv4 (x.x.x.x)."))
for part in ip_parts:
try:
num = int(part)
if num < 0 or num > 255:
raise ValidationError(_("Chaque partie de l'adresse IP doit être comprise entre 0 et 255."))
except ValueError:
raise ValidationError(_("L'adresse IP doit contenir uniquement des nombres."))
def name_get(self):
"""Personnalise l'affichage du nom des enregistrements"""
result = []
for record in self:
name = f"Configuration DNS - {record.site_id.name}"
result.append((record.id, name))
return result
def sync_from_unifi(self):
"""Synchronise les données depuis le système UniFi"""
for record in self:
site = record.site_id
if not site:
continue
_logger.info("Synchronizing DNS configuration from UniFi site %s", site.name)
# La synchronisation dépend du type d'API du site
if site.api_type == 'controller':
# TODO: Implémenter la synchronisation depuis l'API Controller
pass
elif site.api_type == 'site_manager':
# TODO: Implémenter la synchronisation depuis l'API Site Manager
pass
record.last_sync = fields.Datetime.now()
def push_to_unifi(self):
"""Pousse les modifications vers le système UniFi"""
for record in self:
site = record.site_id
if not site:
continue
_logger.info("Pushing DNS configuration to UniFi site %s", site.name)
# La synchronisation dépend du type d'API du site
if site.api_type == 'controller':
# TODO: Implémenter la synchronisation vers l'API Controller
pass
elif site.api_type == 'site_manager':
# TODO: Implémenter la synchronisation vers l'API Site Manager
pass
record.last_sync = fields.Datetime.now()

View file

@ -0,0 +1,289 @@
# -*- coding: utf-8 -*-
import json
import logging
from datetime import datetime
from odoo import models, fields, api, _
_logger = logging.getLogger(__name__)
class UnifiFirewallRule(models.Model):
"""Représente une règle de pare-feu dans le système UniFi
Ce modèle stocke les règles de pare-feu qui contrôlent le trafic réseau. Chaque règle
spécifie quel trafic est autorisé ou bloqué en fonction de divers critères tels que
les adresses source/destination, les ports et les protocoles.
Les règles sont liées à un site spécifique et sont automatiquement supprimées lorsque
le site est supprimé (cascade).
"""
_name = 'unifi.firewall.rule'
_description = 'Règle de pare-feu UniFi'
_order = 'sequence, id'
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='Site auquel appartient cette règle de pare-feu'
)
name = fields.Char(
string='Nom',
required=True,
help='Nom de la règle'
)
description = fields.Text(
string='Description',
help='Description détaillée de l\'objectif de la règle'
)
enabled = fields.Boolean(
string='Activée',
default=True,
help='Indique si cette règle est active'
)
sequence = fields.Integer(
string='Séquence',
default=10,
help='Ordre dans lequel les règles sont évaluées'
)
action = fields.Selection(
selection=[
('accept', 'Accepter'),
('drop', 'Rejeter silencieusement'),
('reject', 'Rejeter avec notification')
],
string='Action',
required=True,
default='drop',
help='Action à effectuer lorsque la règle correspond'
)
protocol = fields.Selection(
selection=[
('tcp', 'TCP'),
('udp', 'UDP'),
('icmp', 'ICMP'),
('all', 'Tous')
],
string='Protocole',
required=True,
default='all',
help='Protocole réseau auquel cette règle s\'applique'
)
source = fields.Char(
string='Source',
help='Réseau source ou adresse IP au format CIDR'
)
destination = fields.Char(
string='Destination',
help='Réseau de destination ou adresse IP au format CIDR'
)
src_port = fields.Char(
string='Port source',
help='Numéro de port source ou plage (ex: 80 ou 1024-2048)'
)
dst_port = fields.Char(
string='Port de destination',
help='Numéro de port de destination ou plage (ex: 80 ou 1024-2048)'
)
# Champs spécifiques à l'API
rule_id = fields.Char(
string='ID de la règle',
help='Identifiant unique de la règle dans le système UniFi'
)
rule_type = fields.Selection(
selection=[
('default', 'Défaut'),
('user', 'Utilisateur'),
('system', 'Système')
],
string='Type de règle',
default='user',
help='Type de règle dans le système UniFi'
)
rule_index = fields.Integer(
string='Index de la règle',
help='Position de la règle dans la liste des règles'
)
# Champs d'audit
created_at = fields.Datetime(
string='Créé le',
readonly=True,
help='Date et heure de création de la règle'
)
updated_at = fields.Datetime(
string='Mis à jour le',
readonly=True,
help='Date et heure de la dernière mise à jour de la règle'
)
last_sync = fields.Datetime(
string='Dernière synchronisation',
readonly=True,
help='Date et heure de la dernière synchronisation avec l\'API UniFi'
)
raw_data = fields.Text(
string='Données brutes',
help='Données brutes de la règle de pare-feu au format JSON'
)
# Champs calculés
rule_summary = fields.Char(
string='Résumé de la règle',
compute='_compute_rule_summary'
)
@api.depends('action', 'protocol', 'source', 'destination', 'src_port', 'dst_port')
def _compute_rule_summary(self):
"""Calcule un résumé lisible de la règle de pare-feu
Cette méthode génère une description concise de l'action de la règle,
du protocole et des adresses et ports source/destination. Le résumé
est utilisé dans les vues de liste et les rapports pour comprendre rapidement
ce que fait la règle sans afficher tous les détails.
Exemples de résumés:
- ACCEPT TCP de 192.168.1.0/24:80 vers 10.0.0.0/8:443
- DROP ICMP de 172.16.0.0/16 vers n'importe où
- REJECT UDP de n'importe où vers 192.168.2.10:53
"""
for record in self:
parts = []
if record.action:
parts.append(record.action.upper())
if record.protocol:
parts.append(record.protocol.upper())
src_addr = record.source or 'n\'importe où'
dst_addr = record.destination or 'n\'importe où'
src = f"de {src_addr}"
if record.src_port:
src += f":{record.src_port}"
parts.append(src)
dst = f"vers {dst_addr}"
if record.dst_port:
dst += f":{record.dst_port}"
parts.append(dst)
record.rule_summary = ' '.join(parts)
@api.model
def create_or_update_from_data(self, site, rule_data):
"""Crée ou met à jour une règle de pare-feu à partir des données de l'API
Cette méthode prend les données brutes d'une règle de pare-feu provenant de l'API UniFi
et crée ou met à jour l'enregistrement correspondant dans Odoo.
Args:
site: L'enregistrement du site UniFi
rule_data: Les données de la règle de pare-feu provenant de l'API
Returns:
record: L'enregistrement de la règle de pare-feu créé ou mis à jour
"""
# Extraire l'ID de la règle
rule_id = rule_data.get('_id')
if not rule_id:
_logger.warning("Données de règle de pare-feu sans ID: %s", json.dumps(rule_data))
return False
# Rechercher une règle existante avec cet ID
existing_rule = self.search([
('site_id', '=', site.id),
('rule_id', '=', rule_id)
], limit=1)
# Préparer les valeurs pour la création ou la mise à jour
values = {
'site_id': site.id,
'rule_id': rule_id,
'name': rule_data.get('name', 'Sans nom'),
'description': rule_data.get('description', ''),
'enabled': rule_data.get('enabled', True),
'sequence': rule_data.get('rule_index', 10),
'rule_index': rule_data.get('rule_index', 0),
'action': rule_data.get('action', 'drop'),
'protocol': rule_data.get('protocol', 'all'),
'source': rule_data.get('src', ''),
'destination': rule_data.get('dst', ''),
'src_port': rule_data.get('src_port', ''),
'dst_port': rule_data.get('dst_port', ''),
'rule_type': rule_data.get('rule_type', 'user'),
'raw_data': json.dumps(rule_data),
'last_sync': fields.Datetime.now()
}
# Si la règle existe, la mettre à jour
if existing_rule:
values['updated_at'] = fields.Datetime.now()
existing_rule.write(values)
return existing_rule
# Sinon, créer une nouvelle règle
values['created_at'] = fields.Datetime.now()
return self.create(values)
@api.model
def sync_firewall_rules(self, site):
"""Synchronise les règles de pare-feu depuis l'API UniFi
Cette méthode récupère toutes les règles de pare-feu depuis l'API UniFi
et les synchronise avec les enregistrements dans Odoo.
Args:
site: L'enregistrement du site UniFi
Returns:
bool: True si la synchronisation a réussi, False sinon
"""
# Récupérer les données des règles de pare-feu depuis l'API
rule_data = site.get_firewall_data()
if not rule_data:
_logger.warning("Aucune donnée de règle de pare-feu récupérée pour le site %s", site.name)
return False
# Garder une trace des règles synchronisées
synced_rule_ids = []
# Créer ou mettre à jour chaque règle
for rule in rule_data:
rule_record = self.create_or_update_from_data(site, rule)
if rule_record:
synced_rule_ids.append(rule_record.id)
# Rechercher les règles qui n'existent plus dans l'API
orphaned_rules = self.search([
('site_id', '=', site.id),
('id', 'not in', synced_rule_ids)
])
# Supprimer les règles orphelines
if orphaned_rules:
_logger.info("Suppression de %s règles de pare-feu orphelines pour le site %s",
len(orphaned_rules), site.name)
orphaned_rules.unlink()
return True

View file

@ -0,0 +1,263 @@
# -*- coding: utf-8 -*-
# These imports will work in an Odoo environment, even if your IDE marks them as not found
# pylint: disable=import-error
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
# pylint: enable=import-error
import logging
import requests
from datetime import datetime, timedelta
_logger = logging.getLogger(__name__)
class UnifiMfa(models.TransientModel):
"""Modèle transitoire pour l'authentification à deux facteurs UniFi
Ce modèle gère le processus d'authentification à deux facteurs pour les sites
qui ont activé cette fonctionnalité. Il est utilisé dans le flux d'authentification
pour collecter et valider les codes MFA.
"""
_name = 'unifi.mfa'
_description = 'UniFi MFA Authentication'
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='Le site pour lequel l\'authentification à deux facteurs est requise'
)
mfa_code = fields.Char(
string='Code MFA',
required=True,
help='Code d\'authentification à deux facteurs fourni par l\'application d\'authentification'
)
mfa_token = fields.Char(
string='Token MFA',
help='Token temporaire reçu lors de la première étape d\'authentification'
)
auth_type = fields.Selection(
selection=[
('site_manager', 'Site Manager API'),
('controller', 'Controller API')
],
string='Type d\'authentification',
required=True,
default='site_manager',
help='Type d\'API pour lequel l\'authentification à deux facteurs est requise'
)
expiry = fields.Datetime(
string='Date d\'expiration',
help='Date et heure d\'expiration de ce code MFA'
)
@api.model_create_multi
def create(self, vals_list):
"""Surcharge de la méthode create pour définir la date d'expiration
Args:
vals_list (list): Liste de dictionnaires contenant les valeurs pour la création des enregistrements
Returns:
unifi.mfa: Les enregistrements créés
"""
for vals in vals_list:
# Définir une expiration par défaut (10 minutes)
if not vals.get('expiry'):
vals['expiry'] = fields.Datetime.now() + timedelta(minutes=10)
return super(UnifiMfa, self).create(vals_list)
def validate_mfa(self):
"""Valide le code MFA et poursuit le processus d'authentification
Cette méthode vérifie le code MFA fourni par l'utilisateur et, s'il est valide,
poursuit le processus d'authentification en créant une session.
Returns:
dict: Action à effectuer après la validation (redirection, message, etc.)
"""
self.ensure_one()
# Vérifier que le code n'est pas expiré
if self.expiry and self.expiry < fields.Datetime.now():
raise ValidationError(_("Le code MFA a expiré. Veuillez recommencer le processus d'authentification."))
try:
if self.auth_type == 'site_manager':
return self._validate_site_manager_mfa()
elif self.auth_type == 'controller':
return self._validate_controller_mfa()
else:
raise ValidationError(_("Type d'authentification non pris en charge."))
except Exception as e:
_logger.error("Erreur lors de la validation du code MFA: %s", str(e))
raise UserError(_("Erreur lors de la validation du code MFA: %s") % str(e))
def _validate_site_manager_mfa(self):
"""Valide le code MFA pour l'API Site Manager
Returns:
dict: Action à effectuer après la validation
"""
self.ensure_one()
site = self.site_id
if not site.api_key:
raise ValidationError(_("Clé API manquante pour le site."))
if not self.mfa_token:
raise ValidationError(_("Token MFA temporaire manquant."))
try:
# Simulation de l'appel API pour valider le code MFA
# Dans une implémentation réelle, cela ferait un appel à l'API UniFi
# URL de base pour l'API Site Manager
base_url = "https://sitemanager.ui.com/api"
# Préparer les données pour la requête
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {site.api_key}"
}
data = {
"mfa_token": self.mfa_token,
"mfa_code": self.mfa_code
}
# Simuler la réponse - dans une implémentation réelle, cela serait:
# response = requests.post(f"{base_url}/auth/mfa/validate", headers=headers, json=data)
# response.raise_for_status()
# result = response.json()
# Simuler un résultat positif
result = {
"success": True,
"token": "simulated_auth_token_after_mfa",
"csrf_token": "simulated_csrf_token",
"expiry": (datetime.now() + timedelta(hours=24)).isoformat()
}
# Créer une session d'authentification
expiry_date = datetime.fromisoformat(result["expiry"])
session = self.env['unifi.auth.session'].create_session(
site_id=site.id,
auth_type='site_manager',
token=result["token"],
csrf_token=result["csrf_token"],
expiry=expiry_date
)
# Retourner une action pour rediriger vers la fiche du site
return {
'type': 'ir.actions.act_window',
'res_model': 'unifi.site',
'res_id': site.id,
'view_mode': 'form',
'target': 'current',
'context': {'show_success_notification': True}
}
except Exception as e:
_logger.error("Erreur lors de la validation MFA pour Site Manager: %s", str(e))
raise UserError(_("Erreur lors de la validation du code MFA: %s") % str(e))
def _validate_controller_mfa(self):
"""Valide le code MFA pour l'API Controller
Returns:
dict: Action à effectuer après la validation
"""
self.ensure_one()
site = self.site_id
# L'API Controller actuelle ne supporte pas l'authentification à deux facteurs
# Cette méthode est incluse pour une future implémentation
raise UserError(_("L'authentification à deux facteurs n'est pas encore supportée pour l'API Controller."))
@api.model
def request_mfa_challenge(self, site_id, auth_type, username=None, password=None, api_key=None):
"""Demande un challenge MFA à l'API
Cette méthode initie le processus d'authentification à deux facteurs en demandant
un challenge à l'API. Elle retourne un token temporaire qui sera utilisé avec
le code MFA pour compléter l'authentification.
Args:
site_id (int): ID du site
auth_type (str): Type d'authentification ('site_manager' ou 'controller')
username (str, optional): Nom d'utilisateur pour l'API Controller
password (str, optional): Mot de passe pour l'API Controller
api_key (str, optional): Clé API pour l'API Site Manager
Returns:
dict: Informations sur le challenge MFA, incluant le token temporaire
"""
site = self.env['unifi.site'].browse(site_id)
if auth_type == 'site_manager':
if not api_key and not site.api_key:
raise ValidationError(_("Clé API manquante pour le site."))
api_key = api_key or site.api_key
try:
# Simulation de l'appel API pour demander un challenge MFA
# Dans une implémentation réelle, cela ferait un appel à l'API UniFi
# URL de base pour l'API Site Manager
base_url = "https://sitemanager.ui.com/api"
# Préparer les données pour la requête
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
# Simuler la réponse - dans une implémentation réelle, cela serait:
# response = requests.post(f"{base_url}/auth/mfa/challenge", headers=headers)
# response.raise_for_status()
# result = response.json()
# Simuler un résultat positif
result = {
"success": True,
"mfa_token": "simulated_temporary_mfa_token",
"expiry": (datetime.now() + timedelta(minutes=10)).isoformat()
}
# Créer un enregistrement MFA transitoire
mfa_record = self.create({
'site_id': site_id,
'auth_type': auth_type,
'mfa_token': result["mfa_token"],
'expiry': datetime.fromisoformat(result["expiry"])
})
return {
'type': 'ir.actions.act_window',
'res_model': 'unifi.mfa',
'res_id': mfa_record.id,
'view_mode': 'form',
'target': 'new',
'context': {'default_site_id': site_id, 'default_auth_type': auth_type}
}
except Exception as e:
_logger.error("Erreur lors de la demande de challenge MFA: %s", str(e))
raise UserError(_("Erreur lors de la demande de challenge MFA: %s") % str(e))
elif auth_type == 'controller':
# L'API Controller actuelle ne supporte pas l'authentification à deux facteurs
raise UserError(_("L'authentification à deux facteurs n'est pas encore supportée pour l'API Controller."))
else:
raise ValidationError(_("Type d'authentification non pris en charge."))

View file

@ -0,0 +1,304 @@
# -*- coding: utf-8 -*-
# These imports will work in an Odoo environment, even if your IDE marks them as not found
# pylint: disable=import-error
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
# pylint: enable=import-error
import json
import logging
from datetime import datetime
_logger = logging.getLogger(__name__)
class UnifiNetwork(models.Model):
"""Modèle pour gérer les réseaux UniFi
Ce modèle représente les réseaux configurés dans les sites UniFi,
qu'ils soient gérés par l'API Controller ou l'API Site Manager.
"""
_name = 'unifi.network'
_description = 'Réseau UniFi'
_order = 'name'
# Champs d'identification
name = fields.Char(
string='Nom',
required=True,
help='Nom du réseau'
)
network_id = fields.Char(
string='ID Réseau',
required=True,
help='Identifiant unique du réseau dans le système UniFi'
)
# Champs de configuration réseau
purpose = fields.Selection(
selection=[
('corporate', 'Corporate'),
('guest', 'Guest'),
('wan', 'WAN'),
('lan', 'LAN'),
('vpn', 'VPN'),
('vlan-only', 'VLAN Only'),
('other', 'Autre')
],
string='Type',
default='corporate',
help='Type/objectif du réseau'
)
subnet = fields.Char(
string='Sous-réseau',
help='Sous-réseau au format CIDR (ex: 192.168.1.0/24)'
)
vlan_id = fields.Integer(
string='ID VLAN',
help='Identifiant du VLAN associé à ce réseau'
)
dhcp_enabled = fields.Boolean(
string='DHCP Activé',
default=True,
help='Indique si le DHCP est activé pour ce réseau'
)
dhcp_start = fields.Char(
string='Début DHCP',
help='Adresse IP de début de la plage DHCP'
)
dhcp_stop = fields.Char(
string='Fin DHCP',
help='Adresse IP de fin de la plage DHCP'
)
dhcp_lease_time = fields.Integer(
string='Durée du bail DHCP',
default=86400, # 24 heures en secondes
help='Durée du bail DHCP en secondes'
)
domain_name = fields.Char(
string='Nom de domaine',
help='Nom de domaine pour ce réseau'
)
dns_servers = fields.Char(
string='Serveurs DNS',
help='Liste des serveurs DNS séparés par des virgules'
)
enabled = fields.Boolean(
string='Activé',
default=True,
help='Indique si le réseau est actif'
)
# Champs pour la gestion des invités (si applicable)
is_guest = fields.Boolean(
string='Réseau invité',
default=False,
help='Indique si ce réseau est configuré pour les invités'
)
guest_portal_enabled = fields.Boolean(
string='Portail invité activé',
default=False,
help='Indique si le portail captif est activé pour ce réseau'
)
# Champs pour le suivi et l'audit
created_at = fields.Datetime(
string='Créé le',
default=fields.Datetime.now,
help='Date de création du réseau dans Odoo'
)
updated_at = fields.Datetime(
string='Mis à jour le',
help='Date de dernière mise à jour du réseau dans Odoo'
)
last_sync = fields.Datetime(
string='Dernière synchronisation',
help='Date de la dernière synchronisation avec l\'API UniFi'
)
# Stockage des données brutes
raw_data = fields.Text(
string='Données brutes',
help='Données brutes du réseau au format JSON'
)
# Relations
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='Site UniFi auquel appartient ce réseau'
)
# Champs calculés
ip_subnet = fields.Char(
string='Sous-réseau IP',
compute='_compute_ip_subnet',
store=True,
help='Adresse IP du sous-réseau'
)
netmask = fields.Char(
string='Masque de sous-réseau',
compute='_compute_ip_subnet',
store=True,
help='Masque de sous-réseau'
)
@api.depends('subnet')
def _compute_ip_subnet(self):
"""Calcule l'adresse IP du sous-réseau et le masque à partir du CIDR"""
for network in self:
if network.subnet:
try:
# Séparation de l'adresse IP et du préfixe CIDR
parts = network.subnet.split('/')
if len(parts) == 2:
ip_address = parts[0]
prefix = int(parts[1])
# Calcul du masque de sous-réseau à partir du préfixe
mask_bits = '1' * prefix + '0' * (32 - prefix)
mask_int = int(mask_bits, 2)
mask_octets = [(mask_int >> i) & 0xFF for i in (24, 16, 8, 0)]
mask = '.'.join(map(str, mask_octets))
network.ip_subnet = ip_address
network.netmask = mask
else:
network.ip_subnet = False
network.netmask = False
except Exception as e:
_logger.error(f"Erreur lors du calcul du sous-réseau: {str(e)}")
network.ip_subnet = False
network.netmask = False
else:
network.ip_subnet = False
network.netmask = False
@api.model
def create_or_update_from_data(self, site, network_data):
"""Crée ou met à jour un réseau à partir des données de l'API
Args:
site: L'enregistrement du site UniFi
network_data: Données du réseau provenant de l'API
Returns:
record: L'enregistrement du réseau créé ou mis à jour
"""
# Extraction de l'identifiant du réseau
network_id = network_data.get('_id') or network_data.get('id')
if not network_id:
_logger.error("Impossible de créer/mettre à jour le réseau: identifiant manquant")
return False
# Recherche d'un réseau existant avec cet identifiant
existing_network = self.search([
('network_id', '=', network_id),
('site_id', '=', site.id)
], limit=1)
# Préparation des valeurs pour la création/mise à jour
vals = {
'network_id': network_id,
'name': network_data.get('name', f"Réseau {network_id}"),
'site_id': site.id,
'purpose': network_data.get('purpose', 'other'),
'subnet': network_data.get('subnet'),
'vlan_id': network_data.get('vlan_id') or network_data.get('vlan'),
'dhcp_enabled': network_data.get('dhcp_enabled', True),
'dhcp_start': network_data.get('dhcp_start') or network_data.get('dhcpd_start'),
'dhcp_stop': network_data.get('dhcp_stop') or network_data.get('dhcpd_stop'),
'dhcp_lease_time': network_data.get('dhcp_lease_time') or network_data.get('dhcpd_leasetime', 86400),
'domain_name': network_data.get('domain_name'),
'dns_servers': network_data.get('dns_servers') or network_data.get('dns1'),
'enabled': network_data.get('enabled', True),
'is_guest': network_data.get('is_guest', False) or network_data.get('purpose') == 'guest',
'guest_portal_enabled': network_data.get('guest_portal_enabled', False),
'last_sync': fields.Datetime.now(),
'raw_data': json.dumps(network_data)
}
if existing_network:
# Mise à jour du réseau existant
vals['updated_at'] = fields.Datetime.now()
existing_network.write(vals)
return existing_network
else:
# Création d'un nouveau réseau
return self.create(vals)
def sync_networks(self, site):
"""Synchronise les réseaux depuis l'API UniFi
Args:
site: L'enregistrement du site UniFi
Returns:
bool: True si la synchronisation a réussi, False sinon
"""
self.ensure_one()
# Déterminer quelle méthode utiliser en fonction du type d'API
if site.api_type == 'controller':
return self._sync_networks_controller(site)
elif site.api_type == 'site_manager':
return self._sync_networks_site_manager(site)
else:
_logger.error(f"Type d'API non pris en charge: {site.api_type}")
return False
def _sync_networks_controller(self, site):
"""Synchronise les réseaux depuis l'API Controller
Args:
site: L'enregistrement du site UniFi
Returns:
bool: True si la synchronisation a réussi, False sinon
"""
# Obtenir les données des réseaux depuis l'API Controller
networks_data = self.env['unifi.site.controller'].get_network_data(site)
if not networks_data:
return False
# Créer ou mettre à jour les réseaux
for network_data in networks_data:
self.create_or_update_from_data(site, network_data)
return True
def _sync_networks_site_manager(self, site):
"""Synchronise les réseaux depuis l'API Site Manager
Args:
site: L'enregistrement du site UniFi
Returns:
bool: True si la synchronisation a réussi, False sinon
"""
# Obtenir les données des réseaux depuis l'API Site Manager
networks_data = self.env['unifi.site.manager'].get_network_data(site)
if not networks_data:
return False
# Créer ou mettre à jour les réseaux
for network_data in networks_data:
self.create_or_update_from_data(site, network_data)
return True

View file

@ -0,0 +1,259 @@
# -*- coding: utf-8 -*-
import json
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class UnifiPortForward(models.Model):
"""Représente une règle de redirection de port dans le système UniFi
Ce modèle stocke les règles de redirection de port qui permettent un accès externe
aux services internes. Chaque règle associe un port externe à une adresse IP
et un port internes.
Les redirections de port sont liées à un site spécifique et sont automatiquement
supprimées lorsque le site est supprimé (cascade).
"""
_name = 'unifi.port.forward'
_description = 'Redirection de port UniFi'
_order = 'sequence, id'
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='Site auquel appartient cette redirection de port'
)
name = fields.Char(
string='Nom',
required=True,
help='Nom de la règle'
)
description = fields.Text(
string='Description',
help='Description détaillée de l\'objectif de la redirection de port'
)
enabled = fields.Boolean(
string='Activée',
default=True,
help='Indique si cette règle est active'
)
sequence = fields.Integer(
string='Séquence',
default=10,
help='Ordre dans lequel les règles sont évaluées'
)
protocol = fields.Selection(
selection=[
('tcp', 'TCP'),
('udp', 'UDP'),
('tcp_udp', 'TCP & UDP')
],
string='Protocole',
required=True,
default='tcp',
help='Protocole réseau à rediriger'
)
source = fields.Char(
string='Source',
help='Réseau source ou adresse IP au format CIDR'
)
dst_port = fields.Char(
string='Port de destination',
required=True,
help='Numéro de port externe ou plage (ex: 80 ou 1024-2048)'
)
fwd_ip = fields.Char(
string='IP de redirection',
required=True,
help='Adresse IP interne vers laquelle rediriger'
)
fwd_port = fields.Char(
string='Port de redirection',
required=True,
help='Numéro de port interne ou plage'
)
# Champs spécifiques à l'API
rule_id = fields.Char(
string='ID de la règle',
help='Identifiant unique de la règle dans le système UniFi'
)
rule_index = fields.Integer(
string='Index de la règle',
help='Position de la règle dans la liste des règles'
)
# Champs d'audit
created_at = fields.Datetime(
string='Créé le',
readonly=True,
help='Date et heure de création de la règle'
)
updated_at = fields.Datetime(
string='Mis à jour le',
readonly=True,
help='Date et heure de la dernière mise à jour de la règle'
)
last_sync = fields.Datetime(
string='Dernière synchronisation',
readonly=True,
help='Date et heure de la dernière synchronisation avec l\'API UniFi'
)
raw_data = fields.Text(
string='Données brutes',
help='Données brutes de la redirection de port au format JSON'
)
# Champs calculés
rule_summary = fields.Char(
string='Résumé de la règle',
compute='_compute_rule_summary'
)
@api.depends('protocol', 'source', 'dst_port', 'fwd_ip', 'fwd_port')
def _compute_rule_summary(self):
"""Calcule un résumé lisible de la règle de redirection de port
Cette méthode génère une description concise du protocole de la règle,
de la source et des mappages de ports. Le résumé est utilisé dans les
vues de liste et les rapports pour comprendre rapidement ce que fait
la règle sans afficher tous les détails.
Exemples de résumés:
- TCP de n'importe où:80 vers 192.168.1.100:8080
- UDP de 203.0.113.0/24:53 vers 192.168.2.10:53
- TCP & UDP de n'importe où:25565 vers 192.168.3.50:25565
"""
for record in self:
parts = []
if record.protocol:
parts.append(record.protocol.upper())
src = f"de {record.source or 'n\'importe où'}"
if record.dst_port:
src += f":{record.dst_port}"
parts.append(src)
dst = f"vers {record.fwd_ip}"
if record.fwd_port:
dst += f":{record.fwd_port}"
parts.append(dst)
record.rule_summary = ' '.join(parts)
@api.model
def create_or_update_from_data(self, site, rule_data):
"""Crée ou met à jour une redirection de port à partir des données de l'API
Cette méthode prend les données brutes d'une redirection de port provenant de l'API UniFi
et crée ou met à jour l'enregistrement correspondant dans Odoo.
Args:
site: L'enregistrement du site UniFi
rule_data: Les données de la redirection de port provenant de l'API
Returns:
record: L'enregistrement de la redirection de port créé ou mis à jour
"""
# Extraire l'ID de la règle
rule_id = rule_data.get('_id')
if not rule_id:
_logger.warning("Données de redirection de port sans ID: %s", json.dumps(rule_data))
return False
# Rechercher une règle existante avec cet ID
existing_rule = self.search([
('site_id', '=', site.id),
('rule_id', '=', rule_id)
], limit=1)
# Préparer les valeurs pour la création ou la mise à jour
values = {
'site_id': site.id,
'rule_id': rule_id,
'name': rule_data.get('name', 'Sans nom'),
'description': rule_data.get('description', ''),
'enabled': rule_data.get('enabled', True),
'sequence': rule_data.get('rule_index', 10),
'rule_index': rule_data.get('rule_index', 0),
'protocol': rule_data.get('proto', 'tcp'),
'source': rule_data.get('src', ''),
'dst_port': rule_data.get('dst_port', ''),
'fwd_ip': rule_data.get('fwd', ''),
'fwd_port': rule_data.get('fwd_port', ''),
'raw_data': json.dumps(rule_data),
'last_sync': fields.Datetime.now()
}
# Si la règle existe, la mettre à jour
if existing_rule:
values['updated_at'] = fields.Datetime.now()
existing_rule.write(values)
return existing_rule
# Sinon, créer une nouvelle règle
values['created_at'] = fields.Datetime.now()
return self.create(values)
@api.model
def sync_port_forwards(self, site):
"""Synchronise les redirections de port depuis l'API UniFi
Cette méthode récupère toutes les redirections de port depuis l'API UniFi
et les synchronise avec les enregistrements dans Odoo.
Args:
site: L'enregistrement du site UniFi
Returns:
bool: True si la synchronisation a réussi, False sinon
"""
# Récupérer les données des redirections de port depuis l'API
rule_data = site.get_port_forward_data()
if not rule_data:
_logger.warning("Aucune donnée de redirection de port récupérée pour le site %s", site.name)
return False
# Garder une trace des règles synchronisées
synced_rule_ids = []
# Créer ou mettre à jour chaque règle
for rule in rule_data:
rule_record = self.create_or_update_from_data(site, rule)
if rule_record:
synced_rule_ids.append(rule_record.id)
# Rechercher les règles qui n'existent plus dans l'API
orphaned_rules = self.search([
('site_id', '=', site.id),
('id', 'not in', synced_rule_ids)
])
# Supprimer les règles orphelines
if orphaned_rules:
_logger.info("Suppression de %s redirections de port orphelines pour le site %s",
len(orphaned_rules), site.name)
orphaned_rules.unlink()
return True

View file

@ -0,0 +1,195 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
import logging
_logger = logging.getLogger(__name__)
class UnifiRouting(models.Model):
"""Route pour le système UniFi
Ce modèle stocke les routes individuelles pour les contrôleurs UniFi.
Il gère les routes statiques et dynamiques.
Les routes sont liées à un site spécifique et sont automatiquement supprimées
lorsque le site est supprimé (cascade).
"""
_name = 'unifi.routing'
_description = 'UniFi Route'
_rec_name = 'destination'
_order = 'destination'
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='Site this route belongs to'
)
destination = fields.Char(
string='Destination',
required=True,
help='Destination network in CIDR format (e.g., 192.168.1.0/24)'
)
gateway = fields.Char(
string='Gateway',
required=True,
help='Next hop IP address'
)
interface = fields.Char(
string='Interface',
help='Network interface for this route'
)
metric = fields.Integer(
string='Metric',
default=0,
help='Route metric (priority)'
)
route_type = fields.Selection(
selection=[
('static', 'Static'),
('dynamic', 'Dynamic'),
],
string='Route Type',
default='static',
help='Type of route'
)
protocol = fields.Selection(
selection=[
('static', 'Static'),
('ospf', 'OSPF'),
('bgp', 'BGP'),
('rip', 'RIP'),
],
string='Protocol',
default='static',
help='Routing protocol'
)
enabled = fields.Boolean(
string='Enabled',
default=True,
help='Whether this route is active'
)
description = fields.Text(
string='Description',
help='Optional description for this route'
)
unifi_id = fields.Char(
string='UniFi ID',
help='ID of this route in the UniFi system'
)
last_sync = fields.Datetime(
string='Last Synchronization',
help='Last time this route was synchronized with the UniFi system'
)
raw_data = fields.Text(
string='Raw Data',
help='Raw route data in JSON format'
)
@api.constrains('destination')
def _check_destination_format(self):
"""Vérifie que la destination est au format CIDR valide"""
for record in self:
# Validation simple pour le format CIDR
if '/' not in record.destination:
raise ValidationError(_("La destination doit être au format CIDR (réseau/préfixe)."))
network, prefix = record.destination.split('/')
# Validation simple pour IPv4
ip_parts = network.split('.')
if len(ip_parts) != 4:
raise ValidationError(_("L'adresse réseau doit être au format IPv4 (x.x.x.x)."))
for part in ip_parts:
try:
num = int(part)
if num < 0 or num > 255:
raise ValidationError(_("Chaque partie de l'adresse IP doit être comprise entre 0 et 255."))
except ValueError as exc:
raise ValidationError(_("L'adresse IP doit contenir uniquement des nombres.")) from exc
# Validation du préfixe
try:
prefix_num = int(prefix)
if prefix_num < 0 or prefix_num > 32:
raise ValidationError(_("Le préfixe doit être compris entre 0 et 32."))
except ValueError as exc:
raise ValidationError(_("Le préfixe doit être un nombre.")) from exc
@api.constrains('gateway')
def _check_gateway_format(self):
"""Vérifie que la passerelle est au format IP valide"""
for record in self:
# Validation simple pour IPv4
ip_parts = record.gateway.split('.')
if len(ip_parts) != 4:
raise ValidationError(_("La passerelle doit être au format IPv4 (x.x.x.x)."))
for part in ip_parts:
try:
num = int(part)
if num < 0 or num > 255:
raise ValidationError(_("Chaque partie de l'adresse IP doit être comprise entre 0 et 255."))
except ValueError as exc:
raise ValidationError(_("L'adresse IP doit contenir uniquement des nombres.")) from exc
def name_get(self):
"""Personnalise l'affichage du nom des enregistrements"""
result = []
for record in self:
name = f"{record.destination} via {record.gateway}"
result.append((record.id, name))
return result
def sync_from_unifi(self):
"""Synchronise les données depuis le système UniFi"""
for record in self:
site = record.site_id
if not site:
continue
_logger.info("Synchronizing route %s from UniFi site %s", record.destination, site.name)
# La synchronisation dépend du type d'API du site
if site.api_type == 'controller':
# TODO(dev): Implémenter la synchronisation depuis l'API Controller
pass
elif site.api_type == 'site_manager':
# TODO(dev): Implémenter la synchronisation depuis l'API Site Manager
pass
record.last_sync = fields.Datetime.now()
def push_to_unifi(self):
"""Pousse les modifications vers le système UniFi"""
for record in self:
site = record.site_id
if not site:
continue
_logger.info("Pushing route %s to UniFi site %s", record.destination, site.name)
# La synchronisation dépend du type d'API du site
if site.api_type == 'controller':
# TODO(dev): Implémenter la synchronisation vers l'API Controller
pass
elif site.api_type == 'site_manager':
# TODO(dev): Implémenter la synchronisation vers l'API Site Manager
pass
record.last_sync = fields.Datetime.now()

View file

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
import logging
_logger = logging.getLogger(__name__)
class UnifiRoutingConfig(models.Model):
"""Configuration de routage pour le système UniFi
Ce modèle stocke la configuration de routage pour les contrôleurs UniFi.
Il gère les paramètres de routage comme OSPF et les routes statiques.
La configuration de routage est liée à un site spécifique et est automatiquement supprimée
lorsque le site est supprimé (cascade).
"""
_name = 'unifi.routing.config'
_description = 'UniFi Routing Configuration'
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='Site this routing configuration belongs to'
)
ospf_enabled = fields.Boolean(
string='OSPF Enabled',
default=False,
help='Enable OSPF routing'
)
static_routes = fields.Text(
string='Static Routes',
help='Comma-separated list of static routes in format: network/prefix via nexthop'
)
bgp_enabled = fields.Boolean(
string='BGP Enabled',
default=False,
help='Enable BGP routing'
)
rip_enabled = fields.Boolean(
string='RIP Enabled',
default=False,
help='Enable RIP routing'
)
unifi_id = fields.Char(
string='UniFi ID',
help='ID of this routing configuration in the UniFi system'
)
last_sync = fields.Datetime(
string='Last Synchronization',
help='Last time this routing configuration was synchronized with the UniFi system'
)
raw_data = fields.Text(
string='Raw Data',
help='Raw routing configuration data in JSON format'
)
def name_get(self):
"""Personnalise l'affichage du nom des enregistrements"""
result = []
for record in self:
name = f"Configuration de routage - {record.site_id.name}"
result.append((record.id, name))
return result
def sync_from_unifi(self):
"""Synchronise les données depuis le système UniFi"""
for record in self:
site = record.site_id
if not site:
continue
_logger.info("Synchronizing routing configuration from UniFi site %s", site.name)
# La synchronisation dépend du type d'API du site
if site.api_type == 'controller':
# TODO(dev): Implémenter la synchronisation depuis l'API Controller
pass
elif site.api_type == 'site_manager':
# TODO(dev): Implémenter la synchronisation depuis l'API Site Manager
pass
record.last_sync = fields.Datetime.now()
def push_to_unifi(self):
"""Pousse les modifications vers le système UniFi"""
for record in self:
site = record.site_id
if not site:
continue
_logger.info("Pushing routing configuration to UniFi site %s", site.name)
# La synchronisation dépend du type d'API du site
if site.api_type == 'controller':
# TODO(dev): Implémenter la synchronisation vers l'API Controller
pass
elif site.api_type == 'site_manager':
# TODO(dev): Implémenter la synchronisation vers l'API Site Manager
pass
record.last_sync = fields.Datetime.now()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
# These imports will work in an Odoo environment, even if your IDE marks them as not found
# pylint: disable=import-error
from odoo import models, fields, api, _
# pylint: enable=import-error
import logging
from datetime import datetime, timedelta
_logger = logging.getLogger(__name__)
class UnifiSyncJob(models.Model):
"""Manages synchronization jobs for UniFi sites
This model tracks synchronization jobs, including their status, duration,
and any errors that occurred during synchronization.
"""
_name = 'unifi.sync.job'
_description = 'UniFi Synchronization Job'
_order = 'start_time desc'
site_id = fields.Many2one(
comodel_name='unifi.site',
string='Site',
required=True,
ondelete='cascade',
help='The site this synchronization job is for'
)
name = fields.Char(
string='Name',
compute='_compute_name',
store=True,
help='Name of the synchronization job'
)
sync_type = fields.Selection(
selection=[
('manual', 'Manual'),
('scheduled', 'Scheduled'),
('automatic', 'Automatic')
],
string='Sync Type',
required=True,
default='manual',
help='Type of synchronization'
)
api_type = fields.Selection(
selection=[
('site_manager', 'Site Manager API'),
('controller', 'Controller API')
],
string='API Type',
required=True,
help='Type of API used for this synchronization job'
)
state = fields.Selection(
selection=[
('pending', 'Pending'),
('running', 'Running'),
('completed', 'Completed'),
('failed', 'Failed'),
('cancelled', 'Cancelled')
],
string='State',
required=True,
default='pending',
help='Current state of the synchronization job'
)
status = fields.Selection(
selection=[
('pending', 'Pending'),
('running', 'Running'),
('completed', 'Completed'),
('failed', 'Failed'),
('cancelled', 'Cancelled'),
('success', 'Success'),
('warning', 'Warning'),
('error', 'Error'),
('info', 'Information')
],
string='Status',
help='Status of the synchronization job'
)
start_time = fields.Datetime(
string='Start Time',
default=fields.Datetime.now,
help='Date and time when this job started'
)
end_time = fields.Datetime(
string='End Time',
help='Date and time when this job ended'
)
duration = fields.Float(
string='Duration (seconds)',
compute='_compute_duration',
store=True,
help='Duration of the synchronization job in seconds'
)
error_message = fields.Text(
string='Error Message',
help='Error message if the job failed'
)
message = fields.Text(
string='Message',
help='Detailed message about the synchronization job'
)
log_ids = fields.One2many(
comodel_name='unifi.api.log',
inverse_name='sync_job_id',
string='API Logs',
help='API calls made during this synchronization job'
)
api_call_count = fields.Integer(
string='API Call Count',
compute='_compute_api_call_count',
store=True,
help='Number of API calls made during this job'
)
success_rate = fields.Float(
string='Success Rate (%)',
compute='_compute_success_rate',
store=True,
help='Percentage of successful API calls'
)
created_records = fields.Integer(
string='Created Records',
default=0,
help='Number of records created during this job'
)
updated_records = fields.Integer(
string='Updated Records',
default=0,
help='Number of records updated during this job'
)
deleted_records = fields.Integer(
string='Deleted Records',
default=0,
help='Number of records deleted during this job'
)
@api.depends('site_id', 'start_time', 'sync_type')
def _compute_name(self):
"""Compute a descriptive name for the synchronization job"""
for job in self:
if job.site_id and job.start_time:
start_time_str = fields.Datetime.to_string(job.start_time)
job.name = f"{job.site_id.name} - {job.sync_type.capitalize()} Sync - {start_time_str}"
else:
job.name = f"New Sync Job"
@api.depends('start_time', 'end_time')
def _compute_duration(self):
"""Compute the duration of the synchronization job"""
for job in self:
if job.start_time and job.end_time:
delta = job.end_time - job.start_time
job.duration = delta.total_seconds()
else:
job.duration = 0
@api.depends('log_ids')
def _compute_api_call_count(self):
"""Compute the number of API calls made during this job"""
for job in self:
job.api_call_count = len(job.log_ids)
@api.depends('log_ids', 'log_ids.success')
def _compute_success_rate(self):
"""Compute the success rate of API calls"""
for job in self:
if job.api_call_count > 0:
successful_calls = len(job.log_ids.filtered(lambda l: l.success))
job.success_rate = (successful_calls / job.api_call_count) * 100
else:
job.success_rate = 0
def action_cancel(self):
"""Cancel the synchronization job"""
self.ensure_one()
if self.state in ['pending', 'running']:
self.write({
'state': 'cancelled',
'end_time': fields.Datetime.now()
})
return True
def action_retry(self):
"""Retry a failed synchronization job"""
self.ensure_one()
if self.state in ['failed', 'cancelled']:
# Create a new job
new_job = self.copy({
'state': 'pending',
'start_time': fields.Datetime.now(),
'end_time': False,
'error_message': False,
'created_records': 0,
'updated_records': 0,
'deleted_records': 0
})
# Start the new job
new_job.start()
return {
'type': 'ir.actions.act_window',
'res_model': 'unifi.sync.job',
'res_id': new_job.id,
'view_mode': 'form',
'target': 'current',
}
return True
def action_view_logs(self):
"""View API logs for this synchronization job"""
self.ensure_one()
return {
'name': _('API Logs'),
'view_mode': 'tree,form',
'res_model': 'unifi.api.log',
'domain': [('sync_job_id', '=', self.id)],
'type': 'ir.actions.act_window',
'context': {'default_sync_job_id': self.id}
}
def start(self):
"""Start the synchronization job"""
self.ensure_one()
if self.state == 'pending':
self.write({
'state': 'running',
'start_time': fields.Datetime.now()
})
# Trigger the synchronization on the site
try:
self.site_id.with_context(sync_job_id=self.id).action_sync_now()
# If we get here, the sync was successful
self.write({
'state': 'completed',
'end_time': fields.Datetime.now()
})
except Exception as e:
# If an error occurred, mark the job as failed
self.write({
'state': 'failed',
'end_time': fields.Datetime.now(),
'error_message': str(e)
})
_logger.error('Error during synchronization job: %s', str(e))
return True
@api.model
def run_scheduled_sync(self):
"""Run scheduled synchronization jobs
This method is called by a scheduled action to synchronize sites
that have auto_sync enabled and are due for synchronization.
Returns:
bool: True if successful
"""
# Find sites that are due for synchronization
sites = self.env['unifi.site'].search([
('active', '=', True),
('auto_sync', '=', True)
])
for site in sites:
# Check if the site is due for synchronization
if not site.last_sync or (fields.Datetime.now() - site.last_sync).total_seconds() / 60 >= site.sync_interval:
# Create a new sync job
sync_job = self.create({
'site_id': site.id,
'sync_type': 'scheduled',
'state': 'pending'
})
# Start the sync job
sync_job.start()
return True
def log_record_changes(self, created=0, updated=0, deleted=0):
"""Log record changes during synchronization
Args:
created (int, optional): Number of records created
updated (int, optional): Number of records updated
deleted (int, optional): Number of records deleted
Returns:
bool: True if successful
"""
self.ensure_one()
self.write({
'created_records': self.created_records + created,
'updated_records': self.updated_records + updated,
'deleted_records': self.deleted_records + deleted
})
return True

Some files were not shown because too many files have changed in this diff Show more