Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add FileField support #120

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions modeltrans/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
get_translated_field_label,
)

SUPPORTED_FIELDS = (fields.CharField, fields.TextField, JSONField)
SUPPORTED_FIELDS = (fields.CharField, fields.TextField, JSONField, fields.files.FileField)

DEFAULT_LANGUAGE = get_default_language()

Expand Down Expand Up @@ -108,6 +108,16 @@ def get_instance_fallback_chain(self, instance, language):

return default

def get_localized_value(self, instance, field_name):
value = instance.i18n.get(field_name)

if isinstance(self.original_field, fields.files.FileField):
descriptor = self.descriptor_class(self)
descriptor.__set__(instance, value)
return descriptor.__get__(instance)

return value

def __get__(self, instance, instance_type=None):
# This method is apparently called with instance=None from django.
# django-hstor raises AttributeError here, but that doesn't solve our problem.
Expand All @@ -132,7 +142,7 @@ def __get__(self, instance, instance_type=None):

# Just return the value if this is an explicit field (<name>_<lang>)
if self.language is not None:
return instance.i18n.get(field_name)
return self.get_localized_value(instance, field_name)

# This is the _i18n version of the field, and the current language is not available,
# so we walk the fallback chain:
Expand All @@ -145,7 +155,7 @@ def __get__(self, instance, instance_type=None):

field_name = build_localized_fieldname(self.original_name, fallback_language)
if field_name in instance.i18n and instance.i18n[field_name]:
return instance.i18n.get(field_name)
return self.get_localized_value(instance, field_name)

# finally, return the original field if all else fails.
return getattr(instance, self.original_name)
Expand Down Expand Up @@ -307,8 +317,43 @@ def get_translated_fields(self):
if isinstance(field, TranslatedVirtualField):
yield field

def get_file_fields(self):
"""Return a generator for all translated FileFields."""
for field in self.get_translated_fields():
if isinstance(field.original_field, fields.files.FileField):
yield field

def contribute_to_class(self, cls, name):
if name != "i18n":
raise ImproperlyConfigured('{} must have name "i18n"'.format(self.__class__.__name__))

super().contribute_to_class(cls, name)

def pre_save(self, model_instance, add):
"""Ensure that translated field values are serializable before save"""
data = super().pre_save(model_instance, add)

if data is None:
return data

for field in self.get_file_fields():
localized_file_attname = field.attname
if localized_file_attname in data:
current_value = data[localized_file_attname]

# wrap the value in the descriptor class, which will set
# the value within the model instance, _NOT_ the i18n key
descriptor = field.descriptor_class(field)
descriptor.__set__(model_instance, current_value)

# retrieve the descriptor value, which will check to see if the
# file reference has been committed
file = descriptor.__get__(model_instance)
if file and not file._committed:
# Commit the file to storage prior to saving the model
file.save(file.name, file.file, save=False)

# finally, mimic how `get_prep_value` works
# for "concrete" FileField instances
data[localized_file_attname] = str(file)
return data
15 changes: 15 additions & 0 deletions tests/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,18 @@ class Comment(models.Model):

def __str__(self):
return self.text


class Attachment(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
limit_choices_to={"is_published": True},
related_name="attachments",
)
file = models.FileField()

i18n = TranslationField(fields=("file",))

def __str__(self):
return self.file.name
9 changes: 9 additions & 0 deletions tests/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,12 @@
USE_TZ = True

STATIC_URL = "/static/"

STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.InMemoryStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
76 changes: 76 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.storage.memory import InMemoryFileNode
from django.db import DataError, models, transaction
from django.test import TestCase, override_settings
from django.utils.translation import override
Expand All @@ -7,11 +9,13 @@

from .app.models import (
Article,
Attachment,
Blog,
Challenge,
ChallengeContent,
ChildArticle,
NullableTextModel,
Post,
TaggedBlog,
TextModel,
)
Expand Down Expand Up @@ -137,6 +141,78 @@ def test_fallback_getting_JSONField(self):
# an empty list)
self.assertEqual(m.tags_i18n, [])

def test_fallback_getting_FileField(self):
post = Post.objects.create(title="Test Post", is_published=True)
sample_file = ContentFile("sample content", name="sample-en.txt")
attachment = Attachment.objects.create(post=post, file=sample_file)

