# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import requests import json import logging import re from datetime import datetime, timedelta from odoo import fields, models, api, _ from odoo.exceptions import UserError, ValidationError _logger = logging.getLogger(__name__) # Cache for Google Drive API responses (5 minutes) _GOOGLE_DRIVE_CACHE = {} _CACHE_TIMEOUT = 300 # 5 minutes def _clear_google_drive_cache(): """Clear expired cache entries""" global _GOOGLE_DRIVE_CACHE current_time = datetime.now() expired_keys = [ key for key, entry in _GOOGLE_DRIVE_CACHE.items() if current_time >= entry['expires'] ] for key in expired_keys: del _GOOGLE_DRIVE_CACHE[key] class CrmLead(models.Model): _inherit = 'crm.lead' google_drive_documents_count = fields.Integer( string='Google Drive Documents', compute='_compute_google_drive_documents_count', help='Number of documents in Google Drive for this opportunity' ) google_drive_folder_id = fields.Char( string='Google Drive Folder ID', help='ID del folder específico en Google Drive para esta oportunidad', readonly=True ) google_drive_folder_name = fields.Char( string='Google Drive Folder Name', help='Nombre del folder en Google Drive para esta oportunidad', readonly=True ) google_drive_url = fields.Char( string='URL Drive', help='URL de la carpeta de Google Drive de la oportunidad' ) @api.depends('google_drive_folder_id') def _compute_google_drive_documents_count(self): """Compute the number of documents in Google Drive with caching""" for record in self: if record.google_drive_folder_id: # Check cache first cache_key = f'doc_count_{record.google_drive_folder_id}' if cache_key in _GOOGLE_DRIVE_CACHE: cache_entry = _GOOGLE_DRIVE_CACHE[cache_key] if datetime.now() < cache_entry['expires']: record.google_drive_documents_count = cache_entry['count'] continue # TODO: Implement Google Drive API call to count documents # For now, return 0 but cache the result count = 0 _GOOGLE_DRIVE_CACHE[cache_key] = { 'count': count, 'expires': datetime.now() + timedelta(seconds=300) # 5 minutes } record.google_drive_documents_count = count else: record.google_drive_documents_count = 0 def _get_google_drive_access_token(self): """Get Google Drive access token from system parameters with caching""" # Check cache first cache_key = 'access_token' if cache_key in _GOOGLE_DRIVE_CACHE: cache_entry = _GOOGLE_DRIVE_CACHE[cache_key] if datetime.now() < cache_entry['expires']: return cache_entry['token'] access_token = self.env['ir.config_parameter'].sudo().get_param('google_api.access_token') if not access_token: raise UserError(_('No OAuth token found. Please connect your Google account in Google API settings first.')) # Test if the token is still valid if not self._test_access_token(access_token): # Try to refresh the token if self._refresh_access_token(): access_token = self.env['ir.config_parameter'].sudo().get_param('google_api.access_token') else: raise UserError(_('Google Drive access token has expired and could not be refreshed. Please reconnect your Google account.')) # Cache the token for 5 minutes _GOOGLE_DRIVE_CACHE[cache_key] = { 'token': access_token, 'expires': datetime.now() + timedelta(seconds=_CACHE_TIMEOUT) } return access_token def _test_access_token(self, access_token): """Test if the access token is still valid with better error handling""" try: headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } response = requests.get( 'https://www.googleapis.com/drive/v3/about', headers=headers, params={'fields': 'user'}, timeout=10 ) if response.status_code == 200: return True elif response.status_code == 401: _logger.warning("Google Drive access token is invalid (401)") return False else: _logger.warning(f"Google Drive API test failed with status {response.status_code}") return False except requests.exceptions.Timeout: _logger.error("Google Drive API test timeout") return False except requests.exceptions.ConnectionError: _logger.error("Google Drive API connection error") return False except Exception as e: _logger.error(f"Google Drive API test error: {str(e)}") return False def _refresh_access_token(self): """Refresh the access token using the refresh token""" try: refresh_token = self.env['ir.config_parameter'].sudo().get_param('google_api.refresh_token') client_id = self.env['ir.config_parameter'].sudo().get_param('google_api.client_id') client_secret = self.env['ir.config_parameter'].sudo().get_param('google_api.client_secret') if not all([refresh_token, client_id, client_secret]): return False # Exchange refresh token for new access token token_url = 'https://oauth2.googleapis.com/token' data = { 'client_id': client_id, 'client_secret': client_secret, 'refresh_token': refresh_token, 'grant_type': 'refresh_token' } response = requests.post(token_url, data=data, timeout=30) if response.status_code == 200: token_data = response.json() new_access_token = token_data.get('access_token') if new_access_token: # Store the new access token self.env['ir.config_parameter'].sudo().set_param('google_api.access_token', new_access_token) return True return False except Exception as e: _logger.error(f"Failed to refresh access token: {str(e)}") return False def _get_company_root_folder_id(self): """Get the company's root Google Drive folder ID""" if not self.company_id or not self.company_id.google_drive_crm_enabled: return None if not self.company_id.google_drive_crm_folder_id: return None return self.company_id.google_drive_crm_folder_id def _extract_folder_id_from_url(self, url): """Extract folder ID from Google Drive URL""" if not url: return None # Handle different Google Drive URL formats import re # Format: https://drive.google.com/drive/folders/FOLDER_ID folder_pattern = r'drive\.google\.com/drive/folders/([a-zA-Z0-9_-]+)' match = re.search(folder_pattern, url) if match: return match.group(1) # Format: https://drive.google.com/open?id=FOLDER_ID open_pattern = r'drive\.google\.com/open\?id=([a-zA-Z0-9_-]+)' match = re.search(open_pattern, url) if match: return match.group(1) return None def _get_google_drive_field_value(self): """Get value from the configured Google Drive field in company settings""" if not self.company_id.google_drive_crm_field_id: return None field_name = self.company_id.google_drive_crm_field_id.name if not field_name: return None # Get the field value from the record field_value = getattr(self, field_name, None) return field_value def _try_extract_folder_id_from_field(self): """Try to extract folder ID from the configured field value""" field_value = self._get_google_drive_field_value() if not field_value: return None # Try to extract folder ID from the field value (could be URL or direct ID) folder_id = self._extract_folder_id_from_url(field_value) if folder_id: return folder_id # If it's not a URL, check if it looks like a folder ID if isinstance(field_value, str) and len(field_value) >= 10 and len(field_value) <= 50: # Basic validation for Google Drive folder ID format import re if re.match(r'^[a-zA-Z0-9_-]+$', field_value): return field_value return None def _validate_folder_creation_prerequisites(self): """Validate prerequisites before creating Google Drive folder""" # Check if company has Google Drive folder configured root_folder_id = self._get_company_root_folder_id() if not root_folder_id: self.message_post( body=_("⚠️ Google Drive folder creation skipped: Company doesn't have Google Drive configured."), message_type='comment' ) return False # Validate contact exists if not self.partner_id: self.message_post( body=_("⚠️ Google Drive folder creation skipped: No contact associated with this opportunity. Please assign a contact before creating Google Drive folders."), message_type='comment' ) return False return True def _try_get_existing_folder_id(self): """Try to get existing folder ID from various sources""" # First, check if we already have a folder ID if self.google_drive_folder_id: return self.google_drive_folder_id # Second, try to extract from the configured field field_folder_id = self._try_extract_folder_id_from_field() if field_folder_id: _logger.info(f"Found folder ID from configured field: {field_folder_id}") return field_folder_id # Third, try to extract from google_drive_url field if self.google_drive_url: url_folder_id = self._extract_folder_id_from_url(self.google_drive_url) if url_folder_id: _logger.info(f"Found folder ID from URL field: {url_folder_id}") return url_folder_id return None def _check_folder_company_mismatch(self): """Check if the current folder belongs to the correct company""" if not self.google_drive_folder_id or not self.company_id: return False try: # Get access token access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Get company root folder ID company_root_folder_id = self._get_company_root_folder_id() if not company_root_folder_id: return False # Get current folder info response = requests.get( f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}?fields=parents', headers=headers, timeout=30 ) if response.status_code != 200: return False folder_data = response.json() parent_ids = folder_data.get('parents', []) if not parent_ids: return False # Navigate up to find if the folder is under the correct company root current_parent_id = parent_ids[0] max_levels = 5 # Prevent infinite loop for _ in range(max_levels): # Check if current parent is the company root folder if current_parent_id == company_root_folder_id: # Folder is under the correct company root return False parent_response = requests.get( f'https://www.googleapis.com/drive/v3/files/{current_parent_id}?fields=parents', headers=headers, timeout=30 ) if parent_response.status_code != 200: break parent_data = parent_response.json() parent_parent_ids = parent_data.get('parents', []) if not parent_parent_ids: # Reached the top level (My Drive), folder is not under the correct company root _logger.info(f"Folder belongs to wrong company. Current root: {current_parent_id}, Expected: {company_root_folder_id}") return True current_parent_id = parent_parent_ids[0] # If we reach here, folder is not under the correct company root _logger.info(f"Folder belongs to wrong company. Could not find company root: {company_root_folder_id}") return True except Exception as e: _logger.error(f"Error checking folder company mismatch: {str(e)}") return False def _get_folder_name_components(self): """Get the components for folder naming with priority for partner_id""" primary_name = None if self.partner_id: _logger.info(f"Contact found: {self.partner_id.name} (ID: {self.partner_id.id})") # Prioridad 1: partner_id.company_id.name if self.partner_id.company_id and self.partner_id.company_id.name: primary_name = self.partner_id.company_id.name _logger.info(f"Using company_id.name: {primary_name}") # Prioridad 2: partner_id.company_name elif self.partner_id.company_name: primary_name = self.partner_id.company_name _logger.info(f"Using company_name: {primary_name}") # Prioridad 3: partner_id.name else: primary_name = self.partner_id.name _logger.info(f"Using partner name: {primary_name}") else: _logger.warning("No contact assigned to opportunity") primary_name = "Sin Contacto" if not primary_name: raise UserError(_('No company or contact name available. Please assign a contact with company information before creating Google Drive folders.')) # Validate and sanitize the primary name sanitized_primary_name = self._sanitize_folder_name(primary_name) sanitized_opportunity_name = self._sanitize_folder_name(self.name) year = str(self.create_date.year) if self.create_date else str(datetime.now().year) _logger.info(f"Folder components - Primary: '{sanitized_primary_name}', Opportunity: '{sanitized_opportunity_name}', Year: {year}") return { 'primary_name': sanitized_primary_name, 'opportunity_name': sanitized_opportunity_name, 'year': year } def _check_partner_name_changes(self, vals): """Check if partner or partner's company name has changed""" if 'partner_id' not in vals: return False old_partner = self.partner_id new_partner_id = vals['partner_id'] if not new_partner_id: return False new_partner = self.env['res.partner'].browse(new_partner_id) # Check if partner changed if old_partner.id != new_partner.id: return True # Check if partner's company name changed old_company_name = old_partner.parent_id.name if old_partner.parent_id else old_partner.name new_company_name = new_partner.parent_id.name if new_partner.parent_id else new_partner.name return old_company_name != new_company_name def _sanitize_folder_name(self, name): """Sanitize folder name to be Google Drive compatible with optimization""" if not name: return 'Sin nombre' # Use regex for better performance sanitized_name = re.sub(r'[<>:"|?*/\\]', '_', name) # Remove leading/trailing spaces and dots sanitized_name = sanitized_name.strip(' .') # Ensure it's not empty after sanitization if not sanitized_name: return 'Sin nombre' # Limit length to 255 characters (Google Drive limit) if len(sanitized_name) > 255: sanitized_name = sanitized_name[:252] + '...' return sanitized_name def _validate_folder_id_with_google_drive(self, folder_id): """Validate if the folder ID exists and is accessible in Google Drive""" try: access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Test access to the specific folder response = requests.get( f'https://www.googleapis.com/drive/v3/files/{folder_id}?fields=id,name,mimeType', headers=headers, timeout=10 ) if response.status_code == 200: folder_data = response.json() if folder_data.get('mimeType') == 'application/vnd.google-apps.folder': _logger.info(f"✅ Folder ID {folder_id} validated successfully in Google Drive") return True, folder_data.get('name', 'Unknown') else: _logger.warning(f"❌ ID {folder_id} exists but is not a folder") return False, "Not a folder" elif response.status_code == 404: _logger.warning(f"❌ Folder ID {folder_id} not found in Google Drive") return False, "Not found" elif response.status_code == 403: _logger.warning(f"❌ Access denied to folder ID {folder_id}") return False, "Access denied" elif response.status_code == 401: _logger.error(f"❌ OAuth token expired or invalid") return False, "Authentication error" else: _logger.warning(f"❌ Google Drive API error: {response.status_code}") return False, f"API error: {response.status_code}" except requests.exceptions.Timeout: _logger.error(f"❌ Timeout validating folder ID {folder_id}") return False, "Timeout" except requests.exceptions.ConnectionError: _logger.error(f"❌ Connection error validating folder ID {folder_id}") return False, "Connection error" except Exception as e: _logger.error(f"❌ Error validating folder ID {folder_id}: {str(e)}") return False, str(e) def _create_google_drive_folder_structure(self): """Create the complete Google Drive folder structure for this opportunity with optimization""" self.ensure_one() # Validate prerequisites if not self._validate_folder_creation_prerequisites(): return None # Try to get existing folder ID first existing_folder_id = self._try_get_existing_folder_id() if existing_folder_id: _logger.info(f"Found existing folder ID: {existing_folder_id}") # Validate the folder ID against Google Drive is_valid, error_message = self._validate_folder_id_with_google_drive(existing_folder_id) if is_valid: _logger.info(f"✅ Folder ID {existing_folder_id} validated successfully") # Update the record with the existing folder ID self.with_context(skip_google_drive_update=True).write({ 'google_drive_folder_id': existing_folder_id }) # Get expected structure and store it expected_components = self._get_folder_name_components() expected_structure = self._build_structure_string(expected_components) self.with_context(skip_google_drive_update=True).write({ 'google_drive_folder_name': expected_structure }) self.message_post( body=_("✅ Using existing Google Drive folder: %s") % existing_folder_id, message_type='comment' ) return True else: _logger.warning(f"❌ Folder ID {existing_folder_id} validation failed: {error_message}") self.message_post( body=_("⚠️ Folder ID from configured field is not accessible: %s. Creating new folder structure.") % error_message, message_type='comment' ) # Continue to create new folder structure access_token = self._get_google_drive_access_token() components = self._get_folder_name_components() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } try: # Create folder structure in batch for better performance folder_structure = self._create_folder_structure_batch(headers, components) # Update the opportunity with the main folder ID self.with_context(skip_google_drive_update=True).write({ 'google_drive_folder_id': folder_structure['opportunity_folder_id'], 'google_drive_folder_name': self._build_structure_string(components) }) return folder_structure except Exception as e: _logger.error(f"Failed to create folder structure for opportunity {self.id}: {str(e)}") raise UserError(_('Failed to create Google Drive folder structure: %s') % str(e)) def _create_folder_structure_batch(self, headers, components): """Create folder structure in batch for better performance""" root_folder_id = self._get_company_root_folder_id() # Step 1: Create or get primary folder (Company/Contact) primary_folder_id = self._create_or_get_folder( headers, root_folder_id, components['primary_name'] ) # Step 2: Create or get year folder year_folder_id = self._create_or_get_folder( headers, primary_folder_id, components['year'] ) # Step 3: Create or get opportunity folder opportunity_folder_id = self._create_or_get_folder( headers, year_folder_id, components['opportunity_name'] ) # Step 4: Create Meets and Archivos cliente folders (parallel creation) meets_folder_id = self._create_or_get_folder( headers, opportunity_folder_id, 'Meets' ) archivos_folder_id = self._create_or_get_folder( headers, opportunity_folder_id, 'Archivos cliente' ) return { 'opportunity_folder_id': opportunity_folder_id, 'meets_folder_id': meets_folder_id, 'archivos_folder_id': archivos_folder_id } def _create_or_get_folder(self, headers, parent_folder_id, folder_name): """Create a folder or get existing one by name""" # First, check if folder already exists existing_folder = self._find_folder_by_name(headers, parent_folder_id, folder_name) if existing_folder: return existing_folder['id'] # Create new folder folder_metadata = { 'name': folder_name, 'mimeType': 'application/vnd.google-apps.folder', 'parents': [parent_folder_id] } response = requests.post( 'https://www.googleapis.com/drive/v3/files', headers=headers, json=folder_metadata, timeout=30 ) if response.status_code == 200: folder_data = response.json() return folder_data['id'] else: raise UserError(_('Failed to create Google Drive folder "%s". Status: %s') % (folder_name, response.status_code)) def _find_folder_by_name(self, headers, parent_folder_id, folder_name): """Find a folder by name in the parent folder with caching""" # Check cache first cache_key = f'folder_{parent_folder_id}_{folder_name}' if cache_key in _GOOGLE_DRIVE_CACHE: cache_entry = _GOOGLE_DRIVE_CACHE[cache_key] if datetime.now() < cache_entry['expires']: return cache_entry['result'] params = { 'q': f"'{parent_folder_id}' in parents and name='{folder_name}' and mimeType='application/vnd.google-apps.folder' and trashed=false", 'fields': 'files(id,name)', 'pageSize': 1 } try: response = requests.get( 'https://www.googleapis.com/drive/v3/files', headers=headers, params=params, timeout=30 ) if response.status_code == 200: data = response.json() folders = data.get('files', []) result = folders[0] if folders else None # Cache the result for 2 minutes _GOOGLE_DRIVE_CACHE[cache_key] = { 'result': result, 'expires': datetime.now() + timedelta(seconds=120) } return result else: _logger.warning(f"Failed to find folder '{folder_name}' in parent {parent_folder_id}. Status: {response.status_code}") return None except Exception as e: _logger.error(f"Error finding folder '{folder_name}' in parent {parent_folder_id}: {str(e)}") return None def _rename_google_drive_folder(self, new_name): """Rename the Google Drive folder with optimization""" if not self.google_drive_folder_id: return # Sanitize the new name sanitized_name = self._sanitize_folder_name(new_name) # Check if the name is actually different if self.google_drive_folder_name == sanitized_name: return access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } try: folder_metadata = { 'name': sanitized_name } _logger.info(f"Renaming Google Drive folder {self.google_drive_folder_id} to '{sanitized_name}'") response = requests.patch( f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}', headers=headers, json=folder_metadata, timeout=30 ) if response.status_code == 200: # Update the folder name in Odoo (with context to prevent loop) self.with_context(skip_google_drive_update=True).write({'google_drive_folder_name': sanitized_name}) _logger.info(f"Successfully renamed Google Drive folder to '{sanitized_name}'") # Clear cache for this folder cache_key = f'folder_{self.google_drive_folder_id}' if cache_key in _GOOGLE_DRIVE_CACHE: del _GOOGLE_DRIVE_CACHE[cache_key] else: error_msg = f'Failed to rename Google Drive folder. Status: {response.status_code}' if response.text: error_msg += f' - Response: {response.text}' _logger.error(error_msg) raise UserError(_(error_msg)) except requests.exceptions.Timeout: raise UserError(_('Timeout while renaming Google Drive folder. Please try again.')) except requests.exceptions.ConnectionError: raise UserError(_('Connection error while renaming Google Drive folder. Please check your internet connection.')) except Exception as e: _logger.error(f"Error renaming folder {self.google_drive_folder_id}: {str(e)}") raise UserError(_('Failed to rename Google Drive folder: %s') % str(e)) def _move_google_drive_folder(self, new_company_id): """Move the Google Drive folder to a new company's structure""" if not self.google_drive_folder_id: return # Get new company's root folder new_root_folder_id = new_company_id.google_drive_crm_folder_id if not new_root_folder_id: raise UserError(_('New company does not have Google Drive CRM folder configured')) access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Get current folder's parent response = requests.get( f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}?fields=parents', headers=headers, timeout=30 ) if response.status_code != 200: raise UserError(_('Failed to get current folder information')) folder_data = response.json() current_parents = folder_data.get('parents', []) # Remove from current parent and add to new parent if current_parents: # Remove from current parent remove_response = requests.delete( f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}/parents/{current_parents[0]}', headers=headers, timeout=30 ) if remove_response.status_code != 204: raise UserError(_('Failed to remove folder from current parent')) # Add to new parent add_response = requests.post( f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}/parents', headers=headers, json={'id': new_root_folder_id}, timeout=30 ) if add_response.status_code != 200: raise UserError(_('Failed to move folder to new company')) def _delete_google_drive_folder_structure(self): """Delete the Google Drive folder structure when contact is removed""" if not self.google_drive_folder_id: return access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', } # Delete the folder (this will also delete subfolders) response = requests.delete( f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}', headers=headers, timeout=30 ) if response.status_code == 204: # Clear the folder ID from the record self.write({ 'google_drive_folder_id': False, 'google_drive_folder_name': False }) else: raise UserError(_('Failed to delete Google Drive folder structure')) def _recreate_google_drive_folder_structure(self): """Recreate the Google Drive folder structure when contact changes""" if not self.partner_id: raise UserError(_('No contact associated with this opportunity. Cannot recreate folder structure.')) # Store old folder information for reference old_folder_id = self.google_drive_folder_id old_folder_name = self.google_drive_folder_name # Clear the folder ID but don't delete the actual folder self.write({ 'google_drive_folder_id': False, 'google_drive_folder_name': False }) # Create new structure try: new_structure = self._create_google_drive_folder_structure() # Log the recreation for audit purposes if old_folder_id: _logger.info(f"Recreated Google Drive folder structure for opportunity {self.id}: " f"Old folder: {old_folder_id} ({old_folder_name}) -> " f"New folder: {self.google_drive_folder_id} ({self.google_drive_folder_name})") return new_structure except Exception as e: # Restore old values if recreation fails self.write({ 'google_drive_folder_id': old_folder_id, 'google_drive_folder_name': old_folder_name }) raise def _rename_entire_folder_structure(self, old_components, new_components): """Rename the entire folder structure instead of recreating it""" if not self.google_drive_folder_id: return access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Get the current folder path to understand the structure current_folder_id = self.google_drive_folder_id # Get folder information to find parent folders response = requests.get( f'https://www.googleapis.com/drive/v3/files/{current_folder_id}?fields=parents,name', headers=headers, timeout=30 ) if response.status_code != 200: raise UserError(_('Failed to get current folder information')) folder_data = response.json() parent_ids = folder_data.get('parents', []) if not parent_ids: raise UserError(_('Cannot rename folder structure: no parent folder found')) # Navigate up the hierarchy to find the primary folder (company/contact) primary_folder_id = self._find_primary_folder_id(headers, current_folder_id) if not primary_folder_id: raise UserError(_('Cannot find primary folder in the structure')) # Rename the primary folder (company/contact name) if old_components['primary_name'] != new_components['primary_name']: self._rename_folder(headers, primary_folder_id, new_components['primary_name']) # Find and rename year folder if needed if old_components['year'] != new_components['year']: year_folder_id = self._find_year_folder_id(headers, primary_folder_id, old_components['year']) if year_folder_id: self._rename_folder(headers, year_folder_id, new_components['year']) # Rename opportunity folder if needed if old_components['opportunity_name'] != new_components['opportunity_name']: _logger.info(f"Renaming opportunity folder from '{old_components['opportunity_name']}' to '{new_components['opportunity_name']}'") self._rename_folder(headers, current_folder_id, new_components['opportunity_name']) # Update the folder name in Odoo (with context to prevent loop) self.with_context(skip_google_drive_update=True).write({'google_drive_folder_name': new_components['opportunity_name']}) else: _logger.info(f"Opportunity folder name is already correct: '{new_components['opportunity_name']}'") def _find_primary_folder_id(self, headers, current_folder_id): """Find the primary folder (company/contact) by navigating up the hierarchy""" max_depth = 5 # Prevent infinite loops current_id = current_folder_id for depth in range(max_depth): response = requests.get( f'https://www.googleapis.com/drive/v3/files/{current_id}?fields=parents,name', headers=headers, timeout=30 ) if response.status_code != 200: return None folder_data = response.json() parent_ids = folder_data.get('parents', []) if not parent_ids: # This is the root folder, go back one level return current_id # Check if this folder is the primary folder (not a year or opportunity folder) # Primary folder is typically the company/contact name folder_name = folder_data.get('name', '') if not folder_name.isdigit() and folder_name not in ['Meets', 'Archivos cliente']: # This could be the primary folder, but let's check if it has a year folder as child year_folders = self._find_folders_by_name(headers, current_id, r'^\d{4}$') if year_folders: # This is the primary folder return current_id current_id = parent_ids[0] return None def _find_year_folder_id(self, headers, primary_folder_id, year): """Find the year folder within the primary folder""" year_folders = self._find_folders_by_name(headers, primary_folder_id, f'^{year}$') return year_folders[0]['id'] if year_folders else None def _find_folders_by_name(self, headers, parent_id, name_pattern): """Find folders by name pattern in a parent folder""" import re params = { 'q': f"'{parent_id}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false", 'fields': 'files(id,name)', 'pageSize': 100 } response = requests.get( 'https://www.googleapis.com/drive/v3/files', headers=headers, params=params, timeout=30 ) if response.status_code != 200: return [] data = response.json() folders = data.get('files', []) # Filter by name pattern pattern = re.compile(name_pattern) return [folder for folder in folders if pattern.match(folder.get('name', ''))] def _rename_folder(self, headers, folder_id, new_name): """Rename a specific folder""" _logger.info(f"Attempting to rename folder {folder_id} to '{new_name}'") folder_metadata = { 'name': new_name } try: response = requests.patch( f'https://www.googleapis.com/drive/v3/files/{folder_id}', headers=headers, json=folder_metadata, timeout=30 ) if response.status_code == 200: _logger.info(f"Successfully renamed folder {folder_id} to '{new_name}'") else: _logger.error(f"Failed to rename folder {folder_id}. Status: {response.status_code}, Response: {response.text}") raise UserError(_('Failed to rename folder "%s" to "%s". Status: %s') % (folder_id, new_name, response.status_code)) except requests.exceptions.Timeout: _logger.error(f"Timeout while renaming folder {folder_id}") raise UserError(_('Timeout while renaming folder "%s"') % folder_id) except requests.exceptions.ConnectionError: _logger.error(f"Connection error while renaming folder {folder_id}") raise UserError(_('Connection error while renaming folder "%s"') % folder_id) except Exception as e: _logger.error(f"Error renaming folder {folder_id}: {str(e)}") raise UserError(_('Failed to rename folder "%s" to "%s": %s') % (folder_id, new_name, str(e))) def _update_google_drive_folder_structure(self, vals): """Update the Google Drive folder structure based on changes""" if not self.google_drive_folder_id: return # Check if company has Google Drive folder configured root_folder_id = self._get_company_root_folder_id() if not root_folder_id: # Company doesn't have Google Drive configured, do nothing return # Get current folder information access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Check if company changed - move folder to new company structure if 'company_id' in vals and vals['company_id'] != self.company_id.id: new_company = self.env['res.company'].browse(vals['company_id']) if new_company.google_drive_crm_enabled and new_company.google_drive_crm_folder_id: _logger.info(f"Company changed from {self.company_id.name} to {new_company.name}. Moving Google Drive folder structure.") self._move_google_drive_folder(new_company) else: # If new company doesn't have Google Drive configured, keep the folder but log it self.message_post( body=_("⚠️ Company changed to one without Google Drive configuration. Existing folder structure remains unchanged."), message_type='comment' ) return # Check if contact changed - this requires recreating the entire structure if 'partner_id' in vals: if not vals['partner_id']: # Contact was removed, but we don't delete - just log it self.message_post( body=_("⚠️ Contact was removed from opportunity. Google Drive folder structure remains unchanged."), message_type='comment' ) return else: # Contact changed, recreate the entire structure _logger.info(f"Contact changed. Recreating entire Google Drive folder structure.") self._recreate_google_drive_folder_structure() return # Check if name changed - rename the opportunity folder if 'name' in vals and vals['name'] != self.name: _logger.info(f"Name changed from '{self.name}' to '{vals['name']}'. Renaming Google Drive folder.") self._rename_google_drive_folder(vals['name']) # Validate and update entire folder structure if needed (only for non-name changes) # This will handle changes in company name, contact name, or year if 'partner_id' in vals or 'create_date' in vals: self._validate_and_update_folder_structure(vals) # Check if stage changed and we need to create folder if 'stage_id' in vals: if self.company_id.google_drive_crm_enabled and self.company_id.google_drive_crm_stage_id: if vals['stage_id'] == self.company_id.google_drive_crm_stage_id.id and not self.google_drive_folder_id: # Check if company has Google Drive folder configured root_folder_id = self._get_company_root_folder_id() if not root_folder_id: # Company doesn't have Google Drive configured, do nothing return # Validate contact exists before attempting to create folder if not self.partner_id: self.message_post( body=_("⚠️ Google Drive folder creation skipped: No contact associated with this opportunity. Please assign a contact before creating Google Drive folders."), message_type='comment' ) else: try: self._create_google_drive_folder_structure() except Exception as e: _logger.error(f"Failed to create Google Drive folder for opportunity {self.id}: {str(e)}") self.message_post( body=_("⚠️ Google Drive folder creation failed: %s") % str(e), message_type='comment' ) def _validate_and_update_folder_structure(self, vals): """Validate and update the entire folder structure if any component changed""" if not self.google_drive_folder_id: return # Get current and new components current_components = self._get_folder_name_components() # Create a temporary record with new values to get new components temp_vals = {} if 'name' in vals: temp_vals['name'] = vals['name'] if 'partner_id' in vals: temp_vals['partner_id'] = vals['partner_id'] if 'create_date' in vals: temp_vals['create_date'] = vals['create_date'] # Create a temporary record to get new components temp_record = self.with_context(skip_google_drive_update=True) for field, value in temp_vals.items(): setattr(temp_record, field, value) try: new_components = temp_record._get_folder_name_components() except: # If we can't get new components, skip validation return # Check if any component changed components_changed = ( current_components['primary_name'] != new_components['primary_name'] or current_components['year'] != new_components['year'] ) # Also check if partner name changed (even if same partner, name might have changed) partner_name_changed = self._check_partner_name_changes(vals) if components_changed or partner_name_changed: _logger.info(f"Folder structure components changed. Renaming entire structure.") _logger.info(f"Old components: {current_components}") _logger.info(f"New components: {new_components}") # Store the old folder information for reference old_folder_id = self.google_drive_folder_id old_folder_name = self.google_drive_folder_name old_structure = f"{current_components['primary_name']}/{current_components['year']}/{current_components['opportunity_name']}" new_structure = f"{new_components['primary_name']}/{new_components['year']}/{new_components['opportunity_name']}" # Rename the entire folder structure instead of recreating try: self._rename_entire_folder_structure(current_components, new_components) # Log the change for audit self.message_post( body=_("🔄 Google Drive folder structure renamed due to changes:
" "• Old structure: %s
" "• New structure: %s
" "• Folder ID: %s (same folder, renamed)") % ( old_structure, new_structure, self.google_drive_folder_id ), message_type='comment' ) except Exception as e: _logger.error(f"Failed to rename folder structure: {str(e)}") self.message_post( body=_("❌ Failed to rename Google Drive folder structure: %s") % str(e), message_type='comment' ) raise return # Check if company has Google Drive folder configured root_folder_id = self._get_company_root_folder_id() if not root_folder_id: # Company doesn't have Google Drive configured, do nothing return # Get current folder information access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Check if company changed - move folder to new company structure if 'company_id' in vals and vals['company_id'] != self.company_id.id: new_company = self.env['res.company'].browse(vals['company_id']) if new_company.google_drive_crm_enabled and new_company.google_drive_crm_folder_id: _logger.info(f"Company changed from {self.company_id.name} to {new_company.name}. Moving Google Drive folder structure.") self._move_google_drive_folder(new_company) else: # If new company doesn't have Google Drive configured, keep the folder but log it self.message_post( body=_("⚠️ Company changed to one without Google Drive configuration. Existing folder structure remains unchanged."), message_type='comment' ) return # Check if contact changed - this requires recreating the entire structure if 'partner_id' in vals: if not vals['partner_id']: # Contact was removed, but we don't delete - just log it self.message_post( body=_("⚠️ Contact was removed from opportunity. Google Drive folder structure remains unchanged."), message_type='comment' ) return else: # Contact changed, recreate the entire structure _logger.info(f"Contact changed. Recreating entire Google Drive folder structure.") self._recreate_google_drive_folder_structure() return # Check if name changed - rename the opportunity folder if 'name' in vals and vals['name'] != self.name: _logger.info(f"Name changed from '{self.name}' to '{vals['name']}'. Renaming Google Drive folder.") self._rename_google_drive_folder(vals['name']) # Validate and update entire folder structure if needed self._validate_and_update_folder_structure(vals) # Check if stage changed and we need to create folder if 'stage_id' in vals: if self.company_id.google_drive_crm_enabled and self.company_id.google_drive_crm_stage_id: if vals['stage_id'] == self.company_id.google_drive_crm_stage_id.id and not self.google_drive_folder_id: if self.partner_id: self._create_google_drive_folder_structure() else: self.message_post( body=_("⚠️ Google Drive folder creation skipped: No contact associated with this opportunity."), message_type='comment' ) @api.model def create(self, vals): """Override create to handle Google Drive folder creation with optimization""" record = super().create(vals) # Check if we should create Google Drive folder (optimized conditions) if (record.company_id.google_drive_crm_enabled and record.company_id.google_drive_crm_stage_id and record.stage_id.id == record.company_id.google_drive_crm_stage_id.id): # Validate prerequisites before creating folder if record._validate_folder_creation_prerequisites(): try: record._create_google_drive_folder_structure() # Store the initial structure and update URL if record._store_initial_structure_and_update_url(): record.message_post( body=_("✅ Google Drive folder created automatically"), message_type='comment' ) except Exception as e: # Log error but don't fail record creation _logger.error(f"Failed to create Google Drive folder for opportunity {record.id}: {str(e)}") record.message_post( body=_("⚠️ Google Drive folder creation failed: %s") % str(e), message_type='comment' ) return record def write(self, vals): """Override write method to handle Google Drive folder updates""" # Skip Google Drive updates if this is an internal update (to prevent loops) if self.env.context.get('skip_google_drive_update'): return super().write(vals) # Clear cache before processing _clear_google_drive_cache() # Check if any relevant fields are being updated relevant_fields = ['name', 'partner_id', 'create_date', 'stage_id', 'company_id'] needs_update = any(field in vals for field in relevant_fields) if not needs_update: return super().write(vals) # Store current values for comparison - PROCESAR TODAS LAS OPORTUNIDADES current_values = {} for record in self: current_values[record.id] = { 'name': record.name, 'partner_id': record.partner_id.id if record.partner_id else None, 'create_date': record.create_date, 'company_id': record.company_id.id if record.company_id else None, 'google_drive_folder_name': record.google_drive_folder_name or '' } # Execute the write first result = super().write(vals) # Now process Google Drive updates with updated values - PROCESAR TODAS for record in self: record._process_google_drive_updates_immediate(vals, current_values[record.id]) return result def _process_google_drive_updates(self, vals): """Process Google Drive updates for a single record""" try: # Check if we need to create folder (stage-based creation) if 'stage_id' in vals and not self.google_drive_folder_id: if not self._validate_folder_creation_prerequisites(): return # If we have a folder, verify and update structure if needed if self.google_drive_folder_id: self._verify_and_update_folder_structure(vals) except Exception as e: _logger.error(f"Error processing Google Drive updates for record {self.id}: {str(e)}") self.message_post( body=_("❌ Error updating Google Drive: %s") % str(e), message_type='comment' ) def _process_google_drive_updates_immediate(self, vals, old_values): """Process Google Drive updates immediately after write""" try: _logger.info(f"=== INICIO _process_google_drive_updates_immediate para oportunidad {self.id} ===") _logger.info(f"Vals recibidos: {vals}") _logger.info(f"Google Drive Folder ID actual: {self.google_drive_folder_id}") # PASO 1: Verificar si necesitamos crear carpeta (stage-based creation) should_create_folder = False # Caso 1: Cambió stage_id y no tiene carpeta if 'stage_id' in vals and not self.google_drive_folder_id: should_create_folder = True _logger.info(f"CASO 1: Stage changed for opportunity {self.id}. Checking if should create folder.") # Caso 2: Ya está en el stage correcto, no tiene carpeta, y se actualizó cualquier campo elif not self.google_drive_folder_id: # Verificar si está en el stage correcto para crear automáticamente company = self.company_id _logger.info(f"CASO 2: Checking if opportunity {self.id} is in correct stage for auto-creation") _logger.info(f"Company Google Drive enabled: {company.google_drive_crm_enabled}") _logger.info(f"Company stage configured: {company.google_drive_crm_stage_id.name if company.google_drive_crm_stage_id else 'None'}") _logger.info(f"Opportunity stage: {self.stage_id.name}") if (company.google_drive_crm_enabled and company.google_drive_crm_stage_id and self.stage_id.id == company.google_drive_crm_stage_id.id): should_create_folder = True _logger.info(f"CASO 2: Opportunity {self.id} is in correct stage but has no folder. Will create.") else: _logger.info(f"CASO 2: Opportunity {self.id} is NOT in correct stage for auto-creation") _logger.info(f"¿Debería crear carpeta?: {should_create_folder}") # Crear carpeta si es necesario if should_create_folder: _logger.info(f"Intentando crear carpeta para oportunidad {self.id}") if self._validate_folder_creation_prerequisites(): _logger.info(f"Prerrequisitos válidos. Creando Google Drive folder para opportunity {self.id}") try: self._create_google_drive_folder_structure() _logger.info(f"Carpeta creada exitosamente para oportunidad {self.id}") # Store the initial structure and update URL if self._store_initial_structure_and_update_url(): self.message_post( body=_("✅ Google Drive folder created automatically"), message_type='comment' ) except Exception as e: _logger.error(f"ERROR creando carpeta para oportunidad {self.id}: {str(e)}") raise else: _logger.info(f"Prerrequisitos no cumplidos para oportunidad {self.id}. Skipping folder creation.") # PASO 2: Si ya tiene carpeta, verificar y actualizar estructura si es necesario if self.google_drive_folder_id: _logger.info(f"Oportunidad {self.id} ya tiene carpeta. Verificando estructura.") self._verify_and_update_folder_structure_immediate(vals, old_values) _logger.info(f"=== FIN _process_google_drive_updates_immediate para oportunidad {self.id} ===") except Exception as e: _logger.error(f"Error processing Google Drive updates for record {self.id}: {str(e)}") self.message_post( body=_("❌ Error updating Google Drive: %s") % str(e), message_type='comment' ) def _verify_and_update_folder_structure_immediate(self, vals, old_values): """Verificar y actualizar estructura de Google Drive - SECUENCIAL""" try: _logger.info(f"Processing Google Drive updates for opportunity {self.id}") # PASO 1: Si cambia company_id y tiene folder_id → Mover if 'company_id' in vals and self.google_drive_folder_id: new_company_id = vals['company_id'] if new_company_id != old_values.get('company_id'): _logger.info(f"Company changed from {old_values.get('company_id')} to {new_company_id}. Moving folder.") self._move_folder_to_new_company(new_company_id) # PASO 2: Si cambia otro campo → Verificar string de estructura relevant_fields = ['name', 'partner_id', 'create_date'] if any(field in vals for field in relevant_fields): expected_structure = self._build_structure_string(self._get_folder_name_components()) current_structure = old_values.get('google_drive_folder_name', '') _logger.info(f"Structure comparison: Current='{current_structure}' vs Expected='{expected_structure}'") if expected_structure != current_structure: _logger.info(f"Structure changed. Renaming folder structure.") self._rename_entire_folder_structure_from_components(self._get_folder_name_components()) # Actualizar estructura local al final expected_structure = self._build_structure_string(self._get_folder_name_components()) self.with_context(skip_google_drive_update=True).write({ 'google_drive_folder_name': expected_structure }) self.message_post( body=_("✅ Google Drive folder structure updated immediately"), message_type='comment' ) except Exception as e: _logger.error(f"Error verifying folder structure: {str(e)}") raise def _verify_and_update_folder_structure(self, vals): """Verify current structure vs expected and update if needed""" try: # Get expected structure components expected_components = self._get_folder_name_components() # Build expected structure string expected_structure = self._build_structure_string(expected_components) # Get current structure from stored field current_structure = self.google_drive_folder_name or '' _logger.info(f"Structure comparison for opportunity {self.id}:") _logger.info(f"Current: '{current_structure}'") _logger.info(f"Expected: '{expected_structure}'") # Compare structures if current_structure != expected_structure: _logger.info(f"Structure mismatch detected. Updating Google Drive...") # Determine what type of change occurred if 'company_id' in vals: self._handle_company_change(vals['company_id']) else: # For other changes, rename the structure self._rename_entire_folder_structure_from_components(expected_components) # Update the stored structure self.with_context(skip_google_drive_update=True).write({ 'google_drive_folder_name': expected_structure }) self.message_post( body=_("✅ Google Drive folder structure updated successfully"), message_type='comment' ) else: _logger.info(f"Structure is up to date. No changes needed.") except Exception as e: _logger.error(f"Error verifying folder structure: {str(e)}") raise def _build_structure_string(self, components): """Build a string representation of the folder structure""" return f"{components['primary_name']}/{components['year']}/{components['opportunity_name']}" def _rename_entire_folder_structure_from_components(self, expected_components): """Rename entire folder structure based on expected components""" try: access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Get current structure from Google Drive current_structure = self._analyze_complete_folder_structure(headers) if not current_structure: raise UserError(_('Could not analyze current folder structure')) # Build current components from actual structure current_components = { 'primary_name': current_structure.get('primary_folder', {}).get('name', ''), 'year': current_structure.get('year_folder', {}).get('name', ''), 'opportunity_name': current_structure.get('opportunity_folder', {}).get('name', '') } # Rename the structure self._rename_entire_folder_structure(current_components, expected_components) except Exception as e: _logger.error(f"Error renaming folder structure: {str(e)}") raise def _move_folder_to_new_company(self, new_company_id): """Mover folder a nueva empresa - SIMPLE""" if not self.google_drive_folder_id: return try: # Obtener nueva empresa new_company = self.env['res.company'].browse(new_company_id) new_root_folder_id = new_company.google_drive_crm_folder_id if not new_root_folder_id: self.message_post( body=_("⚠️ No se puede mover: Nueva empresa no tiene Google Drive configurado."), message_type='comment' ) return _logger.info(f"Moviendo folder de {self.company_id.name} a {new_company.name}") # Obtener access token access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Mover el folder primario al nuevo root self._move_folder_to_new_parent(headers, new_root_folder_id) self.message_post( body=_("✅ Folder movido a nueva empresa: %s") % new_company.name, message_type='comment' ) except Exception as e: _logger.error(f"Error moviendo folder: {str(e)}") raise def _handle_structural_changes(self, vals): """Handle structural changes (partner_id, create_date) - rename entire structure""" if not self.google_drive_folder_id: return try: # Get current and new components current_components = self._get_folder_name_components() # Temporarily update the record to get new components temp_record = self.with_context(skip_google_drive_update=True) temp_vals = {} if 'partner_id' in vals: temp_vals['partner_id'] = vals['partner_id'] if 'create_date' in vals: temp_vals['create_date'] = vals['create_date'] if temp_vals: temp_record.write(temp_vals) new_components = temp_record._get_folder_name_components() # Check if any component changed components_changed = ( current_components['primary_name'] != new_components['primary_name'] or current_components['year'] != new_components['year'] ) if components_changed: _logger.info(f"Structural changes detected. Renaming folder structure.") self._rename_entire_folder_structure(current_components, new_components) else: _logger.info(f"No structural changes detected. Skipping rename.") except Exception as e: _logger.error(f"Error handling structural changes: {str(e)}") raise def _handle_name_change(self, new_name): """Handle simple name change - only rename opportunity folder""" if not self.google_drive_folder_id: return try: sanitized_new_name = self._sanitize_folder_name(new_name) self._rename_google_drive_folder(sanitized_new_name) except Exception as e: _logger.error(f"Error handling name change: {str(e)}") raise def _move_folder_to_new_parent(self, headers, new_parent_id): """Move entire folder structure to new parent in Google Drive""" try: # Get current folder info and navigate up to find the primary folder (company/contact) current_folder_id = self.google_drive_folder_id # Navigate up the hierarchy to find the primary folder primary_folder_id = self._find_primary_folder_id(headers, current_folder_id) if not primary_folder_id: raise UserError(_('Could not find primary folder in hierarchy')) # Get current parent of primary folder response = requests.get( f'https://www.googleapis.com/drive/v3/files/{primary_folder_id}?fields=parents', headers=headers, timeout=30 ) if response.status_code != 200: raise UserError(_('Failed to get primary folder information')) current_data = response.json() current_parents = current_data.get('parents', []) if not current_parents: raise UserError(_('Primary folder has no parent')) current_parent_id = current_parents[0] # Move the entire structure by moving the primary folder move_response = requests.patch( f'https://www.googleapis.com/drive/v3/files/{primary_folder_id}?addParents={new_parent_id}&removeParents={current_parent_id}', headers=headers, timeout=30 ) if move_response.status_code != 200: raise UserError(_('Failed to move folder structure to new parent')) _logger.info(f"Successfully moved entire folder structure from {current_parent_id} to {new_parent_id}") except Exception as e: _logger.error(f"Error moving folder structure: {str(e)}") raise def _find_primary_folder_id(self, headers, start_folder_id): """Find the primary folder (company/contact level) in the hierarchy""" try: current_id = start_folder_id # Navigate up to 3 levels to find the primary folder for _ in range(3): response = requests.get( f'https://www.googleapis.com/drive/v3/files/{current_id}?fields=name,parents', headers=headers, timeout=30 ) if response.status_code != 200: break folder_data = response.json() folder_name = folder_data.get('name', '') parent_ids = folder_data.get('parents', []) # Check if this is the primary folder (not year, not opportunity) # Primary folder is typically the company/contact name if not parent_ids: break # If this folder's name looks like a year (4 digits), continue up if folder_name.isdigit() and len(folder_name) == 4: current_id = parent_ids[0] continue # If this folder's name contains opportunity ID pattern, continue up if ' - ' in folder_name and folder_name.split(' - ')[0].isdigit(): current_id = parent_ids[0] continue # This should be the primary folder return current_id return None except Exception as e: _logger.error(f"Error finding primary folder: {str(e)}") return None def action_open_google_drive_folder(self): """Open Google Drive folder for this opportunity""" self.ensure_one() if not self.google_drive_folder_id: raise UserError(_('No Google Drive folder configured for this opportunity')) folder_url = f"https://drive.google.com/drive/folders/{self.google_drive_folder_id}" return { 'type': 'ir.actions.act_url', 'url': folder_url, 'target': 'new', } def action_create_google_drive_folder(self): """Create Google Drive folder structure for this opportunity""" self.ensure_one() if self.google_drive_folder_id: raise UserError(_('Google Drive folder already exists for this opportunity')) # Check if company has Google Drive folder configured root_folder_id = self._get_company_root_folder_id() if not root_folder_id: raise UserError(_('Google Drive CRM folder is not configured for this company. Please configure it in company settings.')) try: folder_structure = self._create_google_drive_folder_structure() # Store the initial structure and update URL self._store_initial_structure_and_update_url() return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Success'), 'message': _('Google Drive folder structure created successfully!'), 'type': 'success', 'sticky': False, } } except Exception as e: raise UserError(_('Failed to create Google Drive folder structure: %s') % str(e)) def action_upload_to_google_drive(self): """Upload documents to Google Drive""" self.ensure_one() if not self.google_drive_folder_id: raise UserError(_('Please create a Google Drive folder for this opportunity first')) try: # TODO: Implement Google Drive API call to upload documents # For now, just show a message return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Info'), 'message': _('Document upload to Google Drive will be implemented soon.'), 'type': 'info', 'sticky': False, } } except Exception as e: raise UserError(_('Failed to upload to Google Drive: %s') % str(e)) def action_recreate_google_drive_structure(self): """Manually rename the Google Drive folder structure""" self.ensure_one() if not self.google_drive_folder_id: raise UserError(_('No Google Drive folder exists for this opportunity. Please create one first.')) # Check if company has Google Drive folder configured root_folder_id = self._get_company_root_folder_id() if not root_folder_id: raise UserError(_('Google Drive CRM folder is not configured for this company. Please configure it in company settings.')) try: # Get expected components expected_components = self._get_folder_name_components() # Get current folder name from Google Drive access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } response = requests.get( f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}?fields=name', headers=headers, timeout=30 ) if response.status_code != 200: raise UserError(_('Failed to get current folder information from Google Drive')) current_folder_data = response.json() current_folder_name = current_folder_data.get('name', '') _logger.info(f"Current folder name in Google Drive: '{current_folder_name}'") _logger.info(f"Expected folder name: '{expected_components['opportunity_name']}'") # Create old components with current Google Drive name old_components = expected_components.copy() old_components['opportunity_name'] = current_folder_name # Rename the structure self._rename_entire_folder_structure(old_components, expected_components) # Update the stored structure expected_structure = self._build_structure_string(expected_components) self.with_context(skip_google_drive_update=True).write({ 'google_drive_folder_name': expected_structure }) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Success'), 'message': _('Google Drive folder structure renamed successfully!
' 'Folder ID: %s
' 'Old name: %s
' 'New name: %s') % (self.google_drive_folder_id, current_folder_name, expected_components['opportunity_name']), 'type': 'success', 'sticky': False, } } except Exception as e: _logger.error(f"Failed to rename Google Drive folder structure: {str(e)}") raise UserError(_('Failed to rename Google Drive folder structure: %s') % str(e)) def action_analyze_folder_structure(self): """Analyze current vs expected folder structure""" self.ensure_one() if not self.google_drive_folder_id: raise UserError(_('No Google Drive folder exists for this opportunity. Please create one first.')) try: # Get expected components expected_components = self._get_folder_name_components() # Get current folder information access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Analyze current structure current_structure = self._analyze_complete_folder_structure(headers) # Compare structures analysis = self._compare_folder_structures(expected_components, current_structure, headers) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Folder Structure Analysis'), 'message': analysis, 'type': 'info', 'sticky': True, } } except Exception as e: raise UserError(_('Failed to analyze folder structure: %s') % str(e)) def _analyze_current_folder_structure(self, headers): """Analyze the current folder structure in Google Drive""" current_folder_id = self.google_drive_folder_id # Get current folder info response = requests.get( f'https://www.googleapis.com/drive/v3/files/{current_folder_id}?fields=name,parents', headers=headers, timeout=30 ) if response.status_code != 200: return None folder_data = response.json() current_name = folder_data.get('name', '') parent_ids = folder_data.get('parents', []) # Navigate up the hierarchy structure = { 'opportunity_folder': { 'id': current_folder_id, 'name': current_name } } if parent_ids: # Get year folder year_response = requests.get( f'https://www.googleapis.com/drive/v3/files/{parent_ids[0]}?fields=name,parents', headers=headers, timeout=30 ) if year_response.status_code == 200: year_data = year_response.json() structure['year_folder'] = { 'id': parent_ids[0], 'name': year_data.get('name', '') } year_parent_ids = year_data.get('parents', []) if year_parent_ids: # Get primary folder (company/contact) primary_response = requests.get( f'https://www.googleapis.com/drive/v3/files/{year_parent_ids[0]}?fields=name', headers=headers, timeout=30 ) if primary_response.status_code == 200: primary_data = primary_response.json() structure['primary_folder'] = { 'id': year_parent_ids[0], 'name': primary_data.get('name', '') } return structure def _analyze_complete_folder_structure(self, headers): """Analyze the complete folder structure from root to opportunity""" current_folder_id = self.google_drive_folder_id # Get current folder info response = requests.get( f'https://www.googleapis.com/drive/v3/files/{current_folder_id}?fields=name,parents', headers=headers, timeout=30 ) if response.status_code != 200: return None folder_data = response.json() current_name = folder_data.get('name', '') parent_ids = folder_data.get('parents', []) # Build complete structure complete_structure = { 'opportunity_folder': { 'id': current_folder_id, 'name': current_name, 'level': 3 } } current_id = current_folder_id level = 3 # Opportunity level # Navigate up the hierarchy for _ in range(5): # Max 5 levels up if not parent_ids: break parent_id = parent_ids[0] # Get parent folder info parent_response = requests.get( f'https://www.googleapis.com/drive/v3/files/{parent_id}?fields=name,parents', headers=headers, timeout=30 ) if parent_response.status_code != 200: break parent_data = parent_response.json() parent_name = parent_data.get('name', '') parent_ids = parent_data.get('parents', []) level -= 1 if level == 2: # Year level complete_structure['year_folder'] = { 'id': parent_id, 'name': parent_name, 'level': level } elif level == 1: # Primary level (company/contact) complete_structure['primary_folder'] = { 'id': parent_id, 'name': parent_name, 'level': level } elif level == 0: # Root level complete_structure['root_folder'] = { 'id': parent_id, 'name': parent_name, 'level': level } current_id = parent_id return complete_structure def _compare_folder_structures(self, expected_components, current_structure, headers): """Compare expected vs current folder structure""" if not current_structure: return _('❌ Could not analyze current folder structure') analysis = f"📁 Complete Folder Structure Analysis

" # Expected structure analysis += f"Expected Structure:
" analysis += f"📁 [Root Folder] (MC Team)
" analysis += f"└── 📁 {expected_components['primary_name']} (Company/Contact)
" analysis += f" └── 📁 {expected_components['year']} (Year)
" analysis += f" └── 📁 {expected_components['opportunity_name']} (Opportunity)
" analysis += f" ├── 📁 Meets
" analysis += f" └── 📁 Archivos cliente

" # Current structure analysis += f"Current Structure in Google Drive:
" # Root folder if 'root_folder' in current_structure: root_name = current_structure['root_folder']['name'] analysis += f"📁 {root_name} (Root)
" else: analysis += f"📁 [Unknown Root]
" # Primary folder if 'primary_folder' in current_structure: primary_name = current_structure['primary_folder']['name'] analysis += f"└── 📁 {primary_name}" if primary_name != expected_components['primary_name']: analysis += f" ❌ (Expected: {expected_components['primary_name']})" else: analysis += " ✅" analysis += "
" else: analysis += f"└── 📁 [Missing Primary Folder] ❌
" # Year folder if 'year_folder' in current_structure: year_name = current_structure['year_folder']['name'] analysis += f" └── 📁 {year_name}" if year_name != expected_components['year']: analysis += f" ❌ (Expected: {expected_components['year']})" else: analysis += " ✅" analysis += "
" else: analysis += f" └── 📁 [Missing Year Folder] ❌
" # Opportunity folder if 'opportunity_folder' in current_structure: opp_name = current_structure['opportunity_folder']['name'] analysis += f" └── 📁 {opp_name}" if opp_name != expected_components['opportunity_name']: analysis += f" ❌ (Expected: {expected_components['opportunity_name']})" else: analysis += " ✅" analysis += "
" else: analysis += f" └── 📁 [Missing Opportunity Folder] ❌
" # Check subfolders if 'opportunity_folder' in current_structure: opp_id = current_structure['opportunity_folder']['id'] subfolders = self._get_subfolders(headers, opp_id) if subfolders: analysis += f" ├── 📁 Meets ✅
" analysis += f" └── 📁 Archivos cliente ✅
" else: analysis += f" ├── 📁 Meets ❌ (Missing)
" analysis += f" └── 📁 Archivos cliente ❌ (Missing)
" # Summary analysis += f"
Summary:
" correct_count = 0 total_count = 0 if 'primary_folder' in current_structure: total_count += 1 if current_structure['primary_folder']['name'] == expected_components['primary_name']: correct_count += 1 if 'year_folder' in current_structure: total_count += 1 if current_structure['year_folder']['name'] == expected_components['year']: correct_count += 1 if 'opportunity_folder' in current_structure: total_count += 1 if current_structure['opportunity_folder']['name'] == expected_components['opportunity_name']: correct_count += 1 if total_count == 3 and correct_count == 3: analysis += "✅ Complete structure is correct" else: analysis += f"❌ Structure has issues ({correct_count}/{total_count} correct). Use 'Rename Folder Structure' button to fix." return analysis def _get_subfolders(self, headers, parent_id): """Get subfolders of a parent folder""" params = { 'q': f"'{parent_id}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false", 'fields': 'files(id,name)', 'pageSize': 100 } try: response = requests.get( 'https://www.googleapis.com/drive/v3/files', headers=headers, params=params, timeout=30 ) if response.status_code == 200: data = response.json() return data.get('files', []) else: return [] except: return [] def _store_initial_structure_and_update_url(self): """Centralized method to store initial structure and update URL""" if self.google_drive_folder_id: expected_components = self._get_folder_name_components() expected_structure = self._build_structure_string(expected_components) self.with_context(skip_google_drive_update=True).write({ 'google_drive_folder_name': expected_structure }) # Update Google Drive URL if empty self._update_google_drive_url() # Copy URL to configured field if it's empty self._copy_google_drive_url_to_configured_field() _logger.info(f"Estructura inicial almacenada para oportunidad {self.id}") return True else: _logger.error(f"ERROR: _create_google_drive_folder_structure no asignó google_drive_folder_id para oportunidad {self.id}") return False def _generate_google_drive_url(self): """Generate Google Drive URL for the opportunity folder""" if self.google_drive_folder_id: return f"https://drive.google.com/drive/folders/{self.google_drive_folder_id}" return False def _update_google_drive_url(self): """Update the google_drive_url field if it's empty and we have a folder ID""" if self.google_drive_folder_id and not self.google_drive_url: url = self._generate_google_drive_url() if url: self.with_context(skip_google_drive_update=True).write({ 'google_drive_url': url }) _logger.info(f"Updated Google Drive URL for opportunity {self.id}: {url}") def _extract_folder_id_from_url(self, url): """Extract folder ID from Google Drive URL""" if not url: return None # Handle different Google Drive URL formats import re # Format: https://drive.google.com/drive/folders/FOLDER_ID folder_pattern = r'drive\.google\.com/drive/folders/([a-zA-Z0-9_-]+)' match = re.search(folder_pattern, url) if match: return match.group(1) # Format: https://drive.google.com/open?id=FOLDER_ID open_pattern = r'drive\.google\.com/open\?id=([a-zA-Z0-9_-]+)' match = re.search(open_pattern, url) if match: return match.group(1) return None def _get_configured_field_name(self): """Get the field name configured in company settings""" if not self.company_id or not self.company_id.google_drive_crm_field_id: return None return self.company_id.google_drive_crm_field_id.name def _get_configured_field_value(self): """Get the value of the configured field""" field_name = self._get_configured_field_name() if not field_name: return None return getattr(self, field_name, None) def _set_configured_field_value(self, value): """Set the value of the configured field""" field_name = self._get_configured_field_name() if not field_name: return False self.with_context(skip_google_drive_update=True).write({field_name: value}) return True def _copy_google_drive_url_to_configured_field(self): """Copy google_drive_url to the configured field if it's empty""" if not self.google_drive_url: return False field_name = self._get_configured_field_name() if not field_name: return False current_value = getattr(self, field_name, None) if not current_value: # Solo si está vacío self._set_configured_field_value(self.google_drive_url) _logger.info(f"Copied google_drive_url to {field_name} for opportunity {self.id}") return True return False def _validate_folder_id_with_google_drive(self, folder_id): """Validate if the folder ID exists and is accessible in Google Drive""" try: access_token = self._get_google_drive_access_token() headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # Test access to the specific folder - try both regular Drive and Shared Drive endpoints folder_found = False folder_name = 'Unknown' # First, try the regular Drive API endpoint response = requests.get( f'https://www.googleapis.com/drive/v3/files/{folder_id}?fields=id,name,mimeType', headers=headers, timeout=10 ) if response.status_code == 200: folder_data = response.json() if folder_data.get('mimeType') == 'application/vnd.google-apps.folder': _logger.info(f"✅ Folder ID {folder_id} validated successfully in Google Drive") folder_found = True folder_name = folder_data.get('name', 'Unknown') else: _logger.warning(f"❌ ID {folder_id} exists but is not a folder") return False, "Not a folder" elif response.status_code == 404: # If not found in regular Drive, try Shared Drive endpoint shared_drive_response = requests.get( f'https://www.googleapis.com/drive/v3/drives/{folder_id}?fields=id,name', headers=headers, timeout=10 ) if shared_drive_response.status_code == 200: shared_drive_data = shared_drive_response.json() folder_name = shared_drive_data.get('name', 'Unknown') folder_found = True # For shared drives, we also need to check if we can access files within it files_in_drive_url = f'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true&includeItemsFromAllDrives=true&corpora=drive&driveId={folder_id}&pageSize=1' files_response = requests.get(files_in_drive_url, headers=headers, timeout=10) if files_response.status_code != 200: _logger.warning(f"❌ Access denied to Shared Drive {folder_id}") return False, "Access denied to Shared Drive" _logger.info(f"✅ Shared Drive ID {folder_id} validated successfully") elif shared_drive_response.status_code == 403: _logger.warning(f"❌ Access denied to Shared Drive {folder_id}") return False, "Access denied to Shared Drive" elif shared_drive_response.status_code == 404: _logger.warning(f"❌ Folder ID {folder_id} not found in Google Drive or Shared Drives") return False, "Not found" else: _logger.warning(f"❌ Shared Drive API error: {shared_drive_response.status_code}") return False, f"Shared Drive API error: {shared_drive_response.status_code}" elif response.status_code == 403: _logger.warning(f"❌ Access denied to folder ID {folder_id}") return False, "Access denied" elif response.status_code == 401: _logger.error(f"❌ OAuth token expired or invalid") return False, "Authentication error" else: _logger.warning(f"❌ Google Drive API error: {response.status_code}") return False, f"API error: {response.status_code}" if folder_found: return True, folder_name else: return False, "Not found" except requests.exceptions.Timeout: _logger.error(f"❌ Timeout validating folder ID {folder_id}") return False, "Timeout" except requests.exceptions.ConnectionError: _logger.error(f"❌ Connection error validating folder ID {folder_id}") return False, "Connection error" except Exception as e: _logger.error(f"❌ Error validating folder ID {folder_id}: {str(e)}") return False, str(e)