v0.164.1
  1"""Field references in admin declarations.
  2
  3`search_fields`, `queryset_order` and a `TrendCard`'s datetime/group fields are
  4declared as either a typed field reference (`User.email`, or the traversed
  5`FlagResult.flag.name`) or the lookup path as a string. Both spellings are
  6normalized to the path, so every query site downstream builds the same
  7`Q(**{f"{path}__icontains": ...})` / `order_by(path)` it always built -- the
  8SQL is identical whichever spelling the declaration used.
  9
 10Normalizing happens twice, for two different reasons:
 11
 12- **At class definition**, over a plain tuple (or a plain `Field`/`str`) found
 13  on the class. That is where a reference to another model's field can be
 14  refused while the traceback still points at the declaration -- and it also
 15  takes the `Field` back off the class attribute. A `Field` is a data
 16  descriptor, so one left sitting on a view or card class would run on every
 17  `self.search_fields`, which is not what the attribute means there.
 18- **At runtime**, over whatever the instance actually has. A declaration can
 19  be a `property`, or replaced on the instance from `get()`, and neither goes
 20  through class definition.
 21
 22Strings stay supported because not every path is expressible as a reference:
 23traversal has to *start* at a forward foreign key, so a path that starts at a
 24reverse accessor or a many-to-many (`"memberships__team__name"`) has no field
 25to reference.
 26"""
 27
 28from __future__ import annotations
 29
 30import inspect
 31from typing import Any
 32
 33from plain.postgres import Field, Model
 34
 35# What a field-taking admin declaration accepts.
 36type FieldRef = Field[Any] | str
 37
 38
 39def field_lookup_path(
 40    ref: FieldRef, *, model: type[Model] | None, declared_as: str
 41) -> str:
 42    """Normalize one declared reference to its lookup path.
 43
 44    `model` is the model the declaring view or card queries, or None when
 45    there is nothing to check against (an `AdminListView` over plain objects).
 46    `declared_as` names the declaration in the error, e.g.
 47    `"UserAdmin.ListView.search_fields"`.
 48    """
 49    if isinstance(ref, str):
 50        return ref
 51
 52    if not ref.lookup_path:
 53        # A field declared on a `ModelMixin`, or built standalone for an
 54        # aggregate, was never named -- only the model that mixes it in has an
 55        # attached copy. Left alone it normalizes to "" and surfaces much
 56        # later as `FieldError: Cannot resolve keyword ''`.
 57        raise TypeError(
 58            f"{declared_as} references an unattached "
 59            f"{type(ref).__name__} -- a field declared on a mixin, or built "
 60            f"on its own, carries no name to look up. Reference the field on "
 61            f"the model that declares it."
 62        )
 63
 64    # A field reference carries the model a condition built from it would
 65    # belong to -- the declaring model for a column, the model the traversal
 66    # started from for `FlagResult.flag.name`. The same identity where()
 67    # checks, checked here instead, because these declarations are turned into
 68    # string lookups and would otherwise resolve silently against the wrong
 69    # column.
 70    source = ref.source_model
 71    if model is not None and source is not None and source is not model:
 72        raise TypeError(
 73            f"{declared_as} references {source.__name__}.{ref.lookup_path}, "
 74            f"but this is a {model.__name__} view. Reference "
 75            f"{model.__name__}'s own field, or traverse to it from "
 76            f"{model.__name__}."
 77        )
 78
 79    return ref.lookup_path
 80
 81
 82def field_lookup_paths(
 83    refs: tuple[FieldRef, ...], *, model: type[Model] | None, declared_as: str
 84) -> tuple[str, ...]:
 85    """Normalize a declared tuple of references to lookup paths."""
 86    return tuple(
 87        field_lookup_path(ref, model=model, declared_as=declared_as) for ref in refs
 88    )
 89
 90
 91def _declared(obj: Any, attr: str) -> Any:
 92    """Read `attr` off a class or instance without running a descriptor.
 93
 94    Class definition has to see the declaration itself, not what `Field`'s
 95    descriptor would hand back; an instance read has to see its own override
 96    rather than the class default. `getattr_static` does both.
 97    """
 98    return inspect.getattr_static(obj, attr, None)
 99
100
101def converge_declared_tuple(cls: type, attr: str, *, model: type[Model] | None) -> None:
102    """Normalize a tuple of field references declared on `cls`, in place.
103
104    Anything that isn't a plain tuple -- a `property`, a method, a descriptor
105    of someone else's -- is left alone; it computes its value per instance, so
106    it is normalized at runtime instead.
107    """
108    raw = _declared(cls, attr)
109    if not isinstance(raw, tuple):
110        return
111
112    paths = field_lookup_paths(
113        raw, model=model, declared_as=f"{cls.__qualname__}.{attr}"
114    )
115    if paths != raw:
116        setattr(cls, attr, paths)
117
118
119def converge_declared_field(cls: type, attr: str, *, model: type[Model] | None) -> None:
120    """Normalize a single field reference declared on `cls`, in place."""
121    raw = _declared(cls, attr)
122    if not isinstance(raw, Field | str):
123        return
124
125    path = field_lookup_path(raw, model=model, declared_as=f"{cls.__qualname__}.{attr}")
126    if path != raw:
127        setattr(cls, attr, path)
128
129
130def instance_field_ref(obj: Any, attr: str) -> FieldRef | None:
131    """Read a single `FieldRef | None` declaration off a live instance.
132
133    The static read is what picks up an instance's own override. Anything it
134    finds that isn't a reference already is something that computes one -- a
135    `property`, say -- so that one is asked for normally.
136    """
137    ref = _declared(obj, attr)
138    if ref is None or isinstance(ref, Field | str):
139        return ref
140    return getattr(obj, attr)