Coverage for wizards / lox_backup_wizard.py: 16%

226 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, fields, api, _ 

4from odoo.exceptions import UserError 

5import logging 

6import tempfile 

7import os 

8import json 

9 

10_logger = logging.getLogger(__name__) 

11 

12 

13class LoxBackupWizard(models.TransientModel): 

14 _name = 'lox.backup.wizard' 

15 _description = 'LOX Backup Wizard' 

16 

17 config_id = fields.Many2one( 

18 'lox.backup.config', 

19 string='Configuration', 

20 required=True, 

21 default=lambda self: self._default_config_id(), 

22 ) 

23 profile_id = fields.Many2one( 

24 'lox.backup.profile', 

25 string='Use Profile', 

26 domain="[('config_id', '=', config_id), ('active', '=', True)]", 

27 help='Select a saved profile or customize below', 

28 ) 

29 

30 # Backup type 

31 backup_mode = fields.Selection([ 

32 ('full', 'Full Backup'), 

33 ('custom', 'Custom Selection'), 

34 ], string='Backup Mode', default='full', required=True) 

35 

36 # What to include 

37 backup_database = fields.Boolean( 

38 string='Database', 

39 default=True, 

40 help='Full PostgreSQL database dump', 

41 ) 

42 backup_filestore = fields.Boolean( 

43 string='Filestore', 

44 default=True, 

45 help='All attachments and uploaded files', 

46 ) 

47 backup_modules = fields.Boolean( 

48 string='Modules Info', 

49 default=True, 

50 help='List of installed modules with versions', 

51 ) 

52 

53 # Custom settings 

54 custom_tags = fields.Char( 

55 string='Additional Tags', 

56 help='Comma-separated tags to add to this backup', 

57 ) 

58 retention_days = fields.Integer( 

59 string='Retention Days', 

60 default=30, 

61 help='Days to keep this backup (0 = use default)', 

62 ) 

63 backup_name = fields.Char( 

64 string='Backup Name', 

65 help='Optional custom name for this backup', 

66 ) 

67 description = fields.Text( 

68 string='Description', 

69 help='Optional description for this backup', 

70 ) 

71 

72 # Save as profile 

73 save_as_profile = fields.Boolean( 

74 string='Save as Profile', 

75 default=False, 

76 help='Save current settings as a reusable profile', 

77 ) 

78 profile_name = fields.Char( 

79 string='Profile Name', 

80 ) 

81 

82 @api.model 

83 def _default_config_id(self): 

84 config = self.env['lox.backup.config'].search([ 

85 ('active', '=', True), 

86 ('source_registered', '=', True), 

87 ], limit=1) 

88 return config.id if config else False 

89 

90 @api.onchange('profile_id') 

91 def _onchange_profile_id(self): 

92 """Load profile settings""" 

93 if self.profile_id: 

94 self.backup_mode = 'custom' 

95 self.backup_database = self.profile_id.include_database 

96 self.backup_filestore = self.profile_id.include_filestore 

97 self.backup_modules = self.profile_id.include_modules 

98 self.custom_tags = self.profile_id.custom_tags 

99 if self.profile_id.retention_days: 

100 self.retention_days = self.profile_id.retention_days 

101 

102 @api.onchange('backup_mode') 

103 def _onchange_backup_mode(self): 

104 """Set defaults based on mode""" 

105 if self.backup_mode == 'full': 

106 self.backup_database = True 

107 self.backup_filestore = True 

108 self.backup_modules = True 

109 

110 @api.onchange('config_id') 

111 def _onchange_config_id(self): 

112 """Load config defaults""" 

113 if self.config_id: 

114 self.retention_days = self.config_id.retention_days 

115 

116 def _get_modules_info(self): 

117 """Get installed modules information""" 

118 modules = self.env['ir.module.module'].search([ 

119 ('state', '=', 'installed'), 

120 ]) 

121 return [{ 

122 'name': m.name, 

123 'version': m.installed_version or m.latest_version, 

124 'author': m.author, 

125 'summary': m.summary, 

126 'category': m.category_id.name if m.category_id else '', 

127 } for m in modules] 

128 

129 def _build_tags(self): 

