Coverage for models / lox_api.py: 79%

249 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-28 01:16 +0000

1# -*- coding: utf-8 -*- 

2 

3from odoo import models, api 

4import requests 

5import json 

6import logging 

7import tempfile 

8import os 

9import subprocess 

10 

11_logger = logging.getLogger(__name__) 

12 

13 

14class LoxApi(models.AbstractModel): 

15 """LOX API Client - Abstract model for API interactions""" 

16 _name = 'lox.api' 

17 _description = 'LOX API Client' 

18 

19 @api.model 

20 def create_client(self, config, db_name=None): 

21 """Create a new API client instance wrapper""" 

22 if db_name is None: 

23 db_name = self.env.cr.dbname 

24 return LoxApiClient(config.api_url, config.api_key, config.source_id, db_name) 

25 

26 @api.model 

27 def create_client_from_params(self, api_url, api_key, source_id=None, db_name=None): 

28 """Create a new API client instance from parameters""" 

29 if db_name is None: 

30 db_name = self.env.cr.dbname 

31 return LoxApiClient(api_url, api_key, source_id, db_name) 

32 

33 

34class LoxApiClient: 

35 """API Client for LOX Backup Service""" 

36 

37 def __init__(self, api_url, api_key, source_id=None, db_name=None): 

38 self.api_url = api_url.rstrip('/') 

39 self.api_key = api_key 

40 self.source_id = source_id 

41 self.db_name = db_name 

42 self.timeout = 30 

43 self._site_identifier = None 

44 

45 def get_site_identifier(self): 

46 """Generate a unique identifier for this Odoo installation""" 

47 if self._site_identifier: 

48 return self._site_identifier 

49 

50 import hashlib 

51 

52 # Use database name as the base identifier 

53 db_name = self.db_name or 'odoo' 

54 

55 # Create a consistent hash for uniqueness 

56 hash_input = f"{db_name}-{self.api_url}" 

57 hash_suffix = hashlib.md5(hash_input.encode()).hexdigest()[:8] 

58 

59 self._site_identifier = f"odoo-{db_name}-{hash_suffix}" 

60 return self._site_identifier 

61 

62 def _get_headers(self): 

63 """Get request headers""" 

64 return { 

65 'X-API-Key': self.api_key, 

66 'Content-Type': 'application/json', 

67 'Accept': 'application/json', 

68 } 

69 

70 def _request(self, method, endpoint, data=None, params=None, timeout=None): 

71 """Make API request""" 

72 url = f'{self.api_url}/v1{endpoint}' 

73 headers = self._get_headers() 

74 timeout = timeout or self.timeout 

75 

76 _logger.debug(f'LOX API {method} {url}') 

77 

78 try: 

79 response = requests.request( 

80 method=method, 

81 url=url, 

82 headers=headers, 

83 json=data, 

84 params=params, 

85 timeout=timeout, 

86 ) 

87 

88 if response.status_code >= 400: 

89 _logger.error(f'LOX API error: {response.status_code} - {response.text}') 

90 return self._handle_error_response(response) 

91 

92 try: 

93 return response.json() 

94 except: 

95 return {'success': True, 'data': response.text} 

96 

97 except requests.exceptions.Timeout: 

98 _logger.error('LOX API timeout') 

99 return {'success': False, 'error_code': 'lox_conn_timeout', 'error': 'Request timeout'} 

100 except requests.exceptions.ConnectionError as e: 

101 _logger.error(f'LOX API connection error: {e}') 

102 return {'success': False, 'error_code': 'lox_conn_failed', 'error': f'Connection error: {str(e)}'} 

103 except Exception as e: 

104 _logger.exception('LOX API unexpected error') 

105 return {'success': False, 'error_code': 'lox_upload_failed', 'error': str(e)} 

106 

107 def _handle_error_response(self, response): 

108 """Handle API error response with standard error codes""" 

109 status_code = response.status_code 

110 try: 

111 error_data = response.json() 

112 message = error_data.get('detail', response.text) 

113 except: 

114 message = response.text 

115 

116 if status_code == 401: 

117 return {'success': False, 'error_code': 'lox_auth_invalid_key', 'error': message or 'Authentication failed'} 

118 elif status_code == 403: 

119 return {'success': False, 'error_code': 'lox_auth_forbidden', 'error': message or 'Access denied'} 

120 elif status_code == 404: 

121 return {'success': False, 'error_code': 'lox_backup_not_found', 'error': message or 'Resource not found'} 

122 elif status_code == 413: 

123 if 'quota' in message.lower(): 

