Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,8 @@ dependencies = [
readme = "README.md"
requires-python = ">= 3.10"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv]
managed = true
dev-dependencies = [
[dependency-groups]
dev = [
"GitPython",
"anys>=0.3.1",
"bumpver",
Expand Down Expand Up @@ -70,6 +65,13 @@ dev-dependencies = [
"lxml<=5.3.2",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv]
managed = true

[tool.hatch.metadata]
allow-direct-references = true

Expand Down
33 changes: 33 additions & 0 deletions src/common/mitol/common/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import logging

from mitol.common.serializers import QuerySetSerializer

log = logging.getLogger()


class PrefetchQuerySetSerializerMixin:
"""
APIView mixin that derives prefetches from the serializer(s) used to
render responses.

This allows for the prefetches to be specific to what we intend to serialize
to JSON and it also allows this to vary if the serializer_class that is used
also varies.
"""

def get_queryset(self):
"""Get the queryset"""
serializer_class = self.get_serializer_class()

if not issubclass(serializer_class, QuerySetSerializer):
log.error(
"Serializer %s does not subclass QuerySetSerializer, skipping",
serializer_class,
)

return super().get_queryset()

# `self.queryset` defaults to None
serializer = serializer_class()

return serializer.get_queryset_tree(self.queryset, self.request)
106 changes: 106 additions & 0 deletions src/common/mitol/common/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from collections.abc import Callable

from django.db.models import Prefetch, QuerySet
from django.http import HttpRequest
from rest_framework import serializers


class QuerySetSerializer(serializers.ModelSerializer):
"""
A serializer for serializing QuerySets.

This provides more functionality over ModelSerializer by implementing functionality
analogous to ViewSet's queryset and get_queryset. It recursively constructucts a
QuerySet that has been annotated and prefetched appropriately to fulfill serializers
without incurring further queries during serialization

Subclasses can set either queryset or override `get_queryset` if access
to `request` is needed.

Optionally when the `QuerySetSerializer` is initialized `queryset` can be passed
and this will override both `queryset` and `get_queryset`.
"""

queryset: QuerySet | None = None

def get_base_queryset(self) -> QuerySet:
# critical to compare against None to avoid an evaluation
return (
self.queryset
if self.queryset is not None
else self.Meta.model.objects.all()
)

def get_queryset(self, queryset: QuerySet, request: HttpRequest) -> QuerySet:
"""
Get the queryset for this serializer

Override this method to customize the queryset used for fetching models
to be serialized.

It is *required* that you call `super().get_queryset(queryset, request)` and
extend the return value.

If you need to customize the queryset used for a nested serializer field, define
a `get_{field_name}_queryset` with the following signature:

`
def get_books_queryset(
self,
queryset: QuerySet,
request: HttpRequest
) -> QuerySet:
...
`

It is *required* that you extend the queryset passed in so you get the
prefetches defined on the nested serializer too.

"""
return self.get_nested_prefetches(queryset, request)

def get_prefetch_for_field(
self,
name: str,
field: serializers.Field,
serializer: "QuerySetSerializer",
request: HttpRequest,
) -> Prefetch:
queryset = serializer.get_queryset_tree(serializer.get_base_queryset(), request)

get_serializer_queryset_func = getattr(self, f"get_{name}_queryset", None)

if get_serializer_queryset_func is not None and isinstance(
get_serializer_queryset_func,
Callable,
):
queryset = get_serializer_queryset_func(queryset, request)

return Prefetch(
field.source,
queryset=queryset,
to_attr=name if name != field.source else None,
)

def get_nested_prefetches(
self, queryset: QuerySet, request: HttpRequest
) -> QuerySet:
"""Get prefetches for nested serializers"""
for name, field in self.fields.items():
serializer = field

if isinstance(field, serializers.ListSerializer):
serializer = field.child

if isinstance(serializer, QuerySetSerializer):
queryset = queryset.prefetch_related(
self.get_prefetch_for_field(name, field, serializer, request)
)

return queryset

def get_queryset_tree(self, queryset: QuerySet, request: HttpRequest) -> QuerySet:
"""Get the queryset required for the serializer"""
queryset = queryset if queryset is not None else self.get_base_queryset()

return self.get_queryset(queryset, request)
Empty file added testapp/libraries/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions testapp/libraries/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class LibrariesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "libraries"
35 changes: 35 additions & 0 deletions testapp/libraries/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import factory
import factory.fuzzy
from factory.django import DjangoModelFactory


class AuthorFactory(DjangoModelFactory):
name = factory.Faker("name")

class Meta:
model = "libraries.Author"


class BookFactory(DjangoModelFactory):
title = factory.Faker("words")

author = factory.SubFactory(AuthorFactory)

class Meta:
model = "libraries.Book"


class LibraryFactory(DjangoModelFactory):
name = factory.fuzzy.FuzzyText(suffix=" Library")

@factory.post_generation
def books(self, create, extracted, **kwargs):
extracted = extracted or BookFactory.create_batch(50, **kwargs)

if not create:
return

self.books.add(*extracted)

class Meta:
model = "libraries.Library"
72 changes: 72 additions & 0 deletions testapp/libraries/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Generated by Django 5.1.7 on 2025-12-09 19:53

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="Author",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name="Book",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255)),
(
"author",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="books",
to="libraries.author",
),
),
],
),
migrations.CreateModel(
name="Library",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"books",
models.ManyToManyField(
related_name="libraries", to="libraries.book"
),
),
],
),
]
Empty file.
16 changes: 16 additions & 0 deletions testapp/libraries/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.db import models