130 """Build tags list for the backup""" 

131 tags = ['odoo'] 

132 

133 # Add component tags 

134 if self.backup_database: 

135 tags.append('database') 

136 if self.backup_filestore: 

137 tags.append('filestore') 

138 if self.backup_modules: 

139 tags.append('modules') 

140 

141 # Add mode tag 

142 if self.backup_mode == 'full': 

143 tags.append('full') 

144 else: 

145 tags.append('custom') 

146 

147 # Add manual tag 

148 tags.append('manual') 

149 

150 # Add config default tags 

151 if self.config_id.default_tags: 

152 for tag in self.config_id.default_tags.split(','): 

153 tag = tag.strip() 

154 if tag and tag not in tags: 

155 tags.append(tag) 

156 

157 # Add custom tags 

158 if self.custom_tags: 

159 for tag in self.custom_tags.split(','): 

160 tag = tag.strip() 

161 if tag and tag not in tags: 

162 tags.append(tag) 

163 

164 # Add profile tag if using profile 

165 if self.profile_id: 

166 tags.append(f'profile:{self.profile_id.name}') 

167 

168 return tags 

169 

170 def _save_profile(self): 

171 """Save current settings as a profile""" 

172 if self.save_as_profile and self.profile_name: 

173 self.env['lox.backup.profile'].create({ 

174 'name': self.profile_name, 

175 'config_id': self.config_id.id, 

176 'include_database': self.backup_database, 

177 'include_filestore': self.backup_filestore, 

178 'include_modules': self.backup_modules, 

179 'custom_tags': self.custom_tags, 

180 'retention_days': self.retention_days if self.retention_days != self.config_id.retention_days else 0, 

181 'description': self.description, 

182 }) 

183 

184 def action_run_backup(self): 

185 """Execute manual backup""" 

186 self.ensure_one() 

187 

188 config = self.config_id 

189 if not config.source_registered: 

190 raise UserError(_('Source is not registered. Please register first.')) 

191 

192 # Validate at least one component 

193 if not any([self.backup_database, self.backup_filestore, self.backup_modules]): 

194 raise UserError(_('Please select at least one component to backup.')) 

195 

196 api = self.env['lox.api'].create_client(config) 

197 

198 # Build components list 

199 components = [] 

200 if self.backup_database: 

201 components.append('database') 

202 if self.backup_filestore: 

203 components.append('filestore') 

204 

205 # Build tags 

206 tags = self._build_tags() 

207 

208 # Get modules info if requested 

209 modules_info = None 

210 if self.backup_modules: 

211 modules_info = self._get_modules_info() 

212 

213 # Determine component type for log 

214 if self.backup_database and self.backup_filestore: 

215 log_component = 'full' 

216 elif self.backup_database: 

217 log_component = 'database' 

218 elif self.backup_filestore: 

219 log_component = 'filestore' 

220 else: 

221 log_component = 'full' # modules only 

222 

223 try: 

224 # Create backup log entry 

225 log = self.env['lox.backup.log'].create({ 

226 'config_id': config.id, 

227 'component': log_component, 

228 'status': 'pending', 

229 }) 

230 

231 # Build metadata 

232 metadata = { 

233 'manual_backup': True, 

234 'odoo_database': self.env.cr.dbname, 

235 'user': self.env.user.name, 

236 'backup_mode': self.backup_mode, 

237 'include_database': self.backup_database, 

238 'include_filestore': self.backup_filestore, 

239 'include_modules': self.backup_modules, 

240 } 

241 

242 if modules_info: 

243 metadata['modules'] = modules_info 

244 metadata['modules_count'] = len(modules_info) 

245 

246 if self.profile_id: 

247 metadata['profile_id'] = self.profile_id.id 

248 metadata['profile_name'] = self.profile_id.name 

249 

250 if self.backup_name: 

251 metadata['custom_name'] = self.backup_name 

252 

253 if self.description: 

254 metadata['description'] = self.description 

255 

256 # Create backup via API 

257 backup_data = { 

258 'source_id': config.source_id, 

259 'components': components if components else ['database'], 

260 'tags': ','.join(tags), 

261 'retention_days': self.retention_days or config.retention_days, 

262 'metadata': metadata, 

263 } 

