Skip to content

Commit 86c88ce

Browse files
⚡ - perf: attempt to increase performance of DestructionList related API endpoints
1 parent db69f79 commit 86c88ce

File tree

8 files changed

+225
-54
lines changed

8 files changed

+225
-54
lines changed

backend/src/openarchiefbeheer/accounts/api/serializers.py

+28-9
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.contrib.auth.models import Permission
2+
from django.db.models import Q
23
from django.utils.translation import gettext_lazy as _
34

45
from drf_spectacular.utils import extend_schema_field
@@ -34,15 +35,33 @@ class Meta:
3435

3536
@extend_schema_field(RoleSerializer)
3637
def get_role(self, user: User) -> dict | None:
37-
data = {}
38-
for permission in Permission.objects.filter(user=user):
39-
data[permission.codename] = True
38+
"""
39+
Annotating a `UserQuerySet` using `annotate_permissions` (or `annotate_user_permission` on
40+
`DestructionListQuerySet`) causes `user_permission_codenames` and `group_permission_codenames` to be set and
41+
used for serialization. This may improve performance in cases where otherwise an n+1 issues could occur.
42+
"""
4043

41-
for group in user.groups.all():
42-
for permission in group.permissions.all():
43-
data[permission.codename] = True
44+
# Retrieve all permission codenames.
45+
permissions = []
4446

45-
serializer = RoleSerializer(data=data)
46-
serializer.is_valid()
47+
try:
48+
permissions += user.user_permission_codenames
49+
permissions += user.group_permission_codenames
4750

48-
return serializer.data
51+
except AttributeError:
52+
permissions = (
53+
Permission.objects.filter(Q(user=user) | Q(group__user=user))
54+
.distinct()
55+
.values_list("codename", flat=True)
56+
)
57+
58+
permissions_set = set(permissions)
59+
60+
data = {
61+
"can_start_destruction": "can_start_destruction" in permissions_set,
62+
"can_review_destruction": "can_review_destruction" in permissions_set,
63+
"can_co_review_destruction": "can_co_review_destruction" in permissions_set,
64+
"can_review_final_list": "can_review_final_list" in permissions_set,
65+
}
66+
67+
return RoleSerializer(data).data

backend/src/openarchiefbeheer/accounts/managers.py

+43-10
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,46 @@
1-
from typing import TYPE_CHECKING
1+
from django.contrib.auth.models import BaseUserManager, Group, Permission
2+
from django.contrib.postgres.aggregates import ArrayAgg
3+
from django.db.models import Prefetch, Q, QuerySet
24

3-
from django.contrib.auth.models import BaseUserManager, Permission
4-
from django.db.models import Q, QuerySet
55

6-
if TYPE_CHECKING:
7-
from .models import User
6+
class UserQuerySet(QuerySet):
7+
def annotate_permissions(self) -> "UserQuerySet":
8+
"""
9+
Adds `user_permission_codenames` and `group_permission_codenames` as `ArrayField` to the current QuerySet
10+
containing the codenames of the applicable permissions. `user_permissions` and `permissions` are prefetched to
11+
minimize queries.
12+
13+
This is used to avoid n+1 issues with nested `UserSerializer`.
14+
"""
15+
return self.prefetch_related(
16+
Prefetch(
17+
"user_permissions",
18+
queryset=Permission.objects.select_related("content_type").order_by(
19+
"codename"
20+
),
21+
),
22+
Prefetch(
23+
"groups",
24+
queryset=Group.objects.prefetch_related(
25+
Prefetch(
26+
"permissions",
27+
queryset=Permission.objects.select_related(
28+
"content_type"
29+
).order_by("codename"),
30+
)
31+
),
32+
),
33+
).annotate(
34+
user_permission_codenames=ArrayAgg(
35+
"user_permissions__codename", distinct=True
36+
),
37+
group_permission_codenames=ArrayAgg(
38+
"groups__permissions__codename", distinct=True
39+
),
40+
)
841

942

10-
class UserManager(BaseUserManager):
43+
class UserManager(BaseUserManager.from_queryset(UserQuerySet)):
1144
use_in_migrations = True
1245

1346
def _create_user(self, username, email, password, **extra_fields):
@@ -39,19 +72,19 @@ def create_superuser(self, username, email, password, **extra_fields):
3972

