# 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 import base64 from datetime import datetime, timedelta from odoo import models, fields, api, tools from odoo.exceptions import ValidationError from odoo.tools import config import pytz # Check if boto3 is available for S3 archiving try: import boto3 from botocore.exceptions import ClientError HAS_BOTO3 = True except ImportError: HAS_BOTO3 = False _logger = logging.getLogger(__name__) class OdooToBemadeSyncLog(models.Model): """Synchronization log for Bemade operations. Extends the base synchronization log to handle Bemade-specific logging requirements with advanced features: - Log levels (DEBUG, INFO, WARNING, ERROR) - Retention policy (90 days local, 7 years in S3 Glacier) - Sensitive data masking """ _name = 'odoo.to.bemade.sync.log' _description = 'Bemade Sync Log Entry' _inherit = 'odoo.sync.log' _order = 'create_date desc, log_level' # Make logs read-only by default _auto_write = False # 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', ) # Fields used by the log method operation = fields.Char( string='Operation', help='Type of operation performed (create, update, delete, etc.)', ) model = fields.Char( string='Model', help='Technical name of the model that was synchronized', ) record_id = fields.Integer( string='Record ID', help='ID of the record that was synchronized', ) result = fields.Selection( selection=[ ('success', 'Success'), ('error', 'Error'), ], string='Result', help='Result of the operation', ) # Advanced logging fields log_level = fields.Selection( selection=[ ('debug', 'DEBUG'), ('info', 'INFO'), ('warning', 'WARNING'), ('error', 'ERROR'), ], string='Log Level', default='info', help='Severity level of the log entry', index=True, ) payload = fields.Text( string='Full Payload', help='Complete data payload for debug purposes', ) diff = fields.Text( string='Changes', help='Differences between before and after states', ) metadata = fields.Text( string='Metadata', help='Additional contextual information', ) archived = fields.Boolean( string='Archived', default=False, help='Whether this log has been archived to long-term storage', index=True, ) archive_reference = fields.Char( string='Archive Reference', help='Reference to the archived log in long-term storage', ) sanitized = fields.Boolean( string='Sanitized', default=False, help='Whether sensitive data has been masked in this log', ) @api.model def log(self, operation, model=None, record_id=None, result='success', details=None, instance_id=None, queue_id=None, log_level='info', payload=None, diff=None, metadata=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 queue_id: ID of the queue entry (will create a special queue entry if not provided) Returns: The created log entry record """ if details and not isinstance(details, str): details = json.dumps(details) # Handle payload, diff and metadata serialization if payload and not isinstance(payload, str): payload = json.dumps(payload) if diff and not isinstance(diff, str): diff = json.dumps(diff) if metadata and not isinstance(metadata, str): metadata = json.dumps(metadata) # Sanitize sensitive data sanitized = False if payload: payload, sanitized_payload = self._sanitize_data(payload) sanitized = sanitized_payload if details: details, sanitized_details = self._sanitize_data(details) sanitized = sanitized or sanitized_details if metadata: metadata, sanitized_metadata = self._sanitize_data(metadata) sanitized = sanitized or sanitized_metadata # If no queue_id is provided, create a special queue entry for this log if not queue_id: # For connection tests, we need to create a special queue entry # Find or create a sync model for the instance model # Note: odoo.sync.model uses 'name' field (related to model_id.model) to store the model name sync_model = self.env['odoo.sync.model'].search( [('name', '=', model)], limit=1) if not sync_model and model: # Create a sync model if it doesn't exist # First, find the ir.model record for this model ir_model = self.env['ir.model'].search([('model', '=', model)], limit=1) if ir_model: # Find any instance to use for this auto-created sync model # For connection tests, we can use the instance_id parameter if provided instance = False if instance_id: instance = self.env['odoo.to.bemade.instance'].browse(instance_id) # If no instance provided or found, try to find any instance if not instance: instance = self.env['odoo.sync.instance'].search([], limit=1) if instance: sync_model = self.env['odoo.sync.model'].create({ 'model_id': ir_model.id, 'target_model': model, # Use same model name as target 'instance_id': instance.id, # Required field }) else: _logger.error("Cannot create sync model: No instance available") else: _logger.error(f"Cannot create sync model: No ir.model found for {model}") # Create a queue entry for this log queue_vals = { 'model_id': sync_model.id if sync_model else False, 'record_id': record_id or 0, 'operation': 'create', # Use 'create' as a valid operation for queue entries 'state': 'done' if result == 'success' else 'error', 'data': details, } if not sync_model: # If we couldn't find or create a sync model, we need to handle this case # by using a fallback approach - find any sync model to use fallback_model = self.env['odoo.sync.model'].search([], limit=1) if fallback_model: queue_vals['model_id'] = fallback_model.id else: # We can't create a log without a sync model for the queue _logger.error( "Cannot create sync log: No sync model available for queue creation") return False queue = self.env['odoo.sync.queue'].create(queue_vals) queue_id = queue.id log_entry = self.create({ 'operation': operation, 'model': model, 'record_id': record_id, 'result': result, 'details': details, 'bemade_instance_id': instance_id, 'queue_id': queue_id, 'state': result, # Map our result to the parent's state field 'message': details, # Map our details to the parent's message field 'log_level': log_level, 'payload': payload, 'diff': diff, 'metadata': metadata, 'sanitized': sanitized, }) return log_entry def _sanitize_data(self, data_str): """Mask sensitive data in log entries. Args: data_str: String representation of data to sanitize Returns: tuple: (sanitized_data, was_sanitized) """ was_sanitized = False if not data_str: return data_str, was_sanitized try: # Try to parse as JSON to sanitize structured data if data_str.startswith('{') or data_str.startswith('['): data = json.loads(data_str) sanitized_data, was_sanitized = self._sanitize_dict_or_list(data) return json.dumps(sanitized_data), was_sanitized else: # For plain text, check for sensitive patterns sensitive_patterns = [ 'password=', 'api_key=', 'token=', 'secret=', 'pwd=', 'PASSWORD', 'API_KEY', 'TOKEN', 'SECRET', 'PWD' ] sanitized = data_str for pattern in sensitive_patterns: if pattern in sanitized: # Simple masking for plain text parts = sanitized.split(pattern) result = [parts[0]] for i in range(1, len(parts)): # Mask until next whitespace or common delimiter value_end = 0 for j, char in enumerate(parts[i]): if char in [' ', '\n', '\t', '&', ';', ',']: value_end = j break if value_end > 0: result.append(pattern + '*****' + parts[i][value_end:]) else: result.append(pattern + '*****') sanitized = ''.join(result) was_sanitized = True return sanitized, was_sanitized except (ValueError, TypeError): # If parsing fails, return as is return data_str, was_sanitized def _sanitize_dict_or_list(self, data): """Recursively sanitize sensitive data in dictionaries and lists. Args: data: Dictionary or list to sanitize Returns: tuple: (sanitized_data, was_sanitized) """ sensitive_fields = [ 'password', 'api_key', 'token', 'secret', 'pwd', 'auth_token', 'access_token', 'refresh_token', 'private_key', 'client_secret' ] was_sanitized = False if isinstance(data, dict): result = {} for key, value in data.items(): if key.lower() in sensitive_fields: result[key] = '*****' was_sanitized = True elif isinstance(value, (dict, list)): result[key], child_sanitized = self._sanitize_dict_or_list(value) was_sanitized = was_sanitized or child_sanitized else: result[key] = value return result, was_sanitized elif isinstance(data, list): result = [] for item in data: if isinstance(item, (dict, list)): sanitized_item, child_sanitized = self._sanitize_dict_or_list(item) result.append(sanitized_item) was_sanitized = was_sanitized or child_sanitized else: result.append(item) return result, was_sanitized else: return data, was_sanitized @api.model def _cron_archive_old_logs(self): """Archive logs older than 90 days to long-term storage. This method is intended to be called by a scheduled action (cron job). It will find logs older than 90 days, archive them to S3 Glacier, and mark them as archived in the database. """ if not HAS_BOTO3: _logger.warning("Cannot archive logs: boto3 library not installed") return False # Get AWS credentials from system parameters ICP = self.env['ir.config_parameter'].sudo() aws_access_key = ICP.get_param('bemade.aws_access_key_id', False) aws_secret_key = ICP.get_param('bemade.aws_secret_access_key', False) aws_region = ICP.get_param('bemade.aws_region', 'us-east-1') aws_bucket = ICP.get_param('bemade.log_archive_bucket', False) if not (aws_access_key and aws_secret_key and aws_bucket): _logger.warning("Cannot archive logs: AWS credentials not configured") return False # Find logs older than 90 days that haven't been archived yet cutoff_date = fields.Datetime.now() - timedelta(days=90) old_logs = self.search([ ('create_date', '<', cutoff_date), ('archived', '=', False) ], limit=1000) # Process in batches to avoid memory issues if not old_logs: _logger.info("No logs to archive") return True try: # Connect to S3 s3_client = boto3.client( 's3', aws_access_key_id=aws_access_key, aws_secret_access_key=aws_secret_key, region_name=aws_region ) # Group logs by month for better organization logs_by_month = {} for log in old_logs: month_key = log.create_date.strftime('%Y-%m') if month_key not in logs_by_month: logs_by_month[month_key] = [] logs_by_month[month_key].append(log) # Archive each month's logs as a separate Parquet file for month, logs in logs_by_month.items(): # Convert logs to a format suitable for Parquet log_data = [{ 'id': log.id, 'create_date': log.create_date.isoformat(), 'operation': log.operation, 'model': log.model, 'record_id': log.record_id, 'result': log.result, 'log_level': log.log_level, 'details': log.details, 'payload': log.payload, 'diff': log.diff, 'metadata': log.metadata, 'instance_id': log.bemade_instance_id.id if log.bemade_instance_id else None, 'queue_id': log.queue_id.id if log.queue_id else None, } for log in logs] # Convert to JSON format (since we can't rely on pandas being installed) json_data = json.dumps(log_data) # Generate a unique key for this archive archive_key = f"sync_logs/{month}/logs_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}.json" # Upload to S3 with Glacier storage class s3_client.put_object( Body=json_data, Bucket=aws_bucket, Key=archive_key, StorageClass='DEEP_ARCHIVE' # Glacier Deep Archive (7 years retention) ) # Update logs to mark them as archived for log in logs: log.write({ 'archived': True, 'archive_reference': archive_key }) return True except Exception as e: _logger.error(f"Error archiving logs: {str(e)}") return False @api.model def init(self): """Set up database indexes for performance.""" super(OdooToBemadeSyncLog, self).init() # Add index on create_date for efficient archiving queries tools.create_index(self._cr, 'odoo_to_bemade_sync_log_create_date_idx', self._table, ['create_date'])