list_filterの項目を別のlist_filterの結果で絞り込む方法を調べたのでメモ.
やりたいこと
1対多の関係が連結しているモデルに,list_filterに連結元のモデルを指定する.
この時に,選んだ項目によってlist_filterの項目を減らしたい.
言葉だとわかりづらいのでサンプルとして以下のようなモデルを考える.
シフトの管理画面のlist_filterとして店とスタッフを指定する.
ここで店を選択すると,スタッフの選択項目を絞り込みたい.
結論
admin.RelatedFieldListFilter
の field_choices
メソッドをオーバーライドし,
list_filter
にfield名と作ったクラスをタプルで渡す.
オーバーライドする時に,field.get_choices
の呼び出し時に limit_choices_to
引数を渡す.
渡す値は Q
オブジェクトか辞書.
店で一度絞り込みをかけ,どういうクエリが発行されるかを確認し,その値で絞り込みをかける.
コード
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)
結果
店を選択した時にスタッフが所属店で絞り込まれた.
--
調べたこと
ModelAdmin
クラスで list_filter
を定義すると自動で項目が作成されるので,その部分を確認.
表示用テンプレートの確認
django/contrib/admin/templates/admin/change_list.htmlでフィルターの出力方法を調べると,
admin_list_filter
というtemplateタグが使用されていることを確認.
{% 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の418行目から定義は発見.
specの choices(cl)
が作成メソッドらしい.
@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行目付近で渡されたlist_filterをごにょごにょしてspecに突っ込んでいるのを確認.
list_filterには文字列以外に関数やタプルも渡せるらしい.
タプルの場合は (field, field_list_filter_class)
として定義するらしい.
文字列の場合は適切なfilterを自動作成するらしい.
ということで,specは field_list_filter_class
のインスタンスなので,このクラスの choices
メソッドを確認しにいく.
ここで print(type(spec)) を挟むとどのクラスを使っているかまで確定する.
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行目に問題のメソッド発見.
解除用の項目(全て
)を出した後に,各項目を出すgeneratorメソッドらしい.
選択項目は self.lookup_choices
なので,次はこいつを調べる.
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
メソッドらしい.
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
メソッドを呼び出している.
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の中を確認するも,get_choices
メソッドの定義は確認できず.
継承元の Field
クラスのメソッドを使用している模様
Field/get_choicesメソッドの確認
django/db/models/fields/init.pyの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行目付近で定義あり.
Managerクラスには定義はないが,ManagerクラスはQuerySetクラスのメソッドを呼び出す処理があるので定義場所が異なる.
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オブジェクトか辞書を渡せ,とのこと.
というわけで,こいつを渡すように調整しておしまい.