crm_lead.py 71 KB

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