Django管理画面 list_filterのカスタマイズ


list_filterの項目を別のlist_filterの結果で絞り込む方法を調べたのでメモ．

# やりたいこと

1対多の関係が連結しているモデルに，list_filterに連結元のモデルを指定する．
この時に，選んだ項目によってlist_filterの項目を減らしたい．

言葉だとわかりづらいのでサンプルとして以下のようなモデルを考える．

![Kobito.R4Pvai.png](https://qiita-image-store.s3.amazonaws.com/0/58048/40a0871e-aca9-2ba9-aa06-3f1fda277284.png "Kobito.R4Pvai.png")

シフトの管理画面のlist_filterとして店とスタッフを指定する．
ここで店を選択すると，スタッフの選択項目を絞り込みたい．

![Kobito.me0mLo.png](https://qiita-image-store.s3.amazonaws.com/0/58048/a0cc0c78-615a-21b0-b1fc-8ffab840e4d2.png "Kobito.me0mLo.png")


# 結論

`admin.RelatedFieldListFilter` の `field_choices` メソッドをオーバーライドし，
`list_filter` にfield名と作ったクラスをタプルで渡す．

オーバーライドする時に，`field.get_choices` の呼び出し時に `limit_choices_to` 引数を渡す．
渡す値は `Q` オブジェクトか辞書．

店で一度絞り込みをかけ，どういうクエリが発行されるかを確認し，その値で絞り込みをかける．

## コード

```python:admin.py
from django.contrib import admin
from django.db.models import Q

from .models import Shift


class StaffFieldListFilter(admin.RelatedFieldListFilter):
    def field_choices(self, field, request, model_admin):
        shop_id = request.GET.get('staff__shop__id__exact')
        limit_choices_to = Q(shop_id__exact=shop_id) if shop_id else None
        return field.get_choices(include_blank=False, limit_choices_to=limit_choices_to)


class ShiftAdmin(admin.ModelAdmin):
    list_display = (
        'staff',
        'start_at',
        'end_at',
    )
    list_filter = (
        'staff__shop',
        ('staff', StaffFieldListFilter),
    )

admin.site.register(Shift, ShiftAdmin)
```

## 結果

![Kobito.4xD8xK.png](https://qiita-image-store.s3.amazonaws.com/0/58048/dcf0c914-f1b9-a09f-dc59-6935847eabac.png "Kobito.4xD8xK.png")

![Kobito.lOpw7b.png](https://qiita-image-store.s3.amazonaws.com/0/58048/f89bc0c3-94e6-7693-d901-302e618c29f4.png "Kobito.lOpw7b.png")

店を選択した時にスタッフが所属店で絞り込まれた．

--

# 調べたこと

`ModelAdmin` クラスで `list_filter` を定義すると自動で項目が作成されるので，その部分を確認．

## 表示用テンプレートの確認

[django/contrib/admin/templates/admin/change_list.html](https://github.com/django/django/blob/44a6c27fd461e1d2f37388c26c629f8f170e8722/django/contrib/admin/templates/admin/change_list.html#L66)でフィルターの出力方法を調べると，
`admin_list_filter` というtemplateタグが使用されていることを確認．

```html:66行目くらい
      {% block filters %}
        {% if cl.has_filters %}
          <div id="changelist-filter">
            <h2>{% trans 'Filter' %}</h2>
            {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
          </div>
        {% endif %}
      {% endblock %}
```

## 出力用テンプレートタグの確認

[django/contrib/admin/templatetags/admin_list.py](https://github.com/django/django/blob/44a6c27fd461e1d2f37388c26c629f8f170e8722/django/contrib/admin/templatetags/admin_list.py#L418)の418行目から定義は発見．
specの `choices(cl)` が作成メソッドらしい．

```python:418行目
@register.simple_tag
def admin_list_filter(cl, spec):
    tpl = get_template(spec.template)
    return tpl.render({
        'title': spec.title,
        'choices': list(spec.choices(cl)),
        'spec': spec,
    })
```

## specの正体確認

changelist_viewのほうに戻ってcl.filter_specsをどうやって作ってるかを確認．

[django.contrib.admin.views.mainの107行目付近](https://github.com/django/django/blob/44a6c27fd461e1d2f37388c26c629f8f170e8722/django/contrib/admin/views/main.py#L107)で渡されたlist_filterをごにょごにょしてspecに突っ込んでいるのを確認．
list_filterには文字列以外に関数やタプルも渡せるらしい．
タプルの場合は `(field, field_list_filter_class)` として定義するらしい．
文字列の場合は適切なfilterを自動作成するらしい．
ということで，specは `field_list_filter_class` のインスタンスなので，このクラスの `choices` メソッドを確認しにいく．
> ここで print(type(spec)) を挟むとどのクラスを使っているかまで確定する．

```python:django/contrib/admin/views/main.py_l.107
        if self.list_filter:
            for list_filter in self.list_filter:
                if callable(list_filter):
                    # This is simply a custom list filter class.
                    spec = list_filter(request, lookup_params, self.model, self.model_admin)
                else:
                    field_path = None
                    if isinstance(list_filter, (tuple, list)):
                        # This is a custom FieldListFilter class for a given field.
                        field, field_list_filter_class = list_filter
                    else:
                        # This is simply a field name, so use the default
                        # FieldListFilter class that has been registered for
                        # the type of the given field.
                        field, field_list_filter_class = list_filter, FieldListFilter.create
                    if not isinstance(field, models.Field):
                        field_path = field
                        field = get_fields_from_path(self.model, field_path)[-1]

                    lookup_params_count = len(lookup_params)
                    spec = field_list_filter_class(
                        field, request, lookup_params,
                        self.model, self.model_admin, field_path=field_path
                    )
```

## RelatedFieldListFilter/choicesメソッド

[django/contrib/admin/filters.pyの197行目](https://github.com/django/django/blob/44a6c27fd461e1d2f37388c26c629f8f170e8722/django/contrib/admin/filters.py#L197)に問題のメソッド発見．
解除用の項目（`全て`）を出した後に，各項目を出すgeneratorメソッドらしい．
選択項目は `self.lookup_choices` なので，次はこいつを調べる．

```python:django/contrib/admin/filters.py_l.197
    def choices(self, changelist):
        yield {
            'selected': self.lookup_val is None and not self.lookup_val_isnull,
            'query_string': changelist.get_query_string(
                {},
                [self.lookup_kwarg, self.lookup_kwarg_isnull]
            ),
            'display': _('All'),
        }
        for pk_val, val in self.lookup_choices:
            yield {
                'selected': self.lookup_val == str(pk_val),
                'query_string': changelist.get_query_string({
                    self.lookup_kwarg: pk_val,
                }, [self.lookup_kwarg_isnull]),
                'display': val,
            }
        if self.include_empty_choice:
            yield {
                'selected': bool(self.lookup_val_isnull),
                'query_string': changelist.get_query_string({
                    self.lookup_kwarg_isnull: 'True',
                }, [self.lookup_kwarg]),
                'display': self.empty_value_display,
            }
```

### self.lookup_choicesの確認

同クラス内の __init__メソッドで代入．中身は同クラス内の `field_choices` メソッドらしい．

```python:django/contrib/admin/filters.py_l.161
    def __init__(self, field, request, params, model, model_admin, field_path):
        other_model = get_model_from_relation(field)
        self.lookup_kwarg = '%s__%s__exact' % (field_path, field.target_field.name)
        self.lookup_kwarg_isnull = '%s__isnull' % field_path
        self.lookup_val = request.GET.get(self.lookup_kwarg)
        self.lookup_val_isnull = request.GET.get(self.lookup_kwarg_isnull)
        super().__init__(field, request, params, model, model_admin, field_path)
        self.lookup_choices = self.field_choices(field, request, model_admin)
```

### field_choicesメソッドの確認

これも同クラス内に定義あり．
fieldの`get_choices`メソッドを呼び出している．

```python:django/contrib/admin/filters.py_l.194
    def field_choices(self, field, request, model_admin):
        return field.get_choices(include_blank=False)
```

ここで再度 `print(type(field))` を挟み，fieldのクラスを確認．
出力は以下の通り．
> `<class 'django.db.models.fields.related.ForeignKey'>`

## ForeignKey/get_choicesメソッドの確認

[django/db/models/fields/related.py](https://github.com/django/django/blob/master/django/db/models/fields/related.py)の中を確認するも，`get_choices`メソッドの定義は確認できず．
継承元の `Field` クラスのメソッドを使用している模様

## Field/get_choicesメソッドの確認

[django/db/models/fields/__init__.py](https://github.com/django/django/blob/44a6c27fd461e1d2f37388c26c629f8f170e8722/django/db/models/fields/__init__.py#L783)の783行目で定義発見．

```python:django/db/models/fields/__init__.py_l.783
    def get_choices(self, include_blank=True, blank_choice=BLANK_CHOICE_DASH, limit_choices_to=None):
        """
        Return choices with a default blank choices included, for use
        as <select> choices for this field.
        """
        blank_defined = False
        choices = list(self.choices) if self.choices else []
        named_groups = choices and isinstance(choices[0][1], (list, tuple))
        if not named_groups:
            for choice, __ in choices:
                if choice in ('', None):
                    blank_defined = True
                    break

        first_choice = (blank_choice if include_blank and
                        not blank_defined else [])
        if self.choices:
            return first_choice + choices
        rel_model = self.remote_field.model
        limit_choices_to = limit_choices_to or self.get_limit_choices_to()
        if hasattr(self.remote_field, 'get_related_field'):
            lst = [(getattr(x, self.remote_field.get_related_field().attname),
                   smart_text(x))
                   for x in rel_model._default_manager.complex_filter(
                       limit_choices_to)]
        else:
            lst = [(x.pk, smart_text(x))
                   for x in rel_model._default_manager.complex_filter(
                       limit_choices_to)]
        return first_choice + lst
```

`limit_choices_to` が設定されていれば `complex_filter` というメソッドで何かしらのfilterがかかるらしいことを確認．

## complex_filterの確認

[django/db/models/query.pyの855行目付近](https://github.com/django/django/blob/44a6c27fd461e1d2f37388c26c629f8f170e8722/django/db/models/query.py#L855)で定義あり．
> Managerクラスには定義はないが，ManagerクラスはQuerySetクラスのメソッドを呼び出す処理があるので定義場所が異なる．

```python:django/db/models/query.py_l.855
    def complex_filter(self, filter_obj):
        """
        Return a new QuerySet instance with filter_obj added to the filters.
        filter_obj can be a Q object or a dictionary of keyword lookup
        arguments.
        This exists to support framework features such as 'limit_choices_to',
        and usually it will be more natural to use other methods.
        """
        if isinstance(filter_obj, Q):
            clone = self._chain()
            clone.query.add_q(filter_obj)
            return clone
        else:
            return self._filter_or_exclude(None, **filter_obj)
```

コメントの通り，Qオブジェクトか辞書を渡せ，とのこと．
というわけで，こいつを渡すように調整しておしまい．