""" Utils for handle files. """ import logging from django.core.exceptions import ImproperlyConfigured from . import settings, utils def get_storage(path=None, options=None): """ Get the specified storage configured with options. :param path: Path in Python dot style to module containing the storage class. If empty settings.DBBACKUP_STORAGE will be used. :type path: ``str`` :param options: Parameters for configure the storage, if empty settings.DBBACKUP_STORAGE_OPTIONS will be used. :type options: ``dict`` :return: Storage configured :rtype: :class:`.Storage` """ path = path or settings.STORAGE options = options or settings.STORAGE_OPTIONS if not path: raise ImproperlyConfigured( "You must specify a storage class using " "DBBACKUP_STORAGE settings." ) return Storage(path, **options) class StorageError(Exception): pass class FileNotFound(StorageError): pass class Storage: """ This object make high-level storage operations for upload/download or list and filter files. It uses a Django storage object for low-level operations. """ @property def logger(self): if not hasattr(self, "_logger"): self._logger = logging.getLogger("dbbackup.storage") return self._logger def __init__(self, storage_path=None, **options): """ Initialize a Django Storage instance with given options. :param storage_path: Path to a Django Storage class with dot style If ``None``, ``settings.DBBACKUP_STORAGE`` will be used. :type storage_path: str """ self._storage_path = storage_path or settings.STORAGE options = options.copy() options.update(settings.STORAGE_OPTIONS) options = {key.lower(): value for key, value in options.items()} self.storageCls = get_storage_class(self._storage_path) self.storage = self.storageCls(**options) self.name = self.storageCls.__name__ def __str__(self): return f"dbbackup-{self.storage.__str__()}" def delete_file(self, filepath): self.logger.debug("Deleting file %s", filepath) self.storage.delete(name=filepath) def list_directory(self, path=""): return self.storage.listdir(path)[1] def write_file(self, filehandle, filename): self.logger.debug("Writing file %s", filename) self.storage.save(name=filename, content=filehandle) def read_file(self, filepath): self.logger.debug("Reading file %s", filepath) file_ = self.storage.open(name=filepath, mode="rb") if not getattr(file_, "name", None): file_.name = filepath return file_ def list_backups( self, encrypted=None, compressed=None, content_type=None, database=None, servername=None, ): """ List stored files except given filter. If filter is None, it won't be used. ``content_type`` must be ``'db'`` for database backups or ``'media'`` for media backups. :param encrypted: Filter by encrypted or not :type encrypted: ``bool`` or ``None`` :param compressed: Filter by compressed or not :type compressed: ``bool`` or ``None`` :param content_type: Filter by media or database backup, must be ``'db'`` or ``'media'`` :type content_type: ``str`` or ``None`` :param database: Filter by source database's name :type: ``str`` or ``None`` :param servername: Filter by source server's name :type: ``str`` or ``None`` :returns: List of files :rtype: ``list`` of ``str`` """ if content_type not in ("db", "media", None): msg = "Bad content_type %s, must be 'db', 'media', or None" % (content_type) raise TypeError(msg) # TODO: Make better filter for include only backups files = [f for f in self.list_directory() if utils.filename_to_datestring(f)] if encrypted is not None: files = [f for f in files if (".gpg" in f) == encrypted] if compressed is not None: files = [f for f in files if (".gz" in f) == compressed] if content_type == "media": files = [f for f in files if ".tar" in f] elif content_type == "db": files = [f for f in files if ".tar" not in f] if database: files = [f for f in files if database in f] if servername: files = [f for f in files if servername in f] return files def get_latest_backup( self, encrypted=None, compressed=None, content_type=None, database=None, servername=None, ): """ Return the latest backup file name. :param encrypted: Filter by encrypted or not :type encrypted: ``bool`` or ``None`` :param compressed: Filter by compressed or not :type compressed: ``bool`` or ``None`` :param content_type: Filter by media or database backup, must be ``'db'`` or ``'media'`` :type content_type: ``str`` or ``None`` :param database: Filter by source database's name :type: ``str`` or ``None`` :param servername: Filter by source server's name :type: ``str`` or ``None`` :returns: Most recent file :rtype: ``str`` :raises: FileNotFound: If no backup file is found """ files = self.list_backups( encrypted=encrypted, compressed=compressed, content_type=content_type, database=database, servername=servername, ) if not files: raise FileNotFound("There's no backup file available.") return max(files, key=utils.filename_to_date) def get_older_backup( self, encrypted=None, compressed=None, content_type=None, database=None, servername=None, ): """ Return the older backup's file name. :param encrypted: Filter by encrypted or not :type encrypted: ``bool`` or ``None`` :param compressed: Filter by compressed or not :type compressed: ``bool`` or ``None`` :param content_type: Filter by media or database backup, must be ``'db'`` or ``'media'`` :type content_type: ``str`` or ``None`` :param database: Filter by source database's name :type: ``str`` or ``None`` :param servername: Filter by source server's name :type: ``str`` or ``None`` :returns: Older file :rtype: ``str`` :raises: FileNotFound: If no backup file is found """ files = self.list_backups( encrypted=encrypted, compressed=compressed, content_type=content_type, database=database, servername=servername, ) if not files: raise FileNotFound("There's no backup file available.") return min(files, key=utils.filename_to_date) def clean_old_backups( self, encrypted=None, compressed=None, content_type=None, database=None, servername=None, keep_number=None, ): """ Delete olders backups and hold the number defined. :param encrypted: Filter by encrypted or not :type encrypted: ``bool`` or ``None`` :param compressed: Filter by compressed or not :type compressed: ``bool`` or ``None`` :param content_type: Filter by media or database backup, must be ``'db'`` or ``'media'`` :type content_type: ``str`` or ``None`` :param database: Filter by source database's name :type: ``str`` or ``None`` :param servername: Filter by source server's name :type: ``str`` or ``None`` :param keep_number: Number of files to keep, other will be deleted :type keep_number: ``int`` or ``None`` """ if keep_number is None: keep_number = ( settings.CLEANUP_KEEP if content_type == "db" else settings.CLEANUP_KEEP_MEDIA ) keep_filter = settings.CLEANUP_KEEP_FILTER files = self.list_backups( encrypted=encrypted, compressed=compressed, content_type=content_type, database=database, servername=servername, ) files = sorted(files, key=utils.filename_to_date, reverse=True) files_to_delete = [fi for i, fi in enumerate(files) if i >= keep_number] for filename in files_to_delete: if keep_filter(filename): continue self.delete_file(filename) def get_storage_class(path=None): """ Return the configured storage class. :param path: Path in Python dot style to module containing the storage class. If empty, the default storage class will be used. :type path: str or None :returns: Storage class :rtype: :class:`django.core.files.storage.Storage` """ from django.utils.module_loading import import_string if path: # this is a workaround to keep compatibility with Django >= 5.1 (django.core.files.storage.get_storage_class is removed) return import_string(path) try: from django.core.files.storage import DefaultStorage return DefaultStorage except Exception: from django.core.files.storage import get_storage_class return get_storage_class()