124 return {'success': False, 'error_code': 'lox_upload_quota_exceeded', 'error': message} 

125 return {'success': False, 'error_code': 'lox_upload_file_too_large', 'error': message or 'File too large'} 

126 elif status_code == 429: 

127 return {'success': False, 'error_code': 'lox_rate_exceeded', 'error': message or 'Rate limit exceeded'} 

128 else: 

129 return {'success': False, 'error_code': 'lox_upload_failed', 'error': message} 

130 

131 def test_connection(self): 

132 """Test API connection by fetching tenant info""" 

133 result = self._request('GET', '/tenants/me') 

134 if result.get('success') and result.get('data'): 

135 return {'success': True, 'tenant': result.get('data')} 

136 elif result.get('id') or result.get('slug'): 

137 # Direct response from API (not wrapped) 

138 return {'success': True, 'tenant': result} 

139 return result 

140 

141 def register_source(self, source_data): 

142 """Register a new backup source""" 

143 return self._request('POST', '/sources', data=source_data) 

144 

145 def get_source(self, source_id): 

146 """Get source details""" 

147 return self._request('GET', f'/sources/{source_id}') 

148 

149 def update_source(self, source_id, source_data): 

150 """Update source""" 

151 return self._request('PATCH', f'/sources/{source_id}', data=source_data) 

152 

153 def create_backup(self, backup_data): 

154 """Create a new backup""" 

155 return self._request('POST', '/backups', data=backup_data) 

156 

157 def get_backup_status(self, backup_uuid): 

158 """Get backup status""" 

159 return self._request('GET', f'/backups/{backup_uuid}') 

160 

161 def list_backups(self, source_id=None, status=None, limit=50, offset=0): 

162 """List backups""" 

163 params = { 

164 'limit': limit, 

165 'offset': offset, 

166 } 

167 if source_id: 

168 params['source_id'] = source_id 

169 if status: 

170 params['status'] = status 

171 

172 return self._request('GET', '/backups', params=params) 

173 

174 def request_restore(self, backup_uuid, priority='normal'): 

175 """Request backup restore""" 

176 return self._request('POST', f'/backups/{backup_uuid}/restore', data={ 

177 'priority': priority, 

178 }) 

179 

180 def get_download_url(self, backup_uuid): 

181 """Get download URL for backup""" 

182 return self._request('GET', f'/backups/{backup_uuid}/download') 

183 

184 def cancel_backup(self, backup_uuid): 

185 """Cancel a pending backup""" 

186 return self._request('POST', f'/backups/{backup_uuid}/cancel') 

187 

188 # ============================================ 

189 # Remote Profiles API 

190 # ============================================ 

191 

192 def get_profiles(self, source='odoo', source_identifier=None): 

193 """Get backup profiles from server. 

194 

195 Args: 

196 source: Filter by source (default: odoo) 

197 source_identifier: Filter by site identifier (default: current site) 

198 

199 Returns: 

200 API response with profiles list 

201 """ 

202 params = {'source': source} 

203 if source_identifier is None: 

204 source_identifier = self.get_site_identifier() 

205 if source_identifier: 

206 params['source_identifier'] = source_identifier 

207 

208 # Note: trailing slash required for this endpoint 

209 return self._request('GET', '/backup-profiles/', params=params) 

210 

211 def get_profile(self, profile_uuid, limit=10): 

212 """Get a specific backup profile with recent backups. 

213 

214 Args: 

215 profile_uuid: Profile UUID 

216 limit: Number of recent backups to include 

217 

218 Returns: 

219 API response with profile details and backups 

220 """ 

221 return self._request('GET', f'/backup-profiles/{profile_uuid}', params={'limit': limit}) 

222 

223 def get_profile_versions(self, profile_uuid, page=1, per_page=20): 

224 """Get all versions (backups) for a profile. 

225 

226 Args: 

227 profile_uuid: Profile UUID 

228 page: Page number 

229 per_page: Items per page 

230 

231 Returns: 

232 API response with paginated backups list 

233 """ 

234 return self._request('GET', f'/backup-profiles/{profile_uuid}/versions', params={ 

235 'page': page, 

236 'per_page': per_page, 

237 }) 

238 

239 def create_profile(self, name, options=None): 

240 """Create a custom backup profile on the server. 

241 

242 Args: 

243 name: Profile name 

244 options: Additional profile options (description, metadata, etc.) 

245 

246 Returns: 

247 API response with created profile 

248 """ 

249 options = options or {} 

250 data = { 

251 'name': name, 

252 'source': 'odoo', 

253 'source_identifier': self.get_site_identifier(), 

254 'is_custom': True, 

255 } 

