Coverage for src/backend/InvenTree/InvenTree/backup.py: 35%
94 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Configuration options for InvenTree backup / restore functionality.
3We use the django-dbbackup library to handle backup and restore operations.
5Ref: https://archmonger.github.io/django-dbbackup/latest/configuration/
6"""
8from datetime import datetime, timedelta
10from django.conf import settings
12import structlog
14import InvenTree.config
15import InvenTree.version
17logger = structlog.get_logger('inventree')
20def get_backup_connector_options() -> dict:
21 """Options which are specific to the selected backup connector.
23 These options apply to the database connector, not to the backup storage.
25 Ref: https://archmonger.github.io/django-dbbackup/latest/databases/
26 """
27 default_options = {'EXCLUDE': ['django_session']}
29 # Allow user to specify custom options here if necessary
30 connector_options = InvenTree.config.get_setting(
31 'INVENTREE_BACKUP_CONNECTOR_OPTIONS',
32 'backup_connector_options',
33 default_value={},
34 typecast=dict,
35 )
37 return {**default_options, **connector_options}
40def get_backup_storage_backend() -> str:
41 """Return the backup storage backend string."""
42 backend = InvenTree.config.get_setting(
43 'INVENTREE_BACKUP_STORAGE',
44 'backup_storage',
45 'django.core.files.storage.FileSystemStorage',
46 )
48 # Validate that the selected backend is valid
49 # It must be able to be imported, and a class must be found
50 # It also must be a subclass of django.core.files.storage.Storage
51 try:
52 from django.core.files.storage import Storage
53 from django.utils.module_loading import import_string
55 backend_class = import_string(backend)
57 if not issubclass(backend_class, Storage): 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true
58 raise TypeError(
59 f"Backup storage backend '{backend}' is not a valid Storage class"
60 )
61 except Exception as e:
62 raise ImportError(f"Could not load backup storage backend '{backend}': {e}")
64 return backend
67def get_backup_storage_options() -> dict:
68 """Return the backup storage options dictionary."""
69 # Default backend options which are used for FileSystemStorage
70 default_options = {'location': InvenTree.config.get_backup_dir()}
72 options = InvenTree.config.get_setting(
73 'INVENTREE_BACKUP_OPTIONS',
74 'backup_options',
75 default_value=default_options,
76 typecast=dict,
77 )
79 if not isinstance(options, dict): 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true
80 raise ValueError('Backup storage options must be a dictionary')
82 return options
85def backup_email_on_error() -> bool:
86 """Return whether to send emails to admins on backup failure."""
87 return InvenTree.config.get_setting(
88 'INVENTREE_BACKUP_SEND_EMAIL',
89 'backup_send_email',
90 default_value=False,
91 typecast=bool,
92 )
95def backup_email_prefix() -> str:
96 """Return the email subject prefix for backup emails."""
97 return InvenTree.config.get_setting(
98 'INVENTREE_BACKUP_EMAIL_PREFIX',
99 'backup_email_prefix',
100 default_value='[InvenTree Backup]',
101 typecast=str,
102 )
105def backup_gpg_recipient() -> str:
106 """Return the GPG recipient for encrypted backups."""
107 return InvenTree.config.get_setting(
108 'INVENTREE_BACKUP_GPG_RECIPIENT',
109 'backup_gpg_recipient',
110 default_value='',
111 typecast=str,
112 )
115def backup_date_format() -> str:
116 """Return the date format string for database backups."""
117 return InvenTree.config.get_setting(
118 'INVENTREE_BACKUP_DATE_FORMAT',
119 'backup_date_format',
120 default_value='%Y-%m-%d-%H%M%S',
121 typecast=str,
122 )
125def backup_filename_template() -> str:
126 """Return the filename template for database backups."""
127 return InvenTree.config.get_setting(
128 'INVENTREE_BACKUP_DATABASE_FILENAME_TEMPLATE',
129 'backup_database_filename_template',
130 default_value='InvenTree-db-{datetime}.{extension}',
131 typecast=str,
132 )
135def backup_media_filename_template() -> str:
136 """Return the filename template for media backups."""
137 return InvenTree.config.get_setting(
138 'INVENTREE_BACKUP_MEDIA_FILENAME_TEMPLATE',
139 'backup_media_filename_template',
140 default_value='InvenTree-media-{datetime}.{extension}',
141 typecast=str,
142 )
145# schema for backup metadata
146InvenTreeBackupMetadata = dict[str, str | int | bool | None]
149def _gather_environment_metadata() -> InvenTreeBackupMetadata:
150 """Gather metadata about the current environment to be stored with the backup."""
151 import plugin.installer
153 new_data: InvenTreeBackupMetadata = {}
155 new_data['ivt_1_debug'] = settings.DEBUG
156 new_data['ivt_1_version'] = InvenTree.version.inventreeVersion()
157 new_data['ivt_1_version_api'] = InvenTree.version.inventreeApiVersion()
158 new_data['ivt_1_plugins_enabled'] = settings.PLUGINS_ENABLED
159 new_data['ivt_1_plugins_file_hash'] = plugin.installer.plugins_file_hash()
160 new_data['ivt_1_installer'] = InvenTree.config.inventreeInstaller()
161 new_data['ivt_1_backup_time'] = datetime.now().isoformat()
163 return new_data
166def _parse_environment_metadata(metadata: InvenTreeBackupMetadata) -> dict[str, str]:
167 """Parse backup metadata to extract environment information."""
168 data = {}
170 data['debug'] = metadata.get('ivt_1_debug', False)
171 data['version'] = metadata.get('ivt_1_version', 'unknown')
172 data['version_api'] = metadata.get('ivt_1_version_api', 'unknown')
173 data['plugins_enabled'] = metadata.get('ivt_1_plugins_enabled', False)
174 data['plugins_file_hash'] = metadata.get('ivt_1_plugins_file_hash', 'unknown')
175 data['installer'] = metadata.get('ivt_1_installer', 'unknown')
176 data['backup_time'] = metadata.get('ivt_1_backup_time', 'unknown')
178 return data
181def metadata_set(metadata) -> InvenTreeBackupMetadata:
182 """Set backup metadata for the current backup operation."""
183 return _gather_environment_metadata()
186def validate_restore(metadata: InvenTreeBackupMetadata) -> bool | None:
187 """Validate whether a backup restore operation should proceed, based on the provided metadata."""
188 if metadata.get('ivt_1_version') is None:
189 logger.warning(
190 'INVE-W13: Backup metadata does not contain version information',
191 error_code='INVE-W13',
192 )
193 return True
195 current_environment = _parse_environment_metadata(_gather_environment_metadata())
196 backup_environment = _parse_environment_metadata(metadata)
198 # Version mismatch
199 if backup_environment['version'] != current_environment['version']:
200 logger.warning(
201 f'INVE-W13: Backup being restored was created with InvenTree version {backup_environment["version"]}, but current version is {current_environment["version"]}',
202 error_code='INVE-W13',
203 )
205 # Backup is from newer version - fail
206 try:
207 if int(backup_environment['version_api']) > int(
208 str(current_environment['version_api'])
209 ):
210 logger.error(
211 'INVE-E16: Backup being restored was created with a newer version of InvenTree - restore cannot proceed. If you are using the invoke task for your restore this warning might be overridden once with `--restore-allow-newer-version`',
212 error_code='INVE-E16',
213 )
214 # Check for pass flag to allow restore
215 if not settings.BACKUP_RESTORE_ALLOW_NEWER_VERSION: # defaults to False
216 return False
217 else:
218 logger.warning(
219 'INVE-W13: Backup restore is allowing a restore from a newer version of InvenTree - this can lead to data loss or corruption',
220 error_code='INVE-W13',
221 )
222 except ValueError: # pragma: no cover
223 logger.warning(
224 'INVE-W13: Could not parse API version from backup metadata - cannot determine if backup is from newer version',
225 error_code='INVE-W13',
226 )
228 # Plugins enabled on backup but not restore environment - warn
229 if (
230 backup_environment['plugins_enabled']
231 and not current_environment['plugins_enabled']
232 ):
233 logger.warning(
234 'INVE-W13: Backup being restored was created with plugins enabled, but current environment has plugins disabled - this can lead to data loss',
235 error_code='INVE-W13',
236 )
238 # Plugins file hash mismatch - warn
239 if pg_hash := backup_environment['plugins_file_hash']:
240 if pg_hash != current_environment['plugins_file_hash']:
241 logger.warning(
242 'INVE-W13: Backup being restored has a different plugins file hash to the current environment - this can lead to data loss or corruption',
243 error_code='INVE-W13',
244 )
246 # Installer mismatch - warn
247 if installer := backup_environment['installer']:
248 if installer != current_environment['installer']:
249 logger.warning(
250 f"INVE-W13: Backup being restored was created with installer '{installer}', but current environment has installer '{current_environment['installer']}'",
251 error_code='INVE-W13',
252 )
254 # Age of backup
255 last_backup_time = backup_environment.get('backup_time')
256 if datetime.now() - datetime.fromisoformat(last_backup_time) > timedelta(days=120):
257 logger.warning(
258 f'INVE-W13: Backup being restored is over 120 days old (last backup time: {last_backup_time})',
259 error_code='INVE-W13',
260 )
262 if settings.DEBUG: # pragma: no cover
263 logger.info(
264 f'INVE-I3: Backup environment: {backup_environment}', error_code='INVE-I3'
265 )
266 logger.info(
267 f'INVE-I3: Current environment: {current_environment}', error_code='INVE-I3'
268 )
270 return True