Performance: unify permission-filtering backends, fixes Correspondent/Tag list slowness (#13601)

* feat: add unified PermittedObjectsFilter backed by permitted_object_ids

* refactor: migrate all ViewSets to unified PermittedObjectsFilter

Replace the deprecated ObjectOwnedOrGrantedPermissionsFilter,
DocumentPermissionsFilter, and ObjectOwnedPermissionsFilter aliases
with PermittedObjectsFilter directly across documents/views.py (8
sites, including TrashView's include_granted=False subclass) and
paperless_mail/views.py (3 sites), then delete the now-unreferenced
alias classes from documents/filters.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFyrt7FWbRRdTUAcdqBcsc

* docs: document legacy status of get_objects_for_user_owner_aware/has_perms_owner_aware

Stage 4's PermittedObjectsFilter/permitted_object_ids() covers the
queryset-filtering use case, but both functions still have production
callers outside this plan's scope (documents/views.py,
documents/serialisers.py, documents/signals/handlers.py,
paperless_ai/matching.py, paperless_ai/ai_classifier.py). Per Task 20
Step 2, they are kept in place rather than partially deleted, with
docstrings updated to note their legacy status and remaining callers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFyrt7FWbRRdTUAcdqBcsc

* Fix: address final review findings for permission-filter unification

- Add a permanent regression test pinning TrashView's include_granted=False
  wiring: an explicit view_document grant on a trashed document must not
  leak it into /api/trash/ for a non-owner, non-superuser requester.
- Drop the now-dead direct dependency djangorestframework-guardian; the
  last rest_framework_guardian import was removed by this branch's
  migration onto PermittedObjectsFilter. django-guardian is untouched.
- Replace the hand-maintained, already-stale caller lists in
  get_objects_for_user_owner_aware/has_perms_owner_aware docstrings with a
  pointer to grep for remaining callers instead.
- In PermittedObjectsFilter.filter_queryset, compute `model` only on the
  include_granted=True path that actually uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFyrt7FWbRRdTUAcdqBcsc

* perf: check bulk-edit-objects apply_to_all permissions via DB-side exclude/exists

Materialized the full permitted_object_ids() set into a Python set() just
to check membership for the request's objs queryset -- the same pattern
already fixed at four other sites for Document. This one is used by
apply_to_all, where objs can be an unbounded filtered selection (e.g. all
tags matching a filter) rather than a small request-supplied ID list,
making the wasted materialization worse here than at the sites already
fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Cleans up the comment about why this is still here for now

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Trenton H
2026-08-08 07:27:18 -07:00
committed by GitHub
co-authored by Claude Sonnet 5
parent b192a419fd
commit fc242bb570
8 changed files with 681 additions and 606 deletions
+19 -15
View File
@@ -133,12 +133,10 @@ from documents.file_handling import format_filename
from documents.filters import CorrespondentFilterSet
from documents.filters import CustomFieldFilterSet
from documents.filters import DocumentFilterSet
from documents.filters import DocumentPermissionsFilter
from documents.filters import DocumentsOrderingFilter
from documents.filters import DocumentTypeFilterSet
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
from documents.filters import ObjectOwnedPermissionsFilter
from documents.filters import PaperlessTaskFilterSet
from documents.filters import PermittedObjectsFilter
from documents.filters import ShareLinkBundleFilterSet
from documents.filters import ShareLinkFilterSet
from documents.filters import StoragePathFilterSet
@@ -551,7 +549,7 @@ class CorrespondentViewSet(
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
PermittedObjectsFilter,
)
filterset_class = CorrespondentFilterSet
ordering_fields = (
@@ -592,7 +590,7 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
PermittedObjectsFilter,
)
filterset_class = TagFilterSet
ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count")
@@ -684,7 +682,7 @@ class DocumentTypeViewSet(
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
PermittedObjectsFilter,
)
filterset_class = DocumentTypeFilterSet
ordering_fields = ("name", "matching_algorithm", "match", "document_count")
@@ -988,7 +986,7 @@ class DocumentViewSet(
DjangoFilterBackend,
SearchFilter,
DocumentsOrderingFilter,
DocumentPermissionsFilter,
PermittedObjectsFilter,
)
filterset_class = DocumentFilterSet
search_fields = ("title", "correspondent__name", "effective_content")
@@ -2674,7 +2672,7 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet[SavedVie
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
PermittedObjectsFilter,
)
ordering_fields = ("name",)
@@ -3921,7 +3919,7 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Storag
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
PermittedObjectsFilter,
)
filterset_class = StoragePathFilterSet
ordering_fields = ("name", "path", "matching_algorithm", "match", "document_count")
@@ -4452,7 +4450,7 @@ class ShareLinkViewSet(
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
PermittedObjectsFilter,
)
filterset_class = ShareLinkFilterSet
ordering_fields = ("created", "expiration", "document")
@@ -4482,7 +4480,7 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
PermittedObjectsFilter,
)
filterset_class = ShareLinkBundleFilterSet
ordering_fields = ("created", "expiration", "status")
@@ -4791,9 +4789,11 @@ class BulkEditObjectsView(PassUserMixin):
if not user.is_superuser:
perm = f"documents.{perm_codename}"
permitted_ids = set(permitted_object_ids(user, object_class, perm_codename))
has_perms = user.has_perm(perm) and all(
obj.pk in permitted_ids for obj in objs
has_perms = (
user.has_perm(perm)
and not objs.exclude(
pk__in=permitted_object_ids(user, object_class, perm_codename),
).exists()
)
if not has_perms:
@@ -5294,7 +5294,11 @@ class SystemStatusView(PassUserMixin):
class TrashView(ListModelMixin, PassUserMixin):
permission_classes = (IsAuthenticated,)
serializer_class = TrashSerializer
filter_backends = (ObjectOwnedPermissionsFilter,)
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
include_granted = False
filter_backends = (_TrashPermittedObjectsFilter,)
pagination_class = StandardPagination
model = Document