256 data.update(options) 

257 return self._request('POST', '/backup-profiles', data=data) 

258 

259 def update_profile(self, profile_uuid, data): 

260 """Update a backup profile. 

261 

262 Args: 

263 profile_uuid: Profile UUID 

264 data: Update data 

265 

266 Returns: 

267 API response with updated profile 

268 """ 

269 return self._request('PATCH', f'/backup-profiles/{profile_uuid}', data=data) 

270 

271 def get_site_metadata(self, env=None): 

272 """Get Odoo site metadata for backup context. 

273 

274 Args: 

275 env: Odoo environment (optional, for additional metadata) 

276 

277 Returns: 

278 Dict with site metadata 

279 """ 

280 import odoo 

281 from odoo.release import version_info 

282 

283 metadata = { 

284 'site_name': self.db_name, 

285 'odoo_version': '.'.join(map(str, version_info[:3])), 

286 'odoo_edition': 'enterprise' if hasattr(odoo, 'addons_path') else 'community', 

287 'python_version': '.'.join(map(str, __import__('sys').version_info[:3])), 

288 'source': 'odoo', 

289 'source_identifier': self.get_site_identifier(), 

290 } 

291 

292 if env: 

293 try: 

294 # Get additional metadata from Odoo 

295 IrModule = env['ir.module.module'].sudo() 

296 installed_count = IrModule.search_count([('state', '=', 'installed')]) 

297 metadata['module_count'] = installed_count 

298 

299 # Get company info 

300 company = env.company 

301 if company: 

302 metadata['company_name'] = company.name 

303 metadata['currency'] = company.currency_id.name if company.currency_id else None 

304 

305 # Get language 

306 metadata['locale'] = env.lang or 'en_US' 

307 

308 except Exception as e: 

309 _logger.warning(f'Could not get additional metadata: {e}') 

310 

311 return metadata 

312 

313 # NOTE: delete_backup is intentionally NOT implemented 

314 # Backups expire automatically based on retention policy 

315 # Manual deletion is disabled as a security measure to prevent 

316 # attackers from deleting backups if credentials are compromised 

317 

318 def upload_backup(self, file_path, options=None, env=None): 

319 """Upload backup file using standard multipart POST to /v1/backups. 

320 

321 Args: 

322 file_path: Path to the backup file 

323 options: Dict with optional keys: 

324 - name: Backup name (defaults to filename) 

325 - description: Backup description 

326 - tags: Comma-separated tags or list 

327 - retention_days: Retention period (default 30) 

328 - component: Component being backed up (database, filestore, full) 

329 - metadata: Additional metadata dict 

330 - profile_uuid: Associate backup with a remote profile 

331 - extra_metadata: Site metadata (auto-generated if not provided) 

332 env: Odoo environment for generating site metadata 

333 

334 Returns: 

335 API response dict with backup info or error 

336 """ 

337 options = options or {} 

338 url = f'{self.api_url}/v1/backups' 

339 headers = { 

340 'X-API-Key': self.api_key, 

341 'User-Agent': 'LOX-Odoo/1.0.0', 

342 } 

343 

344 # Prepare form fields 

345 form_data = { 

346 'name': options.get('name', os.path.basename(file_path)), 

347 'source': 'odoo', 

348 'source_identifier': self.get_site_identifier(), 

349 'retention_days': str(options.get('retention_days', 30)), 

350 } 

351 

352 if options.get('description'): 

353 form_data['description'] = options['description'] 

354 

355 if options.get('tags'): 

356 tags = options['tags'] 

357 if isinstance(tags, list): 

358 tags = ','.join(tags) 

359 form_data['tags'] = tags 

360 

361 if options.get('component'): 

362 form_data['component'] = options['component'] 

363 

364 if options.get('metadata'): 

365 form_data['metadata'] = json.dumps(options['metadata']) 

366 

367 # Add profile_uuid if provided (for versioned profiles) 

368 if options.get('profile_uuid'): 

369 form_data['profile_uuid'] = options['profile_uuid'] 

370 

371 # Add extra_metadata (site info for backup context) 

372 if options.get('extra_metadata'): 

373 extra = options['extra_metadata'] 

374 if isinstance(extra, dict): 

375 extra = json.dumps(extra) 

376 form_data['extra_metadata'] = extra 

377 else: 

378 # Auto-generate site metadata 

379 form_data['extra_metadata'] = json.dumps(self.get_site_metadata(env)) 

380 

381 try: 

