crm_lead.py 98 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import requests
  4. import json
  5. import logging
  6. import re
  7. from datetime import datetime, timedelta
  8. from odoo import fields, models, api, _
  9. from odoo.exceptions import UserError, ValidationError
  10. _logger = logging.getLogger(__name__)
  11. # Cache for Google Drive API responses (5 minutes)
  12. _GOOGLE_DRIVE_CACHE = {}
  13. _CACHE_TIMEOUT = 300 # 5 minutes
  14. def _clear_google_drive_cache():
  15. """Clear expired cache entries"""
  16. global _GOOGLE_DRIVE_CACHE
  17. current_time = datetime.now()
  18. expired_keys = [
  19. key for key, entry in _GOOGLE_DRIVE_CACHE.items()
  20. if current_time >= entry['expires']
  21. ]
  22. for key in expired_keys:
  23. del _GOOGLE_DRIVE_CACHE[key]
  24. class CrmLead(models.Model):
  25. _inherit = 'crm.lead'
  26. google_drive_documents_count = fields.Integer(
  27. string='Google Drive Documents',
  28. compute='_compute_google_drive_documents_count',
  29. help='Number of documents in Google Drive for this opportunity'
  30. )
  31. google_drive_folder_id = fields.Char(
  32. string='Google Drive Folder ID',
  33. help='ID del folder específico en Google Drive para esta oportunidad',
  34. readonly=True
  35. )
  36. google_drive_folder_name = fields.Char(
  37. string='Google Drive Folder Name',
  38. help='Nombre del folder en Google Drive para esta oportunidad',
  39. readonly=True
  40. )
  41. google_drive_url = fields.Char(
  42. string='URL Drive',
  43. help='URL de la carpeta de Google Drive de la oportunidad'
  44. )
  45. @api.depends('google_drive_folder_id')
  46. def _compute_google_drive_documents_count(self):
  47. """Compute the number of documents in Google Drive with caching"""
  48. for record in self:
  49. if record.google_drive_folder_id:
  50. # Check cache first
  51. cache_key = f'doc_count_{record.google_drive_folder_id}'
  52. if cache_key in _GOOGLE_DRIVE_CACHE:
  53. cache_entry = _GOOGLE_DRIVE_CACHE[cache_key]
  54. if datetime.now() < cache_entry['expires']:
  55. record.google_drive_documents_count = cache_entry['count']
  56. continue
  57. # TODO: Implement Google Drive API call to count documents
  58. # For now, return 0 but cache the result
  59. count = 0
  60. _GOOGLE_DRIVE_CACHE[cache_key] = {
  61. 'count': count,
  62. 'expires': datetime.now() + timedelta(seconds=300) # 5 minutes
  63. }
  64. record.google_drive_documents_count = count
  65. else:
  66. record.google_drive_documents_count = 0
  67. def _get_google_drive_access_token(self):
  68. """Get Google Drive access token from system parameters with caching"""
  69. # Check cache first
  70. cache_key = 'access_token'
  71. if cache_key in _GOOGLE_DRIVE_CACHE:
  72. cache_entry = _GOOGLE_DRIVE_CACHE[cache_key]
  73. if datetime.now() < cache_entry['expires']:
  74. return cache_entry['token']
  75. access_token = self.env['ir.config_parameter'].sudo().get_param('google_api.access_token')
  76. if not access_token:
  77. raise UserError(_('No OAuth token found. Please connect your Google account in Google API settings first.'))
  78. # Test if the token is still valid
  79. if not self._test_access_token(access_token):
  80. # Try to refresh the token
  81. if self._refresh_access_token():
  82. access_token = self.env['ir.config_parameter'].sudo().get_param('google_api.access_token')
  83. else:
  84. raise UserError(_('Google Drive access token has expired and could not be refreshed. Please reconnect your Google account.'))
  85. # Cache the token for 5 minutes
  86. _GOOGLE_DRIVE_CACHE[cache_key] = {
  87. 'token': access_token,
  88. 'expires': datetime.now() + timedelta(seconds=_CACHE_TIMEOUT)
  89. }
  90. return access_token
  91. def _test_access_token(self, access_token):
  92. """Test if the access token is still valid with better error handling"""
  93. try:
  94. headers = {
  95. 'Authorization': f'Bearer {access_token}',
  96. 'Content-Type': 'application/json'
  97. }
  98. response = requests.get(
  99. 'https://www.googleapis.com/drive/v3/about',
  100. headers=headers,
  101. params={'fields': 'user'},
  102. timeout=10
  103. )
  104. if response.status_code == 200:
  105. return True
  106. elif response.status_code == 401:
  107. _logger.warning("Google Drive access token is invalid (401)")
  108. return False
  109. else:
  110. _logger.warning(f"Google Drive API test failed with status {response.status_code}")
  111. return False
  112. except requests.exceptions.Timeout:
  113. _logger.error("Google Drive API test timeout")
  114. return False
  115. except requests.exceptions.ConnectionError:
  116. _logger.error("Google Drive API connection error")
  117. return False
  118. except Exception as e:
  119. _logger.error(f"Google Drive API test error: {str(e)}")
  120. return False
  121. def _refresh_access_token(self):
  122. """Refresh the access token using the refresh token"""
  123. try:
  124. refresh_token = self.env['ir.config_parameter'].sudo().get_param('google_api.refresh_token')
  125. client_id = self.env['ir.config_parameter'].sudo().get_param('google_api.client_id')
  126. client_secret = self.env['ir.config_parameter'].sudo().get_param('google_api.client_secret')
  127. if not all([refresh_token, client_id, client_secret]):
  128. return False
  129. # Exchange refresh token for new access token
  130. token_url = 'https://oauth2.googleapis.com/token'
  131. data = {
  132. 'client_id': client_id,
  133. 'client_secret': client_secret,
  134. 'refresh_token': refresh_token,
  135. 'grant_type': 'refresh_token'
  136. }
  137. response = requests.post(token_url, data=data, timeout=30)
  138. if response.status_code == 200:
  139. token_data = response.json()
  140. new_access_token = token_data.get('access_token')
  141. if new_access_token:
  142. # Store the new access token
  143. self.env['ir.config_parameter'].sudo().set_param('google_api.access_token', new_access_token)
  144. return True
  145. return False
  146. except Exception as e:
  147. _logger.error(f"Failed to refresh access token: {str(e)}")
  148. return False
  149. def _get_company_root_folder_id(self):
  150. """Get the company's root Google Drive folder ID"""
  151. if not self.company_id or not self.company_id.google_drive_crm_enabled:
  152. return None
  153. if not self.company_id.google_drive_crm_folder_id:
  154. return None
  155. return self.company_id.google_drive_crm_folder_id
  156. def _extract_folder_id_from_url(self, url):
  157. """Extract folder ID from Google Drive URL"""
  158. if not url:
  159. return None
  160. # Handle different Google Drive URL formats
  161. import re
  162. # Format: https://drive.google.com/drive/folders/FOLDER_ID
  163. folder_pattern = r'drive\.google\.com/drive/folders/([a-zA-Z0-9_-]+)'
  164. match = re.search(folder_pattern, url)
  165. if match:
  166. return match.group(1)
  167. # Format: https://drive.google.com/open?id=FOLDER_ID
  168. open_pattern = r'drive\.google\.com/open\?id=([a-zA-Z0-9_-]+)'
  169. match = re.search(open_pattern, url)
  170. if match:
  171. return match.group(1)
  172. return None
  173. def _get_google_drive_field_value(self):
  174. """Get value from the configured Google Drive field in company settings"""
  175. if not self.company_id.google_drive_crm_field_id:
  176. return None
  177. field_name = self.company_id.google_drive_crm_field_id.name
  178. if not field_name:
  179. return None
  180. # Get the field value from the record
  181. field_value = getattr(self, field_name, None)
  182. return field_value
  183. def _try_extract_folder_id_from_field(self):
  184. """Try to extract folder ID from the configured field value"""
  185. field_value = self._get_google_drive_field_value()
  186. if not field_value:
  187. return None
  188. # Try to extract folder ID from the field value (could be URL or direct ID)
  189. folder_id = self._extract_folder_id_from_url(field_value)
  190. if folder_id:
  191. return folder_id
  192. # If it's not a URL, check if it looks like a folder ID
  193. if isinstance(field_value, str) and len(field_value) >= 10 and len(field_value) <= 50:
  194. # Basic validation for Google Drive folder ID format
  195. import re
  196. if re.match(r'^[a-zA-Z0-9_-]+$', field_value):
  197. return field_value
  198. return None
  199. def _validate_folder_creation_prerequisites(self):
  200. """Validate prerequisites before creating Google Drive folder"""
  201. # Check if company has Google Drive folder configured
  202. root_folder_id = self._get_company_root_folder_id()
  203. if not root_folder_id:
  204. self.message_post(
  205. body=_("⚠️ Google Drive folder creation skipped: Company doesn't have Google Drive configured."),
  206. message_type='comment'
  207. )
  208. return False
  209. # Validate contact exists
  210. if not self.partner_id:
  211. self.message_post(
  212. body=_("⚠️ Google Drive folder creation skipped: No contact associated with this opportunity. Please assign a contact before creating Google Drive folders."),
  213. message_type='comment'
  214. )
  215. return False
  216. return True
  217. def _try_get_existing_folder_id(self):
  218. """Try to get existing folder ID from various sources"""
  219. # First, check if we already have a folder ID
  220. if self.google_drive_folder_id:
  221. return self.google_drive_folder_id
  222. # Second, try to extract from the configured field
  223. field_folder_id = self._try_extract_folder_id_from_field()
  224. if field_folder_id:
  225. _logger.info(f"Found folder ID from configured field: {field_folder_id}")
  226. return field_folder_id
  227. # Third, try to extract from google_drive_url field
  228. if self.google_drive_url:
  229. url_folder_id = self._extract_folder_id_from_url(self.google_drive_url)
  230. if url_folder_id:
  231. _logger.info(f"Found folder ID from URL field: {url_folder_id}")
  232. return url_folder_id
  233. return None
  234. def _check_folder_company_mismatch(self):
  235. """Check if the current folder belongs to the correct company"""
  236. if not self.google_drive_folder_id or not self.company_id:
  237. return False
  238. try:
  239. # Get access token
  240. access_token = self._get_google_drive_access_token()
  241. headers = {
  242. 'Authorization': f'Bearer {access_token}',
  243. 'Content-Type': 'application/json'
  244. }
  245. # Get company root folder ID
  246. company_root_folder_id = self._get_company_root_folder_id()
  247. if not company_root_folder_id:
  248. return False
  249. # Get current folder info
  250. response = requests.get(
  251. f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}?fields=parents',
  252. headers=headers,
  253. timeout=30
  254. )
  255. if response.status_code != 200:
  256. return False
  257. folder_data = response.json()
  258. parent_ids = folder_data.get('parents', [])
  259. if not parent_ids:
  260. return False
  261. # Navigate up to find if the folder is under the correct company root
  262. current_parent_id = parent_ids[0]
  263. max_levels = 5 # Prevent infinite loop
  264. for _ in range(max_levels):
  265. # Check if current parent is the company root folder
  266. if current_parent_id == company_root_folder_id:
  267. # Folder is under the correct company root
  268. return False
  269. parent_response = requests.get(
  270. f'https://www.googleapis.com/drive/v3/files/{current_parent_id}?fields=parents',
  271. headers=headers,
  272. timeout=30
  273. )
  274. if parent_response.status_code != 200:
  275. break
  276. parent_data = parent_response.json()
  277. parent_parent_ids = parent_data.get('parents', [])
  278. if not parent_parent_ids:
  279. # Reached the top level (My Drive), folder is not under the correct company root
  280. _logger.info(f"Folder belongs to wrong company. Current root: {current_parent_id}, Expected: {company_root_folder_id}")
  281. return True
  282. current_parent_id = parent_parent_ids[0]
  283. # If we reach here, folder is not under the correct company root
  284. _logger.info(f"Folder belongs to wrong company. Could not find company root: {company_root_folder_id}")
  285. return True
  286. except Exception as e:
  287. _logger.error(f"Error checking folder company mismatch: {str(e)}")
  288. return False
  289. def _get_folder_name_components(self):
  290. """Get the components for folder naming with priority for partner_id"""
  291. primary_name = None
  292. if self.partner_id:
  293. _logger.info(f"Contact found: {self.partner_id.name} (ID: {self.partner_id.id})")
  294. # Prioridad 1: partner_id.company_id.name
  295. if self.partner_id.company_id and self.partner_id.company_id.name:
  296. primary_name = self.partner_id.company_id.name
  297. _logger.info(f"Using company_id.name: {primary_name}")
  298. # Prioridad 2: partner_id.company_name
  299. elif self.partner_id.company_name:
  300. primary_name = self.partner_id.company_name
  301. _logger.info(f"Using company_name: {primary_name}")
  302. # Prioridad 3: partner_id.name
  303. else:
  304. primary_name = self.partner_id.name
  305. _logger.info(f"Using partner name: {primary_name}")
  306. else:
  307. _logger.warning("No contact assigned to opportunity")
  308. primary_name = "Sin Contacto"
  309. if not primary_name:
  310. raise UserError(_('No company or contact name available. Please assign a contact with company information before creating Google Drive folders.'))
  311. # Validate and sanitize the primary name
  312. sanitized_primary_name = self._sanitize_folder_name(primary_name)
  313. sanitized_opportunity_name = self._sanitize_folder_name(self.name)
  314. year = str(self.create_date.year) if self.create_date else str(datetime.now().year)
  315. _logger.info(f"Folder components - Primary: '{sanitized_primary_name}', Opportunity: '{sanitized_opportunity_name}', Year: {year}")
  316. return {
  317. 'primary_name': sanitized_primary_name,
  318. 'opportunity_name': sanitized_opportunity_name,
  319. 'year': year
  320. }
  321. def _check_partner_name_changes(self, vals):
  322. """Check if partner or partner's company name has changed"""
  323. if 'partner_id' not in vals:
  324. return False
  325. old_partner = self.partner_id
  326. new_partner_id = vals['partner_id']
  327. if not new_partner_id:
  328. return False
  329. new_partner = self.env['res.partner'].browse(new_partner_id)
  330. # Check if partner changed
  331. if old_partner.id != new_partner.id:
  332. return True
  333. # Check if partner's company name changed
  334. old_company_name = old_partner.parent_id.name if old_partner.parent_id else old_partner.name
  335. new_company_name = new_partner.parent_id.name if new_partner.parent_id else new_partner.name
  336. return old_company_name != new_company_name
  337. def _sanitize_folder_name(self, name):
  338. """Sanitize folder name to be Google Drive compatible with optimization"""
  339. if not name:
  340. return 'Sin nombre'
  341. # Use regex for better performance
  342. sanitized_name = re.sub(r'[<>:"|?*/\\]', '_', name)
  343. # Remove leading/trailing spaces and dots
  344. sanitized_name = sanitized_name.strip(' .')
  345. # Ensure it's not empty after sanitization
  346. if not sanitized_name:
  347. return 'Sin nombre'
  348. # Limit length to 255 characters (Google Drive limit)
  349. if len(sanitized_name) > 255:
  350. sanitized_name = sanitized_name[:252] + '...'
  351. return sanitized_name
  352. def _validate_folder_id_with_google_drive(self, folder_id):
  353. """Validate if the folder ID exists and is accessible in Google Drive"""
  354. try:
  355. access_token = self._get_google_drive_access_token()
  356. headers = {
  357. 'Authorization': f'Bearer {access_token}',
  358. 'Content-Type': 'application/json'
  359. }
  360. # Test access to the specific folder
  361. response = requests.get(
  362. f'https://www.googleapis.com/drive/v3/files/{folder_id}?fields=id,name,mimeType',
  363. headers=headers,
  364. timeout=10
  365. )
  366. if response.status_code == 200:
  367. folder_data = response.json()
  368. if folder_data.get('mimeType') == 'application/vnd.google-apps.folder':
  369. _logger.info(f"✅ Folder ID {folder_id} validated successfully in Google Drive")
  370. return True, folder_data.get('name', 'Unknown')
  371. else:
  372. _logger.warning(f"❌ ID {folder_id} exists but is not a folder")
  373. return False, "Not a folder"
  374. elif response.status_code == 404:
  375. _logger.warning(f"❌ Folder ID {folder_id} not found in Google Drive")
  376. return False, "Not found"
  377. elif response.status_code == 403:
  378. _logger.warning(f"❌ Access denied to folder ID {folder_id}")
  379. return False, "Access denied"
  380. elif response.status_code == 401:
  381. _logger.error(f"❌ OAuth token expired or invalid")
  382. return False, "Authentication error"
  383. else:
  384. _logger.warning(f"❌ Google Drive API error: {response.status_code}")
  385. return False, f"API error: {response.status_code}"
  386. except requests.exceptions.Timeout:
  387. _logger.error(f"❌ Timeout validating folder ID {folder_id}")
  388. return False, "Timeout"
  389. except requests.exceptions.ConnectionError:
  390. _logger.error(f"❌ Connection error validating folder ID {folder_id}")
  391. return False, "Connection error"
  392. except Exception as e:
  393. _logger.error(f"❌ Error validating folder ID {folder_id}: {str(e)}")
  394. return False, str(e)
  395. def _create_google_drive_folder_structure(self):
  396. """Create the complete Google Drive folder structure for this opportunity with optimization"""
  397. self.ensure_one()
  398. # Validate prerequisites
  399. if not self._validate_folder_creation_prerequisites():
  400. return None
  401. # Try to get existing folder ID first
  402. existing_folder_id = self._try_get_existing_folder_id()
  403. if existing_folder_id:
  404. _logger.info(f"Found existing folder ID: {existing_folder_id}")
  405. # Validate the folder ID against Google Drive
  406. is_valid, error_message = self._validate_folder_id_with_google_drive(existing_folder_id)
  407. if is_valid:
  408. _logger.info(f"✅ Folder ID {existing_folder_id} validated successfully")
  409. # Update the record with the existing folder ID
  410. self.with_context(skip_google_drive_update=True).write({
  411. 'google_drive_folder_id': existing_folder_id
  412. })
  413. # Get expected structure and store it
  414. expected_components = self._get_folder_name_components()
  415. expected_structure = self._build_structure_string(expected_components)
  416. self.with_context(skip_google_drive_update=True).write({
  417. 'google_drive_folder_name': expected_structure
  418. })
  419. self.message_post(
  420. body=_("✅ Using existing Google Drive folder: %s") % existing_folder_id,
  421. message_type='comment'
  422. )
  423. return True
  424. else:
  425. _logger.warning(f"❌ Folder ID {existing_folder_id} validation failed: {error_message}")
  426. self.message_post(
  427. body=_("⚠️ Folder ID from configured field is not accessible: %s. Creating new folder structure.") % error_message,
  428. message_type='comment'
  429. )
  430. # Continue to create new folder structure
  431. access_token = self._get_google_drive_access_token()
  432. components = self._get_folder_name_components()
  433. headers = {
  434. 'Authorization': f'Bearer {access_token}',
  435. 'Content-Type': 'application/json'
  436. }
  437. try:
  438. # Create folder structure in batch for better performance
  439. folder_structure = self._create_folder_structure_batch(headers, components)
  440. # Update the opportunity with the main folder ID
  441. self.with_context(skip_google_drive_update=True).write({
  442. 'google_drive_folder_id': folder_structure['opportunity_folder_id'],
  443. 'google_drive_folder_name': self._build_structure_string(components)
  444. })
  445. return folder_structure
  446. except Exception as e:
  447. _logger.error(f"Failed to create folder structure for opportunity {self.id}: {str(e)}")
  448. raise UserError(_('Failed to create Google Drive folder structure: %s') % str(e))
  449. def _create_folder_structure_batch(self, headers, components):
  450. """Create folder structure in batch for better performance"""
  451. root_folder_id = self._get_company_root_folder_id()
  452. # Step 1: Create or get primary folder (Company/Contact)
  453. primary_folder_id = self._create_or_get_folder(
  454. headers, root_folder_id, components['primary_name']
  455. )
  456. # Step 2: Create or get year folder
  457. year_folder_id = self._create_or_get_folder(
  458. headers, primary_folder_id, components['year']
  459. )
  460. # Step 3: Create or get opportunity folder
  461. opportunity_folder_id = self._create_or_get_folder(
  462. headers, year_folder_id, components['opportunity_name']
  463. )
  464. # Step 4: Create Meets and Archivos cliente folders (parallel creation)
  465. meets_folder_id = self._create_or_get_folder(
  466. headers, opportunity_folder_id, 'Meets'
  467. )
  468. archivos_folder_id = self._create_or_get_folder(
  469. headers, opportunity_folder_id, 'Archivos cliente'
  470. )
  471. return {
  472. 'opportunity_folder_id': opportunity_folder_id,
  473. 'meets_folder_id': meets_folder_id,
  474. 'archivos_folder_id': archivos_folder_id
  475. }
  476. def _create_or_get_folder(self, headers, parent_folder_id, folder_name):
  477. """Create a folder or get existing one by name"""
  478. # First, check if folder already exists
  479. existing_folder = self._find_folder_by_name(headers, parent_folder_id, folder_name)
  480. if existing_folder:
  481. return existing_folder['id']
  482. # Create new folder
  483. folder_metadata = {
  484. 'name': folder_name,
  485. 'mimeType': 'application/vnd.google-apps.folder',
  486. 'parents': [parent_folder_id]
  487. }
  488. response = requests.post(
  489. 'https://www.googleapis.com/drive/v3/files',
  490. headers=headers,
  491. json=folder_metadata,
  492. timeout=30
  493. )
  494. if response.status_code == 200:
  495. folder_data = response.json()
  496. return folder_data['id']
  497. else:
  498. raise UserError(_('Failed to create Google Drive folder "%s". Status: %s') % (folder_name, response.status_code))
  499. def _find_folder_by_name(self, headers, parent_folder_id, folder_name):
  500. """Find a folder by name in the parent folder with caching"""
  501. # Check cache first
  502. cache_key = f'folder_{parent_folder_id}_{folder_name}'
  503. if cache_key in _GOOGLE_DRIVE_CACHE:
  504. cache_entry = _GOOGLE_DRIVE_CACHE[cache_key]
  505. if datetime.now() < cache_entry['expires']:
  506. return cache_entry['result']
  507. params = {
  508. 'q': f"'{parent_folder_id}' in parents and name='{folder_name}' and mimeType='application/vnd.google-apps.folder' and trashed=false",
  509. 'fields': 'files(id,name)',
  510. 'pageSize': 1
  511. }
  512. try:
  513. response = requests.get(
  514. 'https://www.googleapis.com/drive/v3/files',
  515. headers=headers,
  516. params=params,
  517. timeout=30
  518. )
  519. if response.status_code == 200:
  520. data = response.json()
  521. folders = data.get('files', [])
  522. result = folders[0] if folders else None
  523. # Cache the result for 2 minutes
  524. _GOOGLE_DRIVE_CACHE[cache_key] = {
  525. 'result': result,
  526. 'expires': datetime.now() + timedelta(seconds=120)
  527. }
  528. return result
  529. else:
  530. _logger.warning(f"Failed to find folder '{folder_name}' in parent {parent_folder_id}. Status: {response.status_code}")
  531. return None
  532. except Exception as e:
  533. _logger.error(f"Error finding folder '{folder_name}' in parent {parent_folder_id}: {str(e)}")
  534. return None
  535. def _rename_google_drive_folder(self, new_name):
  536. """Rename the Google Drive folder with optimization"""
  537. if not self.google_drive_folder_id:
  538. return
  539. # Sanitize the new name
  540. sanitized_name = self._sanitize_folder_name(new_name)
  541. # Check if the name is actually different
  542. if self.google_drive_folder_name == sanitized_name:
  543. return
  544. access_token = self._get_google_drive_access_token()
  545. headers = {
  546. 'Authorization': f'Bearer {access_token}',
  547. 'Content-Type': 'application/json'
  548. }
  549. try:
  550. folder_metadata = {
  551. 'name': sanitized_name
  552. }
  553. _logger.info(f"Renaming Google Drive folder {self.google_drive_folder_id} to '{sanitized_name}'")
  554. response = requests.patch(
  555. f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}',
  556. headers=headers,
  557. json=folder_metadata,
  558. timeout=30
  559. )
  560. if response.status_code == 200:
  561. # Update the folder name in Odoo (with context to prevent loop)
  562. self.with_context(skip_google_drive_update=True).write({'google_drive_folder_name': sanitized_name})
  563. _logger.info(f"Successfully renamed Google Drive folder to '{sanitized_name}'")
  564. # Clear cache for this folder
  565. cache_key = f'folder_{self.google_drive_folder_id}'
  566. if cache_key in _GOOGLE_DRIVE_CACHE:
  567. del _GOOGLE_DRIVE_CACHE[cache_key]
  568. else:
  569. error_msg = f'Failed to rename Google Drive folder. Status: {response.status_code}'
  570. if response.text:
  571. error_msg += f' - Response: {response.text}'
  572. _logger.error(error_msg)
  573. raise UserError(_(error_msg))
  574. except requests.exceptions.Timeout:
  575. raise UserError(_('Timeout while renaming Google Drive folder. Please try again.'))
  576. except requests.exceptions.ConnectionError:
  577. raise UserError(_('Connection error while renaming Google Drive folder. Please check your internet connection.'))
  578. except Exception as e:
  579. _logger.error(f"Error renaming folder {self.google_drive_folder_id}: {str(e)}")
  580. raise UserError(_('Failed to rename Google Drive folder: %s') % str(e))
  581. def _move_google_drive_folder(self, new_company_id):
  582. """Move the Google Drive folder to a new company's structure"""
  583. if not self.google_drive_folder_id:
  584. return
  585. # Get new company's root folder
  586. new_root_folder_id = new_company_id.google_drive_crm_folder_id
  587. if not new_root_folder_id:
  588. raise UserError(_('New company does not have Google Drive CRM folder configured'))
  589. access_token = self._get_google_drive_access_token()
  590. headers = {
  591. 'Authorization': f'Bearer {access_token}',
  592. 'Content-Type': 'application/json'
  593. }
  594. # Get current folder's parent
  595. response = requests.get(
  596. f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}?fields=parents',
  597. headers=headers,
  598. timeout=30
  599. )
  600. if response.status_code != 200:
  601. raise UserError(_('Failed to get current folder information'))
  602. folder_data = response.json()
  603. current_parents = folder_data.get('parents', [])
  604. # Remove from current parent and add to new parent
  605. if current_parents:
  606. # Remove from current parent
  607. remove_response = requests.delete(
  608. f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}/parents/{current_parents[0]}',
  609. headers=headers,
  610. timeout=30
  611. )
  612. if remove_response.status_code != 204:
  613. raise UserError(_('Failed to remove folder from current parent'))
  614. # Add to new parent
  615. add_response = requests.post(
  616. f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}/parents',
  617. headers=headers,
  618. json={'id': new_root_folder_id},
  619. timeout=30
  620. )
  621. if add_response.status_code != 200:
  622. raise UserError(_('Failed to move folder to new company'))
  623. def _delete_google_drive_folder_structure(self):
  624. """Delete the Google Drive folder structure when contact is removed"""
  625. if not self.google_drive_folder_id:
  626. return
  627. access_token = self._get_google_drive_access_token()
  628. headers = {
  629. 'Authorization': f'Bearer {access_token}',
  630. }
  631. # Delete the folder (this will also delete subfolders)
  632. response = requests.delete(
  633. f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}',
  634. headers=headers,
  635. timeout=30
  636. )
  637. if response.status_code == 204:
  638. # Clear the folder ID from the record
  639. self.write({
  640. 'google_drive_folder_id': False,
  641. 'google_drive_folder_name': False
  642. })
  643. else:
  644. raise UserError(_('Failed to delete Google Drive folder structure'))
  645. def _recreate_google_drive_folder_structure(self):
  646. """Recreate the Google Drive folder structure when contact changes"""
  647. if not self.partner_id:
  648. raise UserError(_('No contact associated with this opportunity. Cannot recreate folder structure.'))
  649. # Store old folder information for reference
  650. old_folder_id = self.google_drive_folder_id
  651. old_folder_name = self.google_drive_folder_name
  652. # Clear the folder ID but don't delete the actual folder
  653. self.write({
  654. 'google_drive_folder_id': False,
  655. 'google_drive_folder_name': False
  656. })
  657. # Create new structure
  658. try:
  659. new_structure = self._create_google_drive_folder_structure()
  660. # Log the recreation for audit purposes
  661. if old_folder_id:
  662. _logger.info(f"Recreated Google Drive folder structure for opportunity {self.id}: "
  663. f"Old folder: {old_folder_id} ({old_folder_name}) -> "
  664. f"New folder: {self.google_drive_folder_id} ({self.google_drive_folder_name})")
  665. return new_structure
  666. except Exception as e:
  667. # Restore old values if recreation fails
  668. self.write({
  669. 'google_drive_folder_id': old_folder_id,
  670. 'google_drive_folder_name': old_folder_name
  671. })
  672. raise
  673. def _rename_entire_folder_structure(self, old_components, new_components):
  674. """Rename the entire folder structure instead of recreating it"""
  675. if not self.google_drive_folder_id:
  676. return
  677. access_token = self._get_google_drive_access_token()
  678. headers = {
  679. 'Authorization': f'Bearer {access_token}',
  680. 'Content-Type': 'application/json'
  681. }
  682. # Get the current folder path to understand the structure
  683. current_folder_id = self.google_drive_folder_id
  684. # Get folder information to find parent folders
  685. response = requests.get(
  686. f'https://www.googleapis.com/drive/v3/files/{current_folder_id}?fields=parents,name',
  687. headers=headers,
  688. timeout=30
  689. )
  690. if response.status_code != 200:
  691. raise UserError(_('Failed to get current folder information'))
  692. folder_data = response.json()
  693. parent_ids = folder_data.get('parents', [])
  694. if not parent_ids:
  695. raise UserError(_('Cannot rename folder structure: no parent folder found'))
  696. # Navigate up the hierarchy to find the primary folder (company/contact)
  697. primary_folder_id = self._find_primary_folder_id(headers, current_folder_id)
  698. if not primary_folder_id:
  699. raise UserError(_('Cannot find primary folder in the structure'))
  700. # Rename the primary folder (company/contact name)
  701. if old_components['primary_name'] != new_components['primary_name']:
  702. self._rename_folder(headers, primary_folder_id, new_components['primary_name'])
  703. # Find and rename year folder if needed
  704. if old_components['year'] != new_components['year']:
  705. year_folder_id = self._find_year_folder_id(headers, primary_folder_id, old_components['year'])
  706. if year_folder_id:
  707. self._rename_folder(headers, year_folder_id, new_components['year'])
  708. # Rename opportunity folder if needed
  709. if old_components['opportunity_name'] != new_components['opportunity_name']:
  710. _logger.info(f"Renaming opportunity folder from '{old_components['opportunity_name']}' to '{new_components['opportunity_name']}'")
  711. self._rename_folder(headers, current_folder_id, new_components['opportunity_name'])
  712. # Update the folder name in Odoo (with context to prevent loop)
  713. self.with_context(skip_google_drive_update=True).write({'google_drive_folder_name': new_components['opportunity_name']})
  714. else:
  715. _logger.info(f"Opportunity folder name is already correct: '{new_components['opportunity_name']}'")
  716. def _find_primary_folder_id(self, headers, current_folder_id):
  717. """Find the primary folder (company/contact) by navigating up the hierarchy"""
  718. max_depth = 5 # Prevent infinite loops
  719. current_id = current_folder_id
  720. for depth in range(max_depth):
  721. response = requests.get(
  722. f'https://www.googleapis.com/drive/v3/files/{current_id}?fields=parents,name',
  723. headers=headers,
  724. timeout=30
  725. )
  726. if response.status_code != 200:
  727. return None
  728. folder_data = response.json()
  729. parent_ids = folder_data.get('parents', [])
  730. if not parent_ids:
  731. # This is the root folder, go back one level
  732. return current_id
  733. # Check if this folder is the primary folder (not a year or opportunity folder)
  734. # Primary folder is typically the company/contact name
  735. folder_name = folder_data.get('name', '')
  736. if not folder_name.isdigit() and folder_name not in ['Meets', 'Archivos cliente']:
  737. # This could be the primary folder, but let's check if it has a year folder as child
  738. year_folders = self._find_folders_by_name(headers, current_id, r'^\d{4}$')
  739. if year_folders:
  740. # This is the primary folder
  741. return current_id
  742. current_id = parent_ids[0]
  743. return None
  744. def _find_year_folder_id(self, headers, primary_folder_id, year):
  745. """Find the year folder within the primary folder"""
  746. year_folders = self._find_folders_by_name(headers, primary_folder_id, f'^{year}$')
  747. return year_folders[0]['id'] if year_folders else None
  748. def _find_folders_by_name(self, headers, parent_id, name_pattern):
  749. """Find folders by name pattern in a parent folder"""
  750. import re
  751. params = {
  752. 'q': f"'{parent_id}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false",
  753. 'fields': 'files(id,name)',
  754. 'pageSize': 100
  755. }
  756. response = requests.get(
  757. 'https://www.googleapis.com/drive/v3/files',
  758. headers=headers,
  759. params=params,
  760. timeout=30
  761. )
  762. if response.status_code != 200:
  763. return []
  764. data = response.json()
  765. folders = data.get('files', [])
  766. # Filter by name pattern
  767. pattern = re.compile(name_pattern)
  768. return [folder for folder in folders if pattern.match(folder.get('name', ''))]
  769. def _rename_folder(self, headers, folder_id, new_name):
  770. """Rename a specific folder"""
  771. _logger.info(f"Attempting to rename folder {folder_id} to '{new_name}'")
  772. folder_metadata = {
  773. 'name': new_name
  774. }
  775. try:
  776. response = requests.patch(
  777. f'https://www.googleapis.com/drive/v3/files/{folder_id}',
  778. headers=headers,
  779. json=folder_metadata,
  780. timeout=30
  781. )
  782. if response.status_code == 200:
  783. _logger.info(f"Successfully renamed folder {folder_id} to '{new_name}'")
  784. else:
  785. _logger.error(f"Failed to rename folder {folder_id}. Status: {response.status_code}, Response: {response.text}")
  786. raise UserError(_('Failed to rename folder "%s" to "%s". Status: %s') % (folder_id, new_name, response.status_code))
  787. except requests.exceptions.Timeout:
  788. _logger.error(f"Timeout while renaming folder {folder_id}")
  789. raise UserError(_('Timeout while renaming folder "%s"') % folder_id)
  790. except requests.exceptions.ConnectionError:
  791. _logger.error(f"Connection error while renaming folder {folder_id}")
  792. raise UserError(_('Connection error while renaming folder "%s"') % folder_id)
  793. except Exception as e:
  794. _logger.error(f"Error renaming folder {folder_id}: {str(e)}")
  795. raise UserError(_('Failed to rename folder "%s" to "%s": %s') % (folder_id, new_name, str(e)))
  796. def _update_google_drive_folder_structure(self, vals):
  797. """Update the Google Drive folder structure based on changes"""
  798. if not self.google_drive_folder_id:
  799. return
  800. # Check if company has Google Drive folder configured
  801. root_folder_id = self._get_company_root_folder_id()
  802. if not root_folder_id:
  803. # Company doesn't have Google Drive configured, do nothing
  804. return
  805. # Get current folder information
  806. access_token = self._get_google_drive_access_token()
  807. headers = {
  808. 'Authorization': f'Bearer {access_token}',
  809. 'Content-Type': 'application/json'
  810. }
  811. # Check if company changed - move folder to new company structure
  812. if 'company_id' in vals and vals['company_id'] != self.company_id.id:
  813. new_company = self.env['res.company'].browse(vals['company_id'])
  814. if new_company.google_drive_crm_enabled and new_company.google_drive_crm_folder_id:
  815. _logger.info(f"Company changed from {self.company_id.name} to {new_company.name}. Moving Google Drive folder structure.")
  816. self._move_google_drive_folder(new_company)
  817. else:
  818. # If new company doesn't have Google Drive configured, keep the folder but log it
  819. self.message_post(
  820. body=_("⚠️ Company changed to one without Google Drive configuration. Existing folder structure remains unchanged."),
  821. message_type='comment'
  822. )
  823. return
  824. # Check if contact changed - this requires recreating the entire structure
  825. if 'partner_id' in vals:
  826. if not vals['partner_id']:
  827. # Contact was removed, but we don't delete - just log it
  828. self.message_post(
  829. body=_("⚠️ Contact was removed from opportunity. Google Drive folder structure remains unchanged."),
  830. message_type='comment'
  831. )
  832. return
  833. else:
  834. # Contact changed, recreate the entire structure
  835. _logger.info(f"Contact changed. Recreating entire Google Drive folder structure.")
  836. self._recreate_google_drive_folder_structure()
  837. return
  838. # Check if name changed - rename the opportunity folder
  839. if 'name' in vals and vals['name'] != self.name:
  840. _logger.info(f"Name changed from '{self.name}' to '{vals['name']}'. Renaming Google Drive folder.")
  841. self._rename_google_drive_folder(vals['name'])
  842. # Validate and update entire folder structure if needed (only for non-name changes)
  843. # This will handle changes in company name, contact name, or year
  844. if 'partner_id' in vals or 'create_date' in vals:
  845. self._validate_and_update_folder_structure(vals)
  846. # Check if stage changed and we need to create folder
  847. if 'stage_id' in vals:
  848. if self.company_id.google_drive_crm_enabled and self.company_id.google_drive_crm_stage_id:
  849. if vals['stage_id'] == self.company_id.google_drive_crm_stage_id.id and not self.google_drive_folder_id:
  850. # Check if company has Google Drive folder configured
  851. root_folder_id = self._get_company_root_folder_id()
  852. if not root_folder_id:
  853. # Company doesn't have Google Drive configured, do nothing
  854. return
  855. # Validate contact exists before attempting to create folder
  856. if not self.partner_id:
  857. self.message_post(
  858. body=_("⚠️ Google Drive folder creation skipped: No contact associated with this opportunity. Please assign a contact before creating Google Drive folders."),
  859. message_type='comment'
  860. )
  861. else:
  862. try:
  863. self._create_google_drive_folder_structure()
  864. except Exception as e:
  865. _logger.error(f"Failed to create Google Drive folder for opportunity {self.id}: {str(e)}")
  866. self.message_post(
  867. body=_("⚠️ Google Drive folder creation failed: %s") % str(e),
  868. message_type='comment'
  869. )
  870. def _validate_and_update_folder_structure(self, vals):
  871. """Validate and update the entire folder structure if any component changed"""
  872. if not self.google_drive_folder_id:
  873. return
  874. # Get current and new components
  875. current_components = self._get_folder_name_components()
  876. # Create a temporary record with new values to get new components
  877. temp_vals = {}
  878. if 'name' in vals:
  879. temp_vals['name'] = vals['name']
  880. if 'partner_id' in vals:
  881. temp_vals['partner_id'] = vals['partner_id']
  882. if 'create_date' in vals:
  883. temp_vals['create_date'] = vals['create_date']
  884. # Create a temporary record to get new components
  885. temp_record = self.with_context(skip_google_drive_update=True)
  886. for field, value in temp_vals.items():
  887. setattr(temp_record, field, value)
  888. try:
  889. new_components = temp_record._get_folder_name_components()
  890. except:
  891. # If we can't get new components, skip validation
  892. return
  893. # Check if any component changed
  894. components_changed = (
  895. current_components['primary_name'] != new_components['primary_name'] or
  896. current_components['year'] != new_components['year']
  897. )
  898. # Also check if partner name changed (even if same partner, name might have changed)
  899. partner_name_changed = self._check_partner_name_changes(vals)
  900. if components_changed or partner_name_changed:
  901. _logger.info(f"Folder structure components changed. Renaming entire structure.")
  902. _logger.info(f"Old components: {current_components}")
  903. _logger.info(f"New components: {new_components}")
  904. # Store the old folder information for reference
  905. old_folder_id = self.google_drive_folder_id
  906. old_folder_name = self.google_drive_folder_name
  907. old_structure = f"{current_components['primary_name']}/{current_components['year']}/{current_components['opportunity_name']}"
  908. new_structure = f"{new_components['primary_name']}/{new_components['year']}/{new_components['opportunity_name']}"
  909. # Rename the entire folder structure instead of recreating
  910. try:
  911. self._rename_entire_folder_structure(current_components, new_components)
  912. # Log the change for audit
  913. self.message_post(
  914. body=_("🔄 Google Drive folder structure renamed due to changes:<br/>"
  915. "• Old structure: %s<br/>"
  916. "• New structure: %s<br/>"
  917. "• Folder ID: %s (same folder, renamed)") % (
  918. old_structure,
  919. new_structure,
  920. self.google_drive_folder_id
  921. ),
  922. message_type='comment'
  923. )
  924. except Exception as e:
  925. _logger.error(f"Failed to rename folder structure: {str(e)}")
  926. self.message_post(
  927. body=_("❌ Failed to rename Google Drive folder structure: %s") % str(e),
  928. message_type='comment'
  929. )
  930. raise
  931. return
  932. # Check if company has Google Drive folder configured
  933. root_folder_id = self._get_company_root_folder_id()
  934. if not root_folder_id:
  935. # Company doesn't have Google Drive configured, do nothing
  936. return
  937. # Get current folder information
  938. access_token = self._get_google_drive_access_token()
  939. headers = {
  940. 'Authorization': f'Bearer {access_token}',
  941. 'Content-Type': 'application/json'
  942. }
  943. # Check if company changed - move folder to new company structure
  944. if 'company_id' in vals and vals['company_id'] != self.company_id.id:
  945. new_company = self.env['res.company'].browse(vals['company_id'])
  946. if new_company.google_drive_crm_enabled and new_company.google_drive_crm_folder_id:
  947. _logger.info(f"Company changed from {self.company_id.name} to {new_company.name}. Moving Google Drive folder structure.")
  948. self._move_google_drive_folder(new_company)
  949. else:
  950. # If new company doesn't have Google Drive configured, keep the folder but log it
  951. self.message_post(
  952. body=_("⚠️ Company changed to one without Google Drive configuration. Existing folder structure remains unchanged."),
  953. message_type='comment'
  954. )
  955. return
  956. # Check if contact changed - this requires recreating the entire structure
  957. if 'partner_id' in vals:
  958. if not vals['partner_id']:
  959. # Contact was removed, but we don't delete - just log it
  960. self.message_post(
  961. body=_("⚠️ Contact was removed from opportunity. Google Drive folder structure remains unchanged."),
  962. message_type='comment'
  963. )
  964. return
  965. else:
  966. # Contact changed, recreate the entire structure
  967. _logger.info(f"Contact changed. Recreating entire Google Drive folder structure.")
  968. self._recreate_google_drive_folder_structure()
  969. return
  970. # Check if name changed - rename the opportunity folder
  971. if 'name' in vals and vals['name'] != self.name:
  972. _logger.info(f"Name changed from '{self.name}' to '{vals['name']}'. Renaming Google Drive folder.")
  973. self._rename_google_drive_folder(vals['name'])
  974. # Validate and update entire folder structure if needed
  975. self._validate_and_update_folder_structure(vals)
  976. # Check if stage changed and we need to create folder
  977. if 'stage_id' in vals:
  978. if self.company_id.google_drive_crm_enabled and self.company_id.google_drive_crm_stage_id:
  979. if vals['stage_id'] == self.company_id.google_drive_crm_stage_id.id and not self.google_drive_folder_id:
  980. if self.partner_id:
  981. self._create_google_drive_folder_structure()
  982. else:
  983. self.message_post(
  984. body=_("⚠️ Google Drive folder creation skipped: No contact associated with this opportunity."),
  985. message_type='comment'
  986. )
  987. @api.model
  988. def create(self, vals):
  989. """Override create to handle Google Drive folder creation with optimization"""
  990. record = super().create(vals)
  991. # Check if we should create Google Drive folder (optimized conditions)
  992. if (record.company_id.google_drive_crm_enabled and
  993. record.company_id.google_drive_crm_stage_id and
  994. record.stage_id.id == record.company_id.google_drive_crm_stage_id.id):
  995. # Validate prerequisites before creating folder
  996. if record._validate_folder_creation_prerequisites():
  997. try:
  998. record._create_google_drive_folder_structure()
  999. # Store the initial structure and update URL
  1000. if record._store_initial_structure_and_update_url():
  1001. record.message_post(
  1002. body=_("✅ Google Drive folder created automatically"),
  1003. message_type='comment'
  1004. )
  1005. except Exception as e:
  1006. # Log error but don't fail record creation
  1007. _logger.error(f"Failed to create Google Drive folder for opportunity {record.id}: {str(e)}")
  1008. record.message_post(
  1009. body=_("⚠️ Google Drive folder creation failed: %s") % str(e),
  1010. message_type='comment'
  1011. )
  1012. return record
  1013. def write(self, vals):
  1014. """Override write method to handle Google Drive folder updates"""
  1015. # Skip Google Drive updates if this is an internal update (to prevent loops)
  1016. if self.env.context.get('skip_google_drive_update'):
  1017. return super().write(vals)
  1018. # Clear cache before processing
  1019. _clear_google_drive_cache()
  1020. # Check if any relevant fields are being updated
  1021. relevant_fields = ['name', 'partner_id', 'create_date', 'stage_id', 'company_id']
  1022. needs_update = any(field in vals for field in relevant_fields)
  1023. if not needs_update:
  1024. return super().write(vals)
  1025. # Store current values for comparison - PROCESAR TODAS LAS OPORTUNIDADES
  1026. current_values = {}
  1027. for record in self:
  1028. current_values[record.id] = {
  1029. 'name': record.name,
  1030. 'partner_id': record.partner_id.id if record.partner_id else None,
  1031. 'create_date': record.create_date,
  1032. 'company_id': record.company_id.id if record.company_id else None,
  1033. 'google_drive_folder_name': record.google_drive_folder_name or ''
  1034. }
  1035. # Execute the write first
  1036. result = super().write(vals)
  1037. # Now process Google Drive updates with updated values - PROCESAR TODAS
  1038. for record in self:
  1039. record._process_google_drive_updates_immediate(vals, current_values[record.id])
  1040. return result
  1041. def _process_google_drive_updates(self, vals):
  1042. """Process Google Drive updates for a single record"""
  1043. try:
  1044. # Check if we need to create folder (stage-based creation)
  1045. if 'stage_id' in vals and not self.google_drive_folder_id:
  1046. if not self._validate_folder_creation_prerequisites():
  1047. return
  1048. # If we have a folder, verify and update structure if needed
  1049. if self.google_drive_folder_id:
  1050. self._verify_and_update_folder_structure(vals)
  1051. except Exception as e:
  1052. _logger.error(f"Error processing Google Drive updates for record {self.id}: {str(e)}")
  1053. self.message_post(
  1054. body=_("❌ Error updating Google Drive: %s") % str(e),
  1055. message_type='comment'
  1056. )
  1057. def _process_google_drive_updates_immediate(self, vals, old_values):
  1058. """Process Google Drive updates immediately after write"""
  1059. try:
  1060. _logger.info(f"=== INICIO _process_google_drive_updates_immediate para oportunidad {self.id} ===")
  1061. _logger.info(f"Vals recibidos: {vals}")
  1062. _logger.info(f"Google Drive Folder ID actual: {self.google_drive_folder_id}")
  1063. # PASO 1: Verificar si necesitamos crear carpeta (stage-based creation)
  1064. should_create_folder = False
  1065. # Caso 1: Cambió stage_id y no tiene carpeta
  1066. if 'stage_id' in vals and not self.google_drive_folder_id:
  1067. should_create_folder = True
  1068. _logger.info(f"CASO 1: Stage changed for opportunity {self.id}. Checking if should create folder.")
  1069. # Caso 2: Ya está en el stage correcto, no tiene carpeta, y se actualizó cualquier campo
  1070. elif not self.google_drive_folder_id:
  1071. # Verificar si está en el stage correcto para crear automáticamente
  1072. company = self.company_id
  1073. _logger.info(f"CASO 2: Checking if opportunity {self.id} is in correct stage for auto-creation")
  1074. _logger.info(f"Company Google Drive enabled: {company.google_drive_crm_enabled}")
  1075. _logger.info(f"Company stage configured: {company.google_drive_crm_stage_id.name if company.google_drive_crm_stage_id else 'None'}")
  1076. _logger.info(f"Opportunity stage: {self.stage_id.name}")
  1077. if (company.google_drive_crm_enabled and
  1078. company.google_drive_crm_stage_id and
  1079. self.stage_id.id == company.google_drive_crm_stage_id.id):
  1080. should_create_folder = True
  1081. _logger.info(f"CASO 2: Opportunity {self.id} is in correct stage but has no folder. Will create.")
  1082. else:
  1083. _logger.info(f"CASO 2: Opportunity {self.id} is NOT in correct stage for auto-creation")
  1084. _logger.info(f"¿Debería crear carpeta?: {should_create_folder}")
  1085. # Crear carpeta si es necesario
  1086. if should_create_folder:
  1087. _logger.info(f"Intentando crear carpeta para oportunidad {self.id}")
  1088. if self._validate_folder_creation_prerequisites():
  1089. _logger.info(f"Prerrequisitos válidos. Creando Google Drive folder para opportunity {self.id}")
  1090. try:
  1091. self._create_google_drive_folder_structure()
  1092. _logger.info(f"Carpeta creada exitosamente para oportunidad {self.id}")
  1093. # Store the initial structure and update URL
  1094. if self._store_initial_structure_and_update_url():
  1095. self.message_post(
  1096. body=_("✅ Google Drive folder created automatically"),
  1097. message_type='comment'
  1098. )
  1099. except Exception as e:
  1100. _logger.error(f"ERROR creando carpeta para oportunidad {self.id}: {str(e)}")
  1101. raise
  1102. else:
  1103. _logger.info(f"Prerrequisitos no cumplidos para oportunidad {self.id}. Skipping folder creation.")
  1104. # PASO 2: Si ya tiene carpeta, verificar y actualizar estructura si es necesario
  1105. if self.google_drive_folder_id:
  1106. _logger.info(f"Oportunidad {self.id} ya tiene carpeta. Verificando estructura.")
  1107. self._verify_and_update_folder_structure_immediate(vals, old_values)
  1108. _logger.info(f"=== FIN _process_google_drive_updates_immediate para oportunidad {self.id} ===")
  1109. except Exception as e:
  1110. _logger.error(f"Error processing Google Drive updates for record {self.id}: {str(e)}")
  1111. self.message_post(
  1112. body=_("❌ Error updating Google Drive: %s") % str(e),
  1113. message_type='comment'
  1114. )
  1115. def _verify_and_update_folder_structure_immediate(self, vals, old_values):
  1116. """Verificar y actualizar estructura de Google Drive - SECUENCIAL"""
  1117. try:
  1118. _logger.info(f"Processing Google Drive updates for opportunity {self.id}")
  1119. # PASO 1: Si cambia company_id y tiene folder_id → Mover
  1120. if 'company_id' in vals and self.google_drive_folder_id:
  1121. new_company_id = vals['company_id']
  1122. if new_company_id != old_values.get('company_id'):
  1123. _logger.info(f"Company changed from {old_values.get('company_id')} to {new_company_id}. Moving folder.")
  1124. self._move_folder_to_new_company(new_company_id)
  1125. # PASO 2: Si cambia otro campo → Verificar string de estructura
  1126. relevant_fields = ['name', 'partner_id', 'create_date']
  1127. if any(field in vals for field in relevant_fields):
  1128. expected_structure = self._build_structure_string(self._get_folder_name_components())
  1129. current_structure = old_values.get('google_drive_folder_name', '')
  1130. _logger.info(f"Structure comparison: Current='{current_structure}' vs Expected='{expected_structure}'")
  1131. if expected_structure != current_structure:
  1132. _logger.info(f"Structure changed. Renaming folder structure.")
  1133. self._rename_entire_folder_structure_from_components(self._get_folder_name_components())
  1134. # Actualizar estructura local al final
  1135. expected_structure = self._build_structure_string(self._get_folder_name_components())
  1136. self.with_context(skip_google_drive_update=True).write({
  1137. 'google_drive_folder_name': expected_structure
  1138. })
  1139. self.message_post(
  1140. body=_("✅ Google Drive folder structure updated immediately"),
  1141. message_type='comment'
  1142. )
  1143. except Exception as e:
  1144. _logger.error(f"Error verifying folder structure: {str(e)}")
  1145. raise
  1146. def _verify_and_update_folder_structure(self, vals):
  1147. """Verify current structure vs expected and update if needed"""
  1148. try:
  1149. # Get expected structure components
  1150. expected_components = self._get_folder_name_components()
  1151. # Build expected structure string
  1152. expected_structure = self._build_structure_string(expected_components)
  1153. # Get current structure from stored field
  1154. current_structure = self.google_drive_folder_name or ''
  1155. _logger.info(f"Structure comparison for opportunity {self.id}:")
  1156. _logger.info(f"Current: '{current_structure}'")
  1157. _logger.info(f"Expected: '{expected_structure}'")
  1158. # Compare structures
  1159. if current_structure != expected_structure:
  1160. _logger.info(f"Structure mismatch detected. Updating Google Drive...")
  1161. # Determine what type of change occurred
  1162. if 'company_id' in vals:
  1163. self._handle_company_change(vals['company_id'])
  1164. else:
  1165. # For other changes, rename the structure
  1166. self._rename_entire_folder_structure_from_components(expected_components)
  1167. # Update the stored structure
  1168. self.with_context(skip_google_drive_update=True).write({
  1169. 'google_drive_folder_name': expected_structure
  1170. })
  1171. self.message_post(
  1172. body=_("✅ Google Drive folder structure updated successfully"),
  1173. message_type='comment'
  1174. )
  1175. else:
  1176. _logger.info(f"Structure is up to date. No changes needed.")
  1177. except Exception as e:
  1178. _logger.error(f"Error verifying folder structure: {str(e)}")
  1179. raise
  1180. def _build_structure_string(self, components):
  1181. """Build a string representation of the folder structure"""
  1182. return f"{components['primary_name']}/{components['year']}/{components['opportunity_name']}"
  1183. def _rename_entire_folder_structure_from_components(self, expected_components):
  1184. """Rename entire folder structure based on expected components"""
  1185. try:
  1186. access_token = self._get_google_drive_access_token()
  1187. headers = {
  1188. 'Authorization': f'Bearer {access_token}',
  1189. 'Content-Type': 'application/json'
  1190. }
  1191. # Get current structure from Google Drive
  1192. current_structure = self._analyze_complete_folder_structure(headers)
  1193. if not current_structure:
  1194. raise UserError(_('Could not analyze current folder structure'))
  1195. # Build current components from actual structure
  1196. current_components = {
  1197. 'primary_name': current_structure.get('primary_folder', {}).get('name', ''),
  1198. 'year': current_structure.get('year_folder', {}).get('name', ''),
  1199. 'opportunity_name': current_structure.get('opportunity_folder', {}).get('name', '')
  1200. }
  1201. # Rename the structure
  1202. self._rename_entire_folder_structure(current_components, expected_components)
  1203. except Exception as e:
  1204. _logger.error(f"Error renaming folder structure: {str(e)}")
  1205. raise
  1206. def _move_folder_to_new_company(self, new_company_id):
  1207. """Mover folder a nueva empresa - SIMPLE"""
  1208. if not self.google_drive_folder_id:
  1209. return
  1210. try:
  1211. # Obtener nueva empresa
  1212. new_company = self.env['res.company'].browse(new_company_id)
  1213. new_root_folder_id = new_company.google_drive_crm_folder_id
  1214. if not new_root_folder_id:
  1215. self.message_post(
  1216. body=_("⚠️ No se puede mover: Nueva empresa no tiene Google Drive configurado."),
  1217. message_type='comment'
  1218. )
  1219. return
  1220. _logger.info(f"Moviendo folder de {self.company_id.name} a {new_company.name}")
  1221. # Obtener access token
  1222. access_token = self._get_google_drive_access_token()
  1223. headers = {
  1224. 'Authorization': f'Bearer {access_token}',
  1225. 'Content-Type': 'application/json'
  1226. }
  1227. # Mover el folder primario al nuevo root
  1228. self._move_folder_to_new_parent(headers, new_root_folder_id)
  1229. self.message_post(
  1230. body=_("✅ Folder movido a nueva empresa: %s") % new_company.name,
  1231. message_type='comment'
  1232. )
  1233. except Exception as e:
  1234. _logger.error(f"Error moviendo folder: {str(e)}")
  1235. raise
  1236. def _handle_structural_changes(self, vals):
  1237. """Handle structural changes (partner_id, create_date) - rename entire structure"""
  1238. if not self.google_drive_folder_id:
  1239. return
  1240. try:
  1241. # Get current and new components
  1242. current_components = self._get_folder_name_components()
  1243. # Temporarily update the record to get new components
  1244. temp_record = self.with_context(skip_google_drive_update=True)
  1245. temp_vals = {}
  1246. if 'partner_id' in vals:
  1247. temp_vals['partner_id'] = vals['partner_id']
  1248. if 'create_date' in vals:
  1249. temp_vals['create_date'] = vals['create_date']
  1250. if temp_vals:
  1251. temp_record.write(temp_vals)
  1252. new_components = temp_record._get_folder_name_components()
  1253. # Check if any component changed
  1254. components_changed = (
  1255. current_components['primary_name'] != new_components['primary_name'] or
  1256. current_components['year'] != new_components['year']
  1257. )
  1258. if components_changed:
  1259. _logger.info(f"Structural changes detected. Renaming folder structure.")
  1260. self._rename_entire_folder_structure(current_components, new_components)
  1261. else:
  1262. _logger.info(f"No structural changes detected. Skipping rename.")
  1263. except Exception as e:
  1264. _logger.error(f"Error handling structural changes: {str(e)}")
  1265. raise
  1266. def _handle_name_change(self, new_name):
  1267. """Handle simple name change - only rename opportunity folder"""
  1268. if not self.google_drive_folder_id:
  1269. return
  1270. try:
  1271. sanitized_new_name = self._sanitize_folder_name(new_name)
  1272. self._rename_google_drive_folder(sanitized_new_name)
  1273. except Exception as e:
  1274. _logger.error(f"Error handling name change: {str(e)}")
  1275. raise
  1276. def _move_folder_to_new_parent(self, headers, new_parent_id):
  1277. """Move entire folder structure to new parent in Google Drive"""
  1278. try:
  1279. # Get current folder info and navigate up to find the primary folder (company/contact)
  1280. current_folder_id = self.google_drive_folder_id
  1281. # Navigate up the hierarchy to find the primary folder
  1282. primary_folder_id = self._find_primary_folder_id(headers, current_folder_id)
  1283. if not primary_folder_id:
  1284. raise UserError(_('Could not find primary folder in hierarchy'))
  1285. # Get current parent of primary folder
  1286. response = requests.get(
  1287. f'https://www.googleapis.com/drive/v3/files/{primary_folder_id}?fields=parents',
  1288. headers=headers,
  1289. timeout=30
  1290. )
  1291. if response.status_code != 200:
  1292. raise UserError(_('Failed to get primary folder information'))
  1293. current_data = response.json()
  1294. current_parents = current_data.get('parents', [])
  1295. if not current_parents:
  1296. raise UserError(_('Primary folder has no parent'))
  1297. current_parent_id = current_parents[0]
  1298. # Move the entire structure by moving the primary folder
  1299. move_response = requests.patch(
  1300. f'https://www.googleapis.com/drive/v3/files/{primary_folder_id}?addParents={new_parent_id}&removeParents={current_parent_id}',
  1301. headers=headers,
  1302. timeout=30
  1303. )
  1304. if move_response.status_code != 200:
  1305. raise UserError(_('Failed to move folder structure to new parent'))
  1306. _logger.info(f"Successfully moved entire folder structure from {current_parent_id} to {new_parent_id}")
  1307. except Exception as e:
  1308. _logger.error(f"Error moving folder structure: {str(e)}")
  1309. raise
  1310. def _find_primary_folder_id(self, headers, start_folder_id):
  1311. """Find the primary folder (company/contact level) in the hierarchy"""
  1312. try:
  1313. current_id = start_folder_id
  1314. # Navigate up to 3 levels to find the primary folder
  1315. for _ in range(3):
  1316. response = requests.get(
  1317. f'https://www.googleapis.com/drive/v3/files/{current_id}?fields=name,parents',
  1318. headers=headers,
  1319. timeout=30
  1320. )
  1321. if response.status_code != 200:
  1322. break
  1323. folder_data = response.json()
  1324. folder_name = folder_data.get('name', '')
  1325. parent_ids = folder_data.get('parents', [])
  1326. # Check if this is the primary folder (not year, not opportunity)
  1327. # Primary folder is typically the company/contact name
  1328. if not parent_ids:
  1329. break
  1330. # If this folder's name looks like a year (4 digits), continue up
  1331. if folder_name.isdigit() and len(folder_name) == 4:
  1332. current_id = parent_ids[0]
  1333. continue
  1334. # If this folder's name contains opportunity ID pattern, continue up
  1335. if ' - ' in folder_name and folder_name.split(' - ')[0].isdigit():
  1336. current_id = parent_ids[0]
  1337. continue
  1338. # This should be the primary folder
  1339. return current_id
  1340. return None
  1341. except Exception as e:
  1342. _logger.error(f"Error finding primary folder: {str(e)}")
  1343. return None
  1344. def action_open_google_drive_folder(self):
  1345. """Open Google Drive folder for this opportunity"""
  1346. self.ensure_one()
  1347. if not self.google_drive_folder_id:
  1348. raise UserError(_('No Google Drive folder configured for this opportunity'))
  1349. folder_url = f"https://drive.google.com/drive/folders/{self.google_drive_folder_id}"
  1350. return {
  1351. 'type': 'ir.actions.act_url',
  1352. 'url': folder_url,
  1353. 'target': 'new',
  1354. }
  1355. def action_create_google_drive_folder(self):
  1356. """Create Google Drive folder structure for this opportunity"""
  1357. self.ensure_one()
  1358. if self.google_drive_folder_id:
  1359. raise UserError(_('Google Drive folder already exists for this opportunity'))
  1360. # Check if company has Google Drive folder configured
  1361. root_folder_id = self._get_company_root_folder_id()
  1362. if not root_folder_id:
  1363. raise UserError(_('Google Drive CRM folder is not configured for this company. Please configure it in company settings.'))
  1364. try:
  1365. folder_structure = self._create_google_drive_folder_structure()
  1366. # Store the initial structure and update URL
  1367. self._store_initial_structure_and_update_url()
  1368. return {
  1369. 'type': 'ir.actions.client',
  1370. 'tag': 'display_notification',
  1371. 'params': {
  1372. 'title': _('Success'),
  1373. 'message': _('Google Drive folder structure created successfully!'),
  1374. 'type': 'success',
  1375. 'sticky': False,
  1376. }
  1377. }
  1378. except Exception as e:
  1379. raise UserError(_('Failed to create Google Drive folder structure: %s') % str(e))
  1380. def action_upload_to_google_drive(self):
  1381. """Upload documents to Google Drive"""
  1382. self.ensure_one()
  1383. if not self.google_drive_folder_id:
  1384. raise UserError(_('Please create a Google Drive folder for this opportunity first'))
  1385. try:
  1386. # TODO: Implement Google Drive API call to upload documents
  1387. # For now, just show a message
  1388. return {
  1389. 'type': 'ir.actions.client',
  1390. 'tag': 'display_notification',
  1391. 'params': {
  1392. 'title': _('Info'),
  1393. 'message': _('Document upload to Google Drive will be implemented soon.'),
  1394. 'type': 'info',
  1395. 'sticky': False,
  1396. }
  1397. }
  1398. except Exception as e:
  1399. raise UserError(_('Failed to upload to Google Drive: %s') % str(e))
  1400. def action_recreate_google_drive_structure(self):
  1401. """Manually rename the Google Drive folder structure"""
  1402. self.ensure_one()
  1403. if not self.google_drive_folder_id:
  1404. raise UserError(_('No Google Drive folder exists for this opportunity. Please create one first.'))
  1405. # Check if company has Google Drive folder configured
  1406. root_folder_id = self._get_company_root_folder_id()
  1407. if not root_folder_id:
  1408. raise UserError(_('Google Drive CRM folder is not configured for this company. Please configure it in company settings.'))
  1409. try:
  1410. # Get expected components
  1411. expected_components = self._get_folder_name_components()
  1412. # Get current folder name from Google Drive
  1413. access_token = self._get_google_drive_access_token()
  1414. headers = {
  1415. 'Authorization': f'Bearer {access_token}',
  1416. 'Content-Type': 'application/json'
  1417. }
  1418. response = requests.get(
  1419. f'https://www.googleapis.com/drive/v3/files/{self.google_drive_folder_id}?fields=name',
  1420. headers=headers,
  1421. timeout=30
  1422. )
  1423. if response.status_code != 200:
  1424. raise UserError(_('Failed to get current folder information from Google Drive'))
  1425. current_folder_data = response.json()
  1426. current_folder_name = current_folder_data.get('name', '')
  1427. _logger.info(f"Current folder name in Google Drive: '{current_folder_name}'")
  1428. _logger.info(f"Expected folder name: '{expected_components['opportunity_name']}'")
  1429. # Create old components with current Google Drive name
  1430. old_components = expected_components.copy()
  1431. old_components['opportunity_name'] = current_folder_name
  1432. # Rename the structure
  1433. self._rename_entire_folder_structure(old_components, expected_components)
  1434. # Update the stored structure
  1435. expected_structure = self._build_structure_string(expected_components)
  1436. self.with_context(skip_google_drive_update=True).write({
  1437. 'google_drive_folder_name': expected_structure
  1438. })
  1439. return {
  1440. 'type': 'ir.actions.client',
  1441. 'tag': 'display_notification',
  1442. 'params': {
  1443. 'title': _('Success'),
  1444. 'message': _('Google Drive folder structure renamed successfully!<br/>'
  1445. 'Folder ID: %s<br/>'
  1446. 'Old name: %s<br/>'
  1447. 'New name: %s') % (self.google_drive_folder_id, current_folder_name, expected_components['opportunity_name']),
  1448. 'type': 'success',
  1449. 'sticky': False,
  1450. }
  1451. }
  1452. except Exception as e:
  1453. _logger.error(f"Failed to rename Google Drive folder structure: {str(e)}")
  1454. raise UserError(_('Failed to rename Google Drive folder structure: %s') % str(e))
  1455. def action_analyze_folder_structure(self):
  1456. """Analyze current vs expected folder structure"""
  1457. self.ensure_one()
  1458. if not self.google_drive_folder_id:
  1459. raise UserError(_('No Google Drive folder exists for this opportunity. Please create one first.'))
  1460. try:
  1461. # Get expected components
  1462. expected_components = self._get_folder_name_components()
  1463. # Get current folder information
  1464. access_token = self._get_google_drive_access_token()
  1465. headers = {
  1466. 'Authorization': f'Bearer {access_token}',
  1467. 'Content-Type': 'application/json'
  1468. }
  1469. # Analyze current structure
  1470. current_structure = self._analyze_complete_folder_structure(headers)
  1471. # Compare structures
  1472. analysis = self._compare_folder_structures(expected_components, current_structure, headers)
  1473. return {
  1474. 'type': 'ir.actions.client',
  1475. 'tag': 'display_notification',
  1476. 'params': {
  1477. 'title': _('Folder Structure Analysis'),
  1478. 'message': analysis,
  1479. 'type': 'info',
  1480. 'sticky': True,
  1481. }
  1482. }
  1483. except Exception as e:
  1484. raise UserError(_('Failed to analyze folder structure: %s') % str(e))
  1485. def _analyze_current_folder_structure(self, headers):
  1486. """Analyze the current folder structure in Google Drive"""
  1487. current_folder_id = self.google_drive_folder_id
  1488. # Get current folder info
  1489. response = requests.get(
  1490. f'https://www.googleapis.com/drive/v3/files/{current_folder_id}?fields=name,parents',
  1491. headers=headers,
  1492. timeout=30
  1493. )
  1494. if response.status_code != 200:
  1495. return None
  1496. folder_data = response.json()
  1497. current_name = folder_data.get('name', '')
  1498. parent_ids = folder_data.get('parents', [])
  1499. # Navigate up the hierarchy
  1500. structure = {
  1501. 'opportunity_folder': {
  1502. 'id': current_folder_id,
  1503. 'name': current_name
  1504. }
  1505. }
  1506. if parent_ids:
  1507. # Get year folder
  1508. year_response = requests.get(
  1509. f'https://www.googleapis.com/drive/v3/files/{parent_ids[0]}?fields=name,parents',
  1510. headers=headers,
  1511. timeout=30
  1512. )
  1513. if year_response.status_code == 200:
  1514. year_data = year_response.json()
  1515. structure['year_folder'] = {
  1516. 'id': parent_ids[0],
  1517. 'name': year_data.get('name', '')
  1518. }
  1519. year_parent_ids = year_data.get('parents', [])
  1520. if year_parent_ids:
  1521. # Get primary folder (company/contact)
  1522. primary_response = requests.get(
  1523. f'https://www.googleapis.com/drive/v3/files/{year_parent_ids[0]}?fields=name',
  1524. headers=headers,
  1525. timeout=30
  1526. )
  1527. if primary_response.status_code == 200:
  1528. primary_data = primary_response.json()
  1529. structure['primary_folder'] = {
  1530. 'id': year_parent_ids[0],
  1531. 'name': primary_data.get('name', '')
  1532. }
  1533. return structure
  1534. def _analyze_complete_folder_structure(self, headers):
  1535. """Analyze the complete folder structure from root to opportunity"""
  1536. current_folder_id = self.google_drive_folder_id
  1537. # Get current folder info
  1538. response = requests.get(
  1539. f'https://www.googleapis.com/drive/v3/files/{current_folder_id}?fields=name,parents',
  1540. headers=headers,
  1541. timeout=30
  1542. )
  1543. if response.status_code != 200:
  1544. return None
  1545. folder_data = response.json()
  1546. current_name = folder_data.get('name', '')
  1547. parent_ids = folder_data.get('parents', [])
  1548. # Build complete structure
  1549. complete_structure = {
  1550. 'opportunity_folder': {
  1551. 'id': current_folder_id,
  1552. 'name': current_name,
  1553. 'level': 3
  1554. }
  1555. }
  1556. current_id = current_folder_id
  1557. level = 3 # Opportunity level
  1558. # Navigate up the hierarchy
  1559. for _ in range(5): # Max 5 levels up
  1560. if not parent_ids:
  1561. break
  1562. parent_id = parent_ids[0]
  1563. # Get parent folder info
  1564. parent_response = requests.get(
  1565. f'https://www.googleapis.com/drive/v3/files/{parent_id}?fields=name,parents',
  1566. headers=headers,
  1567. timeout=30
  1568. )
  1569. if parent_response.status_code != 200:
  1570. break
  1571. parent_data = parent_response.json()
  1572. parent_name = parent_data.get('name', '')
  1573. parent_ids = parent_data.get('parents', [])
  1574. level -= 1
  1575. if level == 2: # Year level
  1576. complete_structure['year_folder'] = {
  1577. 'id': parent_id,
  1578. 'name': parent_name,
  1579. 'level': level
  1580. }
  1581. elif level == 1: # Primary level (company/contact)
  1582. complete_structure['primary_folder'] = {
  1583. 'id': parent_id,
  1584. 'name': parent_name,
  1585. 'level': level
  1586. }
  1587. elif level == 0: # Root level
  1588. complete_structure['root_folder'] = {
  1589. 'id': parent_id,
  1590. 'name': parent_name,
  1591. 'level': level
  1592. }
  1593. current_id = parent_id
  1594. return complete_structure
  1595. def _compare_folder_structures(self, expected_components, current_structure, headers):
  1596. """Compare expected vs current folder structure"""
  1597. if not current_structure:
  1598. return _('❌ Could not analyze current folder structure')
  1599. analysis = f"<strong>📁 Complete Folder Structure Analysis</strong><br/><br/>"
  1600. # Expected structure
  1601. analysis += f"<strong>Expected Structure:</strong><br/>"
  1602. analysis += f"📁 [Root Folder] (MC Team)<br/>"
  1603. analysis += f"└── 📁 {expected_components['primary_name']} (Company/Contact)<br/>"
  1604. analysis += f" └── 📁 {expected_components['year']} (Year)<br/>"
  1605. analysis += f" └── 📁 {expected_components['opportunity_name']} (Opportunity)<br/>"
  1606. analysis += f" ├── 📁 Meets<br/>"
  1607. analysis += f" └── 📁 Archivos cliente<br/><br/>"
  1608. # Current structure
  1609. analysis += f"<strong>Current Structure in Google Drive:</strong><br/>"
  1610. # Root folder
  1611. if 'root_folder' in current_structure:
  1612. root_name = current_structure['root_folder']['name']
  1613. analysis += f"📁 {root_name} (Root)<br/>"
  1614. else:
  1615. analysis += f"📁 [Unknown Root]<br/>"
  1616. # Primary folder
  1617. if 'primary_folder' in current_structure:
  1618. primary_name = current_structure['primary_folder']['name']
  1619. analysis += f"└── 📁 {primary_name}"
  1620. if primary_name != expected_components['primary_name']:
  1621. analysis += f" ❌ (Expected: {expected_components['primary_name']})"
  1622. else:
  1623. analysis += " ✅"
  1624. analysis += "<br/>"
  1625. else:
  1626. analysis += f"└── 📁 [Missing Primary Folder] ❌<br/>"
  1627. # Year folder
  1628. if 'year_folder' in current_structure:
  1629. year_name = current_structure['year_folder']['name']
  1630. analysis += f" └── 📁 {year_name}"
  1631. if year_name != expected_components['year']:
  1632. analysis += f" ❌ (Expected: {expected_components['year']})"
  1633. else:
  1634. analysis += " ✅"
  1635. analysis += "<br/>"
  1636. else:
  1637. analysis += f" └── 📁 [Missing Year Folder] ❌<br/>"
  1638. # Opportunity folder
  1639. if 'opportunity_folder' in current_structure:
  1640. opp_name = current_structure['opportunity_folder']['name']
  1641. analysis += f" └── 📁 {opp_name}"
  1642. if opp_name != expected_components['opportunity_name']:
  1643. analysis += f" ❌ (Expected: {expected_components['opportunity_name']})"
  1644. else:
  1645. analysis += " ✅"
  1646. analysis += "<br/>"
  1647. else:
  1648. analysis += f" └── 📁 [Missing Opportunity Folder] ❌<br/>"
  1649. # Check subfolders
  1650. if 'opportunity_folder' in current_structure:
  1651. opp_id = current_structure['opportunity_folder']['id']
  1652. subfolders = self._get_subfolders(headers, opp_id)
  1653. if subfolders:
  1654. analysis += f" ├── 📁 Meets ✅<br/>"
  1655. analysis += f" └── 📁 Archivos cliente ✅<br/>"
  1656. else:
  1657. analysis += f" ├── 📁 Meets ❌ (Missing)<br/>"
  1658. analysis += f" └── 📁 Archivos cliente ❌ (Missing)<br/>"
  1659. # Summary
  1660. analysis += f"<br/><strong>Summary:</strong><br/>"
  1661. correct_count = 0
  1662. total_count = 0
  1663. if 'primary_folder' in current_structure:
  1664. total_count += 1
  1665. if current_structure['primary_folder']['name'] == expected_components['primary_name']:
  1666. correct_count += 1
  1667. if 'year_folder' in current_structure:
  1668. total_count += 1
  1669. if current_structure['year_folder']['name'] == expected_components['year']:
  1670. correct_count += 1
  1671. if 'opportunity_folder' in current_structure:
  1672. total_count += 1
  1673. if current_structure['opportunity_folder']['name'] == expected_components['opportunity_name']:
  1674. correct_count += 1
  1675. if total_count == 3 and correct_count == 3:
  1676. analysis += "✅ Complete structure is correct"
  1677. else:
  1678. analysis += f"❌ Structure has issues ({correct_count}/{total_count} correct). Use 'Rename Folder Structure' button to fix."
  1679. return analysis
  1680. def _get_subfolders(self, headers, parent_id):
  1681. """Get subfolders of a parent folder"""
  1682. params = {
  1683. 'q': f"'{parent_id}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false",
  1684. 'fields': 'files(id,name)',
  1685. 'pageSize': 100
  1686. }
  1687. try:
  1688. response = requests.get(
  1689. 'https://www.googleapis.com/drive/v3/files',
  1690. headers=headers,
  1691. params=params,
  1692. timeout=30
  1693. )
  1694. if response.status_code == 200:
  1695. data = response.json()
  1696. return data.get('files', [])
  1697. else:
  1698. return []
  1699. except:
  1700. return []
  1701. def _store_initial_structure_and_update_url(self):
  1702. """Centralized method to store initial structure and update URL"""
  1703. if self.google_drive_folder_id:
  1704. expected_components = self._get_folder_name_components()
  1705. expected_structure = self._build_structure_string(expected_components)
  1706. self.with_context(skip_google_drive_update=True).write({
  1707. 'google_drive_folder_name': expected_structure
  1708. })
  1709. # Update Google Drive URL if empty
  1710. self._update_google_drive_url()
  1711. # Copy URL to configured field if it's empty
  1712. self._copy_google_drive_url_to_configured_field()
  1713. _logger.info(f"Estructura inicial almacenada para oportunidad {self.id}")
  1714. return True
  1715. else:
  1716. _logger.error(f"ERROR: _create_google_drive_folder_structure no asignó google_drive_folder_id para oportunidad {self.id}")
  1717. return False
  1718. def _generate_google_drive_url(self):
  1719. """Generate Google Drive URL for the opportunity folder"""
  1720. if self.google_drive_folder_id:
  1721. return f"https://drive.google.com/drive/folders/{self.google_drive_folder_id}"
  1722. return False
  1723. def _update_google_drive_url(self):
  1724. """Update the google_drive_url field if it's empty and we have a folder ID"""
  1725. if self.google_drive_folder_id and not self.google_drive_url:
  1726. url = self._generate_google_drive_url()
  1727. if url:
  1728. self.with_context(skip_google_drive_update=True).write({
  1729. 'google_drive_url': url
  1730. })
  1731. _logger.info(f"Updated Google Drive URL for opportunity {self.id}: {url}")
  1732. def _extract_folder_id_from_url(self, url):
  1733. """Extract folder ID from Google Drive URL"""
  1734. if not url:
  1735. return None
  1736. # Handle different Google Drive URL formats
  1737. import re
  1738. # Format: https://drive.google.com/drive/folders/FOLDER_ID
  1739. folder_pattern = r'drive\.google\.com/drive/folders/([a-zA-Z0-9_-]+)'
  1740. match = re.search(folder_pattern, url)
  1741. if match:
  1742. return match.group(1)
  1743. # Format: https://drive.google.com/open?id=FOLDER_ID
  1744. open_pattern = r'drive\.google\.com/open\?id=([a-zA-Z0-9_-]+)'
  1745. match = re.search(open_pattern, url)
  1746. if match:
  1747. return match.group(1)
  1748. return None
  1749. def _get_configured_field_name(self):
  1750. """Get the field name configured in company settings"""
  1751. if not self.company_id or not self.company_id.google_drive_crm_field_id:
  1752. return None
  1753. return self.company_id.google_drive_crm_field_id.name
  1754. def _get_configured_field_value(self):
  1755. """Get the value of the configured field"""
  1756. field_name = self._get_configured_field_name()
  1757. if not field_name:
  1758. return None
  1759. return getattr(self, field_name, None)
  1760. def _set_configured_field_value(self, value):
  1761. """Set the value of the configured field"""
  1762. field_name = self._get_configured_field_name()
  1763. if not field_name:
  1764. return False
  1765. self.with_context(skip_google_drive_update=True).write({field_name: value})
  1766. return True
  1767. def _copy_google_drive_url_to_configured_field(self):
  1768. """Copy google_drive_url to the configured field if it's empty"""
  1769. if not self.google_drive_url:
  1770. return False
  1771. field_name = self._get_configured_field_name()
  1772. if not field_name:
  1773. return False
  1774. current_value = getattr(self, field_name, None)
  1775. if not current_value: # Solo si está vacío
  1776. self._set_configured_field_value(self.google_drive_url)
  1777. _logger.info(f"Copied google_drive_url to {field_name} for opportunity {self.id}")
  1778. return True
  1779. return False
  1780. def _validate_folder_id_with_google_drive(self, folder_id):
  1781. """Validate if the folder ID exists and is accessible in Google Drive"""
  1782. try:
  1783. access_token = self._get_google_drive_access_token()
  1784. headers = {
  1785. 'Authorization': f'Bearer {access_token}',
  1786. 'Content-Type': 'application/json'
  1787. }
  1788. # Test access to the specific folder - try both regular Drive and Shared Drive endpoints
  1789. folder_found = False
  1790. folder_name = 'Unknown'
  1791. # First, try the regular Drive API endpoint
  1792. response = requests.get(
  1793. f'https://www.googleapis.com/drive/v3/files/{folder_id}?fields=id,name,mimeType',
  1794. headers=headers,
  1795. timeout=10
  1796. )
  1797. if response.status_code == 200:
  1798. folder_data = response.json()
  1799. if folder_data.get('mimeType') == 'application/vnd.google-apps.folder':
  1800. _logger.info(f"✅ Folder ID {folder_id} validated successfully in Google Drive")
  1801. folder_found = True
  1802. folder_name = folder_data.get('name', 'Unknown')
  1803. else:
  1804. _logger.warning(f"❌ ID {folder_id} exists but is not a folder")
  1805. return False, "Not a folder"
  1806. elif response.status_code == 404:
  1807. # If not found in regular Drive, try Shared Drive endpoint
  1808. shared_drive_response = requests.get(
  1809. f'https://www.googleapis.com/drive/v3/drives/{folder_id}?fields=id,name',
  1810. headers=headers,
  1811. timeout=10
  1812. )
  1813. if shared_drive_response.status_code == 200:
  1814. shared_drive_data = shared_drive_response.json()
  1815. folder_name = shared_drive_data.get('name', 'Unknown')
  1816. folder_found = True
  1817. # For shared drives, we also need to check if we can access files within it
  1818. files_in_drive_url = f'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true&includeItemsFromAllDrives=true&corpora=drive&driveId={folder_id}&pageSize=1'
  1819. files_response = requests.get(files_in_drive_url, headers=headers, timeout=10)
  1820. if files_response.status_code != 200:
  1821. _logger.warning(f"❌ Access denied to Shared Drive {folder_id}")
  1822. return False, "Access denied to Shared Drive"
  1823. _logger.info(f"✅ Shared Drive ID {folder_id} validated successfully")
  1824. elif shared_drive_response.status_code == 403:
  1825. _logger.warning(f"❌ Access denied to Shared Drive {folder_id}")
  1826. return False, "Access denied to Shared Drive"
  1827. elif shared_drive_response.status_code == 404:
  1828. _logger.warning(f"❌ Folder ID {folder_id} not found in Google Drive or Shared Drives")
  1829. return False, "Not found"
  1830. else:
  1831. _logger.warning(f"❌ Shared Drive API error: {shared_drive_response.status_code}")
  1832. return False, f"Shared Drive API error: {shared_drive_response.status_code}"
  1833. elif response.status_code == 403:
  1834. _logger.warning(f"❌ Access denied to folder ID {folder_id}")
  1835. return False, "Access denied"
  1836. elif response.status_code == 401:
  1837. _logger.error(f"❌ OAuth token expired or invalid")
  1838. return False, "Authentication error"
  1839. else:
  1840. _logger.warning(f"❌ Google Drive API error: {response.status_code}")
  1841. return False, f"API error: {response.status_code}"
  1842. if folder_found:
  1843. return True, folder_name
  1844. else:
  1845. return False, "Not found"
  1846. except requests.exceptions.Timeout:
  1847. _logger.error(f"❌ Timeout validating folder ID {folder_id}")
  1848. return False, "Timeout"
  1849. except requests.exceptions.ConnectionError:
  1850. _logger.error(f"❌ Connection error validating folder ID {folder_id}")
  1851. return False, "Connection error"
  1852. except Exception as e:
  1853. _logger.error(f"❌ Error validating folder ID {folder_id}: {str(e)}")
  1854. return False, str(e)