Skip to content

Commit 3fa8308

Browse files
committed
Add tests for the field cloning behavior and add failing tests for when .deconstruct() doesn't allow reconstructing the field 1:1
Refs feincms/django-prose-editor#41
1 parent 3125a13 commit 3fa8308

File tree

7 files changed

+637
-0
lines changed

7 files changed

+637
-0
lines changed

README.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,20 @@ The ``to_attribute`` helper can also be used when filtering, for example:
158158
q |= Q(**{field: s})
159159
questions = Question.objects.filter(q)
160160
161+
.. important::
162+
163+
**Fields with custom deconstruct() methods**
164+
165+
If you use fields with custom ``deconstruct()`` methods that modify field
166+
parameters (such as ``ChoicesCharField`` from feincms3), be aware that
167+
``TranslatedField`` currently uses the output of ``deconstruct()`` to create
168+
the translated fields. This means any parameter modifications in the field's
169+
``deconstruct()`` method will affect the translated fields.
170+
171+
For example, if your custom field's ``deconstruct()`` method changes the
172+
``choices`` parameter, the translated fields will use those modified choices
173+
rather than the original ones.
174+
161175

162176
Changing field attributes per language
163177
======================================

tests/testapp/admin.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.contrib import admin
22

33
from testapp import models
4+
from testapp.field_types_models import CustomFieldModel, FieldTypesModel
45
from translated_fields import TranslatedFieldAdmin
56

67

@@ -25,3 +26,18 @@ class ListDisplayModelAdmin(TranslatedFieldAdmin, admin.ModelAdmin):
2526

2627
def stuff(self, instance):
2728
return "stuff"
29+
30+
31+
@admin.register(FieldTypesModel)
32+
class FieldTypesModelAdmin(TranslatedFieldAdmin, admin.ModelAdmin):
33+
list_display = [
34+
*FieldTypesModel.char_field.fields,
35+
*FieldTypesModel.text_field.fields,
36+
]
37+
38+
39+
@admin.register(CustomFieldModel)
40+
class CustomFieldModelAdmin(TranslatedFieldAdmin, admin.ModelAdmin):
41+
list_display = [
42+
*CustomFieldModel.custom_choices.fields,
43+
]