264 

265 if self.backup_name: 

266 backup_data['name'] = self.backup_name 

267 

268 result = api.create_backup(backup_data) 

269 

270 if result.get('id'): 

271 log.write({ 

272 'backup_uuid': result['id'], 

273 'status': 'in_progress', 

274 'started_at': fields.Datetime.now(), 

275 }) 

276 

277 # Save profile if requested 

278 self._save_profile() 

279 

280 # Update profile stats if using one 

281 if self.profile_id: 

282 self.profile_id._update_stats() 

283 

284 return { 

285 'type': 'ir.actions.client', 

286 'tag': 'display_notification', 

287 'params': { 

288 'title': _('Backup Started'), 

289 'message': _('Backup job created with UUID: %s') % result['id'], 

290 'type': 'success', 

291 'sticky': False, 

292 'next': { 

293 'type': 'ir.actions.act_window', 

294 'res_model': 'lox.backup.log', 

295 'res_id': log.id, 

296 'view_mode': 'form', 

297 'target': 'current', 

298 } 

299 } 

300 } 

301 else: 

302 log.write({ 

303 'status': 'failed', 

304 'error_message': result.get('error', 'Unknown error'), 

305 }) 

306 raise UserError(_('Backup failed: %s') % result.get('error', 'Unknown error')) 

307 

308 except UserError: 

309 raise 

310 except Exception as e: 

311 _logger.exception('Manual backup failed') 

312 if 'log' in locals(): 

313 log.write({ 

314 'status': 'failed', 

315 'error_message': str(e), 

316 }) 

317 raise UserError(_('Backup failed: %s') % str(e)) 

318 

319 def action_run_backup_with_upload(self): 

320 """Execute backup with local file creation and upload""" 

321 self.ensure_one() 

322 

323 config = self.config_id 

324 if not config.source_registered: 

325 raise UserError(_('Source is not registered. Please register first.')) 

326 

327 # Validate at least one component 

328 if not any([self.backup_database, self.backup_filestore, self.backup_modules]): 

329 raise UserError(_('Please select at least one component to backup.')) 

330 

331 from ..models.lox_api import OdooBackupCreator 

332 

333 api = self.env['lox.api'].create_client(config) 

334 backup_creator = OdooBackupCreator(self.env) 

335 

336 # Build tags 

337 tags = self._build_tags() 

338 

339 # Get modules info if requested 

340 modules_info = None 

341 if self.backup_modules: 

342 modules_info = self._get_modules_info() 

343 

344 # Determine component type for log 

345 if self.backup_database and self.backup_filestore: 

346 log_component = 'full' 

347 elif self.backup_database: 

348 log_component = 'database' 

349 elif self.backup_filestore: 

350 log_component = 'filestore' 

351 else: 

352 log_component = 'full' 

353 

354 try: 

355 # Create backup log entry 

356 log = self.env['lox.backup.log'].create({ 

357 'config_id': config.id, 

358 'component': log_component, 

359 'status': 'pending', 

360 'started_at': fields.Datetime.now(), 

361 }) 

362 

363 # Create local backup based on selections 

364 backup_file = None 

365 if self.backup_database and self.backup_filestore: 

366 backup_file = backup_creator.create_full_backup() 

367 elif self.backup_database: 

368 backup_file = backup_creator.create_database_backup() 

369 elif self.backup_filestore: 

370 backup_file = backup_creator.create_filestore_backup() 

371 else: 

372 # Modules only - create a JSON file 

373 backup_file = self._create_modules_only_backup(modules_info) 

374 

375 if not backup_file: 

376 raise UserError(_('Failed to create local backup.')) 

377 

378 # If we have modules info and backup_file is a tarball, inject it 

379 if modules_info and self.backup_database: 

380 self._inject_modules_info(backup_file, modules_info) 

381 

382 # Get file size 

383 file_size = os.path.getsize(backup_file) 

384 log.write({'size_bytes': file_size}) 

385 

386 # Build metadata 

