more unifi
1
odoo_to_odoo_bemade/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
32
odoo_to_odoo_bemade/__manifest__.py
Normal 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',
|
||||
}
|
||||
56
odoo_to_odoo_bemade/data/ir_cron_data.xml
Normal 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>
|
||||
6
odoo_to_odoo_bemade/models/__init__.py
Normal 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
|
||||
517
odoo_to_odoo_bemade/models/sync_instance.py
Normal 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
|
||||
62
odoo_to_odoo_bemade/models/sync_log.py
Normal 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,
|
||||
})
|
||||
393
odoo_to_odoo_bemade/models/sync_manager.py
Normal 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
|
||||
108
odoo_to_odoo_bemade/models/sync_model.py
Normal 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
|
||||
121
odoo_to_odoo_bemade/models/sync_model_field.py
Normal 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
|
||||
68
odoo_to_odoo_bemade/models/sync_queue.py
Normal 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
|
||||
7
odoo_to_odoo_bemade/security/ir.model.access.csv
Normal 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
|
||||
|
BIN
odoo_to_odoo_bemade/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
30
odoo_to_odoo_bemade/static/description/icon.svg
Normal 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 |
93
odoo_to_odoo_bemade/static/description/index.html
Normal 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>
|
||||
33
odoo_to_odoo_bemade/views/menus.xml
Normal 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>
|
||||
126
odoo_to_odoo_bemade/views/sync_instance_views.xml
Normal 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>
|
||||
98
odoo_to_odoo_bemade/views/sync_log_views.xml
Normal 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>
|
||||
129
odoo_to_odoo_bemade/views/sync_model_views.xml
Normal 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>
|
||||
122
odoo_to_odoo_bemade/views/sync_queue_views.xml
Normal 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', '<=', 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>
|
||||
1
odoo_to_odoo_bemade_customer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
31
odoo_to_odoo_bemade_customer/__manifest__.py
Normal 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',
|
||||
}
|
||||
56
odoo_to_odoo_bemade_customer/data/ir_cron_data.xml
Normal 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>
|
||||
6
odoo_to_odoo_bemade_customer/models/__init__.py
Normal 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
|
||||
202
odoo_to_odoo_bemade_customer/models/sync_config.py
Normal 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
|
||||
131
odoo_to_odoo_bemade_customer/models/sync_log.py
Normal 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)
|
||||
281
odoo_to_odoo_bemade_customer/models/sync_manager.py
Normal 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'}
|
||||
176
odoo_to_odoo_bemade_customer/models/sync_model.py
Normal 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)],
|
||||
}
|
||||
117
odoo_to_odoo_bemade_customer/models/sync_model_field.py
Normal 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
|
||||
116
odoo_to_odoo_bemade_customer/models/sync_queue.py
Normal 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)
|
||||
13
odoo_to_odoo_bemade_customer/security/ir.model.access.csv
Normal 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
|
||||
|
BIN
odoo_to_odoo_bemade_customer/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
30
odoo_to_odoo_bemade_customer/static/description/icon.svg
Normal 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 |
126
odoo_to_odoo_bemade_customer/static/description/index.html
Normal 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>
|
||||
33
odoo_to_odoo_bemade_customer/views/menus.xml
Normal 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>
|
||||
129
odoo_to_odoo_bemade_customer/views/sync_instance_views.xml
Normal 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>
|
||||
98
odoo_to_odoo_bemade_customer/views/sync_log_views.xml
Normal 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>
|
||||
129
odoo_to_odoo_bemade_customer/views/sync_model_views.xml
Normal 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>
|
||||
122
odoo_to_odoo_bemade_customer/views/sync_queue_views.xml
Normal 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', '<=', 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1 B After Width: | Height: | Size: 14 KiB |
30
odoo_to_odoo_sync/static/description/icon.svg
Normal 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 |
|
|
@ -3,3 +3,4 @@
|
|||
from . import models
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
from . import utils
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
85
unifi_integration/doc/example.md
Normal 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
|
||||
235
unifi_integration/doc/model_actuels.md
Normal 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.
|
||||
317
unifi_integration/doc/model_relations.md
Normal 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.
|
||||
378
unifi_integration/doc/models_need.md
Normal 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é.
|
||||
84
unifi_integration/doc/module_state.md
Normal 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.
|
||||
47
unifi_integration/doc/objectifs.md
Normal 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
|
||||
215
unifi_integration/doc/site_manager_api.md
Normal 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é
|
||||
300
unifi_integration/doc/todo.md
Normal 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
|
||||
233
unifi_integration/doc/unifi_controller_api.md
Normal 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}` où `{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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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')
|
||||
|
|
@ -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'
|
||||
|
|
@ -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')
|
||||
|
|
@ -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')
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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')
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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'
|
||||
232
unifi_integration/models/unifi_api_config.py
Normal 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
|
||||
307
unifi_integration/models/unifi_api_log.py
Normal 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()
|
||||
238
unifi_integration/models/unifi_auth_session.py
Normal 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
|
||||
145
unifi_integration/models/unifi_dashboard_metric.py
Normal 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)
|
||||
274
unifi_integration/models/unifi_dashboard_stat.py
Normal 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
|
||||
216
unifi_integration/models/unifi_device.py
Normal 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
|
||||
153
unifi_integration/models/unifi_dns.py
Normal 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()
|
||||
142
unifi_integration/models/unifi_dns_config.py
Normal 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()
|
||||
289
unifi_integration/models/unifi_firewall.py
Normal 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
|
||||
263
unifi_integration/models/unifi_mfa.py
Normal 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."))
|
||||
304
unifi_integration/models/unifi_network.py
Normal 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
|
||||
259
unifi_integration/models/unifi_port_forward.py
Normal 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
|
||||
195
unifi_integration/models/unifi_routing.py
Normal 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()
|
||||
112
unifi_integration/models/unifi_routing_config.py
Normal 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()
|
||||
1613
unifi_integration/models/unifi_site.py
Normal file
1700
unifi_integration/models/unifi_site_controller.py
Normal file
1343
unifi_integration/models/unifi_site_manager.py
Normal file
327
unifi_integration/models/unifi_sync_job.py
Normal 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
|
||||