#28490: Descriptors on Models are reported as nonexistent by System Check
Framework
for ModelAdmin.list_display if they return None
-------------------------------------+-------------------------------------
Reporter: Hunter | Owner: nobody
Richards |
Type: Bug | Status: new
Component: | Version: master
contrib.admin | Keywords: admin, descriptor,
Severity: Normal | system, checks, framework,
Triage Stage: | validation
Unreviewed | Has patch: 1
Needs documentation: 0 | Needs tests: 0
Patch needs improvement: 0 | Easy pickings: 0
UI/UX: 0 |
-------------------------------------+-------------------------------------
= Brief Description
The Django ModelAdmin System Checks will erroneously mark as invalid any
reference in `list_display` to a Descriptor on that ModelAdmin's model
that returns `None` when accessed on that model's class, even if the
Descriptor would return non-null values in the resulting ListView.
This error is due to code that subtly conflates a reference to a field
descriptor with the return value of that descriptor. In this ticket, I
give an in-depth explanation of the bug and a short fix, presented inline,
for demonstration purposes, and a larger fix containing an exciting
refactor and a justification for it.
= Example
Given a model with a field that is a Descriptor that returns `None`:
{{{#!python
class DescriptorThatReturnsNone(object):
def __get__(self, obj, objtype):
return None
def __set__(self, obj, value):
pass
class ValidationTestModel(models.Model):
some_other_field = models.CharField(max_length=100)
none_descriptor = DescriptorThatReturnsNone()
}}}
and a ModelAdmin that includes that Descriptor field in `list_display`:
{{{#!python
class TestModelAdmin(ModelAdmin):
list_display = ('some_other_field', 'none_descriptor')
}}}
then the system checks that run at project start will fail to find that
Descriptor attribute with the following error message:
{{{
(admin.E108) The value of 'list_display[4]' refers to 'none_descriptor',
which is not a callable, an attribute of 'TestModelAdmin', or an attribute
or method on 'modeladmin.ValidationTestModel'.
}}}
= Location of Error
The location of the error is in the following code:
https://github.com/django/django/blob/335aa088245a4cc6f1b04ba098458845344290bd/django/contrib/admin/checks.py#L586-L607
{{{#!python
elif hasattr(model, item):
# getattr(model, item) could be an X_RelatedObjectsDescriptor
try:
field = model._meta.get_field(item)
except FieldDoesNotExist:
try:
field = getattr(model, item)
except AttributeError:
field = None
if field is None:
return [
checks.Error([...]
}}}
We follow the branch for `has_attr(model, item)`, and `field =
model._meta.get_field(item)` throws an error, so we call `field =
getattr(model, item)` for the purpose of either getting a reference to
this non-field attribute or its value itself, depending on the type of
`item`. Supposedly, if `getattr` were to throw an error, we'd set `field`
to none and return E108 (more on this below), but in the Descriptor case
above, we don't. But `field` is still `None`, i.e. the return value of
the descriptor, so E108 is triggered anyway, even though the Descriptor
field is a perfectly valid value to display in a ListView.
The cause of the error comes from confusing the "type" of `field`: when
using Descriptors, it is not always the case that `getattr(SomeClass,
some_attr_name)` will return a reference to the field in question and
`getattr(some_class_instance, some_attr_name)` will return the actual
value of the attribute, which is the unstated assumption of the quoted
code. Therefore, `field` sometimes references a field itself, and
sometimes a field's value.
= A Workaround and a Quick Fix
I triggered this error with a slightly more complicated Descriptor that
returned the value of a related field on its declared model, and `None` if
that were not possible. Realizing the special value `None` was at the
heart of the error, as a workaround I rewrote the Descriptor to return the
empty string as an "empty" value instead of `None` which is
indistinguishable from `None` in a ListView.
As an example of how I intend to fix the error, here's The Simplest Thing
that Could Possibly Work:
{{{#!diff
diff --git a/django/contrib/admin/checks.py
b/django/contrib/admin/checks.py
index 830a190..b6b49a5 100644
--- a/django/contrib/admin/checks.py
+++ b/django/contrib/admin/checks.py
@@ -592,9 +592,11 @@ class ModelAdminChecks(BaseModelAdminChecks):
field = model._meta.get_field(item)
except FieldDoesNotExist:
try:
- field = getattr(model, item)
+ getattr(model, item)
except AttributeError:
field = None
+ else:
+ field = True
if field is None:
return [
}}}
In this fix, I discard the return value of `getattr` and just set `field`
based on whether `getattr` succeeded or not. The "type" of `field` is
still a bit confused since `True` isn't a field type, but this fix won't
miss any fields that happen to have the value `None` on classes.
= A Better Fix and a Refactor
But wait! We've rightly discarded the return value of `getattr` above,
but how'd we get to this line of code in the first place? By having
`hasattr(model, item)` be `True`. According to the Python docs, `hasattr`
is implemented using `getattr`:
https://docs.python.org/2/library/functions.html#hasattr
https://docs.python.org/3/library/functions.html#hasattr
> [`hasattr`] is implemented by calling `getattr(object, name)` and seeing
whether it raises an exception or not.
So if we've reached the call to `getattr` above, then it follows by the
definition of `hasattr` that we've already run `getattr` successfully.
If we continue this line of thinking, we realize that `getattr` thus
cannot possibly fail in this branch, and in our new "simple fix" version
above, `item` in this `elif` branch will never refer to an attribute that
cannot be gotten through `getattr` for successful display in the ListView.
We can thus remove the first duplicated instance where E108 is raised, and
thereby simplify the control flow greatly, so that the final version of
this function resembles the following:
{{{#!python
def _check_list_display_item(self, obj, model, item, label):
if callable(item):
return []
elif hasattr(obj, item):
return []
elif hasattr(model, item):
try:
field = model._meta.get_field(item)
except FieldDoesNotExist:
return []
else:
if isinstance(field, models.ManyToManyField):
return [checks.Error([...E109: Disallowed M2Ms...])]
else:
return []
else:
return [checks.Error([...E108...])]
}}}
which coincidentally matches the text of E108 much more closely.
= Submitting a Patch
I've attached a patch version of my changes to this PR for convenience,
but I intend to open a Pull Request on GitHub that will contain all
changes mentioned in this Ticket. Both the patch and the PR will contain
tests recapitulating and then demonstrating the fixing of this error.
--
Ticket URL: <https://code.djangoproject.com/ticket/28490>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.
--
You received this message because you are subscribed to the Google Groups
"Django updates" group.
To unsubscribe from this group and stop receiving emails from it, send an email
to [email protected].
To post to this group, send email to [email protected].
To view this discussion on the web visit
https://groups.google.com/d/msgid/django-updates/052.43b341def963f3911f300a63f806279e%40djangoproject.com.
For more options, visit https://groups.google.com/d/optout.