4073
return self._create_user(username, email, password, **extra_fields)
4174

42-
def _users_with_permission(self, permission: Permission) -> QuerySet["User"]:
75+
def _users_with_permission(self, permission: Permission) -> UserQuerySet:
4376
return self.filter(
4477
Q(groups__permissions=permission) | Q(user_permissions=permission)
4578
).distinct()
4679

47-
def main_reviewers(self) -> QuerySet["User"]:
80+
def main_reviewers(self) -> UserQuerySet:
4881
permission = Permission.objects.get(codename="can_review_destruction")
4982
return self._users_with_permission(permission)
5083

51-
def archivists(self) -> QuerySet["User"]:
84+
def archivists(self) -> UserQuerySet:
5285
permission = Permission.objects.get(codename="can_review_final_list")
5386
return self._users_with_permission(permission)
5487

55-
def co_reviewers(self) -> QuerySet["User"]:
88+
def co_reviewers(self) -> UserQuerySet:
5689
permission = Permission.objects.get(codename="can_co_review_destruction")
5790
return self._users_with_permission(permission)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from django.contrib.auth.models import Group, Permission
2+
from django.contrib.contenttypes.models import ContentType
3+
from django.test import TestCase
4+
5+
from openarchiefbeheer.destruction.models import DestructionList
6+
7+
from ..models import User
8+
from .factories import UserFactory
9+
10+
11+
class UserQuerySetTests(TestCase):
12+
def test_annotate_permissions(self):
13+
content_type = ContentType.objects.get_for_model(DestructionList)
14+
15+
group = Group.objects.create()
16+
group_permission = Permission.objects.create(
17+
codename="group_permission", content_type=content_type
18+
)
19+
group.permissions.add(group_permission)
20+
21+
user_permission = Permission.objects.create(
22+
codename="user_permission", content_type=content_type
23+
)
24+
25+
users = UserFactory.create_batch(3)
26+
for user in users:
27+
user.user_permissions.add(user_permission)
28+
user.groups.add(group)
29+
30+
with self.assertNumQueries(1):
31+
ls = list( # List to force QuerySet evaluation.
32+
User.objects.annotate_permissions().values_list(
33+
"user_permission_codenames", flat=True
34+
)
35+
)
36+
37+
self.assertIn("user_permission", ls[0])
38+
self.assertIn("user_permission", ls[1])
39+
self.assertIn("user_permission", ls[2])
40+
41+
with self.assertNumQueries(1):
42+
ls = list( # List to force QuerySet evaluation.
43+
User.objects.annotate_permissions().values_list(
44+
"group_permission_codenames", flat=True
45+
)
46+
)
47+
48+
self.assertIn("group_permission", ls[0])
49+
self.assertIn("group_permission", ls[1])
50+
self.assertIn("group_permission", ls[2])
51+
52+
53+
class UserManagerTests(TestCase):
54+
def test_create_superuser(self):
55+
user = User.objects.create_superuser("god", "[email protected]", "praisejebus")
56+
self.assertIsNotNone(user.pk)
57+
self.assertTrue(user.is_staff)
58+
self.assertTrue(user.is_superuser)
59+
self.assertEqual(user.username, "god")
60+
self.assertEqual(user.email, "[email protected]")
61+
self.assertTrue(user.check_password("praisejebus"))
62+
self.assertNotEqual(user.password, "praisejebus")
63+
64+
def test_create_user(self):
65+
user = User.objects.create_user("infidel")
66+
self.assertIsNotNone(user.pk)
67+
self.assertFalse(user.is_superuser)
68+
self.assertFalse(user.is_staff)
69+
self.assertFalse(user.has_usable_password())

backend/src/openarchiefbeheer/accounts/tests/test_user_manager.py

-22
This file was deleted.

backend/src/openarchiefbeheer/destruction/api/viewsets.py

+3-13
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from datetime import date, timedelta
22

33
from django.db import transaction
4-
from django.db.models import Case, OuterRef, Prefetch, QuerySet, Subquery, Value, When
4+
from django.db.models import Case, OuterRef, QuerySet, Subquery, Value, When
55
from django.shortcuts import get_object_or_404
66
from django.utils.translation import gettext_lazy as _
77

