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

1"""Configuration options for InvenTree backup / restore functionality. 

2 

3We use the django-dbbackup library to handle backup and restore operations. 

4 

5Ref: https://archmonger.github.io/django-dbbackup/latest/configuration/ 

6""" 

7 

8from datetime import datetime, timedelta 

9 

10from django.conf import settings 

11 

12import structlog 

13 

14import InvenTree.config 

15import InvenTree.version 

16 

17logger = structlog.get_logger('inventree') 

18 

19 

20def get_backup_connector_options() -> dict: 

21 """Options which are specific to the selected backup connector. 

22 

23 These options apply to the database connector, not to the backup storage. 

24 

25 Ref: https://archmonger.github.io/django-dbbackup/latest/databases/ 

26 """ 

27 default_options = {'EXCLUDE': ['django_session']} 

28 

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 ) 

36 

37 return {**default_options, **connector_options} 

38 

39 

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 ) 

47 

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 

54 

55 backend_class = import_string(backend) 

56 

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}") 

63 

64 return backend 

65 

66 

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()} 

71 

72 options = InvenTree.config.get_setting( 

73 'INVENTREE_BACKUP_OPTIONS', 

74 'backup_options', 

75 default_value=default_options, 

76 typecast=dict, 

77 ) 

78 

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') 

81 

82 return options 

83 

84 

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 ) 

93 

94 

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 ) 

103 

104 

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 ) 

113 

114 

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 ) 

123 

124 

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 ) 

133 

134 

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 ) 

143 

144 

145# schema for backup metadata 

146InvenTreeBackupMetadata = dict[str, str | int | bool | None] 

147 

148 

149def _gather_environment_metadata() -> InvenTreeBackupMetadata: 

150 """Gather metadata about the current environment to be stored with the backup.""" 

151 import plugin.installer 

152 

153 new_data: InvenTreeBackupMetadata = {} 

154 

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() 

162 

163 return new_data 

164 

165 

166def _parse_environment_metadata(metadata: InvenTreeBackupMetadata) -> dict[str, str]: 

167 """Parse backup metadata to extract environment information.""" 

168 data = {} 

169 

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') 

177 

178 return data 

179 

180 

181def metadata_set(metadata) -> InvenTreeBackupMetadata: 

182 """Set backup metadata for the current backup operation.""" 

183 return _gather_environment_metadata() 

184 

185 

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 

194 

195 current_environment = _parse_environment_metadata(_gather_environment_metadata()) 

196 backup_environment = _parse_environment_metadata(metadata) 

197 

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 ) 

204 

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 ) 

227 

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 ) 

237 

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 ) 

245 

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 ) 

253 

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 ) 

261 

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 ) 

269 

270 return True