diff --git a/cloudinary_storage/app_settings.py b/cloudinary_storage/app_settings.py index 2d31fd8..00a1e02 100644 --- a/cloudinary_storage/app_settings.py +++ b/cloudinary_storage/app_settings.py @@ -1,3 +1,6 @@ +# Kept for reference, will be removed when all functionality is implemented somewhere else. + + import importlib import os import sys diff --git a/cloudinary_storage/base.py b/cloudinary_storage/base.py new file mode 100644 index 0000000..b6d57c3 --- /dev/null +++ b/cloudinary_storage/base.py @@ -0,0 +1,214 @@ +import os +import requests + +import cloudinary +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.core.files.base import ContentFile +from django.core.files.storage import Storage +from django.core.files.uploadedfile import UploadedFile + +from .helpers import get_resources_by_path + + +user_settings = getattr(settings, 'CLOUDINARY_STORAGE', {}) + +def setting(name, default=None): + """ + Helper function to get a setting by name. If setting doesn't exists + it will return a default. + + :param name: Name of setting + :type name: str + :param default: Value if setting is unfound + :returns: Setting's value + """ + return user_settings.get(name, default) + + +class BaseStorage(Storage): + RESOURCE_TYPES = { + 'IMAGE': 'image', + 'RAW': 'raw', + 'VIDEO': 'video' + } + + def __init__(self, **settings): + default_settings = self.get_default_settings() + + for name, value in default_settings.items(): + if not hasattr(self, name): + setattr(self, name, value) + + for name, value in settings.items(): + if name not in default_settings: + raise ImproperlyConfigured( + "Invalid setting '{}' for {}".format( + name, + self.__class__.__name__, + ) + ) + setattr(self, name, value) + + if not self.cloud_name or not self.api_key or not self.api_secret: + if not os.environ.get('CLOUDINARY_URL'): + raise ImproperlyConfigured(""" + In order to use cloudinary storage, you need to do ONE of the following: + + provide OPTIONS dictionary with cloud_name, api_secret, and api_key in the settings under STORAGES["default"] + OR + provide CLOUDINARY_STORAGE dictionary with CLOUD_NAME, API_SECRET and API_KEY in the settings + OR + set CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET environment variables + OR + set CLOUDINARY_URL environment variable""") + return + else: + cloudinary.config( + cloud_name=self.cloud_name, + api_key=self.api_key, + api_secret=self.api_secret, + secure=self.secure + ) + super().__init__() + + def get_default_settings(self): + return { + 'cloud_name': setting('CLOUD_NAME', os.environ.get('CLOUDINARY_CLOUD_NAME')), + 'api_key': setting('API_KEY', os.environ.get('CLOUDINARY_API_KEY')), + 'api_secret': setting('API_SECRET', os.environ.get('CLOUDINARY_API_SECRET')), + 'secure': setting('SECURE', True), + 'media_tag': setting('MEDIA_TAG', 'media'), + 'invalid_video_error_message': setting('INVALID_VIDEO_ERROR_NESSAGE', 'Please upload a valid video file.'), + 'exclude_delete_orphaned_media_paths': setting('EXCLUDE_DELETE_ORPHANED_MEDIA_PATHS', ()), + 'static_tag': setting('STATIC_TAG', 'static'), + 'staticfiles_manifest_root': setting('STATICFILES_MANIFEST_ROOT', os.path.join(settings.BASE_DIR, 'manifest')), + 'static_images_extensions': setting('STATIC_IMAGES_EXTENSIONS', + [ + 'jpg', + 'jpe', + 'jpeg', + 'jpc', + 'jp2', + 'j2k', + 'wdp', + 'jxr', + 'hdp', + 'png', + 'gif', + 'webp', + 'bmp', + 'tif', + 'tiff', + 'ico' + ]), + 'static_videos_extensions': setting('STATIC_VIDEOS_EXTENSIONS', + [ + 'mp4', + 'webm', + 'flv', + 'mov', + 'ogv', + '3gp', + '3g2', + 'wmv', + 'mpeg', + 'flv', + 'mkv', + 'avi' + ]), + + # used only on Windows, see https://github.com/ahupp/python-magic#dependencies for your reference + + 'magic_file_path': setting('MAGIC_FILE_PATH', 'magic'), + 'prefix': setting('PREFIX', settings.MEDIA_URL), + } + + + def _open(self, name, mode='rb'): + url = self._get_url(name) + response = requests.get(url) + if response.status_code == 404: + raise IOError + response.raise_for_status() + file = ContentFile(response.content) + file.name = name + file.mode = mode + return file + + def _upload(self, name, content): + options = {'use_filename': True, 'resource_type': self.RESOURCE_TYPE, 'tags': self.TAG} + folder = os.path.dirname(name) + if folder: + options['folder'] = folder + return cloudinary.uploader.upload(content, **options) + + def _save(self, name, content): + name = self._normalise_name(name) + name = self._prepend_prefix(name) + content = UploadedFile(content, name) + response = self._upload(name, content) + return response['public_id'] + + def delete(self, name): + response = cloudinary.uploader.destroy(name, invalidate=True, resource_type=self.RESOURCE_TYPE) + return response['result'] == 'ok' + + def _get_url(self, name): + name = self._prepend_prefix(name) + cloudinary_resource = cloudinary.CloudinaryResource(name, default_resource_type=self.RESOURCE_TYPE) + return cloudinary_resource.url + + def url(self, name): + return self._get_url(name) + + def exists(self, name): + url = self._get_url(name) + response = requests.head(url) + if response.status_code == 404: + return False + response.raise_for_status() + return True + + def size(self, name): + url = self._get_url(name) + response = requests.head(url) + if response.status_code == 200: + return int(response.headers['content-length']) + else: + return None + + def get_available_name(self, name, max_length=None): + if max_length is None: + return name + else: + return name[:max_length] + + def _normalize_path(self, path): + if path != '' and not path.endswith('/'): + path += '/' + return path + + def _prepend_prefix(self, name): + prefix = self.prefix.lstrip('/') + prefix = self._normalize_path(prefix) + if not name.startswith(prefix): + name = prefix + name + return name + + def listdir(self, path): + path = self._normalize_path(path) + resources = get_resources_by_path(self.RESOURCE_TYPE, self.TAG, path) + directories = set() + files = [] + for resource in resources: + resource_tail = resource.replace(path, '', 1) + if '/' in resource_tail: + directory = resource_tail.split('/', 1)[0] + directories.add(directory) + else: + files.append(resource_tail) + return list(directories), files + + def _normalise_name(self, name): + return name.replace('\\', '/') \ No newline at end of file diff --git a/cloudinary_storage/management/commands/collectstatic.py b/cloudinary_storage/management/commands/collectstatic.py index 1607444..a4f7afc 100644 --- a/cloudinary_storage/management/commands/collectstatic.py +++ b/cloudinary_storage/management/commands/collectstatic.py @@ -1,4 +1,5 @@ from django.contrib.staticfiles.management.commands import collectstatic +from django.core.files.storage import storages from django.conf import settings @@ -24,6 +25,6 @@ def copy_file(self, path, prefixed_path, source_storage): Overwritten to execute only with --upload-unhashed-files param or StaticCloudinaryStorage. Otherwise only hashed files will be uploaded during postprocessing. """ - if (settings.STATICFILES_STORAGE == 'cloudinary_storage.storage.StaticCloudinaryStorage' or + if (storages['staticfiles'] == 'cloudinary_storage.storage.StaticCloudinaryStorage' or self.upload_unhashed_files): super(Command, self).copy_file(path, prefixed_path, source_storage) diff --git a/cloudinary_storage/storage.old.py b/cloudinary_storage/storage.old.py new file mode 100644 index 0000000..9006f25 --- /dev/null +++ b/cloudinary_storage/storage.old.py @@ -0,0 +1,348 @@ +# The original storage.py module, kept for reference, will be removed + + +import errno +import json +import os +from urllib.parse import unquote, urlsplit, urlunsplit + +import cloudinary +import cloudinary.api +import cloudinary.uploader +import requests +from django.conf import settings +from django.contrib.staticfiles import finders +from django.contrib.staticfiles.storage import HashedFilesMixin, ManifestFilesMixin +from django.core.files.base import ContentFile, File +from django.core.files.storage import Storage, FileSystemStorage +from django.core.files.uploadedfile import UploadedFile +from django.utils.deconstruct import deconstructible + +from . import app_settings +from .helpers import get_resources_by_path + +RESOURCE_TYPES = { + 'IMAGE': 'image', + 'RAW': 'raw', + 'VIDEO': 'video' +} + + +@deconstructible +class MediaCloudinaryStorage(Storage): + RESOURCE_TYPE = RESOURCE_TYPES['IMAGE'] + TAG = app_settings.MEDIA_TAG + + def __init__(self, tag=None, resource_type=None): + if tag is not None: + self.TAG = tag + if resource_type is not None: + self.RESOURCE_TYPE = resource_type + + def _get_resource_type(self, name): + """ + Implemented to allow different resource types per file name + within one storage class. + """ + return self.RESOURCE_TYPE + + def _open(self, name, mode='rb'): + url = self._get_url(name) + response = requests.get(url) + if response.status_code == 404: + raise IOError + response.raise_for_status() + file = ContentFile(response.content) + file.name = name + file.mode = mode + return file + + def _upload(self, name, content): + options = {'use_filename': True, 'resource_type': self._get_resource_type(name), 'tags': self.TAG} + folder = os.path.dirname(name) + if folder: + options['folder'] = folder + return cloudinary.uploader.upload(content, **options) + + def _save(self, name, content): + name = self._normalise_name(name) + name = self._prepend_prefix(name) + content = UploadedFile(content, name) + response = self._upload(name, content) + return response['public_id'] + + def delete(self, name): + response = cloudinary.uploader.destroy(name, invalidate=True, resource_type=self._get_resource_type(name)) + return response['result'] == 'ok' + + def _get_url(self, name): + name = self._prepend_prefix(name) + cloudinary_resource = cloudinary.CloudinaryResource(name, default_resource_type=self._get_resource_type(name)) + return cloudinary_resource.url + + def url(self, name): + return self._get_url(name) + + def exists(self, name): + url = self._get_url(name) + response = requests.head(url) + if response.status_code == 404: + return False + response.raise_for_status() + return True + + def size(self, name): + url = self._get_url(name) + response = requests.head(url) + if response.status_code == 200: + return int(response.headers['content-length']) + else: + return None + + def get_available_name(self, name, max_length=None): + if max_length is None: + return name + else: + return name[:max_length] + + def _normalize_path(self, path): + if path != '' and not path.endswith('/'): + path += '/' + return path + + def _get_prefix(self): + return app_settings.PREFIX + + def _prepend_prefix(self, name): + prefix = self._get_prefix().lstrip('/') + prefix = self._normalize_path(prefix) + if not name.startswith(prefix): + name = prefix + name + return name + + def listdir(self, path): + path = self._normalize_path(path) + resources = get_resources_by_path(self.RESOURCE_TYPE, self.TAG, path) + directories = set() + files = [] + for resource in resources: + resource_tail = resource.replace(path, '', 1) + if '/' in resource_tail: + directory = resource_tail.split('/', 1)[0] + directories.add(directory) + else: + files.append(resource_tail) + return list(directories), files + + def _normalise_name(self, name): + return name.replace('\\', '/') + + +class RawMediaCloudinaryStorage(MediaCloudinaryStorage): + RESOURCE_TYPE = RESOURCE_TYPES['RAW'] + + +class VideoMediaCloudinaryStorage(MediaCloudinaryStorage): + RESOURCE_TYPE = RESOURCE_TYPES['VIDEO'] + + +storages_per_type = { + RESOURCE_TYPES['IMAGE']: MediaCloudinaryStorage(), + RESOURCE_TYPES['RAW']: RawMediaCloudinaryStorage(), + RESOURCE_TYPES['VIDEO']: VideoMediaCloudinaryStorage(), +} + + +class StaticCloudinaryStorage(MediaCloudinaryStorage): + """ + Base storage for staticfiles kept in Cloudinary. + Uploads only unhashed files, so it is highly unrecommended to use it directly, + because static files are cached both by Cloudinary CDN and browsers + and changing files could become problematic. + """ + RESOURCE_TYPE = RESOURCE_TYPES['RAW'] + TAG = app_settings.STATIC_TAG + + def _get_resource_type(self, name): + """ + Implemented as static files can be of different resource types. + Because web developers are the people who control those files, we can distinguish them + simply by looking at their extensions, we don't need any content based validation. + """ + extension = self._get_file_extension(name) + if extension is None: + return self.RESOURCE_TYPE + elif extension in app_settings.STATIC_IMAGES_EXTENSIONS: + return RESOURCE_TYPES['IMAGE'] + elif extension in app_settings.STATIC_VIDEOS_EXTENSIONS: + return RESOURCE_TYPES['VIDEO'] + else: + return self.RESOURCE_TYPE + + @staticmethod + def _get_file_extension(name): + substrings = name.split('.') + if len(substrings) == 1: # no extensions + return None + else: + return substrings[-1].lower() + + def url(self, name): + if settings.DEBUG: + return settings.STATIC_URL + name + return super(StaticCloudinaryStorage, self).url(name) + + def _upload(self, name, content): + resource_type = self._get_resource_type(name) + name = self._remove_extension_for_non_raw_file(name) + return cloudinary.uploader.upload(content, public_id=name, resource_type=resource_type, + invalidate=True, tags=self.TAG) + + def _remove_extension_for_non_raw_file(self, name): + """ + Implemented as image and video files' Cloudinary public id + shouldn't contain file extensions, otherwise Cloudinary url + would contain doubled extension - Cloudinary adds extension to url + to allow file conversion to arbitrary file, like png to jpg. + """ + file_resource_type = self._get_resource_type(name) + if file_resource_type is None or file_resource_type == self.RESOURCE_TYPE: + return name + else: + extension = self._get_file_extension(name) + return name[:-len(extension) - 1] + + # we only need 2 methods of HashedFilesMixin, so we just copy them as function objects to avoid MRO complexities + file_hash = HashedFilesMixin.file_hash + clean_name = HashedFilesMixin.clean_name + + def _exists_with_etag(self, name, content): + """ + Checks whether a file with a name and a content is already uploaded to Cloudinary. + Uses ETAG header and MD5 hash for the content comparison. + """ + url = self._get_url(name) + response = requests.head(url) + if response.status_code == 404: + return False + etag = response.headers['ETAG'].split('"')[1] + hash = self.file_hash(name, content) + return etag.startswith(hash) + + def _save(self, name, content): + """ + Saves only when a file with a name and a content is not already uploaded to Cloudinary. + """ + name = self.clean_name(name) # to change to UNIX style path on windows if necessary + if not self._exists_with_etag(name, content): + content.seek(0) + super(StaticCloudinaryStorage, self)._save(name, content) + return self._prepend_prefix(name) + + def _get_prefix(self): + return settings.STATIC_URL + + def listdir(self, path): + """ + Not implemented as static assets can be of different resource types + in contrast to media storages, which are specialized per given resource type. + That's why we cannot use parent's class listdir. + This method could be implemented in the future if there is a demand for it. + """ + raise NotImplementedError() + + def stored_name(self, name): + """ + Implemented to standardize interface + for StaticCloudinaryStorage and StaticHashedCloudinaryStorage + """ + return self._prepend_prefix(name) + + +class ManifestCloudinaryStorage(FileSystemStorage): + """ + Storage for manifest file which will keep map of hashed paths. + Subclasses FileSystemStorage, so the manifest file is kept locally. + It is highly recommended to keep the manifest in your version control system, + then you are guaranteed the manifest will be used in all production environment, + including Heroku and AWS Elastic Beanstalk. + """ + def __init__(self, location=None, base_url=None, *args, **kwargs): + location = app_settings.STATICFILES_MANIFEST_ROOT if location is None else location + super(ManifestCloudinaryStorage, self).__init__(location, base_url, *args, **kwargs) + + +class HashCloudinaryMixin(object): + def __init__(self, *args, **kwargs): + self.manifest_storage = ManifestCloudinaryStorage() + super(HashCloudinaryMixin, self).__init__(*args, **kwargs) + + def hashed_name(self, name, content=None, filename=None): + parsed_name = urlsplit(unquote(name)) + clean_name = parsed_name.path.strip() + opened = False + if content is None: + absolute_path = finders.find(clean_name) + try: + content = open(absolute_path, 'rb') + except (IOError, OSError) as e: + if e.errno == errno.ENOENT: + raise ValueError("The file '%s' could not be found with %r." % (clean_name, self)) + else: + raise + content = File(content) + opened = True + try: + file_hash = self.file_hash(clean_name, content) + finally: + if opened: + content.close() + path, filename = os.path.split(clean_name) + root, ext = os.path.splitext(filename) + if file_hash is not None: + file_hash = ".%s" % file_hash + hashed_name = os.path.join(path, "%s%s%s" % (root, file_hash, ext)) + unparsed_name = list(parsed_name) + unparsed_name[2] = hashed_name + # Special casing for a @font-face hack, like url(myfont.eot?#iefix") + # http://www.fontspring.com/blog/the-new-bulletproof-font-face-syntax + if '?#' in name and not unparsed_name[3]: + unparsed_name[2] += '?' + return urlunsplit(unparsed_name) + + def post_process(self, paths, dry_run=False, **options): + original_exists = self.exists + self.exists = lambda name: False # temporarily overwritten to prevent any exist check + for response in super(HashCloudinaryMixin, self).post_process(paths, dry_run, **options): + yield response + self.exists = original_exists + + def read_manifest(self): + try: + with self.manifest_storage.open(self.manifest_name) as manifest: + return manifest.read().decode('utf-8') + except IOError: + return None + + def add_unix_path_keys_to_paths(self, paths): + for path in paths.copy(): + if '\\' in path: + clean_path = self.clean_name(path) + paths[clean_path] = paths[path] + + def save_manifest(self): + payload = {'paths': self.hashed_files, 'version': self.manifest_version} + if os.name == 'nt': + paths = payload['paths'] + self.add_unix_path_keys_to_paths(paths) + if self.manifest_storage.exists(self.manifest_name): + self.manifest_storage.delete(self.manifest_name) + contents = json.dumps(payload).encode('utf-8') + self.manifest_storage._save(self.manifest_name, ContentFile(contents)) + + # we only need 1 method of HashedFilesMixin, so we just copy it as function objects to avoid MRO complexities + stored_name = HashedFilesMixin.stored_name + + +class StaticHashedCloudinaryStorage(HashCloudinaryMixin, ManifestFilesMixin, StaticCloudinaryStorage): + pass diff --git a/cloudinary_storage/storage.py b/cloudinary_storage/storage.py index 8a8c3d7..8fbf335 100644 --- a/cloudinary_storage/storage.py +++ b/cloudinary_storage/storage.py @@ -11,154 +11,51 @@ from django.contrib.staticfiles import finders from django.contrib.staticfiles.storage import HashedFilesMixin, ManifestFilesMixin from django.core.files.base import ContentFile, File -from django.core.files.storage import Storage, FileSystemStorage -from django.core.files.uploadedfile import UploadedFile +from django.core.files.storage import FileSystemStorage from django.utils.deconstruct import deconstructible -from . import app_settings -from .helpers import get_resources_by_path - -RESOURCE_TYPES = { - 'IMAGE': 'image', - 'RAW': 'raw', - 'VIDEO': 'video' -} +from .base import BaseStorage @deconstructible -class MediaCloudinaryStorage(Storage): - RESOURCE_TYPE = RESOURCE_TYPES['IMAGE'] - TAG = app_settings.MEDIA_TAG - - def __init__(self, tag=None, resource_type=None): - if tag is not None: - self.TAG = tag - if resource_type is not None: - self.RESOURCE_TYPE = resource_type - - def _get_resource_type(self, name): - """ - Implemented to allow different resource types per file name - within one storage class. - """ - return self.RESOURCE_TYPE - - def _open(self, name, mode='rb'): - url = self._get_url(name) - response = requests.get(url) - if response.status_code == 404: - raise IOError - response.raise_for_status() - file = ContentFile(response.content) - file.name = name - file.mode = mode - return file +class MediaCloudinaryStorage(BaseStorage): - def _upload(self, name, content): - options = {'use_filename': True, 'resource_type': self._get_resource_type(name), 'tags': self.TAG} - folder = os.path.dirname(name) - if folder: - options['folder'] = folder - return cloudinary.uploader.upload(content, **options) - - def _save(self, name, content): - name = self._normalise_name(name) - name = self._prepend_prefix(name) - content = UploadedFile(content, name) - response = self._upload(name, content) - return response['public_id'] - - def delete(self, name): - response = cloudinary.uploader.destroy(name, invalidate=True, resource_type=self._get_resource_type(name)) - return response['result'] == 'ok' - - def _get_url(self, name): - name = self._prepend_prefix(name) - cloudinary_resource = cloudinary.CloudinaryResource(name, default_resource_type=self._get_resource_type(name)) - return cloudinary_resource.url - - def url(self, name): - return self._get_url(name) - - def exists(self, name): - url = self._get_url(name) - response = requests.head(url) - if response.status_code == 404: - return False - response.raise_for_status() - return True - - def size(self, name): - url = self._get_url(name) - response = requests.head(url) - if response.status_code == 200: - return int(response.headers['content-length']) - else: - return None - - def get_available_name(self, name, max_length=None): - if max_length is None: - return name - else: - return name[:max_length] - - def _normalize_path(self, path): - if path != '' and not path.endswith('/'): - path += '/' - return path - - def _get_prefix(self): - return app_settings.PREFIX - - def _prepend_prefix(self, name): - prefix = self._get_prefix().lstrip('/') - prefix = self._normalize_path(prefix) - if not name.startswith(prefix): - name = prefix + name - return name - - def listdir(self, path): - path = self._normalize_path(path) - resources = get_resources_by_path(self.RESOURCE_TYPE, self.TAG, path) - directories = set() - files = [] - for resource in resources: - resource_tail = resource.replace(path, '', 1) - if '/' in resource_tail: - directory = resource_tail.split('/', 1)[0] - directories.add(directory) - else: - files.append(resource_tail) - return list(directories), files - - def _normalise_name(self, name): - return name.replace('\\', '/') + def __init__(self, **settings): + super().__init__(**settings) + self.TAG = self.media_tag + self.RESOURCE_TYPE = self.RESOURCE_TYPES['IMAGE'] class RawMediaCloudinaryStorage(MediaCloudinaryStorage): - RESOURCE_TYPE = RESOURCE_TYPES['RAW'] + def __init__(self, **settings): + super().__init__(**settings) + self.RESOURCE_TYPE = self.RESOURCE_TYPES['RAW'] class VideoMediaCloudinaryStorage(MediaCloudinaryStorage): - RESOURCE_TYPE = RESOURCE_TYPES['VIDEO'] + def __init__(self, **settings): + super().__init__(**settings) + self.RESOURCE_TYPE = self.RESOURCE_TYPES['VIDEO'] -storages_per_type = { - RESOURCE_TYPES['IMAGE']: MediaCloudinaryStorage(), - RESOURCE_TYPES['RAW']: RawMediaCloudinaryStorage(), - RESOURCE_TYPES['VIDEO']: VideoMediaCloudinaryStorage(), -} +# storages_per_type = { +# RESOURCE_TYPES['IMAGE']: MediaCloudinaryStorage(), +# RESOURCE_TYPES['RAW']: RawMediaCloudinaryStorage(), +# RESOURCE_TYPES['VIDEO']: VideoMediaCloudinaryStorage(), +# } -class StaticCloudinaryStorage(MediaCloudinaryStorage): +class StaticCloudinaryStorage(BaseStorage): """ Base storage for staticfiles kept in Cloudinary. Uploads only unhashed files, so it is highly unrecommended to use it directly, because static files are cached both by Cloudinary CDN and browsers and changing files could become problematic. """ - RESOURCE_TYPE = RESOURCE_TYPES['RAW'] - TAG = app_settings.STATIC_TAG + def __init__(self, **settings): + self.RESOURCE_TYPE = self.RESOURCE_TYPES['RAW'] + super().__init__(**settings) + self.TAG = self.static_tag def _get_resource_type(self, name): """ @@ -169,10 +66,10 @@ def _get_resource_type(self, name): extension = self._get_file_extension(name) if extension is None: return self.RESOURCE_TYPE - elif extension in app_settings.STATIC_IMAGES_EXTENSIONS: - return RESOURCE_TYPES['IMAGE'] - elif extension in app_settings.STATIC_VIDEOS_EXTENSIONS: - return RESOURCE_TYPES['VIDEO'] + elif extension in self.static_images_extensions: + return self.RESOURCE_TYPES['IMAGE'] + elif extension in self.static_videos_extensions: + return self.RESOURCE_TYPES['VIDEO'] else: return self.RESOURCE_TYPE @@ -265,14 +162,13 @@ class ManifestCloudinaryStorage(FileSystemStorage): including Heroku and AWS Elastic Beanstalk. """ def __init__(self, location=None, base_url=None, *args, **kwargs): - location = app_settings.STATICFILES_MANIFEST_ROOT if location is None else location - super(ManifestCloudinaryStorage, self).__init__(location, base_url, *args, **kwargs) + super().__init__(location, base_url, *args, **kwargs) class HashCloudinaryMixin(object): def __init__(self, *args, **kwargs): - self.manifest_storage = ManifestCloudinaryStorage() super(HashCloudinaryMixin, self).__init__(*args, **kwargs) + self.manifest_storage = ManifestCloudinaryStorage(location = self.staticfiles_manifest_root) def hashed_name(self, name, content=None, filename=None): parsed_name = urlsplit(unquote(name)) @@ -341,5 +237,9 @@ def save_manifest(self): stored_name = HashedFilesMixin.stored_name -class StaticHashedCloudinaryStorage(HashCloudinaryMixin, ManifestFilesMixin, StaticCloudinaryStorage): - pass +class StaticHashedCloudinaryStorage( + HashCloudinaryMixin, + ManifestFilesMixin, + StaticCloudinaryStorage + ): + pass \ No newline at end of file