with override("fr"):
self.assertIsInstance(attachment.file_i18n, models.fields.files.FieldFile)
self.assertIsInstance(attachment.file_i18n.file, InMemoryFileNode)

def test_set_FileField(self):
post = Post.objects.create(title="Test Post", is_published=True)

en_content = "sample content"
fr_content = "exemple de contenu"
sample_file_en = ContentFile(en_content, name="sample-en.txt")
sample_file_fr = ContentFile(fr_content, name="sample-fr.txt")

attachment = Attachment.objects.create(
post=post, file=sample_file_en, file_fr=sample_file_fr
)
self.assertIsInstance(attachment.file_fr, models.fields.files.FieldFile)

self.assertIsInstance(attachment.file.file, InMemoryFileNode)
self.assertIsInstance(attachment.file_fr.file, InMemoryFileNode)

saved_fr_content = attachment.file_fr.read().decode("utf-8")
self.assertEqual(saved_fr_content, fr_content)

with override("fr"):
self.assertEqual(attachment.file_i18n, attachment.file_fr)

def test_FileField_getter(self):
post = Post.objects.create(title="Test Post", is_published=True)

fr_content = "exemple de contenu 2"
sample_file_fr = ContentFile(fr_content, name="sample-fr-2.txt")

attachment = Attachment(post=post, file_fr=sample_file_fr)
# prior to invoking save, the file_fr should be an instance of FieldFile
self.assertIsInstance(attachment.file_fr, models.fields.files.FieldFile)
# but the file object should be an instance of ContentFile
self.assertIsInstance(attachment.file_fr.file, ContentFile)
attachment.save()

# After saving, the file object should be the default storage class
self.assertIsInstance(attachment.file_fr.file, InMemoryFileNode)

# Retreiving the instance from the database, file and file_fr
# should return the same kind of interface (a FieldFile instance)
attachment = Attachment.objects.get(pk=attachment.pk)
self.assertIsInstance(attachment.file, models.fields.files.FieldFile)
self.assertIsInstance(attachment.file_fr, models.fields.files.FieldFile)

# Test that we can overwrite those with new content
new_content = "new content"
new_fr_content = "new French content"
attachment.file = ContentFile(new_content, name="content-new.txt")
attachment.file_fr = ContentFile(new_fr_content, name="content-new-fr.txt")
self.assertIsInstance(attachment.file, models.fields.files.FieldFile)
self.assertIsInstance(attachment.file_fr, models.fields.files.FieldFile)

attachment.save()

self.assertIsInstance(attachment.file_fr, models.fields.files.FieldFile)
self.assertIsInstance(attachment.file, models.fields.files.FieldFile)

with attachment.file.open() as f:
self.assertEqual(f.read().decode("utf-8"), new_content)

with attachment.file_fr.open() as f:
self.assertEqual(f.read().decode("utf-8"), new_fr_content)

def test_creating_using_virtual_default_language_field(self):
m = Blog.objects.create(title_en="Falcon")

Expand Down
7 changes: 4 additions & 3 deletions tests/test_querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import django
from django.db import models
from django.db.models import F, Q
from django.db.models.functions import Collate
from django.test import TestCase, override_settings
from django.utils.translation import override

Expand Down Expand Up @@ -489,9 +490,9 @@ def test_order_by_lower(self):

filtered = Blog.objects.filter(category=c)

# order by title should result in aA because it is case sensitive.
qs = filtered.order_by("title", "title_nl")
self.assertEqual(key(qs, "title"), "a A")
# order by title should result in Aa because it is case sensitive.
qs = filtered.order_by(Collate("title", "C"), Collate("title_nl", "C"))
self.assertEqual(key(qs, "title"), "A a")

# order by Lower('title') should result in Aa because lower('A') == lower('A')
# so the title_nl field should determine the sorting
Expand Down
1 change: 1 addition & 0 deletions tests/test_translating.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def test_get_translated_models(self):
app_models.Challenge,
app_models.ChallengeContent,
app_models.Post,
app_models.Attachment,
app_models.Comment,
}
self.assertEqual(set(get_translated_models("app")), expected)
Expand Down
Loading