mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-22 17:38:32 +00:00
Fix: batch document user_can_change checks to avoid per-row N+1 (#13204)
* Fix (beta): batch document user_can_change checks to avoid per-row N+1 DocumentSerializer.get_user_can_change() built a fresh ObjectPermissionChecker and issued a guardian permission-table query for every document row not owned by the requesting user -- correct, but O(N) per page load for any non-superuser viewing documents owned by others. BulkPermissionMixin already batches this exact lookup (2 queries total, regardless of page size) for Correspondent/Tag/DocumentType/CustomField, but was gated behind the rarely-used `full_perms` flag and DocumentViewSet didn't inherit it at all. Changed the gate to "any list action" (cheap: just two extra queries per page) and added BulkPermissionMixin to DocumentViewSet, then updated get_user_can_change to consult that batched context before falling back to a fresh guardian check. Preserves guardian's own superuser shortcut explicitly (has_perm() special- cases is_superuser without a query; the batched-context path doesn't, so it needed its own check) -- covered by a new regression test, since no existing test exercised a superuser viewing an other-owned document. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * Fix (beta): don't batch permissions for tantivy search results UnifiedSearchViewSet.list() returns SearchHit/dict-like objects for text/title/query/more_like_id search requests, not Document ORM instances. Adding BulkPermissionMixin to DocumentViewSet (previous commit) meant its get_serializer_context() ran for search responses too, and its _get_object_perms() -- which expects real model instances with .pk -- crashed on the dict-like hits with AttributeError, turning every search request into a 400. Skip the batching specifically for search requests (existing _is_search_request() check) by calling past BulkPermissionMixin in the MRO; non-search list() calls (which return a real Document queryset) are unaffected and still get the batching. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
97662b6c5c
commit
f86bc57880
@@ -392,15 +392,34 @@ class OwnedObjectSerializer(
|
||||
}
|
||||
|
||||
def get_user_can_change(self, obj) -> bool:
|
||||
checker = ObjectPermissionChecker(self.user) if self.user is not None else None
|
||||
return (
|
||||
obj.owner is None
|
||||
or obj.owner == self.user
|
||||
or (
|
||||
self.user is not None
|
||||
and checker.has_perm(f"change_{obj.__class__.__name__.lower()}", obj)
|
||||
if obj.owner is None or obj.owner == self.user:
|
||||
return True
|
||||
if self.user is None:
|
||||
return False
|
||||
if self.user.is_active and self.user.is_superuser:
|
||||
# Mirrors guardian's own ObjectPermissionChecker.has_perm() shortcut --
|
||||
# superusers aren't necessarily granted explicit object permissions,
|
||||
# so the batched context below would otherwise incorrectly say no.
|
||||
return True
|
||||
|
||||
# Prefer the page-level batch computed by BulkPermissionMixin
|
||||
# (get_serializer_context) over a fresh per-object guardian check,
|
||||
# which would otherwise query the permission tables once per row.
|
||||
users_change_perms = self.context.get("users_change_perms")
|
||||
groups_change_perms = self.context.get("groups_change_perms")
|
||||
if users_change_perms is not None and groups_change_perms is not None:
|
||||
if self.user.pk in users_change_perms.get(obj.pk, []):
|
||||
return True
|
||||
user_group_ids = getattr(self, "_user_group_ids", None)
|
||||
if user_group_ids is None:
|
||||
user_group_ids = set(self.user.groups.values_list("id", flat=True))
|
||||
self._user_group_ids = user_group_ids
|
||||
return bool(
|
||||
user_group_ids.intersection(groups_change_perms.get(obj.pk, [])),
|
||||
)
|
||||
)
|
||||
|
||||
checker = ObjectPermissionChecker(self.user)
|
||||
return checker.has_perm(f"change_{obj.__class__.__name__.lower()}", obj)
|
||||
|
||||
@staticmethod
|
||||
def get_shared_object_pks(objects: Iterable):
|
||||
|
||||
Reference in New Issue
Block a user