Annotated paginator page
First things first. Here’s the code. Enjoy!
from django.contrib import admin
from django.core.paginator import Paginator, Page
from django.db.models import Count, Exists, OuterRef
from myapp.models import MyModel, OtherModel
class AnnotatedPaginator(Paginator):
"""
Apply a dictionary of annotations to the page for the paginator.
"""
def __init__(self, *args, annotations=None, **kwargs):
self.annotations = annotations
super().__init__(*args, **kwargs)
def _get_page(self, object_list, *args, **kwargs):
"""
Return an instance of a single page.
This will change the object_list into an actual list.
It will make an additional query to the database to look up
the values to be manually set on the object.
"""
objects = list(object_list)
if objects and self.annotations:
# Make another query for this model type to gather the annotated fields.
annotated_queryset = (
objects[0]
._meta.model.objects.filter(id__in=[obj.id for obj in objects])
.annotate(**self.annotations)
)
# Create a map to associate the original objects to the annotated values.
annotated_maps = {
annotated_map.pop("id"): annotated_map
for annotated_map in annotated_queryset.values(
"id", *self.annotations.keys()
)
}
# Associated the annotated values to the original objects.
for obj in objects:
for key, value in annotated_maps[obj.id].items():
setattr(obj, key, value)
return Page(objects, *args, **kwargs)
class AdminAnnotatedPageMixin:
"""
Extend the ModelAdmin functionality to utilize AnnotatedPaginator
"""
paginator = AnnotatedPaginator
page_annotations = None
def get_paginator(
self, request, queryset, per_page, orphans=0, allow_empty_first_page=True
):
return self.paginator(
queryset,
per_page,
orphans,
allow_empty_first_page,
annotations=self.page_annotations,
)
@admin.site.register(MyModel)
class MyModelAdmin(AdminAnnotatedPageMixin, admin.ModelAdmin):
page_annotations = {
"related_field_count": Count("related_field"),
"special_case_exists": Exists(
OtherModel.objects.filter(relation_id=OuterRef("id"))[:1]
),
}
list_display = ['id', 'related_field_count', 'special_case_exists']
@admin.display(description="Related Count")
def related_field_count(self, obj):
return obj.related_field_count
@admin.display(description="Is Special", boolean=True)
def special_case_exists(self, obj):
return obj.special_case_exists
If you’re curious about what the above does here’s my explanation.
I ran across the use-case recently where I wanted to show additional context for a model’s admin list view. This model had about a million rows and one of the related models’ tables was in the 10 million to 100 million row range. So not small, but not massive. I was running into a problem where the two annotations were being nested resulting in a query that should take 100ms taking 10-30s!
I couldn’t identify a good index or database schema solution, so I
decided to try to solve this by only applying the annotation to the query
that selects the data. Ignoring the hacky solution I attempted with
ModelAdmin.get_changelist_instance
, I ended up subclassing the
Paginator
class to ioverride the _get_page
hook so that I could
apply the annotation exactly where I wanted.
In the above code, AnnotatedPaginator
accepts an annotations
dictionary that will be passed into a .annotate()
function call.
Those annotations are applied within the _get_page()
function.
The approach that my solution takes is to convert the sliced data
into a list, then perform another database query to look up the
desired information only for the instances in that slice.
*It is possible to call annotate
on a sliced QuerySet, but there’s
no restriction that object_list
must be a QuerySet. I decided
to implement for robustness rather than performance. If I were to package
this up, I would do the inspection on object_list
and implement for
optimization.
At this point we have our paginator class that can support the annotation
look up, so now we need to integrate it with Django’s admin site. To help
with this, I created a mixin class that overrides the get_paginator
method to supply the annotations parameter. This class allows you to
specify the annotations as a class member in your own ModelAdmin
class following Django’s pattern.
This means you end up with a pretty compact definition:
@admin.site.register(MyModel)
class MyModelAdmin(AdminAnnotatedPageMixin, admin.ModelAdmin):
page_annotations = {
"related_field_count": Count("related_field"),
"special_case_exists": Exists(
OtherModel.objects.filter(relation_id=OuterRef("id"))[:1]
),
}
list_display = ['id', 'related_field_count', 'special_case_exists']
@admin.display(description="Related Count")
def related_field_count(self, obj):
return obj.related_field_count
@admin.display(description="Is Special", boolean=True)
def special_case_exists(self, obj):
return obj.special_case_exists
Note that you still must add specific functions to expose those annotated values on the list view. Additionally, you won’t be able to sort on these columns since they are only being calculated for the specific page of data.