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
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-28 01:16 +0000
1# -*- coding: utf-8 -*-
3from odoo import models, fields, api, _
4from odoo.exceptions import UserError, ValidationError
5import logging
6import os
7import tempfile
9from .lox_api import OdooBackupCreator
11_logger = logging.getLogger(__name__)
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'
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 )
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)
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)
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 )
72 # Cron job reference
73 cron_id = fields.Many2one(
74 'ir.cron',
75 string='Scheduled Action',
76 readonly=True,
77 ondelete='set null',
78 )
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)
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
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
121 def unlink(self):
122 for record in self:
123 record._delete_cron()
124 return super().unlink()
126 def _create_cron(self):
127 """Create ir.cron for this schedule"""
128 self.ensure_one()
129 if self.cron_id:
130 return
132 interval_mapping = {
133 'hours': 'hours',
134 'days': 'days',
135 'weeks': 'weeks',
136 'months': 'months',
137 }
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 }
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}')
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
161 interval_mapping = {
162 'hours': 'hours',
163 'days': 'days',
164 'weeks': 'weeks',
165 'months': 'months',
166 }
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 })
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}')
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}')
187 self.write({
188 'last_run': fields.Datetime.now(),
189 'last_status': 'running',
190 'run_count': self.run_count + 1,
191 })
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
199 api = self.env['lox.api'].create_client(config)
200 backup_creator = OdooBackupCreator(self.env)
201 backup_file = None
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')
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
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 })
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')
232 if not backup_file or not os.path.exists(backup_file):
233 raise ValueError('Failed to create backup file')
235 file_size = os.path.getsize(backup_file)
236 log.write({
237 'size_bytes': file_size,
238 'status': 'in_progress',
239 })
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 }
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)
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
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 })
279 self.write({'last_status': 'success'})
280 _logger.info(f'Backup uploaded with UUID: {backup_uuid}')
282 # Apply retention policy
283 self._apply_retention()
285 return True
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
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}')
308 def _apply_retention(self):
309 """Apply retention policy - clean up old local log entries
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
321 completed_logs = self.env['lox.backup.log'].search([
322 ('schedule_id', '=', self.id),
323 ('status', '=', 'completed'),
324 ], order='create_date desc')
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:]
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}')
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 }