Coverage for models / lox_backup_schedule.py: 25%

145 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, ValidationError 

5import logging 

6import os 

7import tempfile 

8 

9from .lox_api import OdooBackupCreator 

10 

11_logger = logging.getLogger(__name__) 

12 

13 

14class LoxBackupSchedule(models.Model): 

15 _name = 'lox.backup.schedule' 

16 _description = 'LOX Backup Schedule' 

17 _inherit = ['mail.thread'] 

18 _rec_name = 'name' 

19 _order = 'sequence, id' 

20 

21 name = fields.Char( 

22 string='Name', 

23 required=True, 

24 tracking=True, 

25 ) 

26 config_id = fields.Many2one( 

27 'lox.backup.config', 

28 string='Configuration', 

29 required=True, 

30 ondelete='cascade', 

31 ) 

32 active = fields.Boolean( 

33 string='Active', 

34 default=True, 

35 tracking=True, 

36 ) 

37 sequence = fields.Integer( 

38 string='Sequence', 

39 default=10, 

40 ) 

41 

42 # What to backup 

43 component = fields.Selection([ 

44 ('full', 'Full Backup'), 

45 ('database', 'Database Only'), 

46 ('filestore', 'Filestore Only'), 

47 ], string='Component', default='full', required=True, tracking=True) 

48 

49 # Schedule settings 

50 interval_number = fields.Integer( 

51 string='Interval Number', 

52 default=1, 

53 required=True, 

54 ) 

55 interval_type = fields.Selection([ 

56 ('hours', 'Hours'), 

57 ('days', 'Days'), 

58 ('weeks', 'Weeks'), 

59 ('months', 'Months'), 

60 ], string='Interval Type', default='days', required=True) 

61 

62 # Local log retention (backups in LOX expire automatically) 

63 retention_count = fields.Integer( 

64 string='Keep Last N Logs', 

65 default=7, 

66 help='Number of backup log entries to keep locally for display. ' 

67 'Set to 0 for unlimited. Note: Actual backups in LOX storage ' 

68 'are retained according to your LOX retention policy and cannot ' 

69 'be deleted manually (security measure).', 

70 ) 

71 

72 # Cron job reference 

73 cron_id = fields.Many2one( 

74 'ir.cron', 

75 string='Scheduled Action', 

76 readonly=True, 

77 ondelete='set null', 

78 ) 

79 

80 # Stats 

81 last_run = fields.Datetime( 

82 string='Last Run', 

83 readonly=True, 

84 ) 

85 next_run = fields.Datetime( 

86 string='Next Run', 

87 related='cron_id.nextcall', 

88 readonly=True, 

89 ) 

90 run_count = fields.Integer( 

91 string='Run Count', 

92 default=0, 

93 readonly=True, 

94 ) 

95 last_status = fields.Selection([ 

96 ('success', 'Success'), 

97 ('failed', 'Failed'), 

98 ('running', 'Running'), 

99 ], string='Last Status', readonly=True) 

100 

101 @api.model_create_multi 

102 def create(self, vals_list): 

103 records = super().create(vals_list) 

104 for record in records: 

105 if record.active: 

106 record._create_cron() 

107 return records 

108 

109 def write(self, vals): 

110 result = super().write(vals) 

111 for record in self: 

112 if 'active' in vals: 

113 if vals['active']: 

114 record._create_cron() 

115 else: 

116 record._delete_cron() 

117 elif record.active and record.cron_id: 

118 record._update_cron() 

119 return result 

120 

121 def unlink(self): 

122 for record in self: 

123 record._delete_cron() 

124 return super().unlink() 

125 

126 def _create_cron(self): 

127 """Create ir.cron for this schedule""" 

128 self.ensure_one() 

129 if self.cron_id: 

130 return 

131 

132 interval_mapping = { 

133 'hours': 'hours', 

134 'days': 'days', 

135 'weeks': 'weeks', 

136 'months': 'months', 

137 } 

138 

139 cron_vals = { 

140 'name': f'LOX Backup: {self.name}', 

141 'model_id': self.env['ir.model']._get('lox.backup.schedule').id, 

142 'state': 'code', 

143 'code': f'model.browse({self.id})._run_backup()', 

144 'interval_number': self.interval_number, 

145 'interval_type': interval_mapping.get(self.interval_type, 'days'), 

146 'active': True, 

147 'user_id': self.env.ref('base.user_root').id, 

148 } 

149 

150 cron = self.env['ir.cron'].sudo().create(cron_vals) 

151 self.write({'cron_id': cron.id}) 

152 _logger.info(f'Created cron job for schedule {self.name}') 

153 

154 def _update_cron(self): 

155 """Update existing cron job""" 

156 self.ensure_one() 

157 if not self.cron_id: 

158 self._create_cron() 

159 return 

160 

161 interval_mapping = { 

162 'hours': 'hours', 

163 'days': 'days', 

164 'weeks': 'weeks', 

165 'months': 'months', 

166 } 

167 

168 self.cron_id.sudo().write({ 

169 'name': f'LOX Backup: {self.name}', 

170 'interval_number': self.interval_number, 

171 'interval_type': interval_mapping.get(self.interval_type, 'days'), 

172 'active': self.active, 

173 }) 

174 

175 def _delete_cron(self): 

176 """Delete associated cron job""" 

177 self.ensure_one() 

178 if self.cron_id: 

179 self.cron_id.sudo().unlink() 

180 _logger.info(f'Deleted cron job for schedule {self.name}') 

181 

182 def _run_backup(self): 

183 """Execute scheduled backup - creates and uploads backup file""" 

184 self.ensure_one() 

