Repository: jschneier/django-storages

Issue:
S3BotoStorage doesn't properly fallback to other Content-Type heuristics 
Content Type is determined by the following:

1. a property called content-type
2. the filename
3. a default is used

If the `content-type` property doesn't exist, the logic falls back to 2 and 3. But the logic doesn't properly fallback if the content_type exists, but it set to None. 

I found that in django my InMemoryFileUpload had a content_type of None.  And so django-storages would try to make a s3 upload via boto3 with the following params {"Content-Type:": None}

This would cause boto to crash with 

```Invalid type for parameter ContentType, value: None, type: <class 'NoneType'>, valid types: <class 'str'>```


Discussion:

Resolving patch:
diff --git a/storages/backends/s3boto.py b/storages/backends/s3boto.py
--- a/storages/backends/s3boto.py
+++ b/storages/backends/s3boto.py
@@ -378,8 +378,8 @@ def _save(self, name, content):
         name = self._normalize_name(cleaned_name)
         headers = self.headers.copy()
         _type, encoding = mimetypes.guess_type(name)
-        content_type = getattr(content, 'content_type',
-                               _type or self.key_class.DefaultContentType)
+        content_type = getattr(content, 'content_type', None)
+        content_type = content_type or _type or self.key_class.DefaultContentType
 
         # setting the content_type in the key object is not enough.
         headers.update({'Content-Type': content_type})
diff --git a/storages/backends/s3boto3.py b/storages/backends/s3boto3.py
--- a/storages/backends/s3boto3.py
+++ b/storages/backends/s3boto3.py
@@ -419,8 +419,8 @@ def _save(self, name, content):
         name = self._normalize_name(cleaned_name)
         parameters = self.object_parameters.copy()
         _type, encoding = mimetypes.guess_type(name)
-        content_type = getattr(content, 'content_type',
-                               _type or self.default_content_type)
+        content_type = getattr(content, 'content_type', None)
+        content_type = content_type or _type or self.default_content_type
 
         # setting the content_type in the key object is not enough.
         parameters.update({'ContentType': content_type})


Test patch:
diff --git a/tests/test_s3boto.py b/tests/test_s3boto.py
--- a/tests/test_s3boto.py
+++ b/tests/test_s3boto.py
@@ -66,6 +66,26 @@ def test_storage_save(self):
             rewind=True
         )
 
+    def test_content_type(self):
+        """
+        Test saving a file with a None content type.
+        """
+        name = 'test_image.jpg'
+        content = ContentFile('data')
+        content.content_type = None
+        self.storage.save(name, content)
+        self.storage.bucket.get_key.assert_called_once_with(name)
+
+        key = self.storage.bucket.get_key.return_value
+        key.set_metadata.assert_called_with('Content-Type', 'image/jpeg')
+        key.set_contents_from_file.assert_called_with(
+            content,
+            headers={'Content-Type': 'image/jpeg'},
+            policy=self.storage.default_acl,
+            reduced_redundancy=self.storage.reduced_redundancy,
+            rewind=True
+        )
+
     def test_storage_save_gzipped(self):
         """
         Test saving a gzipped file
diff --git a/tests/test_s3boto3.py b/tests/test_s3boto3.py
--- a/tests/test_s3boto3.py
+++ b/tests/test_s3boto3.py
@@ -89,6 +89,25 @@ def test_storage_save(self):
             }
         )
 
+    def test_content_type(self):
+        """
+        Test saving a file with a None content type.
+        """
+        name = 'test_image.jpg'
+        content = ContentFile('data')
+        content.content_type = None
+        self.storage.save(name, content)
+        self.storage.bucket.Object.assert_called_once_with(name)
+
+        obj = self.storage.bucket.Object.return_value
+        obj.upload_fileobj.assert_called_with(
+            content.file,
+            ExtraArgs={
+                'ContentType': 'image/jpeg',
+                'ACL': self.storage.default_acl,
+            }
+        )
+
     def test_storage_save_gzipped(self):
         """
         Test saving a gzipped file