tests/testapp/custom_fields.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from django.db import models
2+
3+
4+
class ChoicesCharField(models.CharField):
5+
"""
6+
CharField with hardcoded default choices in deconstruct() method.
7+
8+
Similar to feincms3.utils.ChoicesCharField, this field sets a default set of
9+
choices in the deconstruct method to avoid migrations when choices change.
10+
"""
11+
12+
def __init__(self, *args, **kwargs):
13+
# Non-empty choices for get_*_display
14+
kwargs.setdefault("choices", [("", "")])
15+
super().__init__(*args, **kwargs)
16+
17+
def deconstruct(self):
18+
name, path, args, kwargs = super().deconstruct()
19+
# Always return hardcoded choices in deconstruct - this is what we want to test
20+
# TranslatedField should preserve the runtime choices, not the ones from deconstruct
21+
kwargs["choices"] = [("", "")]
22+
return name, path, args, kwargs
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from django.db import models
2+
from django.utils.translation import gettext_lazy as _
3+
4+
from testapp.custom_fields import ChoicesCharField
5+
from translated_fields import TranslatedField
6+
7+
8+
class RelatedModel(models.Model):
9+
name = models.CharField(_("name"), max_length=100)
10+
11+
def __str__(self):
12+
return self.name
13+
14+
15+
class FieldTypesModel(models.Model):
16+
"""Model to test various field types with TranslatedField."""
17+
18+
# CharField with max_length and choices
19+
char_field = TranslatedField(
20+
models.CharField(
21+
_("char field"),
22+
max_length=50,
23+
choices=[("a", "Option A"), ("b", "Option B"), ("c", "Option C")],
24+
)
25+
)
26+
27+
# TextField with max_length and help_text
28+
text_field = TranslatedField(
29+
models.TextField(
30+
_("text field"),
31+
max_length=500,
32+
help_text=_("This is a text field"),
33+
)
34+
)
35+
36+
# IntegerField with min/max values
37+
int_field = TranslatedField(
38+
models.IntegerField(
39+
_("integer field"),
40+
default=0,
41+
help_text=_("Enter a number"),
42+
)
43+
)
44+
45+
# BooleanField with default
46+
bool_field = TranslatedField(
47+
models.BooleanField(
48+
_("boolean field"),
49+
default=True,
50+
)
51+
)
52+
53+
# ForeignKey to test relationship fields
54+
foreign_key = TranslatedField(
55+
models.ForeignKey(
56+
RelatedModel,
57+
on_delete=models.CASCADE,
58+
verbose_name=_("related model"),
59+
related_name="+",
60+
null=True,
61+
blank=True,
62+
)
63+
)
64+
65+
# URLField with validators
66+
url_field = TranslatedField(
67+
models.URLField(
68+
_("URL field"),
69+
max_length=200,
70+
)
71+
)
72+
73+
# EmailField
74+
email_field = TranslatedField(
75+
models.EmailField(
76+
_("email field"),
77+
max_length=100,
78+
)
79+
)
80+
81+
# DecimalField with decimal_places and max_digits
82+
decimal_field = TranslatedField(
83+
models.DecimalField(
84+
_("decimal field"),
85+
max_digits=10,
86+
decimal_places=2,
87+
default=0.0,
88+
)
89+
)
90+
91+
# DateField with auto_now_add
92+
date_field = TranslatedField(
93+
models.DateField(
94+
_("date field"),
95+
auto_now_add=True,
96+
)
97+
)
98+
99+
def __str__(self):
100+
return self.char_field
101+
102+
103+
class CustomFieldModel(models.Model):
104+
"""Model with a custom field that has a custom deconstruct() method."""
105+
106+
# Custom field with hardcoded choices in deconstruct
107+
custom_choices = TranslatedField(
108+
ChoicesCharField(
109+
_("custom choices field"),
110+
max_length=10,
111+
choices=[("a", "Option A"), ("b", "Option B"), ("c", "Option C")],
112+
)
113+
)
114+
115+
def __str__(self):
116+
return self.custom_choices
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import sys
2+
3+
import pytest
4+
5+
from testapp.custom_fields import ChoicesCharField
6+
from testapp.field_types_models import CustomFieldModel
7+
8+
9+
@pytest.mark.django_db
10+
def test_custom_field_inspection():
11+
"""Debug test to understand what's happening with custom field choices."""
12+
# Output directly to stderr so we can see it in pytest output
13+
# Original field before TranslatedField processing
14+
original_field = ChoicesCharField(
15+
"Original field",
16+
max_length=10,
17+
choices=[("a", "Option A"), ("b", "Option B"), ("c", "Option C")],
18+
)
19+
20+
# Check original field deconstruct
21+
name, path, args, kwargs = original_field.deconstruct()
22+
print(
23+
f"\nOriginal field deconstruct: {name}, {path}, {args}, {kwargs}",
24+
file=sys.stderr,
25+
)
26+
27+
# Get the translated field instances
28+
custom_en = CustomFieldModel._meta.get_field("custom_choices_en")
29+
custom_de = CustomFieldModel._meta.get_field("custom_choices_de")
30+
31+
# Check their actual class and attributes
32+
print(f"Field class EN: {custom_en.__class__.__name__}", file=sys.stderr)
33+
print(f"Field choices EN: {custom_en.choices}", file=sys.stderr)
34+
35+
print(f"Field class DE: {custom_de.__class__.__name__}", file=sys.stderr)
36+
print(f"Field choices DE: {custom_de.choices}", file=sys.stderr)
37+
38+
# Check their deconstruct values
39+
name_en, path_en, args_en, kwargs_en = custom_en.deconstruct()
40+
print(
41+
f"Translated EN deconstruct: {name_en}, {path_en}, {args_en}, {kwargs_en}",
42+
file=sys.stderr,
43+
)
44+
45+
name_de, path_de, args_de, kwargs_de = custom_de.deconstruct()
46+
print(
47+
f"Translated DE deconstruct: {name_de}, {path_de}, {args_de}, {kwargs_de}",
48+
file=sys.stderr,
49+
)
50+
51+
# Add assertions to make it pass
52+
assert True
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import pytest
2+
from django.forms import modelform_factory
3+
from django.utils.translation import override
4+
5+
from testapp.field_types_models import CustomFieldModel
6+
7+
8+
@pytest.mark.django_db
9+
def test_custom_field_choices_actual_behavior():
10+
"""
11+
Test to demonstrate the current behavior with custom field deconstruct.
12+
13+
This test confirms what actually happens (not what we want to happen).
14+
"""
15+
# Get the runtime field instances
16+
custom_en = CustomFieldModel._meta.get_field("custom_choices_en")
17+
custom_de = CustomFieldModel._meta.get_field("custom_choices_de")
18+
19+
# The actual current behavior - choices are taken from deconstruct() [("", "")]
20+
# rather than the original [("a", "Option A"), ("b", "Option B"), ("c", "Option C")]
21+
actual_choices = [("", "")]
22+
23+
# These assertions show what actually happens (would pass)
24+
assert custom_en.choices == actual_choices
25+
assert custom_de.choices == actual_choices
26+
27+
# Get the deconstruct() values to confirm this behavior
28+
name_en, path_en, args_en, kwargs_en = custom_en.deconstruct()
29+
name_de, path_de, args_de, kwargs_de = custom_de.deconstruct()
30+
31+
# Confirm that deconstruct() returns the same hardcoded choices
32+
assert kwargs_en["choices"] == actual_choices
33+
assert kwargs_de["choices"] == actual_choices
34+
35+
36+
@pytest.mark.xfail(
37+
reason="TranslatedField doesn't handle custom deconstruct correctly yet"
38+
)
39+
@pytest.mark.django_db
40+
def test_custom_field_choices_preserved():
41+
"""
42+
Test that choices from custom fields with custom deconstruct methods are preserved.
43+
44+
This is currently an expected failure because TranslatedField doesn't properly
45+
handle fields with custom deconstruct methods that modify field parameters.
46+
47+
The issue is in translated_fields/fields.py line 82:
48+
_n, _p, args, kwargs = self._field.deconstruct()
49+
50+
When using a field with a custom deconstruct() method that modifies parameters like 'choices',
51+
those modifications affect the translated fields that are created.
52+
53+
A possible fix would be to store the original parameters before deconstruct() is called,
54+
or to copy the field's __dict__ attributes directly instead of using deconstruct().
55+
"""
56+
# Get the runtime field instances
57+
custom_en = CustomFieldModel._meta.get_field("custom_choices_en")
58+
custom_de = CustomFieldModel._meta.get_field("custom_choices_de")
59+
60+
# The runtime choices should be the ones we specified at field creation time,
61+
# not the hardcoded ones from deconstruct()
62+
expected_choices = [("a", "Option A"), ("b", "Option B"), ("c", "Option C")]
63+
64+
# Check the field choices match what we defined, not what deconstruct() returns
65+
assert custom_en.choices == expected_choices
66+
assert custom_de.choices == expected_choices
67+
68+
69+
@pytest.mark.xfail(
70+
reason="TranslatedField doesn't handle custom deconstruct correctly yet"
71+
)
72+
@pytest.mark.django_db
73+
def test_custom_field_form_generation():
74+
"""
75+
Test that forms generated from custom fields have the correct choices.
76+
77+
This fails for the same reason as test_custom_field_choices_preserved:
78+
the choices from the original field definition are not preserved when
79+
deconstruct() replaces them with hardcoded values.
80+
"""
81+
form_class = modelform_factory(CustomFieldModel, fields="__all__")
82+
form = form_class()
83+
84+
# Form field choices should include the default empty choice plus our defined choices
85+
expected_form_choices = [
86+
("", "---------"),
87+
("a", "Option A"),
88+
("b", "Option B"),
89+
("c", "Option C"),
90+
]
91+
92+
# Check that form fields have the expected choices
93+
assert form.fields["custom_choices_en"].choices == expected_form_choices
94+
assert form.fields["custom_choices_de"].choices == expected_form_choices
95+
96+
97+
@pytest.mark.xfail(
98+
reason="TranslatedField doesn't handle custom deconstruct correctly yet"
99+
)
100+
@pytest.mark.django_db
101+
def test_custom_field_model_usage():
102+
"""
103+
Test using the custom field with a model instance.
104+
105+
This fails because the get_FOO_display() method relies on the correct choices
106+
being set on the field. Since the choices are replaced with [("", "")] during
107+
field creation, the display values don't match what we expect.
108+
"""
109+
# Create a model instance with values for the custom field
110+
model = CustomFieldModel.objects.create(
111+
custom_choices_en="a",
112+
custom_choices_de="b",
113+
)
114+
115+
# Check that the values are correctly stored and the choices behavior works
116+
with override("en"):
117+
assert model.custom_choices == "a"
118+
assert model.get_custom_choices_display() == "Option A"
119+
120+
with override("de"):
121+
assert model.custom_choices == "b"
122+
assert model.get_custom_choices_display() == "Option B"
123+
124+
125+
@pytest.mark.parametrize("option", ["a", "b", "c"])
126+
@pytest.mark.django_db
127+
def test_custom_field_valid_options(option):
128+
"""Test that valid choice options can be saved."""
129+
# This test should pass even if the choices aren't preserved correctly
130+
# as long as the field validation isn't overly strict
131+
model = CustomFieldModel()
132+
model.custom_choices_en = option
133+
model.custom_choices_de = option
134+
model.save()
135+
assert model.custom_choices_en == option
136+
assert model.custom_choices_de == option

0 commit comments

Comments
 (0)