#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 django-updates+unsubscr...@googlegroups.com.
To post to this group, send email to django-updates@googlegroups.com.
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.

Reply via email to