class Author(models.Model):
name = models.CharField(max_length=255)


class Book(models.Model):
title = models.CharField(max_length=255)
author = models.ForeignKey(Author, related_name="books", on_delete=models.CASCADE)


class Library(models.Model):
name = models.CharField(max_length=255)

books = models.ManyToManyField(Book, related_name="libraries")
29 changes: 29 additions & 0 deletions testapp/libraries/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from mitol.common.serializers import QuerySetSerializer

from libraries.models import Author, Book, Library


class AuthorSerializer(QuerySetSerializer):
class Meta:
fields = ("name",)
model = Author


class BookSerializer(QuerySetSerializer):
author = AuthorSerializer()
also_the_author = AuthorSerializer(source="author")

class Meta:
fields = ("title", "author", "also_the_author")
model = Book


class LibrarySerializer(QuerySetSerializer):
books = BookSerializer(many=True, queryset=Book.objects.order_by("id"))

class Meta:
fields = (
"name",
"books",
)
model = Library
8 changes: 8 additions & 0 deletions testapp/libraries/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from rest_framework import routers

from libraries import views

router = routers.DefaultRouter()
router.register(r"libraries", views.LibrariesViewSet, basename="libraries_api")

urlpatterns = router.urls
10 changes: 10 additions & 0 deletions testapp/libraries/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from mitol.common.mixins import PrefetchQuerySetSerializerMixin
from rest_framework import viewsets

from libraries.models import Library
from libraries.serializers import LibrarySerializer


class LibrariesViewSet(PrefetchQuerySetSerializerMixin, viewsets.ModelViewSet):
serializer_class = LibrarySerializer
queryset = Library.objects.all()
1 change: 1 addition & 0 deletions testapp/main/settings/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
# test app, integrates the reusable apps
"main",
"users",
"libraries",
]

MIDDLEWARE = [
Expand Down
1 change: 1 addition & 0 deletions testapp/main/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
path("api/", include("mitol.transcoding.urls")),
path("", include("mitol.scim.urls")),
path("", include("mitol.apigateway.urls")),
path("api/", include("libraries.urls")),
]

if sys.version_info < (3, 13):
Expand Down
Loading
Loading