Changes between Initial Version and Version 3 of Ticket #30543


Ignore:
Timestamp:
Jun 5, 2019, 9:09:36 AM (5 years ago)
Author:
ajcsimons
Comment:

Updated description with detail from #30545

Legend:

Unmodified
Added
Removed
Modified
  • Ticket #30543 – Description

    initial v3  
    1 #28490 caused a regression in 47016adbf54b54143d4cf052eeb29fc72d27e6b1, i.e.
    2  
    3 1. if hasattr(obj.model, item) returns false then we go straight to the else clause which returns the error,
    4 2. whereas before the else clause did another check for model._meta.get_field(item) and would only return the error if that raised a FieldDoesNotExist exception
    5 3. So how is it that hasattr(model, item) can return false, but model._meta.get_field(item) will return something meaning the check should not return an error?
    6 4. Answer: the field is a special one which is only valid to access on instances of the model (whose ModelAdmin we are verifying) and not the model class itself. An example of this is the PositionField from the django-positions library (which inherits from djangos models.IntegerField):
     1As part of startup django validates the ModelAdmin's list_display list/tuple for correctness (django.admin.contrib.checks._check_list_display). Having upgraded django from 2.07 to 2.2.1 I found that a ModelAdmin with a list display that used to pass the checks and work fine in admin now fails validation, preventing django from starting. A PositionField from the django-positions library triggers this bug, explanation why follows.
     2
    73{{{
    8     def __get__(self, instance, owner):
    9         if instance is None:
    10             raise AttributeError("%s must be accessed via instance." % self.name)
    11         current, updated = getattr(instance, self.get_cache_name())
    12         return current if updated is None else updated
     4from django.db import models
     5from position.Fields import PositionField
     6
     7class Thing(models.Model)
     8  number = models.IntegerField(default=0)
     9  order = PositionField()
    1310}}}
    14 5. So the simplification of the hasattr branch was valid, but the removal of the else branch to no longer check get_field doesn't throw before returning an error was not a refactor but a functionality altering change which makes this method return errors in cases where it used not to.
     11
     12{{{
     13from django.contrib import admin
     14from .models import Thing
     15
     16@admin.register(Thing)
     17class ThingAdmin(admin.ModelAdmin)
     18  list_display = ['name', 'order']
     19}}}
     20
     21Under 2.2.1 this raises an incorrect admin.E108 message saying "The value of list_display[1] refers to 'order' which is not a callable...".
     22Under 2.0.7 django starts up successfully.
     23If you change 'name' to 'no_name' or 'order' to 'no_order' then the validation correctly complains about those.
     24
     25The reason for this bug is commit https://github.com/django/django/commit/47016adbf54b54143d4cf052eeb29fc72d27e6b1 which was proposed and accepted as a fix for bug https://code.djangoproject.com/ticket/28490. The problem is while it fixed that bug it broke the functionality of _check_list_display_item in other cases. The rationale for that change was that after field=getattr(model, item) field could be None if item was a descriptor returning None, but subsequent code incorrectly interpreted field being None as meaning getattr raised an AttributeError. As this was done **after** trying field = model._meta.get_field(item) and that failing that meant the validation error should be returned. However, after the above change if hasattr(model, item) is false then we no longer even try field = model._meta.get_field(item) before returning an error. The reason hasattr(model, item) is false in the case of a PositionField is its __get__ method throws an exception if called on an instance of the PositionField class on the Thing model class, rather than a Thing instance.
     26
     27For clarity, here are the various logical tests that _check_list_display_item needs to deal with and the behaviour before the above change, after it, and the correct behaviour (which my suggested patch exhibits). Note this is assuming the first 2 tests callable(item) and hasattr(obj, item) are both false (corresponding to item is an actual function/lambda rather than string or an attribute of ThingAdmin).
     28
     29* hasattr(model, item) returns True or  False (which is the same as seeing if getattr(model, item) raises AttributeError)
     30* model._meta.get_field(item) returns a field or raises FieldDoesNotExist
     31Get a field from somewhere, could either be from getattr(model,item) if hasattr was True or from get_field.
     32* Is that field an instance of ManyToManyField?
     33* Is that field None? (True in case of bug 28490)
     34
     35||= hasattr  =||= get_field =||= field is None? =||= field ManyToMany? =||= 2.0 returns =||= 2.2 returns =||= Correct behaviour =||= Comments =||
     36|| True ||  ok  || False || False || [] || [] || [] || - ||
     37|| True ||  ok  || False || True || E109 || E109 || E109 || - ||
     38|| True ||  ok  || True || False || E108 || [] || [] || good bit of 28490 fix, 2.0 was wrong ||
     39|| True ||  raises || False || False || [] || [] || [] || - ||
     40|| True ||  raises || False || True || E109 || [] || E109 || Another bug introduced by 28490 fix, fails to check if ManyToMany in get_field raise case ||
     41|| True ||  raises || True || False || E108 || [] || [] || good bit of 28490 fix, 2.0 was wrong ||
     42|| False ||  ok || False || False || [] || E108 || [] || bad bit of 28490 fix, bug hit with PositionField ||
     43|| False ||  ok || False || True || [] || E108 || E109 || both 2.0 and 2.2 wrong ||
     44|| False ||  ok || True || False || [] || E108 || [] || bad 28490 fix ||
     45|| False ||  raises || False || False || E108 || E108 || E108 || - ||
     46|| False ||  raises || False || True || E108 || E108 || E108 || impossible condition, we got no field assigned to be a ManyToMany ||
     47|| False ||  raises || True || False || E108 || E108 || E108 || impossible condition, we got no field assigned to be None ||
     48
     49The following code exhibits the correct behaviour in all cases. The key changes are there is no longer a check for hasattr(model, item), as that being false should not prevent us form attempting to get the field via get_field, and only return an E108 in the case both of them fail. If either of those means or procuring it are successful then we need to check if it's a ManyToMany. Whether or not the field is None is irrelevant, and behaviour is contained within the exception catching blocks that should cause it instead of signalled through a variable being set to None which is a source of conflation of different cases.
     50
     51{{{
     52def _check_list_display_item(self, obj, item, label):
     53    if callable(item):
     54        return []
     55    elif hasattr(obj, item):
     56        return []
     57    else:
     58        try:
     59            field = obj.model._meta.get_field(item)
     60        except FieldDoesNotExist:
     61            try:
     62                field = getattr(obj.model, item)
     63            except AttributeError:
     64                return [
     65                    checks.Error(
     66                        "The value of '%s' refers to '%s', which is not a callable, "
     67                        "an attribute of '%s', or an attribute or method on '%s.%s'." % (
     68                            label, item, obj.__class__.__name__,
     69                            obj.model._meta.app_label, obj.model._meta.object_name,
     70                        ),
     71                        obj=obj.__class__,
     72                        id='admin.E108',
     73                    )
     74                ]
     75
     76        if isinstance(field, models.ManyToManyField):
     77            return [
     78                checks.Error(
     79                    "The value of '%s' must not be a ManyToManyField." % label,
     80                    obj=obj.__class__,
     81                    id='admin.E109',
     82                )
     83            ]
     84        return []
     85}}}
Back to Top