@@ -221,18 +221,8 @@ class DestructionListViewSet(
221221
viewsets.GenericViewSet,
222222
):
223223
serializer_class = DestructionListWriteSerializer
224-
queryset = (
225-
DestructionList.objects.all()
226-
.select_related("author", "assignee")
227-
.prefetch_related(
228-
Prefetch(
229-
"assignees",
230-
queryset=DestructionListAssignee.objects.prefetch_related(
231-
"user__user_permissions"
232-
).order_by("pk"),
233-
)
234-
)
235-
)
224+
queryset = DestructionList.objects.annotate_user_permissions()
225+
236226
lookup_field = "uuid"
237227
filter_backends = (DjangoFilterBackend,)
238228
filterset_class = DestructionListFilterset
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from django.db.models import Manager, Prefetch, QuerySet
2+
3+
from openarchiefbeheer.accounts.models import User
4+
5+
6+
class DestructionListQuerySet(QuerySet):
7+
def annotate_user_permissions(self):
8+
"""
9+
Adds `user_permission_codenames` and `group_permission_codenames` as `ArrayField` to the `author`,
10+
`assignee`, and `assignees__user` in the current QuerySet containing the codenames of the applicable
11+
permissions. `user_permissions` and `permissions` are prefetched to minimize queries.
12+
13+
This is used to avoid n+1 issues with nested `UserSerializer`.
14+
"""
15+
from openarchiefbeheer.destruction.models import DestructionListAssignee
16+
17+
return self.prefetch_related(
18+
Prefetch(
19+
"author",
20+
queryset=User.objects.annotate_permissions(),
21+
),
22+
Prefetch(
23+
"assignee",
24+
queryset=User.objects.annotate_permissions(),
25+
),
26+
Prefetch(
27+
"assignees",
28+
queryset=DestructionListAssignee.objects.prefetch_related(
29+
Prefetch(
30+
"user",
31+
queryset=User.objects.annotate_permissions(),
32+
)
33+
).order_by("pk"),
34+
),
35+
)
36+
37+
38+
class DestructionListManager(Manager.from_queryset(DestructionListQuerySet)):
39+
pass

backend/src/openarchiefbeheer/destruction/models.py

+3
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
ZaakActionType,
3636
)
3737
from .exceptions import ZaakArchiefactiedatumInFuture, ZaakNotFound
38+
from .managers import DestructionListManager
3839

3940
if TYPE_CHECKING:
4041
from openarchiefbeheer.zaken.models import Zaak
@@ -137,6 +138,8 @@ class DestructionList(models.Model):
137138

138139
logs = GenericRelation(TimelineLog, related_query_name="destruction_list")
139140

141+
objects = DestructionListManager()
142+
140143
class Meta:
141144
verbose_name = _("destruction list")
142145
verbose_name_plural = _("destruction lists")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from django.contrib.auth.models import Group, Permission
2+
from django.contrib.contenttypes.models import ContentType
3+
from django.test import TestCase
4+
5+
from openarchiefbeheer.destruction.models import DestructionList
6+
from openarchiefbeheer.destruction.tests.factories import (
7+
DestructionListAssigneeFactory,
8+
DestructionListFactory,
9+
)
10+
11+
12+
class DestructionListQuerySetTests(TestCase):
13+
def test_annotate_user_permissions(self):
14+
content_type = ContentType.objects.get_for_model(DestructionList)
15+
16+
group = Group.objects.create()
17+
group_permission = Permission.objects.create(
18+
codename="group_permission", content_type=content_type
19+
)
20+
group.permissions.add(group_permission)
21+
22+
user_permission = Permission.objects.create(
23+
codename="user_permission", content_type=content_type
24+
)
25+
26+
assignees = DestructionListAssigneeFactory.create_batch(3)
27+
author = assignees[0].user
28+
author.user_permissions.add(user_permission)
29+
destruction_lists = DestructionListFactory.create_batch(100, author=author)
30+
for destruction_list in destruction_lists:
31+
destruction_list.assignees.set(assignees)
32+
33+
with self.assertNumQueries(11):
34+
ls = list( # List to force QuerySet evaluation.
35+
DestructionList.objects.filter(
36+
author=author
37+
).annotate_user_permissions()
38+
)
39+
for i in range(0, len(ls)):
40+
self.assertIn("user_permission", ls[i].author.user_permission_codenames)

0 commit comments

Comments
 (0)