185 _logger.info(f'Running scheduled backup: {self.name}') 

186 

187 self.write({ 

188 'last_run': fields.Datetime.now(), 

189 'last_status': 'running', 

190 'run_count': self.run_count + 1, 

191 }) 

192 

193 config = self.config_id 

194 if not config.source_registered: 

195 _logger.error(f'Source not registered for config {config.name}') 

196 self.write({'last_status': 'failed'}) 

197 return False 

198 

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

200 backup_creator = OdooBackupCreator(self.env) 

201 backup_file = None 

202 

203 # Determine what to backup based on component setting 

204 backup_database = self.component in ('full', 'database') 

205 backup_filestore = self.component in ('full', 'filestore') 

206 

207 # Override with config settings if component is 'full' 

208 if self.component == 'full': 

209 backup_database = config.backup_database 

210 backup_filestore = config.backup_filestore 

211 

212 try: 

213 # Create backup log entry 

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

215 'config_id': config.id, 

216 'schedule_id': self.id, 

217 'component': self.component, 

218 'status': 'pending', 

219 }) 

220 

221 # Create the actual backup file 

222 _logger.info(f'Creating backup file for schedule {self.name}') 

223 if backup_database and backup_filestore: 

224 backup_file = backup_creator.create_full_backup() 

225 elif backup_database: 

226 backup_file = backup_creator.create_database_backup() 

227 elif backup_filestore: 

228 backup_file = backup_creator.create_filestore_backup() 

229 else: 

230 raise ValueError('No backup component selected') 

231 

232 if not backup_file or not os.path.exists(backup_file): 

233 raise ValueError('Failed to create backup file') 

234 

235 file_size = os.path.getsize(backup_file) 

236 log.write({ 

237 'size_bytes': file_size, 

238 'status': 'in_progress', 

239 }) 

240 

241 # Build upload options 

242 upload_options = { 

243 'name': f'{self.name}-{fields.Datetime.now().strftime("%Y%m%d-%H%M%S")}', 

244 'tags': ['scheduled', 'odoo', self.component], 

245 'retention_days': config.retention_days, 

246 'component': self.component, 

247 'metadata': { 

248 'schedule_name': self.name, 

249 'schedule_id': self.id, 

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

251 'scheduled_backup': True, 

252 }, 

253 'description': f'Scheduled backup: {self.name}', 

254 } 

255 

256 # Upload backup using standard single-step API 

257 _logger.info(f'Uploading backup file for schedule {self.name}') 

258 result = api.upload_backup(backup_file, upload_options) 

259 

260 # Check for errors 

261 if result.get('error') or result.get('success') is False: 

262 error_msg = result.get('error', 'Unknown error') 

263 log.write({ 

264 'status': 'failed', 

265 'error_message': error_msg, 

266 }) 

267 self.write({'last_status': 'failed'}) 

268 _logger.error(f'Backup upload failed: {error_msg}') 

269 return False 

270 

271 # Get backup UUID from response 

272 backup_uuid = result.get('id') or result.get('uuid') 

273 if backup_uuid: 

274 log.write({ 

275 'backup_uuid': backup_uuid, 

276 'status': 'validating', 

277 }) 

278 

279 self.write({'last_status': 'success'}) 

280 _logger.info(f'Backup uploaded with UUID: {backup_uuid}') 

281 

282 # Apply retention policy 

283 self._apply_retention() 

284 

285 return True 

286 

287 except Exception as e: 

288 _logger.exception(f'Backup failed for schedule {self.name}') 

289 self.write({'last_status': 'failed'}) 

290 if 'log' in locals(): 

291 log.write({ 

292 'status': 'failed', 

293 'error_message': str(e), 

294 }) 

295 return False 

296 

297 finally: 

298 # Cleanup temp files 

299 if backup_file and os.path.exists(backup_file): 

300 try: 

301 os.remove(backup_file) 

302 temp_dir = os.path.dirname(backup_file) 

303 if temp_dir and os.path.exists(temp_dir) and not os.listdir(temp_dir): 

304 os.rmdir(temp_dir) 

305 except Exception as cleanup_error: 

306 _logger.warning(f'Failed to cleanup temp file: {cleanup_error}') 

307 

308 def _apply_retention(self): 

309 """Apply retention policy - clean up old local log entries 

310 

311 NOTE: This only removes local log entries for display purposes. 

312 Actual backups in LOX storage are NOT deleted - they expire 

313 automatically based on the retention policy configured in LOX. 

314 Manual deletion is intentionally disabled as a security measure 

315 to prevent attackers from deleting backups if credentials are compromised. 

316 """ 

317 self.ensure_one() 

318 if self.retention_count <= 0: 

319 return # Unlimited local log retention 

320 

321 completed_logs = self.env['lox.backup.log'].search([ 

322 ('schedule_id', '=', self.id), 

323 ('status', '=', 'completed'), 

324 ], order='create_date desc') 

325 

326 # Keep only retention_count log entries locally 

327 # Backups in LOX are retained according to LOX retention policy 

328 logs_to_archive = completed_logs[self.retention_count:] 

329 

330 for log in logs_to_archive: 

331 try: 

332 log.unlink() 

333 _logger.info(f'Archived old backup log entry: {log.backup_uuid}') 

334 except Exception as e: 

335 _logger.error(f'Failed to archive old backup log: {e}') 

336 

337 def action_run_now(self): 

338 """Manually run this schedule""" 

339 self.ensure_one() 

340 self._run_backup() 

341 return { 

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

343 'tag': 'display_notification', 

344 'params': { 

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

346 'message': _('Backup job has been queued.'), 

347 'type': 'info', 

348 'sticky': False, 

349 } 

350 }