387 metadata = { 

388 'manual_backup': True, 

389 'with_upload': True, 

390 'odoo_database': self.env.cr.dbname, 

391 'user': self.env.user.name, 

392 'backup_mode': self.backup_mode, 

393 'include_database': self.backup_database, 

394 'include_filestore': self.backup_filestore, 

395 'include_modules': self.backup_modules, 

396 } 

397 

398 if modules_info: 

399 metadata['modules_count'] = len(modules_info) 

400 

401 if self.profile_id: 

402 metadata['profile_id'] = self.profile_id.id 

403 metadata['profile_name'] = self.profile_id.name 

404 

405 # Build components list 

406 components = [] 

407 if self.backup_database: 

408 components.append('database') 

409 if self.backup_filestore: 

410 components.append('filestore') 

411 if not components: 

412 components = ['modules'] 

413 

414 # Build upload options for single-step upload 

415 upload_options = { 

416 'tags': tags, 

417 'retention_days': self.retention_days or config.retention_days, 

418 'component': ','.join(components), 

419 'metadata': metadata, 

420 'description': f'Odoo backup from {self.env.cr.dbname}', 

421 } 

422 

423 if self.backup_name: 

424 upload_options['name'] = self.backup_name 

425 

426 # Upload backup (single-step: creates record and uploads file) 

427 log.write({'status': 'in_progress'}) 

428 upload_result = api.upload_backup(backup_file, upload_options) 

429 

430 # Check for upload errors 

431 if upload_result.get('error') or upload_result.get('success') is False: 

432 raise UserError(_('Upload failed: %s') % upload_result.get('error', 'Unknown')) 

433 

434 # Get backup UUID from response 

435 backup_uuid = upload_result.get('id') or upload_result.get('uuid') 

436 if backup_uuid: 

437 log.write({'backup_uuid': backup_uuid}) 

438 

439 # Cleanup temp file 

440 try: 

441 os.remove(backup_file) 

442 temp_dir = os.path.dirname(backup_file) 

443 if not os.listdir(temp_dir): 

444 os.rmdir(temp_dir) 

445 except: 

446 pass 

447 

448 # Upload succeeded (errors already handled above) 

449 log.write({'status': 'validating'}) 

450 

451 # Save profile if requested 

452 self._save_profile() 

453 

454 # Update profile stats if using one 

455 if self.profile_id: 

456 self.profile_id._update_stats() 

457 

458 return { 

459 'type': 'ir.actions.client', 

460 'tag': 'display_notification', 

461 'params': { 

462 'title': _('Backup Uploaded'), 

463 'message': _('Backup uploaded successfully. UUID: %s') % backup_uuid, 

464 'type': 'success', 

465 'sticky': False, 

466 'next': { 

467 'type': 'ir.actions.act_window', 

468 'res_model': 'lox.backup.log', 

469 'res_id': log.id, 

470 'view_mode': 'form', 

471 'target': 'current', 

472 } 

473 } 

474 } 

475 

476 except UserError: 

477 raise 

478 except Exception as e: 

479 _logger.exception('Backup with upload failed') 

480 if 'log' in locals(): 

481 log.write({ 

482 'status': 'failed', 

483 'error_message': str(e), 

484 }) 

485 raise UserError(_('Backup failed: %s') % str(e)) 

486 

487 def _create_modules_only_backup(self, modules_info): 

488 """Create a backup containing only modules information""" 

489 import tarfile 

490 import gzip 

491 

492 temp_dir = tempfile.mkdtemp() 

493 modules_file = os.path.join(temp_dir, 'modules.json') 

494 backup_file = os.path.join(temp_dir, f'{self.env.cr.dbname}_modules.tar.gz') 

495 

496 # Write modules info 

497 with open(modules_file, 'w') as f: 

498 json.dump({ 

499 'database': self.env.cr.dbname, 

500 'timestamp': fields.Datetime.now().isoformat(), 

501 'modules': modules_info, 

502 }, f, indent=2) 

503 

504 # Create tarball 

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

506 tar.add(modules_file, arcname='modules.json') 

507 

508 os.remove(modules_file) 

509 return backup_file 

510 

511 def _inject_modules_info(self, backup_file, modules_info): 

512 """Inject modules.json into existing backup archive""" 

513 # For now, we'll include it in metadata 

514 # In a full implementation, we would repack the tarball 

515 pass