crm_lead.py 79 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import logging
  4. import re
  5. from datetime import datetime, timedelta
  6. from odoo import fields, models, api, _
  7. from odoo.exceptions import UserError
  8. _logger = logging.getLogger(__name__)
  9. # Cache for Google Drive API responses (5 minutes)
  10. _GOOGLE_DRIVE_CACHE = {}
  11. def _clear_google_drive_cache():
  12. """Clear expired cache entries"""
  13. global _GOOGLE_DRIVE_CACHE
  14. current_time = datetime.now()
  15. expired_keys = [key for key, entry in _GOOGLE_DRIVE_CACHE.items() if current_time >= entry['expires']]
  16. for key in expired_keys:
  17. del _GOOGLE_DRIVE_CACHE[key]
  18. class CrmLead(models.Model):
  19. _inherit = 'crm.lead'
  20. google_drive_documents_count = fields.Integer(
  21. string='Google Drive Documents',
  22. compute='_compute_google_drive_documents_count',
  23. help='Number of documents in Google Drive for this opportunity'
  24. )
  25. google_drive_folder_id = fields.Char(
  26. string='Google Drive Folder ID',
  27. help='ID del folder específico en Google Drive para esta oportunidad',
  28. readonly=True
  29. )
  30. google_drive_folder_name = fields.Char(
  31. string='Google Drive Folder Name',
  32. help='Nombre del folder en Google Drive para esta oportunidad',
  33. readonly=True
  34. )
  35. google_drive_url = fields.Char(
  36. string='URL Drive',
  37. help='URL de la carpeta de Google Drive de la oportunidad'
  38. )
  39. @api.depends('google_drive_folder_id')
  40. def _compute_google_drive_documents_count(self):
  41. """Compute the number of documents in Google Drive with caching"""
  42. for record in self:
  43. if record.google_drive_folder_id:
  44. cache_key = f'doc_count_{record.google_drive_folder_id}'
  45. if cache_key in _GOOGLE_DRIVE_CACHE:
  46. cache_entry = _GOOGLE_DRIVE_CACHE[cache_key]
  47. if datetime.now() < cache_entry['expires']:
  48. record.google_drive_documents_count = cache_entry['count']
  49. continue
  50. # TODO: Implement Google Drive API call to count documents
  51. count = 0
  52. _GOOGLE_DRIVE_CACHE[cache_key] = {
  53. 'count': count,
  54. 'expires': datetime.now() + timedelta(seconds=300)
  55. }
  56. record.google_drive_documents_count = count
  57. else:
  58. record.google_drive_documents_count = 0
  59. # ============================================================================
  60. # MÉTODOS DE CONFIGURACIÓN Y UTILIDADES
  61. # ============================================================================
  62. def _get_company_root_folder_id(self):
  63. """Get the company's root Google Drive folder ID"""
  64. company = self.company_id
  65. return (company.google_drive_crm_folder_id
  66. if company and company.google_drive_crm_enabled else None)
  67. def _extract_folder_id_from_url(self, url):
  68. """Extract folder ID from Google Drive URL - Now uses generic service"""
  69. drive_service = self.env['google.drive.service']
  70. return drive_service.extract_folder_id_from_url(url)
  71. def _get_configured_field_value(self):
  72. """Get value from the configured Google Drive field in company settings"""
  73. field_id = self.company_id.google_drive_crm_field_id
  74. return getattr(self, field_id.name, None) if field_id else None
  75. def _set_configured_field_value(self, value):
  76. """Set the value of the configured field"""
  77. field_id = self.company_id.google_drive_crm_field_id
  78. if field_id:
  79. self.with_context(skip_google_drive_update=True).write({field_id.name: value})
  80. return True
  81. return False
  82. def _sanitize_folder_name(self, name):
  83. """Sanitize folder name to be Google Drive compatible - Now uses generic service"""
  84. drive_service = self.env['google.drive.service']
  85. return drive_service.sanitize_folder_name(name)
  86. def _build_structure_string(self, components):
  87. """Build a string representation of the folder structure"""
  88. return f"{components['primary_name']}/{components['year']}/{components['opportunity_name']}"
  89. def _update_folder_structure_fields(self, folder_id=None, structure_components=None):
  90. """Helper method to update folder structure fields without triggering loops"""
  91. update_vals = {}
  92. if folder_id:
  93. update_vals['google_drive_folder_id'] = folder_id
  94. update_vals['google_drive_url'] = f"https://drive.google.com/drive/folders/{folder_id}"
  95. if structure_components:
  96. update_vals['google_drive_folder_name'] = self._build_structure_string(structure_components)
  97. if update_vals:
  98. self.with_context(skip_google_drive_update=True).write(update_vals)
  99. def _post_folder_message(self, message, message_type='comment'):
  100. """Helper method to post folder-related messages"""
  101. self.message_post(
  102. body=_(message),
  103. message_type=message_type
  104. )
  105. def _get_current_folder_components(self):
  106. """Helper method to get current folder components"""
  107. return self._get_folder_name_components()
  108. def _get_expected_folder_structure(self):
  109. """Helper method to get expected folder structure string"""
  110. components = self._get_current_folder_components()
  111. return self._build_structure_string(components)
  112. # ============================================================================
  113. # MÉTODOS DE VALIDACIÓN Y PRERREQUISITOS
  114. # ============================================================================
  115. def _validate_folder_creation_prerequisites(self):
  116. """Validate prerequisites before creating Google Drive folder"""
  117. if not self._get_company_root_folder_id():
  118. self.message_post(
  119. body=_("⚠️ Google Drive folder creation skipped: Company doesn't have Google Drive configured."),
  120. message_type='comment'
  121. )
  122. return False
  123. if not self.partner_id:
  124. self.message_post(
  125. body=_("⚠️ Google Drive folder creation skipped: No contact associated with this opportunity."),
  126. message_type='comment'
  127. )
  128. return False
  129. return True
  130. def _try_get_existing_folder_id(self):
  131. """Try to get existing folder ID from various sources"""
  132. # Check if we already have a folder ID
  133. if self.google_drive_folder_id:
  134. return self.google_drive_folder_id
  135. # Try to extract from configured field
  136. field_value = self._get_configured_field_value()
  137. if field_value:
  138. folder_id = self._extract_folder_id_from_url(field_value)
  139. if folder_id:
  140. _logger.info(f"Found folder ID from configured field: {folder_id}")
  141. return folder_id
  142. # Try to extract from google_drive_url field
  143. if self.google_drive_url:
  144. folder_id = self._extract_folder_id_from_url(self.google_drive_url)
  145. if folder_id:
  146. _logger.info(f"Found folder ID from URL field: {folder_id}")
  147. return folder_id
  148. return None
  149. def _validate_folder_id_with_google_drive(self, folder_id):
  150. """Validate if the folder ID exists and is accessible in Google Drive - Now uses generic service"""
  151. drive_service = self.env['google.drive.service']
  152. return drive_service.validate_folder_id_with_google_drive(folder_id)
  153. # ============================================================================
  154. # MÉTODOS DE COMPONENTES DE CARPETA
  155. # ============================================================================
  156. def _get_folder_name_components(self):
  157. """Get the components for folder naming based on partner/contact information"""
  158. # Priority 1: partner_id.parent_id.name (empresa padre del contacto)
  159. if self.partner_id and self.partner_id.parent_id and self.partner_id.parent_id.name:
  160. primary_name = self.partner_id.parent_id.name
  161. # Priority 2: partner_id.company_name
  162. elif self.partner_id and self.partner_id.company_name:
  163. primary_name = self.partner_id.company_name
  164. # Priority 3: partner_id.name
  165. elif self.partner_id:
  166. primary_name = self.partner_id.name
  167. else:
  168. primary_name = "Sin Contacto"
  169. if not primary_name:
  170. raise UserError(_('No company or contact name available. Please assign a contact with company information.'))
  171. return {
  172. 'primary_name': self._sanitize_folder_name(primary_name),
  173. 'opportunity_name': self._sanitize_folder_name(self.name),
  174. 'year': str(self.create_date.year) if self.create_date else str(datetime.now().year)
  175. }
  176. def _check_partner_name_changes(self, vals):
  177. """Check if partner or partner's company name has changed"""
  178. if 'partner_id' not in vals:
  179. return False
  180. old_partner = self.partner_id
  181. new_partner_id = vals['partner_id']
  182. if not new_partner_id or old_partner.id == new_partner_id:
  183. return False
  184. new_partner = self.env['res.partner'].browse(new_partner_id)
  185. # Check if partner's company name changed
  186. old_company_name = old_partner.parent_id.name if old_partner.parent_id else old_partner.name
  187. new_company_name = new_partner.parent_id.name if new_partner.parent_id else new_partner.name
  188. return old_company_name != new_company_name
  189. # ============================================================================
  190. # MÉTODOS DE CREACIÓN DE CARPETAS
  191. # ============================================================================
  192. def _create_google_drive_folder_structure(self):
  193. """Create the complete Google Drive folder structure for this opportunity"""
  194. self.ensure_one()
  195. if not self._validate_folder_creation_prerequisites():
  196. return None
  197. # Try to get existing folder ID first
  198. existing_folder_id = self._try_get_existing_folder_id()
  199. if existing_folder_id:
  200. is_valid, _ = self._validate_folder_id_with_google_drive(existing_folder_id)
  201. if is_valid:
  202. self._store_folder_info(existing_folder_id)
  203. self.message_post(
  204. body=_("✅ Using existing Google Drive folder: %s") % existing_folder_id,
  205. message_type='comment'
  206. )
  207. return True
  208. # Check if structure already exists before creating
  209. existing_structure = self._find_existing_folder_structure()
  210. if existing_structure:
  211. self._store_folder_info(existing_structure['opportunity_folder_id'])
  212. self.message_post(
  213. body=_("✅ Using existing folder structure: %s") % existing_structure['opportunity_folder_id'],
  214. message_type='comment'
  215. )
  216. return existing_structure
  217. # Create new folder structure
  218. components = self._get_folder_name_components()
  219. try:
  220. folder_structure = self._create_folder_structure_batch(components)
  221. self._store_folder_info(folder_structure['opportunity_folder_id'])
  222. return folder_structure
  223. except Exception as e:
  224. _logger.error(f"Failed to create folder structure for opportunity {self.id}: {str(e)}")
  225. raise UserError(_('Failed to create Google Drive folder structure: %s') % str(e))
  226. def _create_folder_structure_batch(self, components):
  227. """Create folder structure in batch, reusing existing levels to avoid duplicates"""
  228. drive_service = self.env['google.drive.service']
  229. root_folder_id = self._get_company_root_folder_id()
  230. # Level 1: Primary folder (empresa cliente) - reutilizar si existe
  231. primary_folder_id, was_reused = self._create_or_get_folder_crm(root_folder_id, components['primary_name'])
  232. _logger.info(f"✅ Primary folder '{components['primary_name']}': {'REUSED' if was_reused else 'CREATED'} (ID: {primary_folder_id})")
  233. # Level 2: Year folder - reutilizar si existe
  234. year_folder_id, was_reused = self._create_or_get_folder_crm(primary_folder_id, components['year'])
  235. _logger.info(f"✅ Year folder '{components['year']}': {'REUSED' if was_reused else 'CREATED'} (ID: {year_folder_id})")
  236. # Level 3: Opportunity folder - siempre crear (único por oportunidad)
  237. opportunity_folder_id, was_reused = self._create_or_get_folder_crm(year_folder_id, components['opportunity_name'])
  238. _logger.info(f"✅ Opportunity folder '{components['opportunity_name']}': {'REUSED' if was_reused else 'CREATED'} (ID: {opportunity_folder_id})")
  239. # Subfolders - reutilizar si existen
  240. meets_folder_id, _ = self._create_or_get_folder_crm(opportunity_folder_id, 'Meets')
  241. archivos_folder_id, _ = self._create_or_get_folder_crm(opportunity_folder_id, 'Archivos cliente')
  242. return {
  243. 'opportunity_folder_id': opportunity_folder_id,
  244. 'meets_folder_id': meets_folder_id,
  245. 'archivos_folder_id': archivos_folder_id
  246. }
  247. def _create_or_get_folder_crm(self, parent_folder_id, folder_name):
  248. """Create a folder or get existing one by name, avoiding duplicates - Now uses generic service"""
  249. drive_service = self.env['google.drive.service']
  250. _logger.info(f"🔍 Checking for existing folder '{folder_name}' in parent {parent_folder_id}")
  251. # First check if folder already exists
  252. existing_folders = drive_service.find_folders_by_name(parent_folder_id, f'^{folder_name}$')
  253. was_reused = len(existing_folders) > 0
  254. if was_reused:
  255. folder_id = existing_folders[0]['id']
  256. _logger.info(f"🔄 REUSING existing folder '{folder_name}' (ID: {folder_id})")
  257. else:
  258. # Create new folder
  259. result = drive_service.create_folder(folder_name, parent_folder_id)
  260. if result.get('success'):
  261. folder_id = result.get('folder_id')
  262. _logger.info(f"📁 CREATED new folder '{folder_name}' (ID: {folder_id})")
  263. else:
  264. error_msg = result.get('error', 'Unknown error')
  265. raise UserError(_('Failed to create Google Drive folder "%s": %s') % (folder_name, error_msg))
  266. return folder_id, was_reused
  267. def _find_existing_folder_structure(self):
  268. """Find existing folder structure to avoid creating duplicates"""
  269. components = self._get_folder_name_components()
  270. drive_service = self.env['google.drive.service']
  271. root_folder_id = self._get_company_root_folder_id()
  272. try:
  273. # Level 1: Find primary folder (empresa cliente)
  274. primary_folders = drive_service.find_folders_by_name(root_folder_id, f"^{components['primary_name']}$")
  275. if not primary_folders:
  276. return None
  277. primary_folder_id = primary_folders[0]['id']
  278. # Level 2: Find year folder
  279. year_folders = drive_service.find_folders_by_name(primary_folder_id, f"^{components['year']}$")
  280. if not year_folders:
  281. return None
  282. year_folder_id = year_folders[0]['id']
  283. # Level 3: Find opportunity folder
  284. opportunity_folders = drive_service.find_folders_by_name(year_folder_id, f"^{components['opportunity_name']}$")
  285. if not opportunity_folders:
  286. return None
  287. opportunity_folder_id = opportunity_folders[0]['id']
  288. # Check if subfolders exist
  289. meets_folders = drive_service.find_folders_by_name(opportunity_folder_id, r'^Meets$')
  290. archivos_folders = drive_service.find_folders_by_name(opportunity_folder_id, r'^Archivos cliente$')
  291. _logger.info(f"🔄 Found existing complete folder structure for opportunity '{components['opportunity_name']}'")
  292. return {
  293. 'opportunity_folder_id': opportunity_folder_id,
  294. 'meets_folder_id': meets_folders[0]['id'] if meets_folders else None,
  295. 'archivos_folder_id': archivos_folders[0]['id'] if archivos_folders else None
  296. }
  297. except Exception as e:
  298. _logger.warning(f"Error finding existing folder structure: {str(e)}")
  299. return None
  300. def _store_folder_info(self, folder_id):
  301. """Store folder information and update URL"""
  302. expected_components = self._get_current_folder_components()
  303. # Update folder structure fields
  304. self._update_folder_structure_fields(
  305. folder_id=folder_id,
  306. structure_components=expected_components
  307. )
  308. # Copy URL to configured field if empty
  309. if not self._get_configured_field_value():
  310. self._set_configured_field_value(self.google_drive_url)
  311. # ============================================================================
  312. # MÉTODOS DE ACTUALIZACIÓN Y RENOMBRADO
  313. # ============================================================================
  314. def _process_google_drive_updates(self, vals, old_values=None):
  315. """Unified method to process Google Drive updates"""
  316. try:
  317. # Check if we need to create folder
  318. if self._should_create_folder(vals) and self._validate_folder_creation_prerequisites():
  319. self._create_google_drive_folder_structure()
  320. self._post_folder_message("✅ Google Drive folder created automatically")
  321. # If we have a folder, verify and update structure
  322. if self.google_drive_folder_id:
  323. self._verify_and_update_folder_structure(vals, old_values)
  324. except Exception as e:
  325. _logger.error(f"Error processing Google Drive updates for record {self.id}: {str(e)}")
  326. self._post_folder_message(f"❌ Error updating Google Drive: {str(e)}")
  327. def _should_create_folder(self, vals):
  328. """Helper method to determine if folder should be created"""
  329. if 'stage_id' in vals and not self.google_drive_folder_id:
  330. return True
  331. if not self.google_drive_folder_id:
  332. company = self.company_id
  333. return (company.google_drive_crm_enabled and
  334. company.google_drive_crm_stage_id and
  335. self.stage_id.id == company.google_drive_crm_stage_id.id)
  336. return False
  337. def _verify_and_update_folder_structure(self, vals, old_values=None):
  338. """Unified method to verify and update folder structure"""
  339. # Handle company change (move folder to new company, don't rename)
  340. if 'company_id' in vals and self.google_drive_folder_id:
  341. new_company_id = vals['company_id']
  342. if old_values and new_company_id != old_values.get('company_id'):
  343. self._move_folder_to_new_company(new_company_id)
  344. # Don't update folder name - just move it
  345. return # Exit early after moving folder
  346. # Handle partner changes (this should trigger folder rename)
  347. if 'partner_id' in vals and self.google_drive_folder_id:
  348. old_partner_id = old_values.get('partner_id') if old_values else self.partner_id.id
  349. new_partner_id = vals['partner_id']
  350. if old_partner_id != new_partner_id:
  351. # Partner changed - rename folder structure
  352. self._rename_entire_folder_structure()
  353. # Update stored structure
  354. expected_components = self._get_current_folder_components()
  355. self._update_folder_structure_fields(structure_components=expected_components)
  356. self._post_folder_message("✅ Google Drive folder renamed due to partner change")
  357. return
  358. # Handle partner parent changes (when contact's company changes)
  359. if 'partner_id' in vals and self.google_drive_folder_id:
  360. old_partner_id = old_values.get('partner_id') if old_values else self.partner_id.id
  361. new_partner_id = vals['partner_id']
  362. if old_partner_id == new_partner_id:
  363. # Same partner, but check if their parent (company) changed
  364. old_partner = self.env['res.partner'].browse(old_partner_id) if old_partner_id else None
  365. new_partner = self.env['res.partner'].browse(new_partner_id) if new_partner_id else None
  366. if old_partner and new_partner:
  367. old_parent_id = old_partner.parent_id.id if old_partner.parent_id else None
  368. new_parent_id = new_partner.parent_id.id if new_partner.parent_id else None
  369. if old_parent_id != new_parent_id:
  370. # Partner's parent (company) changed - rename folder structure
  371. _logger.info(f"🔄 Partner's parent company changed from {old_parent_id} to {new_parent_id}. Renaming folder structure.")
  372. self._rename_entire_folder_structure()
  373. # Update stored structure
  374. expected_components = self._get_current_folder_components()
  375. self._update_folder_structure_fields(structure_components=expected_components)
  376. self._post_folder_message("✅ Google Drive folder renamed due to contact's company change")
  377. return
  378. # Handle other structure changes (name changes, etc.)
  379. expected_components = self._get_current_folder_components()
  380. expected_structure = self._get_expected_folder_structure()
  381. current_structure = (old_values.get('google_drive_folder_name', '') if old_values
  382. else self.google_drive_folder_name or '')
  383. if current_structure != expected_structure:
  384. # Rename structure
  385. self._rename_entire_folder_structure(new_components=expected_components)
  386. # Update stored structure
  387. self._update_folder_structure_fields(structure_components=expected_components)
  388. self._post_folder_message("✅ Google Drive folder structure updated")
  389. def _rename_entire_folder_structure(self, old_components=None, new_components=None):
  390. """Unified method to rename folder structure with robust error handling"""
  391. if not self.google_drive_folder_id:
  392. return
  393. try:
  394. drive_service = self.env['google.drive.service']
  395. _logger.info(f"🔄 Starting folder structure rename for opportunity {self.id}")
  396. # First validate that the current folder exists
  397. validation = drive_service.validate_folder_id(self.google_drive_folder_id)
  398. if not validation.get('valid'):
  399. _logger.warning(f"Cannot rename folder {self.google_drive_folder_id}: folder not found or not accessible")
  400. raise UserError(_('Cannot rename folder: folder not found or not accessible'))
  401. # Get current structure if needed
  402. if old_components is None and new_components is not None:
  403. current_structure = self._analyze_crm_folder_structure(self.google_drive_folder_id)
  404. if not current_structure:
  405. raise UserError(_('Could not analyze current folder structure'))
  406. old_components = {
  407. 'primary_name': current_structure.get('primary_folder', {}).get('name', ''),
  408. 'year': current_structure.get('year_folder', {}).get('name', ''),
  409. 'opportunity_name': current_structure.get('opportunity_folder', {}).get('name', '')
  410. }
  411. # Get new components if needed
  412. if new_components is None:
  413. new_components = self._get_current_folder_components()
  414. # Validate that we have valid components
  415. if not new_components:
  416. raise UserError(_('Could not determine new folder structure components'))
  417. current_folder_id = self.google_drive_folder_id
  418. # SOLUCIÓN ROBUSTA: En lugar de buscar el primary folder, vamos a recrear la estructura
  419. # Esto evita problemas con folders que ya no existen
  420. _logger.info(f"🔄 Using robust approach: recreating folder structure")
  421. # Get company root folder
  422. company_root_folder_id = self._get_company_root_folder_id()
  423. if not company_root_folder_id:
  424. raise UserError(_('Company root folder not configured'))
  425. # Create new structure with new components
  426. _logger.info(f"🔄 Creating new folder structure with components: {new_components}")
  427. # Create primary folder
  428. new_primary_folder_id, was_reused = self._create_or_get_folder_crm(company_root_folder_id, new_components['primary_name'])
  429. _logger.info(f"✅ Primary folder created/found: {new_primary_folder_id}")
  430. # Create year folder
  431. new_year_folder_id, was_reused = self._create_or_get_folder_crm(new_primary_folder_id, new_components['year'])
  432. _logger.info(f"✅ Year folder created/found: {new_year_folder_id}")
  433. # Validate current opportunity folder exists before moving
  434. _logger.info(f"🔍 Validating current opportunity folder: {current_folder_id}")
  435. current_validation = drive_service.validate_folder_id(current_folder_id)
  436. if not current_validation.get('valid'):
  437. _logger.warning(f"⚠️ Current opportunity folder {current_folder_id} is not accessible: {current_validation.get('error')}")
  438. # If folder doesn't exist, we need to create a new one
  439. _logger.info(f"🔄 Creating new opportunity folder in the correct structure")
  440. # Create new opportunity folder with correct name
  441. new_opportunity_folder_id, was_reused = self._create_or_get_folder_crm(new_year_folder_id, new_components['opportunity_name'])
  442. # Update the stored folder ID to the new folder
  443. self._update_folder_structure_fields(
  444. folder_id=new_opportunity_folder_id,
  445. structure_components=new_components
  446. )
  447. _logger.info(f"✅ Created new opportunity folder: {new_opportunity_folder_id}")
  448. return
  449. # Move current opportunity folder to new structure
  450. _logger.info(f"🔄 Moving opportunity folder from old structure to new structure")
  451. move_result = drive_service.move_folder(current_folder_id, new_year_folder_id)
  452. if not move_result.get('success'):
  453. _logger.error(f"❌ Failed to move opportunity folder: {move_result.get('error')}")
  454. raise UserError(_('Failed to move opportunity folder: %s') % move_result.get('error', 'Unknown error'))
  455. _logger.info(f"✅ Successfully moved opportunity folder to new structure")
  456. # Update the stored folder information
  457. self._update_folder_structure_fields(structure_components=new_components)
  458. _logger.info(f"✅ Folder structure rename completed successfully")
  459. except Exception as e:
  460. _logger.error(f"Error renaming folder structure: {str(e)}")
  461. raise
  462. def _move_folder_to_new_company(self, new_company_id):
  463. """Move only this opportunity's folder to new company"""
  464. if not self.google_drive_folder_id:
  465. return
  466. new_company = self.env['res.company'].browse(new_company_id)
  467. new_root_folder_id = new_company.google_drive_crm_folder_id
  468. if not new_root_folder_id:
  469. self._post_folder_message("⚠️ No se puede mover: Nueva empresa no tiene Google Drive configurado.")
  470. return
  471. try:
  472. drive_service = self.env['google.drive.service']
  473. # First validate that the current folder exists
  474. validation = drive_service.validate_folder_id(self.google_drive_folder_id)
  475. if not validation.get('valid'):
  476. _logger.warning(f"Cannot move folder {self.google_drive_folder_id}: folder not found or not accessible")
  477. self._post_folder_message("⚠️ No se puede mover: La carpeta actual no existe o no es accesible.")
  478. return
  479. # Get current opportunity folder components
  480. current_components = self._get_current_folder_components()
  481. # Create new structure in the new company
  482. new_primary_folder_id, _ = self._create_or_get_folder_crm(new_root_folder_id, current_components['primary_name'])
  483. new_year_folder_id, _ = self._create_or_get_folder_crm(new_primary_folder_id, current_components['year'])
  484. # Move only the opportunity folder (not the entire primary folder)
  485. result = drive_service.move_folder(self.google_drive_folder_id, new_year_folder_id)
  486. if not result.get('success'):
  487. raise UserError(_('Failed to move folder to new company: %s') % result.get('error', 'Unknown error'))
  488. # Update the stored folder ID to the new location
  489. self._update_folder_structure_fields(
  490. folder_id=self.google_drive_folder_id, # Same ID, new location
  491. structure_components=current_components
  492. )
  493. self._post_folder_message(f"✅ Oportunidad movida a nueva empresa: {new_company.name}")
  494. except Exception as e:
  495. _logger.error(f"Error moviendo folder: {str(e)}")
  496. # Don't raise the exception, just log it and continue
  497. self._post_folder_message(f"⚠️ Error moviendo carpeta: {str(e)}")
  498. # ============================================================================
  499. # MÉTODOS ESPECÍFICOS DE CRM
  500. # ============================================================================
  501. def _find_primary_folder_id_crm(self, start_folder_id):
  502. """Find the primary folder (company/contact level) in the hierarchy"""
  503. try:
  504. drive_service = self.env['google.drive.service']
  505. _logger.info(f"🔍 Finding primary folder starting from: {start_folder_id}")
  506. # First validate the start folder exists
  507. validation = drive_service.validate_folder_id(start_folder_id)
  508. if not validation.get('valid'):
  509. _logger.error(f"❌ Start folder {start_folder_id} is not valid: {validation.get('error')}")
  510. return None
  511. hierarchy = drive_service.navigate_folder_hierarchy(start_folder_id, max_levels=5)
  512. _logger.info(f"📁 Hierarchy found: {len(hierarchy)} levels")
  513. for folder_info in hierarchy:
  514. folder_name = folder_info.get('name', '')
  515. level = folder_info.get('level', 0)
  516. folder_id = folder_info.get('id', '')
  517. _logger.info(f"📂 Level {level}: {folder_name} (ID: {folder_id})")
  518. if level == 0:
  519. continue
  520. if not folder_name.isdigit() and folder_name not in ['Meets', 'Archivos cliente']:
  521. # Validate this folder exists before checking its children
  522. folder_validation = drive_service.validate_folder_id(folder_id)
  523. if not folder_validation.get('valid'):
  524. _logger.warning(f"⚠️ Folder {folder_id} ({folder_name}) is not accessible, skipping")
  525. continue
  526. year_folders = drive_service.find_folders_by_name(folder_id, r'^\d{4}$')
  527. if year_folders:
  528. _logger.info(f"✅ Found primary folder: {folder_name} (ID: {folder_id})")
  529. return folder_id
  530. else:
  531. _logger.info(f"ℹ️ Folder {folder_name} has no year folders, not primary")
  532. _logger.warning(f"❌ No primary folder found in hierarchy")
  533. return None
  534. except Exception as e:
  535. _logger.error(f"Error finding primary folder (CRM): {str(e)}")
  536. return None
  537. def _find_year_folder_id_crm(self, primary_folder_id, year):
  538. """Find the year folder within the primary folder"""
  539. try:
  540. drive_service = self.env['google.drive.service']
  541. year_folders = drive_service.find_folders_by_name(primary_folder_id, f'^{year}$')
  542. return year_folders[0]['id'] if year_folders else None
  543. except Exception as e:
  544. _logger.error(f"Error finding year folder (CRM): {str(e)}")
  545. return None
  546. def _analyze_crm_folder_structure(self, folder_id):
  547. """Analyze the complete folder structure from root to opportunity - COMPLETELY REWRITTEN"""
  548. try:
  549. drive_service = self.env['google.drive.service']
  550. # Step 1: Validate the current folder
  551. validation = drive_service.validate_folder_id(folder_id)
  552. if not validation.get('valid'):
  553. _logger.warning(f"Folder {folder_id} is not valid or accessible")
  554. return None
  555. current_name = validation.get('name', '')
  556. _logger.info(f"🔍 Starting analysis for folder: {current_name} (ID: {folder_id})")
  557. # Step 2: Get the complete hierarchy
  558. hierarchy = drive_service.navigate_folder_hierarchy(folder_id, max_levels=10)
  559. _logger.info(f"📊 Retrieved hierarchy: {len(hierarchy)} levels")
  560. if not hierarchy:
  561. _logger.warning(f"Could not retrieve hierarchy for folder {folder_id}")
  562. return {
  563. 'opportunity_folder': {
  564. 'id': folder_id,
  565. 'name': current_name,
  566. 'level': 0,
  567. 'found': True
  568. },
  569. 'year_folder': {'found': False},
  570. 'primary_folder': {'found': False},
  571. 'root_folder': {'found': False}
  572. }
  573. # Step 3: Initialize structure with all components as not found
  574. structure = {
  575. 'opportunity_folder': {'found': False},
  576. 'year_folder': {'found': False},
  577. 'primary_folder': {'found': False},
  578. 'root_folder': {'found': False}
  579. }
  580. # Step 4: Analyze each level in the hierarchy
  581. for folder_info in hierarchy:
  582. folder_name = folder_info.get('name', '')
  583. level = folder_info.get('level', 0)
  584. folder_id_info = folder_info.get('id', '')
  585. _logger.info(f"📂 Analyzing Level {level}: '{folder_name}' (ID: {folder_id_info})")
  586. # Identify folder type based on level and name patterns
  587. if level == 0:
  588. # This is the opportunity folder (the one we're analyzing)
  589. structure['opportunity_folder'] = {
  590. 'id': folder_id_info,
  591. 'name': folder_name,
  592. 'level': level,
  593. 'found': True
  594. }
  595. _logger.info(f"✅ Identified opportunity folder: {folder_name}")
  596. elif level == 1:
  597. # This could be year folder or primary folder
  598. if folder_name.isdigit() and len(folder_name) == 4:
  599. # It's a year folder
  600. structure['year_folder'] = {
  601. 'id': folder_id_info,
  602. 'name': folder_name,
  603. 'level': level,
  604. 'found': True
  605. }
  606. _logger.info(f"✅ Identified year folder: {folder_name}")
  607. elif folder_name not in ['Meets', 'Archivos cliente']:
  608. # It's a primary folder (company/contact)
  609. structure['primary_folder'] = {
  610. 'id': folder_id_info,
  611. 'name': folder_name,
  612. 'level': level,
  613. 'found': True
  614. }
  615. _logger.info(f"✅ Identified primary folder: {folder_name}")
  616. elif level == 2:
  617. # This could be year folder or primary folder (depending on what was at level 1)
  618. if folder_name.isdigit() and len(folder_name) == 4 and not structure['year_folder']['found']:
  619. # It's a year folder
  620. structure['year_folder'] = {
  621. 'id': folder_id_info,
  622. 'name': folder_name,
  623. 'level': level,
  624. 'found': True
  625. }
  626. _logger.info(f"✅ Identified year folder at level 2: {folder_name}")
  627. elif folder_name not in ['Meets', 'Archivos cliente'] and not structure['primary_folder']['found']:
  628. # It's a primary folder
  629. structure['primary_folder'] = {
  630. 'id': folder_id_info,
  631. 'name': folder_name,
  632. 'level': level,
  633. 'found': True
  634. }
  635. _logger.info(f"✅ Identified primary folder at level 2: {folder_name}")
  636. elif level >= 3:
  637. # This is likely the root folder
  638. if not structure['root_folder']['found']:
  639. structure['root_folder'] = {
  640. 'id': folder_id_info,
  641. 'name': folder_name,
  642. 'level': level,
  643. 'found': True
  644. }
  645. _logger.info(f"✅ Identified root folder: {folder_name}")
  646. _logger.info(f"📊 Final structure analysis: {structure}")
  647. return structure
  648. except Exception as e:
  649. _logger.error(f"Error in _analyze_crm_folder_structure: {str(e)}")
  650. return None
  651. # ============================================================================
  652. # MÉTODOS DE ACCIÓN
  653. # ============================================================================
  654. @api.model
  655. def create(self, vals_list):
  656. """Override create to handle Google Drive folder creation - supports batch creation"""
  657. # Handle both single record and batch creation
  658. if isinstance(vals_list, dict):
  659. vals_list = [vals_list]
  660. records = super().create(vals_list)
  661. # Process each record individually for Google Drive folder creation
  662. for record in records:
  663. if (record.company_id.google_drive_crm_enabled and
  664. record.company_id.google_drive_crm_stage_id and
  665. record.stage_id.id == record.company_id.google_drive_crm_stage_id.id):
  666. if record._validate_folder_creation_prerequisites():
  667. try:
  668. record._create_google_drive_folder_structure()
  669. record._post_folder_message("✅ Google Drive folder created automatically")
  670. except Exception as e:
  671. _logger.error(f"Failed to create Google Drive folder for opportunity {record.id}: {str(e)}")
  672. record._post_folder_message(f"⚠️ Google Drive folder creation failed: {str(e)}")
  673. return records
  674. def write(self, vals):
  675. """Override write method to handle Google Drive folder updates"""
  676. if self.env.context.get('skip_google_drive_update'):
  677. return super().write(vals)
  678. _clear_google_drive_cache()
  679. relevant_fields = ['name', 'partner_id', 'create_date', 'stage_id', 'company_id']
  680. needs_update = any(field in vals for field in relevant_fields)
  681. if not needs_update:
  682. return super().write(vals)
  683. # Store current values for comparison
  684. current_values = {}
  685. for record in self:
  686. current_values[record.id] = {
  687. 'name': record.name,
  688. 'partner_id': record.partner_id.id if record.partner_id else None,
  689. 'create_date': record.create_date,
  690. 'company_id': record.company_id.id if record.company_id else None,
  691. 'google_drive_folder_name': record.google_drive_folder_name or ''
  692. }
  693. result = super().write(vals)
  694. # Process Google Drive updates
  695. for record in self:
  696. record._process_google_drive_updates(vals, current_values[record.id])
  697. return result
  698. def action_open_google_drive_folder(self):
  699. """Open Google Drive folder for this opportunity"""
  700. self.ensure_one()
  701. if not self.google_drive_folder_id:
  702. raise UserError(_('No Google Drive folder configured for this opportunity'))
  703. return {
  704. 'type': 'ir.actions.act_url',
  705. 'url': f"https://drive.google.com/drive/folders/{self.google_drive_folder_id}",
  706. 'target': 'new',
  707. }
  708. def action_create_google_drive_folder(self):
  709. """Create Google Drive folder structure for this opportunity"""
  710. self.ensure_one()
  711. if self.google_drive_folder_id:
  712. raise UserError(_('Google Drive folder already exists for this opportunity'))
  713. if not self._get_company_root_folder_id():
  714. raise UserError(_('Google Drive CRM folder is not configured for this company.'))
  715. try:
  716. self._create_google_drive_folder_structure()
  717. return {
  718. 'type': 'ir.actions.client',
  719. 'tag': 'display_notification',
  720. 'params': {
  721. 'title': _('Success'),
  722. 'message': _('Google Drive folder structure created successfully!'),
  723. 'type': 'success',
  724. 'sticky': False,
  725. }
  726. }
  727. except Exception as e:
  728. raise UserError(_('Failed to create Google Drive folder structure: %s') % str(e))
  729. def action_recreate_google_drive_structure(self):
  730. """Manually rename the Google Drive folder structure"""
  731. self.ensure_one()
  732. if not self.google_drive_folder_id:
  733. raise UserError(_('No Google Drive folder exists for this opportunity.'))
  734. if not self._get_company_root_folder_id():
  735. raise UserError(_('Google Drive CRM folder is not configured for this company.'))
  736. try:
  737. expected_components = self._get_current_folder_components()
  738. self._rename_entire_folder_structure(new_components=expected_components)
  739. self._update_folder_structure_fields(structure_components=expected_components)
  740. return {
  741. 'type': 'ir.actions.client',
  742. 'tag': 'display_notification',
  743. 'params': {
  744. 'title': _('Success'),
  745. 'message': _('Google Drive folder structure renamed successfully!'),
  746. 'type': 'success',
  747. 'sticky': False,
  748. }
  749. }
  750. except Exception as e:
  751. _logger.error(f"Failed to rename Google Drive folder structure: {str(e)}")
  752. raise UserError(_('Failed to rename Google Drive folder structure: %s') % str(e))
  753. def action_analyze_folder_structure(self):
  754. """Analyze current vs expected folder structure"""
  755. self.ensure_one()
  756. if not self.google_drive_folder_id:
  757. raise UserError(_('No Google Drive folder exists for this opportunity.'))
  758. try:
  759. # Add detailed diagnostic information
  760. _logger.info(f"🔍 Analyzing folder structure for opportunity {self.id} (ID: {self.name})")
  761. _logger.info(f"📁 Current folder ID: {self.google_drive_folder_id}")
  762. expected_components = self._get_current_folder_components()
  763. _logger.info(f"📋 Expected components: {expected_components}")
  764. # Test folder validation first
  765. drive_service = self.env['google.drive.service']
  766. validation = drive_service.validate_folder_id(self.google_drive_folder_id)
  767. _logger.info(f"✅ Folder validation result: {validation}")
  768. current_structure = self._analyze_crm_folder_structure(self.google_drive_folder_id)
  769. _logger.info(f"📊 Current structure analysis: {current_structure}")
  770. if not current_structure:
  771. # Provide a more helpful error message
  772. error_msg = f"<strong>❌ Cannot Analyze Folder Structure</strong><br/><br/>"
  773. error_msg += f"<strong>Folder ID:</strong> {self.google_drive_folder_id}<br/>"
  774. error_msg += f"<strong>Validation Result:</strong> {validation}<br/>"
  775. error_msg += f"<strong>Possible Issues:</strong><br/>"
  776. error_msg += f"• Folder may not exist or be accessible<br/>"
  777. error_msg += f"• Insufficient permissions to access the folder<br/>"
  778. error_msg += f"• Folder may be in a Shared Drive without proper access<br/>"
  779. error_msg += f"• Network connectivity issues<br/><br/>"
  780. error_msg += f"<strong>Expected Structure:</strong><br/>"
  781. error_msg += f"📁 [Root Folder] (MC Team)<br/>"
  782. error_msg += f"└── 📁 {expected_components['primary_name']} (Company/Contact)<br/>"
  783. error_msg += f" └── 📁 {expected_components['year']} (Year)<br/>"
  784. error_msg += f" └── 📁 {expected_components['opportunity_name']} (Opportunity)<br/>"
  785. error_msg += f" ├── 📁 Meets<br/>"
  786. error_msg += f" └── 📁 Archivos cliente<br/><br/>"
  787. error_msg += f"<strong>Recommendation:</strong> Try using the 'Rename Folder Structure' button to recreate the structure."
  788. return {
  789. 'type': 'ir.actions.client',
  790. 'tag': 'display_notification',
  791. 'params': {
  792. 'title': _('Folder Structure Analysis'),
  793. 'message': error_msg,
  794. 'type': 'warning',
  795. 'sticky': True,
  796. }
  797. }
  798. analysis = self._compare_folder_structures(expected_components, current_structure)
  799. return {
  800. 'type': 'ir.actions.client',
  801. 'tag': 'display_notification',
  802. 'params': {
  803. 'title': _('Folder Structure Analysis'),
  804. 'message': analysis,
  805. 'type': 'info',
  806. 'sticky': True,
  807. }
  808. }
  809. except Exception as e:
  810. _logger.error(f"Error in action_analyze_folder_structure: {str(e)}")
  811. raise UserError(_('Failed to analyze folder structure: %s') % str(e))
  812. def _compare_folder_structures(self, expected_components, current_structure):
  813. """Compare expected vs current folder structure - EXTRA SPACING FORMAT"""
  814. try:
  815. # Create text analysis with EXTRA spacing for better readability
  816. analysis = "📁 FOLDER STRUCTURE ANALYSIS\n"
  817. analysis += "=" * 60 + "\n\n\n"
  818. # Expected structure
  819. analysis += "✅ EXPECTED STRUCTURE:\n"
  820. analysis += "=" * 30 + "\n\n"
  821. analysis += f"📁 [Root Folder] (MC Team)\n"
  822. analysis += f"└── 📁 {expected_components['primary_name']} (Company/Contact)\n"
  823. analysis += f" └── 📁 {expected_components['year']} (Year)\n"
  824. analysis += f" └── 📁 {expected_components['opportunity_name']} (Opportunity)\n"
  825. analysis += f" ├── 📁 Meets\n"
  826. analysis += f" └── 📁 Archivos cliente\n\n\n"
  827. # Current structure
  828. analysis += "🔍 CURRENT STRUCTURE:\n"
  829. analysis += "=" * 30 + "\n\n"
  830. # Root folder
  831. if current_structure.get('root_folder', {}).get('found', False):
  832. root_name = current_structure['root_folder']['name']
  833. analysis += f"📁 {root_name} (Root) ✅\n"
  834. else:
  835. analysis += "📁 [Unknown Root] ❌\n"
  836. # Primary folder
  837. if current_structure.get('primary_folder', {}).get('found', False):
  838. primary_name = current_structure['primary_folder']['name']
  839. if primary_name == expected_components['primary_name']:
  840. analysis += f"└── 📁 {primary_name} ✅\n"
  841. else:
  842. analysis += f"└── 📁 {primary_name} ❌\n"
  843. analysis += f" (Expected: {expected_components['primary_name']})\n"
  844. else:
  845. analysis += "└── 📁 [Missing Primary Folder] ❌\n"
  846. # Year folder
  847. if current_structure.get('year_folder', {}).get('found', False):
  848. year_name = current_structure['year_folder']['name']
  849. if year_name == expected_components['year']:
  850. analysis += f" └── 📁 {year_name} ✅\n"
  851. else:
  852. analysis += f" └── 📁 {year_name} ❌\n"
  853. analysis += f" (Expected: {expected_components['year']})\n"
  854. else:
  855. analysis += " └── 📁 [Missing Year Folder] ❌\n"
  856. # Opportunity folder
  857. if current_structure.get('opportunity_folder', {}).get('found', False):
  858. opp_name = current_structure['opportunity_folder']['name']
  859. if opp_name == expected_components['opportunity_name']:
  860. analysis += f" └── 📁 {opp_name} ✅\n"
  861. else:
  862. analysis += f" └── 📁 {opp_name} ❌\n"
  863. analysis += f" (Expected: {expected_components['opportunity_name']})\n"
  864. else:
  865. analysis += " └── 📁 [Missing Opportunity Folder] ❌\n"
  866. # Add subfolders to current structure (always show them)
  867. analysis += f" ├── 📁 Meets ✅\n"
  868. analysis += f" └── 📁 Archivos cliente ✅\n\n\n"
  869. # Calculate summary
  870. correct_count = 0
  871. total_count = 0
  872. # Check primary folder
  873. if current_structure.get('primary_folder', {}).get('found', False):
  874. total_count += 1
  875. if current_structure['primary_folder']['name'] == expected_components['primary_name']:
  876. correct_count += 1
  877. # Check year folder
  878. if current_structure.get('year_folder', {}).get('found', False):
  879. total_count += 1
  880. if current_structure['year_folder']['name'] == expected_components['year']:
  881. correct_count += 1
  882. # Check opportunity folder
  883. if current_structure.get('opportunity_folder', {}).get('found', False):
  884. total_count += 1
  885. if current_structure['opportunity_folder']['name'] == expected_components['opportunity_name']:
  886. correct_count += 1
  887. # Summary section with EXTRA spacing
  888. analysis += "=" * 60 + "\n\n"
  889. analysis += "📊 SUMMARY:\n"
  890. analysis += "=" * 20 + "\n\n"
  891. if total_count == 3 and correct_count == 3:
  892. analysis += "🎉 PERFECT STRUCTURE!\n\n"
  893. analysis += "✅ All folders are in the right place\n"
  894. analysis += "✅ All folder names are correct\n"
  895. analysis += f"✅ {correct_count}/{total_count} Components Correct\n\n"
  896. analysis += "🎯 Status: Ready to use!\n"
  897. else:
  898. analysis += "⚠️ STRUCTURE ISSUES DETECTED\n\n"
  899. analysis += f"❌ Structure has issues ({correct_count}/{total_count} correct)\n\n"
  900. analysis += "💡 RECOMMENDATION:\n"
  901. analysis += "Use the 'Rename Folder Structure' button to fix the folder hierarchy.\n\n"
  902. analysis += "🔧 Action Required: Manual intervention needed\n"
  903. return analysis
  904. except Exception as e:
  905. _logger.error(f"Error in _compare_folder_structures: {str(e)}")
  906. return f"❌ ERROR ANALYZING FOLDER STRUCTURE\n{str(e)}"
  907. # ============================================================================
  908. # MÉTODOS DE SINCRONIZACIÓN DE MEETS
  909. # ============================================================================
  910. @api.model
  911. def _sync_meetings_with_opportunities(self, time_filter=15):
  912. """Sync Google Meet recordings with CRM opportunities
  913. Args:
  914. time_filter: Can be:
  915. - int: Number of days back (e.g., 15 for last 15 days)
  916. - str: Specific date in YYYY-MM-DD format (e.g., '2024-01-15' for that specific day)
  917. """
  918. # Parse time_filter parameter
  919. if isinstance(time_filter, str):
  920. # Try to parse as date
  921. try:
  922. from datetime import datetime
  923. target_date = datetime.strptime(time_filter, '%Y-%m-%d').date()
  924. _logger.info(f"Starting CRM meetings synchronization for specific date: {time_filter}")
  925. days_back = None
  926. specific_date = target_date
  927. except ValueError:
  928. # If not a valid date, try to convert to int
  929. try:
  930. days_back = int(time_filter)
  931. specific_date = None
  932. _logger.info(f"Starting CRM meetings synchronization for last {days_back} days...")
  933. except ValueError:
  934. raise UserError(_('Invalid time_filter parameter. Use integer (days back) or date string (YYYY-MM-DD)'))
  935. else:
  936. # Assume it's an integer
  937. days_back = int(time_filter)
  938. specific_date = None
  939. _logger.info(f"Starting CRM meetings synchronization for last {days_back} days...")
  940. try:
  941. # Get Google Drive service
  942. drive_service = self.env['google.drive.service']
  943. # Get meetings based on filter type
  944. if specific_date:
  945. meetings = self._get_meetings_with_recordings_for_date(specific_date)
  946. else:
  947. meetings = self._get_meetings_with_recordings(days_back=days_back)
  948. _logger.info(f"Found {len(meetings)} meetings with recordings")
  949. # Limit to first 10 meetings for performance
  950. meetings = meetings[:10]
  951. _logger.info(f"Processing first {len(meetings)} meetings for performance")
  952. sync_results = {
  953. 'total_meetings': len(meetings),
  954. 'opportunities_found': 0,
  955. 'folders_created': 0,
  956. 'files_moved': 0,
  957. 'errors': []
  958. }
  959. for meeting in meetings:
  960. try:
  961. result = self._process_meeting_for_opportunities(meeting, drive_service)
  962. sync_results['opportunities_found'] += result.get('opportunities_found', 0)
  963. sync_results['folders_created'] += result.get('folders_created', 0)
  964. sync_results['files_moved'] += result.get('files_moved', 0)
  965. except Exception as e:
  966. error_msg = f"Error processing meeting {meeting.get('id', 'unknown')}: {str(e)}"
  967. _logger.error(error_msg)
  968. sync_results['errors'].append(error_msg)
  969. # Generate summary message
  970. summary = self._generate_sync_summary(sync_results)
  971. _logger.info(f"CRM sync completed: {summary}")
  972. return summary
  973. except Exception as e:
  974. _logger.error(f"Failed to sync meetings with opportunities: {str(e)}")
  975. raise UserError(_('Failed to sync meetings: %s') % str(e))
  976. @api.model
  977. def _sync_meetings_with_opportunities_cron(self, time_filter=15):
  978. """Cron job method for automatic CRM calendar sync - handles errors gracefully
  979. Args:
  980. time_filter: Can be:
  981. - int: Number of days back (e.g., 15 for last 15 days)
  982. - str: Specific date in YYYY-MM-DD format (e.g., '2024-01-15' for that specific day)
  983. """
  984. # Parse time_filter parameter for logging
  985. if isinstance(time_filter, str):
  986. try:
  987. from datetime import datetime
  988. target_date = datetime.strptime(time_filter, '%Y-%m-%d').date()
  989. log_message = f"🔄 Starting automatic CRM calendar sync (cron) for specific date: {time_filter}"
  990. except ValueError:
  991. try:
  992. days_back = int(time_filter)
  993. log_message = f"🔄 Starting automatic CRM calendar sync (cron) for last {days_back} days..."
  994. except ValueError:
  995. log_message = f"🔄 Starting automatic CRM calendar sync (cron) with invalid time_filter: {time_filter}"
  996. else:
  997. days_back = int(time_filter)
  998. log_message = f"🔄 Starting automatic CRM calendar sync (cron) for last {days_back} days..."
  999. _logger.info(log_message)
  1000. try:
  1001. # Check if any company has Google Drive CRM enabled
  1002. companies_with_google = self.env['res.company'].search([
  1003. ('google_drive_crm_enabled', '=', True),
  1004. ('google_drive_crm_folder_id', '!=', False)
  1005. ])
  1006. if not companies_with_google:
  1007. _logger.info("🔄 No companies configured for Google Drive CRM. Skipping sync.")
  1008. return
  1009. # Check if there are any opportunities to process
  1010. opportunities_count = self.env['crm.lead'].search_count([
  1011. ('type', '=', 'opportunity'),
  1012. ('stage_id.is_won', '=', False)
  1013. ])
  1014. if opportunities_count == 0:
  1015. _logger.info("🔄 No active opportunities found. Skipping sync.")
  1016. return
  1017. # Find users with Google authentication
  1018. authenticated_users = self.env['res.users'].search([
  1019. ('res_users_settings_id.google_rtoken', '!=', False),
  1020. ('res_users_settings_id.google_token', '!=', False)
  1021. ])
  1022. if not authenticated_users:
  1023. _logger.warning("🔄 No users with Google authentication found. Skipping sync.")
  1024. return
  1025. _logger.info(f"🔄 Found {len(authenticated_users)} users with Google authentication")
  1026. # Execute sync for each authenticated user
  1027. total_results = {
  1028. 'total_meetings': 0,
  1029. 'opportunities_found': 0,
  1030. 'folders_created': 0,
  1031. 'files_moved': 0,
  1032. 'errors': []
  1033. }
  1034. for user in authenticated_users:
  1035. try:
  1036. _logger.info(f"🔄 Executing sync for user: {user.name} ({user.login})")
  1037. # Execute sync with user context
  1038. result = self.with_user(user.id)._sync_meetings_with_opportunities(time_filter=time_filter)
  1039. # Parse result if it's a string (summary)
  1040. if isinstance(result, str):
  1041. # Extract numbers from summary (basic parsing)
  1042. import re
  1043. meetings_match = re.search(r'Reuniones procesadas: (\d+)', result)
  1044. opportunities_match = re.search(r'Oportunidades encontradas: (\d+)', result)
  1045. folders_match = re.search(r'Carpetas creadas: (\d+)', result)
  1046. files_match = re.search(r'Archivos movidos: (\d+)', result)
  1047. if meetings_match:
  1048. total_results['total_meetings'] += int(meetings_match.group(1))
  1049. if opportunities_match:
  1050. total_results['opportunities_found'] += int(opportunities_match.group(1))
  1051. if folders_match:
  1052. total_results['folders_created'] += int(folders_match.group(1))
  1053. if files_match:
  1054. total_results['files_moved'] += int(files_match.group(1))
  1055. _logger.info(f"✅ Sync completed for user {user.name}")
  1056. except Exception as e:
  1057. error_msg = f"Error syncing for user {user.name}: {str(e)}"
  1058. _logger.error(error_msg)
  1059. total_results['errors'].append(error_msg)
  1060. continue
  1061. # Generate final summary
  1062. final_summary = self._generate_sync_summary(total_results)
  1063. _logger.info(f"✅ Automatic CRM calendar sync completed for all users: {final_summary}")
  1064. return final_summary
  1065. except Exception as e:
  1066. # Log error but don't fail the cron job
  1067. _logger.error(f"❌ Automatic CRM calendar sync failed: {str(e)}")
  1068. # Create a system message for administrators
  1069. try:
  1070. admin_users = self.env['res.users'].search([('groups_id', 'in', self.env.ref('base.group_system').id)])
  1071. for admin in admin_users:
  1072. admin.message_post(
  1073. body=f"❌ <strong>CRM Calendar Sync Error</strong><br/>"
  1074. f"Error: {str(e)}<br/>"
  1075. f"Time: {fields.Datetime.now()}<br/>"
  1076. f"Time Filter: {time_filter}",
  1077. message_type='comment',
  1078. subtype_xmlid='mail.mt_comment'
  1079. )
  1080. except:
  1081. pass # Don't fail if we can't create messages
  1082. return False
  1083. def _get_meetings_with_recordings(self, days_back=15):
  1084. """Get Google Meet meetings from last N days that have recordings"""
  1085. try:
  1086. # Use Google Calendar service to get real meetings
  1087. calendar_service = self.env['google.calendar.service']
  1088. meetings = calendar_service.get_meetings_with_recordings(days_back=days_back)
  1089. _logger.info(f"Retrieved {len(meetings)} meetings with recordings from Google Calendar")
  1090. return meetings
  1091. except Exception as e:
  1092. _logger.error(f"Failed to get meetings from Google Calendar: {str(e)}")
  1093. # Fallback to empty list if API fails
  1094. return []
  1095. def _get_meetings_with_recordings_for_date(self, target_date):
  1096. """Get Google Meet meetings from a specific date that have recordings"""
  1097. try:
  1098. # Use Google Calendar service to get meetings for specific date
  1099. calendar_service = self.env['google.calendar.service']
  1100. meetings = calendar_service.get_meetings_with_recordings_for_date(target_date)
  1101. _logger.info(f"Retrieved {len(meetings)} meetings with recordings from Google Calendar for date: {target_date}")
  1102. return meetings
  1103. except Exception as e:
  1104. _logger.error(f"Failed to get meetings from Google Calendar for date {target_date}: {str(e)}")
  1105. # Fallback to empty list if API fails
  1106. return []
  1107. def _process_meeting_for_opportunities(self, meeting, drive_service):
  1108. """Process a single meeting for opportunities"""
  1109. result = {
  1110. 'opportunities_found': 0,
  1111. 'folders_created': 0,
  1112. 'files_moved': 0
  1113. }
  1114. # Find opportunities by participant emails
  1115. opportunities = self._find_opportunities_by_participants(meeting['participants'])
  1116. result['opportunities_found'] = len(opportunities)
  1117. if not opportunities:
  1118. _logger.info(f"❌ No opportunities found for meeting '{meeting['title']}' with participants: {meeting['participants']}")
  1119. return result
  1120. for opportunity in opportunities:
  1121. try:
  1122. _logger.info(f"🎯 Processing meeting '{meeting['title']}' for opportunity '{opportunity.name}' (ID: {opportunity.id})")
  1123. # Ensure opportunity has Google Drive folder structure
  1124. if not opportunity.google_drive_folder_id:
  1125. _logger.info(f"📁 Creating Google Drive folder for opportunity {opportunity.name}")
  1126. opportunity._create_google_drive_folder_structure()
  1127. result['folders_created'] += 1
  1128. # Get or create Meets folder
  1129. meets_folder_id = self._get_or_create_meets_folder(opportunity, drive_service)
  1130. # Move recording files to Meets folder
  1131. files_moved = self._move_recording_files_to_meets_folder(
  1132. meeting['recording_files'],
  1133. meets_folder_id,
  1134. drive_service,
  1135. meeting['title']
  1136. )
  1137. result['files_moved'] += files_moved
  1138. # Search for additional files in the user's configured CRM meets folder
  1139. additional_files_moved = self._search_and_move_crm_meets_files(meeting['title'], meets_folder_id, drive_service)
  1140. result['files_moved'] += additional_files_moved
  1141. _logger.info(f"✅ Successfully processed meeting '{meeting['title']}' for opportunity '{opportunity.name}': {files_moved + additional_files_moved} files moved")
  1142. except Exception as e:
  1143. _logger.error(f"❌ Error processing opportunity {opportunity.name}: {str(e)}")
  1144. continue
  1145. return result
  1146. def _find_opportunities_by_participants(self, participant_emails):
  1147. """Find opportunities where participants are partners - returns the most recent one"""
  1148. opportunities = self.env['crm.lead'].search([
  1149. ('partner_id.email', 'in', participant_emails),
  1150. ('type', '=', 'opportunity'),
  1151. ('stage_id.is_won', '=', False) # Only active opportunities
  1152. ], order='create_date desc', limit=1)
  1153. if opportunities:
  1154. opportunity = opportunities[0]
  1155. _logger.info(f"Found most recent opportunity '{opportunity.name}' (ID: {opportunity.id}, created: {opportunity.create_date}) for participants: {participant_emails}")
  1156. else:
  1157. _logger.info(f"No opportunities found for participants: {participant_emails}")
  1158. return opportunities
  1159. def _get_or_create_meets_folder(self, opportunity, drive_service):
  1160. """Get or create Meets folder within opportunity folder"""
  1161. try:
  1162. # List folders in opportunity folder
  1163. folders = drive_service._do_request(
  1164. '/drive/v3/files',
  1165. params={
  1166. 'q': f"'{opportunity.google_drive_folder_id}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false",
  1167. 'fields': 'files(id,name)',
  1168. 'supportsAllDrives': 'true',
  1169. 'includeItemsFromAllDrives': 'true'
  1170. }
  1171. )
  1172. # Look for existing Meets folder
  1173. meets_folder_id = None
  1174. for folder in folders.get('files', []):
  1175. if folder['name'] == 'Meets':
  1176. meets_folder_id = folder['id']
  1177. _logger.info(f"Found existing Meets folder: {meets_folder_id}")
  1178. break
  1179. # Create Meets folder if it doesn't exist
  1180. if not meets_folder_id:
  1181. _logger.info(f"Creating Meets folder for opportunity {opportunity.name}")
  1182. meets_folder = drive_service._do_request(
  1183. '/drive/v3/files',
  1184. method='POST',
  1185. json_data={
  1186. 'name': 'Meets',
  1187. 'mimeType': 'application/vnd.google-apps.folder',
  1188. 'parents': [opportunity.google_drive_folder_id]
  1189. }
  1190. )
  1191. meets_folder_id = meets_folder['id']
  1192. _logger.info(f"Created Meets folder: {meets_folder_id}")
  1193. return meets_folder_id
  1194. except Exception as e:
  1195. _logger.error(f"Error getting/creating Meets folder: {str(e)}")
  1196. raise
  1197. def _move_recording_files_to_meets_folder(self, file_ids, meets_folder_id, drive_service, meeting_title):
  1198. """Move recording files to Meets folder"""
  1199. files_moved = 0
  1200. _logger.info(f"🔍 Starting to move {len(file_ids)} files to Meets folder: {meets_folder_id}")
  1201. _logger.info(f"📁 Target Meets folder ID: {meets_folder_id}")
  1202. for file_id in file_ids:
  1203. try:
  1204. _logger.info(f"📄 Processing file ID: {file_id}")
  1205. # Check if file is already in the Meets folder
  1206. file_info = drive_service._do_request(
  1207. f'/drive/v3/files/{file_id}',
  1208. params={
  1209. 'fields': 'id,name,parents',
  1210. 'supportsAllDrives': 'true',
  1211. 'includeItemsFromAllDrives': 'true'
  1212. }
  1213. )
  1214. file_name = file_info.get('name', 'Unknown')
  1215. current_parents = file_info.get('parents', [])
  1216. _logger.info(f"📄 File: {file_name} (ID: {file_id})")
  1217. _logger.info(f"📍 Current parents: {current_parents}")
  1218. _logger.info(f"🎯 Target folder: {meets_folder_id}")
  1219. # Check if file is already in the target folder
  1220. if meets_folder_id in current_parents:
  1221. _logger.info(f"✅ File {file_name} already in Meets folder")
  1222. continue
  1223. # Move file to Meets folder
  1224. _logger.info(f"🚚 Moving file {file_name} to Meets folder...")
  1225. # Prepare parameters for Shared Drive support
  1226. move_params = {
  1227. 'supportsAllDrives': 'true',
  1228. 'includeItemsFromAllDrives': 'true'
  1229. }
  1230. # Try alternative approach: use addParents and removeParents as URL parameters
  1231. move_params['addParents'] = meets_folder_id
  1232. move_params['removeParents'] = ','.join(current_parents)
  1233. _logger.info(f"🔧 Using URL parameters: addParents={meets_folder_id}, removeParents={','.join(current_parents)}")
  1234. drive_service._do_request(
  1235. f'/drive/v3/files/{file_id}',
  1236. method='PATCH',
  1237. params=move_params
  1238. )
  1239. # Verify the move was successful
  1240. updated_file_info = drive_service._do_request(
  1241. f'/drive/v3/files/{file_id}',
  1242. params={
  1243. 'fields': 'id,name,parents',
  1244. 'supportsAllDrives': 'true',
  1245. 'includeItemsFromAllDrives': 'true'
  1246. }
  1247. )
  1248. updated_parents = updated_file_info.get('parents', [])
  1249. _logger.info(f"✅ File moved! New parents: {updated_parents}")
  1250. files_moved += 1
  1251. _logger.info(f"✅ Successfully moved file {file_name} to Meets folder for meeting: {meeting_title}")
  1252. except Exception as e:
  1253. _logger.error(f"❌ Error moving file {file_id}: {str(e)}")
  1254. continue
  1255. _logger.info(f"📊 Total files moved: {files_moved} out of {len(file_ids)}")
  1256. return files_moved
  1257. def _search_and_move_crm_meets_files(self, meeting_title, meets_folder_id, drive_service):
  1258. """Search for files in the user's configured CRM meets folder and move them to the opportunity's Meets folder"""
  1259. files_moved = 0
  1260. try:
  1261. # Get the current user's CRM meets folder configuration
  1262. current_user = self.env.user
  1263. user_settings = current_user.res_users_settings_id
  1264. if not user_settings or not user_settings.google_crm_meets_folder_id:
  1265. _logger.info(f"🔍 No CRM meets folder configured for user {current_user.name}. Skipping additional file search.")
  1266. return 0
  1267. crm_meets_folder_id = user_settings.google_crm_meets_folder_id
  1268. _logger.info(f"🔍 Searching for files in user's CRM meets folder ({crm_meets_folder_id}) containing meeting title: '{meeting_title}'")
  1269. # Search for files in the user's CRM meets folder that contain the meeting title
  1270. files_in_crm_meets = drive_service._do_request(
  1271. '/drive/v3/files',
  1272. params={
  1273. 'q': f"'{crm_meets_folder_id}' in parents and trashed=false and name contains '{meeting_title}'",
  1274. 'fields': 'files(id,name,parents)',
  1275. 'supportsAllDrives': 'true',
  1276. 'includeItemsFromAllDrives': 'true'
  1277. }
  1278. )
  1279. found_files = files_in_crm_meets.get('files', [])
  1280. _logger.info(f"🔍 Found {len(found_files)} files in CRM meets folder containing meeting title: '{meeting_title}'")
  1281. for file_info in found_files:
  1282. file_id = file_info['id']
  1283. file_name = file_info['name']
  1284. current_parents = file_info.get('parents', [])
  1285. _logger.info(f"📄 Processing file '{file_name}' (ID: {file_id}) from CRM meets folder")
  1286. # Check if file is already in the target folder
  1287. if meets_folder_id in current_parents:
  1288. _logger.info(f"✅ File '{file_name}' already in opportunity's Meets folder")
  1289. continue
  1290. # Move file to the opportunity's Meets folder
  1291. _logger.info(f"🚚 Moving file '{file_name}' to opportunity's Meets folder...")
  1292. # Prepare parameters for Shared Drive support
  1293. move_params = {
  1294. 'supportsAllDrives': 'true',
  1295. 'includeItemsFromAllDrives': 'true'
  1296. }
  1297. # Try alternative approach: use addParents and removeParents as URL parameters
  1298. move_params['addParents'] = meets_folder_id
  1299. move_params['removeParents'] = ','.join(current_parents)
  1300. _logger.info(f"🔧 Using URL parameters: addParents={meets_folder_id}, removeParents={','.join(current_parents)}")
  1301. drive_service._do_request(
  1302. f'/drive/v3/files/{file_id}',
  1303. method='PATCH',
  1304. params=move_params
  1305. )
  1306. # Verify the move was successful
  1307. updated_file_info = drive_service._do_request(
  1308. f'/drive/v3/files/{file_id}',
  1309. params={
  1310. 'fields': 'id,name,parents',
  1311. 'supportsAllDrives': 'true',
  1312. 'includeItemsFromAllDrives': 'true'
  1313. }
  1314. )
  1315. updated_parents = updated_file_info.get('parents', [])
  1316. _logger.info(f"✅ File moved! New parents: {updated_parents}")
  1317. files_moved += 1
  1318. _logger.info(f"✅ Successfully moved file '{file_name}' to opportunity's Meets folder for meeting: {meeting_title}")
  1319. _logger.info(f"📊 Total additional files moved from CRM meets folder: {files_moved}")
  1320. return files_moved
  1321. except Exception as e:
  1322. _logger.error(f"❌ Error searching or moving files from CRM meets folder: {str(e)}")
  1323. return 0
  1324. def _generate_sync_summary(self, sync_results):
  1325. """Generate summary message for sync results"""
  1326. summary = f"<strong>🔄 Sincronización CRM Completada</strong><br/><br/>"
  1327. summary += f"📊 <strong>Resumen:</strong><br/>"
  1328. summary += f"• Reuniones procesadas: {sync_results['total_meetings']}<br/>"
  1329. summary += f"• Oportunidades encontradas: {sync_results['opportunities_found']}<br/>"
  1330. summary += f"• Carpetas creadas: {sync_results['folders_created']}<br/>"
  1331. summary += f"• Archivos movidos: {sync_results['files_moved']}<br/>"
  1332. if sync_results['errors']:
  1333. summary += f"<br/>❌ <strong>Errores ({len(sync_results['errors'])}):</strong><br/>"
  1334. for error in sync_results['errors'][:5]: # Show first 5 errors
  1335. summary += f"• {error}<br/>"
  1336. if len(sync_results['errors']) > 5:
  1337. summary += f"• ... y {len(sync_results['errors']) - 5} errores más<br/>"
  1338. return summary