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
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-28 01:16 +0000
1# -*- coding: utf-8 -*-
3from odoo import models, api
4import requests
5import json
6import logging
7import tempfile
8import os
9import subprocess
11_logger = logging.getLogger(__name__)
14class LoxApi(models.AbstractModel):
15 """LOX API Client - Abstract model for API interactions"""
16 _name = 'lox.api'
17 _description = 'LOX API Client'
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)
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)
34class LoxApiClient:
35 """API Client for LOX Backup Service"""
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
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
50 import hashlib
52 # Use database name as the base identifier
53 db_name = self.db_name or 'odoo'
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]
59 self._site_identifier = f"odoo-{db_name}-{hash_suffix}"
60 return self._site_identifier
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 }
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
76 _logger.debug(f'LOX API {method} {url}')
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 )
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)
92 try:
93 return response.json()
94 except:
95 return {'success': True, 'data': response.text}
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)}
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
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}
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
141 def register_source(self, source_data):
142 """Register a new backup source"""
143 return self._request('POST', '/sources', data=source_data)
145 def get_source(self, source_id):
146 """Get source details"""
147 return self._request('GET', f'/sources/{source_id}')
149 def update_source(self, source_id, source_data):
150 """Update source"""
151 return self._request('PATCH', f'/sources/{source_id}', data=source_data)
153 def create_backup(self, backup_data):
154 """Create a new backup"""
155 return self._request('POST', '/backups', data=backup_data)
157 def get_backup_status(self, backup_uuid):
158 """Get backup status"""
159 return self._request('GET', f'/backups/{backup_uuid}')
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
172 return self._request('GET', '/backups', params=params)
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 })
180 def get_download_url(self, backup_uuid):
181 """Get download URL for backup"""
182 return self._request('GET', f'/backups/{backup_uuid}/download')
184 def cancel_backup(self, backup_uuid):
185 """Cancel a pending backup"""
186 return self._request('POST', f'/backups/{backup_uuid}/cancel')
188 # ============================================
189 # Remote Profiles API
190 # ============================================
192 def get_profiles(self, source='odoo', source_identifier=None):
193 """Get backup profiles from server.
195 Args:
196 source: Filter by source (default: odoo)
197 source_identifier: Filter by site identifier (default: current site)
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
208 # Note: trailing slash required for this endpoint
209 return self._request('GET', '/backup-profiles/', params=params)
211 def get_profile(self, profile_uuid, limit=10):
212 """Get a specific backup profile with recent backups.
214 Args:
215 profile_uuid: Profile UUID
216 limit: Number of recent backups to include
218 Returns:
219 API response with profile details and backups
220 """
221 return self._request('GET', f'/backup-profiles/{profile_uuid}', params={'limit': limit})
223 def get_profile_versions(self, profile_uuid, page=1, per_page=20):
224 """Get all versions (backups) for a profile.
226 Args:
227 profile_uuid: Profile UUID
228 page: Page number
229 per_page: Items per page
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 })
239 def create_profile(self, name, options=None):
240 """Create a custom backup profile on the server.
242 Args:
243 name: Profile name
244 options: Additional profile options (description, metadata, etc.)
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)
259 def update_profile(self, profile_uuid, data):
260 """Update a backup profile.
262 Args:
263 profile_uuid: Profile UUID
264 data: Update data
266 Returns:
267 API response with updated profile
268 """
269 return self._request('PATCH', f'/backup-profiles/{profile_uuid}', data=data)
271 def get_site_metadata(self, env=None):
272 """Get Odoo site metadata for backup context.
274 Args:
275 env: Odoo environment (optional, for additional metadata)
277 Returns:
278 Dict with site metadata
279 """
280 import odoo
281 from odoo.release import version_info
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 }
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
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
305 # Get language
306 metadata['locale'] = env.lang or 'en_US'
308 except Exception as e:
309 _logger.warning(f'Could not get additional metadata: {e}')
311 return metadata
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
318 def upload_backup(self, file_path, options=None, env=None):
319 """Upload backup file using standard multipart POST to /v1/backups.
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
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 }
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 }
352 if options.get('description'):
353 form_data['description'] = options['description']
355 if options.get('tags'):
356 tags = options['tags']
357 if isinstance(tags, list):
358 tags = ','.join(tags)
359 form_data['tags'] = tags
361 if options.get('component'):
362 form_data['component'] = options['component']
364 if options.get('metadata'):
365 form_data['metadata'] = json.dumps(options['metadata'])
367 # Add profile_uuid if provided (for versioned profiles)
368 if options.get('profile_uuid'):
369 form_data['profile_uuid'] = options['profile_uuid']
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))
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 )
392 if response.status_code >= 400:
393 _logger.error(f'Upload failed: {response.status_code} - {response.text}')
394 return self._handle_error_response(response)
396 return response.json()
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)}
408class OdooBackupCreator:
409 """Helper class to create Odoo backups"""
411 def __init__(self, env):
412 self.env = env
413 self.db_name = env.cr.dbname
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')
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 ]
430 result = subprocess.run(
431 cmd,
432 capture_output=True,
433 text=True,
434 )
436 if result.returncode != 0:
437 raise Exception(f'pg_dump failed: {result.stderr}')
439 # Compress the backup
440 compressed_file = backup_file + '.gz'
441 import gzip
442 import shutil
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)
448 os.remove(backup_file)
449 return compressed_file
451 except Exception as e:
452 _logger.exception('Database backup failed')
453 raise
455 def create_filestore_backup(self):
456 """Create filestore backup"""
457 import tarfile
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 )
465 filestore_path = os.path.join(data_dir, self.db_name)
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)
471 if not os.path.exists(filestore_path):
472 _logger.warning(f'Filestore not found at {filestore_path}')
473 return None
475 temp_dir = tempfile.mkdtemp()
476 backup_file = os.path.join(temp_dir, f'{self.db_name}_filestore.tar.gz')
478 try:
479 with tarfile.open(backup_file, 'w:gz') as tar:
480 tar.add(filestore_path, arcname='filestore')
482 return backup_file
484 except Exception as e:
485 _logger.exception('Filestore backup failed')
486 raise
488 def create_full_backup(self):
489 """Create full backup (database + filestore)"""
490 import tarfile
492 temp_dir = tempfile.mkdtemp()
493 backup_file = os.path.join(temp_dir, f'{self.db_name}_full.tar.gz')
495 try:
496 db_backup = self.create_database_backup()
497 filestore_backup = self.create_filestore_backup()
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))
505 # Cleanup temp files
506 if db_backup:
507 os.remove(db_backup)
508 if filestore_backup:
509 os.remove(filestore_backup)
511 return backup_file
513 except Exception as e:
514 _logger.exception('Full backup failed')
515 raise