annotateで作った値を使ってdjango-filterでフィルタリングする方法

programming

はじめに

Djangoとdjango-filterをよく使っているのですが、 annotate 関数などで既存のフィールドから計算して作成した値を使ってdjango-filterでフィルタリングする、ということを実現するのに少し手間取ったため、その方法を備忘録として残しておきます。

やりたいこと

例えば業務の案件を管理するようなアプリで、ある案件 (Work) に対して複数の作業記録 (WorkReport) を紐付けて、作業記録にはその日付が保持できるようになっているとします。 やりたかったのは、案件に対して複数存在する作業記録のうち最新のものの日付を利用して案件を絞り込むということです。

例えば案件Aに対して2021年8月10日と2021年8月13日に作業記録を追加した場合、その最新の作業記録の日付は2021年8月13日になりますが、2021年8月13日で絞り込んだ際に案件Aが出てきてほしいということです。

以下にモデル定義を示します。

from django.db import models


class Work(models.Model):
    # some fields
    pass


class WorkReport(models.Model):
    work = models.ForeignKey(
        Work,
        on_delete=models.CASCADE,
    )
    date = models.DateField()

実現方法

方針

以下の方針を考えました。

  1. annotate 関数を用いて、案件の queryset の各アイテムに対して最新の作業記録の日付を計算しその値を追加する
  2. django-filterのFilterの method をオーバーライドする (参考)

手順

1. カスタム queryset を定義

まず、先程のモデル定義に以下のようなカスタム queryset を追加します。 (参考)

from django.db import models


# 追加
class WorkQueryset(models.QuerySet):
    def with_latest_work_report_date(self):
        return self.annotate(
            latest_work_report_date=models.Max('workreport__date')
        )


class Work(models.Model):
    # 追加
    objects = WorkQueryset.as_manager()

このようにすると、例えば Work.objects.with_latest_work_report_date(){queryset}.with_latest_work_report_date() などで、最新の作業記録の日付 latest_work_report_date が annotate された queryset が得られます。

2. FilterSet を定義

次に、 django-filter の FilterSet を定義します。

class WorkFilter(FilterSet):
    latest_work_report_date = filters.DateFromToRangeFilter(
        label='最新作業記録日 (範囲指定)',
        method='filter_latest_work_report_date',
    )

    def filter_latest_work_report_date(self, queryset, name, value):
        queryset = queryset.with_latest_work_report_date()  # ここが大事です
        if value.start:
            queryset = queryset.filter(**{f'{name}__gte': value.start})
        if value.stop:
            queryset = queryset.filter(**{f'{name}__lte': value.stop})
        return queryset

    class Meta:
        model = Work
        # fields = (...)

DateFromToRangeFiltermethod 引数には、 queryset を返すメソッドを作成して指定します。 こうすることで自由に絞り込みのロジックを入れることができます。

ここでややこしいのが今回 DateFromToRangeFilter という日付のレンジのフィルターを使っているため、 filter_latest_work_report_date メソッドの name にはシンプルに latest_work_report_date が入るのですが、 value には slice オブジェクトが入ってきます。

slice オブジェクトはスライスを表していて、例えば以下のように配列のスライスに使えるものです。

l = list(range(10))
slice_instance = slice(1, 3)
print(l[slice_instance])
# --> [1, 2]

latest_work_report_datevalue に入ってくる slice オブジェクトには、以下のように startstopdatetime.datetime オブジェクト、 step には None が入っています。

slice(
    datetime.datetime(2021, 8, 10, 0, 0, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>),
    datetime.datetime(2021, 8, 10, 23, 59, 59, 999999, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>),
    None
)

この startstop を使って日付の範囲で queryset を絞り込んで返すことで、範囲指定の絞り込みが実現できます。

改善したい点

filter_latest_work_report_date のように日付の範囲での絞り込みのロジックをできれば書かずに実現したかったのですが、今の所はまだこのやり方でしかうまく動かせていません。 このままだと他にも似たようなことをするときに同じ絞り込みロジックを書かないといけません。 queryset.with_latest_work_report_date() で annotate したものを DateFromToRangeFilter に既存の絞り込みロジックに渡すことができれば改善できそうです。

おわりに

もし改善点が見つかればまた更新したいと思います。