382 with open(file_path, 'rb') as f: 

383 files = {'file': (os.path.basename(file_path), f, 'application/gzip')} 

384 response = requests.post( 

385 url, 

386 headers=headers, 

387 data=form_data, 

388 files=files, 

389 timeout=3600, # 1 hour timeout for large files 

390 ) 

391 

392 if response.status_code >= 400: 

393 _logger.error(f'Upload failed: {response.status_code} - {response.text}') 

394 return self._handle_error_response(response) 

395 

396 return response.json() 

397 

398 except requests.exceptions.Timeout: 

399 _logger.error('Upload timeout') 

400 return {'success': False, 'error_code': 'lox_conn_timeout', 'error': 'Upload timeout'} 

401 except FileNotFoundError: 

402 return {'success': False, 'error_code': 'lox_upload_file_not_found', 'error': f'File not found: {file_path}'} 

403 except Exception as e: 

404 _logger.exception('Upload failed') 

405 return {'success': False, 'error_code': 'lox_upload_failed', 'error': str(e)} 

406 

407 

408class OdooBackupCreator: 

409 """Helper class to create Odoo backups""" 

410 

411 def __init__(self, env): 

412 self.env = env 

413 self.db_name = env.cr.dbname 

414 

415 def create_database_backup(self): 

416 """Create database backup""" 

417 temp_dir = tempfile.mkdtemp() 

418 backup_file = os.path.join(temp_dir, f'{self.db_name}_db.sql') 

419 

420 try: 

421 # Use pg_dump to create database backup 

422 cmd = [ 

423 'pg_dump', 

424 '--no-owner', 

425 '--no-acl', 

426 '-f', backup_file, 

427 self.db_name, 

428 ] 

429 

430 result = subprocess.run( 

431 cmd, 

432 capture_output=True, 

433 text=True, 

434 ) 

435 

436 if result.returncode != 0: 

437 raise Exception(f'pg_dump failed: {result.stderr}') 

438 

439 # Compress the backup 

440 compressed_file = backup_file + '.gz' 

441 import gzip 

442 import shutil 

443 

444 with open(backup_file, 'rb') as f_in: 

445 with gzip.open(compressed_file, 'wb') as f_out: 

446 shutil.copyfileobj(f_in, f_out) 

447 

448 os.remove(backup_file) 

449 return compressed_file 

450 

451 except Exception as e: 

452 _logger.exception('Database backup failed') 

453 raise 

454 

455 def create_filestore_backup(self): 

456 """Create filestore backup""" 

457 import tarfile 

458 

459 # Get filestore path 

460 data_dir = self.env['ir.config_parameter'].sudo().get_param( 

461 'ir_attachment.location', 

462 default='/var/lib/odoo/filestore' 

463 ) 

464 

465 filestore_path = os.path.join(data_dir, self.db_name) 

466 

467 if not os.path.exists(filestore_path): 

468 # Try alternative path 

469 filestore_path = os.path.join('/var/lib/odoo/filestore', self.db_name) 

470 

471 if not os.path.exists(filestore_path): 

472 _logger.warning(f'Filestore not found at {filestore_path}') 

473 return None 

474 

475 temp_dir = tempfile.mkdtemp() 

476 backup_file = os.path.join(temp_dir, f'{self.db_name}_filestore.tar.gz') 

477 

478 try: 

479 with tarfile.open(backup_file, 'w:gz') as tar: 

480 tar.add(filestore_path, arcname='filestore') 

481 

482 return backup_file 

483 

484 except Exception as e: 

485 _logger.exception('Filestore backup failed') 

486 raise 

487 

488 def create_full_backup(self): 

489 """Create full backup (database + filestore)""" 

490 import tarfile 

491 

492 temp_dir = tempfile.mkdtemp() 

493 backup_file = os.path.join(temp_dir, f'{self.db_name}_full.tar.gz') 

494 

495 try: 

496 db_backup = self.create_database_backup() 

497 filestore_backup = self.create_filestore_backup() 

498 

499 with tarfile.open(backup_file, 'w:gz') as tar: 

500 if db_backup: 

501 tar.add(db_backup, arcname=os.path.basename(db_backup)) 

502 if filestore_backup: 

503 tar.add(filestore_backup, arcname=os.path.basename(filestore_backup)) 

504 

505 # Cleanup temp files 

506 if db_backup: 

507 os.remove(db_backup) 

508 if filestore_backup: 

509 os.remove(filestore_backup) 

510 

511 return backup_file 

512 

513 except Exception as e: 

514 _logger.exception('Full backup failed') 

515 raise