From c278f52fb2b03a2ae98faf7024843c80dcc75bb2 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 1 Feb 2026 12:09:30 -0800 Subject: [PATCH 01/13] Fix: fix broken docker create_classifier command in 2.20.6 (#11965) --- docker/rootfs/usr/local/bin/document_create_classifier | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docker/rootfs/usr/local/bin/document_create_classifier b/docker/rootfs/usr/local/bin/document_create_classifier index 518551a4b..23e3d3d82 100755 --- a/docker/rootfs/usr/local/bin/document_create_classifier +++ b/docker/rootfs/usr/local/bin/document_create_classifier @@ -15,7 +15,3 @@ else echo "Unknown user." exit 1 fi -er "$@" -elif [[ $(id -un) == "paperless" ]]; then - s6-setuidgid paperless python3 manage.py document_create_classifier "$@" -fi From 5b9bb147cfa73b935914606c7ecf5371aff07acf Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 1 Feb 2026 12:16:02 -0800 Subject: [PATCH 02/13] Tweakhancement: tweak bulk delete text (#11967) --- .../manage/management-list/management-list.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts index 44160fcdf..27913ea7d 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts @@ -364,7 +364,7 @@ export abstract class ManagementListComponent backdrop: 'static', }) modal.componentInstance.title = $localize`Confirm delete` - modal.componentInstance.messageBold = $localize`This operation will permanently delete all objects.` + modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected ${this.typeNamePlural}.` modal.componentInstance.message = $localize`This operation cannot be undone.` modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnCaption = $localize`Proceed` From 5b45b89d357cec26cd650091bc8f2b7f2b36da74 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 5 Feb 2026 08:46:32 -0800 Subject: [PATCH 03/13] Performance fix: use subqueries to improve object retrieval in large installs (#11950) --- src/documents/permissions.py | 98 ++++++++++++++++++++++++++++++++---- src/documents/serialisers.py | 3 ++ src/documents/views.py | 80 ++++++++++++++--------------- 3 files changed, 131 insertions(+), 50 deletions(-) diff --git a/src/documents/permissions.py b/src/documents/permissions.py index ac6d3f9ca..813136a3d 100644 --- a/src/documents/permissions.py +++ b/src/documents/permissions.py @@ -2,10 +2,17 @@ from django.contrib.auth.models import Group from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.db.models import Count +from django.db.models import IntegerField +from django.db.models import OuterRef from django.db.models import Q from django.db.models import QuerySet +from django.db.models import Subquery +from django.db.models.functions import Cast +from django.db.models.functions import Coalesce from guardian.core import ObjectPermissionChecker from guardian.models import GroupObjectPermission +from guardian.models import UserObjectPermission from guardian.shortcuts import assign_perm from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_users_with_perms @@ -129,23 +136,96 @@ def set_permissions_for_object(permissions: dict, object, *, merge: bool = False ) +def _permitted_document_ids(user): + """ + Return a queryset of document IDs the user may view, limited to non-deleted + documents. This intentionally avoids ``get_objects_for_user`` to keep the + subquery small and index-friendly. + """ + + base_docs = Document.objects.filter(deleted_at__isnull=True).only("id", "owner") + + if user is None or not getattr(user, "is_authenticated", False): + # Just Anonymous user e.g. for drf-spectacular + return base_docs.filter(owner__isnull=True).values_list("id", flat=True) + + if getattr(user, "is_superuser", False): + return base_docs.values_list("id", flat=True) + + document_ct = ContentType.objects.get_for_model(Document) + perm_filter = { + "permission__codename": "view_document", + "permission__content_type": document_ct, + } + + user_perm_docs = ( + UserObjectPermission.objects.filter(user=user, **perm_filter) + .annotate(object_pk_int=Cast("object_pk", IntegerField())) + .values_list("object_pk_int", flat=True) + ) + + group_perm_docs = ( + GroupObjectPermission.objects.filter(group__user=user, **perm_filter) + .annotate(object_pk_int=Cast("object_pk", IntegerField())) + .values_list("object_pk_int", flat=True) + ) + + permitted_documents = user_perm_docs.union(group_perm_docs) + + return base_docs.filter( + Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_documents), + ).values_list("id", flat=True) + + def get_document_count_filter_for_user(user): """ Return the Q object used to filter document counts for the given user. + + The filter is expressed as an ``id__in`` against a small subquery of permitted + document IDs to keep the generated SQL simple and avoid large OR clauses. """ - if user is None or not getattr(user, "is_authenticated", False): - return Q(documents__deleted_at__isnull=True, documents__owner__isnull=True) if getattr(user, "is_superuser", False): + # Superuser: no permission filtering needed return Q(documents__deleted_at__isnull=True) - return Q( - documents__deleted_at__isnull=True, - documents__id__in=get_objects_for_user_owner_aware( - user, - "documents.view_document", - Document, - ).values_list("id", flat=True), + + permitted_ids = _permitted_document_ids(user) + return Q(documents__id__in=permitted_ids) + + +def annotate_document_count_for_related_queryset( + queryset, + through_model, + related_object_field: str, + target_field: str = "document_id", + user=None, +): + """ + Annotate a queryset with permissions-aware document counts using a subquery + against a relation table. + + Args: + queryset: base queryset to annotate (must contain pk) + through_model: model representing the relation (e.g., Document.tags.through + or CustomFieldInstance) + source_field: field on the relation pointing back to queryset pk + target_field: field on the relation pointing to Document id + user: the user for whom to filter permitted document ids + """ + + permitted_ids = _permitted_document_ids(user) + counts = ( + through_model.objects.filter( + **{ + related_object_field: OuterRef("pk"), + f"{target_field}__in": permitted_ids, + }, + ) + .values(related_object_field) + .annotate(c=Count(target_field)) + .values("c") ) + return queryset.annotate(document_count=Coalesce(Subquery(counts[:1]), 0)) def get_objects_for_user_owner_aware(user, perms, Model) -> QuerySet: diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 75e73d878..a7d852fb8 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -713,6 +713,9 @@ class StoragePathField(serializers.PrimaryKeyRelatedField): class CustomFieldSerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): + # Ignore args passed by permissions mixin + kwargs.pop("user", None) + kwargs.pop("full_perms", None) context = kwargs.get("context") self.api_version = int( context.get("request").version diff --git a/src/documents/views.py b/src/documents/views.py index 5a0f83699..babc4e9aa 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -32,7 +32,6 @@ from django.db.models import Count from django.db.models import IntegerField from django.db.models import Max from django.db.models import Model -from django.db.models import Q from django.db.models import Sum from django.db.models import When from django.db.models.functions import Length @@ -128,6 +127,7 @@ from documents.matching import match_storage_paths from documents.matching import match_tags from documents.models import Correspondent from documents.models import CustomField +from documents.models import CustomFieldInstance from documents.models import Document from documents.models import DocumentType from documents.models import Note @@ -147,6 +147,7 @@ from documents.permissions import PaperlessAdminPermissions from documents.permissions import PaperlessNotePermissions from documents.permissions import PaperlessObjectPermissions from documents.permissions import ViewDocumentsPermissions +from documents.permissions import annotate_document_count_for_related_queryset from documents.permissions import get_document_count_filter_for_user from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import has_perms_owner_aware @@ -370,22 +371,37 @@ class PermissionsAwareDocumentCountMixin(BulkPermissionMixin, PassUserMixin): Mixin to add document count to queryset, permissions-aware if needed """ + # Default is simple relation path, override for through-table/count specialization. + document_count_through = None + document_count_source_field = None + def get_document_count_filter(self): request = getattr(self, "request", None) user = getattr(request, "user", None) if request else None return get_document_count_filter_for_user(user) def get_queryset(self): + base_qs = super().get_queryset() + + # Use optimized through-table counting when configured. + if self.document_count_through: + user = getattr(getattr(self, "request", None), "user", None) + return annotate_document_count_for_related_queryset( + base_qs, + through_model=self.document_count_through, + related_object_field=self.document_count_source_field, + user=user, + ) + + # Fallback: simple Count on relation with permission filter. filter = self.get_document_count_filter() - return ( - super() - .get_queryset() - .annotate(document_count=Count("documents", filter=filter)) + return base_qs.annotate( + document_count=Count("documents", filter=filter), ) @extend_schema_view(**generate_object_with_permissions_schema(CorrespondentSerializer)) -class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): +class CorrespondentViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet): model = Correspondent queryset = Correspondent.objects.select_related("owner").order_by(Lower("name")) @@ -422,8 +438,10 @@ class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): @extend_schema_view(**generate_object_with_permissions_schema(TagSerializer)) -class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): +class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet): model = Tag + document_count_through = Document.tags.through + document_count_source_field = "tag_id" queryset = Tag.objects.select_related("owner").order_by( Lower("name"), @@ -466,12 +484,16 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): descendant_pks = {pk for tag in all_tags for pk in tag.get_descendants_pks()} if descendant_pks: - filter_q = self.get_document_count_filter() + user = getattr(getattr(self, "request", None), "user", None) children_source = list( - Tag.objects.filter(pk__in=descendant_pks | {t.pk for t in all_tags}) - .select_related("owner") - .annotate(document_count=Count("documents", filter=filter_q)) - .order_by(*ordering), + annotate_document_count_for_related_queryset( + Tag.objects.filter(pk__in=descendant_pks | {t.pk for t in all_tags}) + .select_related("owner") + .order_by(*ordering), + through_model=self.document_count_through, + related_object_field=self.document_count_source_field, + user=user, + ), ) else: children_source = all_tags @@ -498,7 +520,7 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): @extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer)) -class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): +class DocumentTypeViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet): model = DocumentType queryset = DocumentType.objects.select_related("owner").order_by(Lower("name")) @@ -2344,7 +2366,7 @@ class BulkDownloadView(GenericAPIView): @extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer)) -class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): +class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet): model = StoragePath queryset = StoragePath.objects.select_related("owner").order_by( @@ -2861,7 +2883,7 @@ class WorkflowViewSet(ModelViewSet): ) -class CustomFieldViewSet(ModelViewSet): +class CustomFieldViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet): permission_classes = (IsAuthenticated, PaperlessObjectPermissions) serializer_class = CustomFieldSerializer @@ -2873,35 +2895,11 @@ class CustomFieldViewSet(ModelViewSet): filterset_class = CustomFieldFilterSet model = CustomField + document_count_through = CustomFieldInstance + document_count_source_field = "field_id" queryset = CustomField.objects.all().order_by("-created") - def get_queryset(self): - filter = ( - Q(fields__document__deleted_at__isnull=True) - if self.request.user is None or self.request.user.is_superuser - else ( - Q( - fields__document__deleted_at__isnull=True, - fields__document__id__in=get_objects_for_user_owner_aware( - self.request.user, - "documents.view_document", - Document, - ).values_list("id", flat=True), - ) - ) - ) - return ( - super() - .get_queryset() - .annotate( - document_count=Count( - "fields", - filter=filter, - ), - ) - ) - @extend_schema_view( get=extend_schema( From 118afa82b3d324b3e4b8f87e63c73644b93d43d5 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 14 Feb 2026 08:15:19 -0800 Subject: [PATCH 04/13] Fix: correct user dropdown button icon styling (#12092) --- src-ui/src/app/components/app-frame/app-frame.component.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-ui/src/app/components/app-frame/app-frame.component.scss b/src-ui/src/app/components/app-frame/app-frame.component.scss index 31fda990a..77bf9bf21 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.scss +++ b/src-ui/src/app/components/app-frame/app-frame.component.scss @@ -290,7 +290,7 @@ main { .navbar .dropdown-menu { font-size: 0.875rem; // body size - a i-bs { + a i-bs, button i-bs { opacity: 0.6; } } From ae234d15c82a0cb00ec9ff486fdd2a169b2e8c25 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:37:35 -0800 Subject: [PATCH 05/13] Chore: track down the margin +   patterns --- .../admin/settings/settings.component.html | 6 +-- .../admin/tasks/tasks.component.html | 8 ++-- .../admin/trash/trash.component.html | 12 ++--- .../users-groups/users-groups.component.html | 12 ++--- .../app-frame/app-frame.component.html | 47 +++++++++---------- .../global-search.component.html | 18 +++---- .../custom-field-display.component.html | 2 +- .../custom-fields-dropdown.component.html | 5 +- ...ustom-fields-query-dropdown.component.html | 3 +- .../dates-dropdown.component.html | 3 +- .../custom-field-edit-dialog.component.html | 2 +- .../workflow-edit-dialog.component.html | 6 +-- .../filterable-dropdown.component.html | 3 +- .../common/input/check/check.component.html | 2 +- .../common/input/date/date.component.html | 2 +- .../document-link.component.html | 6 +-- .../input/entries/entries.component.html | 2 +- .../common/input/file/file.component.html | 2 +- .../input/monetary/monetary.component.html | 2 +- .../common/input/number/number.component.html | 2 +- .../common/input/select/select.component.html | 2 +- .../common/input/switch/switch.component.html | 4 +- .../common/input/text/text.component.html | 2 +- .../input/textarea/textarea.component.html | 2 +- .../common/input/url/url.component.html | 2 +- .../page-header/page-header.component.html | 2 +- ...permissions-filter-dropdown.component.html | 3 +- .../profile-edit-dialog.component.html | 10 ++-- .../system-status-dialog.component.html | 12 ++--- .../common/toast/toast.component.html | 4 +- .../upload-file-widget.component.html | 2 +- .../document-detail.component.html | 20 ++++---- .../bulk-editor/bulk-editor.component.html | 25 +++++----- .../document-card-large.component.html | 8 ++-- .../document-list.component.html | 18 +++---- .../custom-fields.component.html | 6 +-- .../document-attributes.component.html | 17 ++++--- .../management-list.component.html | 6 +-- .../manage/mail/mail.component.html | 26 +++++----- .../manage/workflows/workflows.component.html | 8 ++-- .../not-found/not-found.component.html | 2 +- 41 files changed, 151 insertions(+), 175 deletions(-) diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html index d07ee94a3..617bafd9f 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -5,13 +5,13 @@ i18n-info > @if (permissionsService.isAdmin()) { Open Django Admin -   + } diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.html b/src-ui/src/app/components/admin/tasks/tasks.component.html index ad625789c..418dfa8fa 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.html +++ b/src-ui/src/app/components/admin/tasks/tasks.component.html @@ -6,10 +6,10 @@ >
@@ -113,12 +113,12 @@
@if (task.related_document) { } diff --git a/src-ui/src/app/components/admin/trash/trash.component.html b/src-ui/src/app/components/admin/trash/trash.component.html index 55388d4cd..c729d26f2 100644 --- a/src-ui/src/app/components/admin/trash/trash.component.html +++ b/src-ui/src/app/components/admin/trash/trash.component.html @@ -5,16 +5,16 @@ i18n-info infoLink="usage/#document-trash"> @@ -75,10 +75,10 @@
diff --git a/src-ui/src/app/components/admin/users-groups/users-groups.component.html b/src-ui/src/app/components/admin/users-groups/users-groups.component.html index 9f91a8714..a13bb6633 100644 --- a/src-ui/src/app/components/admin/users-groups/users-groups.component.html +++ b/src-ui/src/app/components/admin/users-groups/users-groups.component.html @@ -11,7 +11,7 @@

Users

    @@ -32,10 +32,10 @@
    @@ -49,7 +49,7 @@

    Groups

      @@ -70,10 +70,10 @@
      diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index 13bf9069c..fbdf40811 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -86,14 +86,14 @@ -  Dashboard + Dashboard
    @@ -117,8 +117,7 @@ routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> - -  
    {{view.name}}
    +
    {{view.name}}
    @if (showSidebarCounts && !slimSidebarEnabled) { {{ savedViewService.getDocumentCount(view) }} } @@ -151,7 +150,7 @@ routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> -  {{d.title | documentTitle}} + {{d.title | documentTitle}} @@ -163,7 +162,7 @@ -  Close all + Close all } @@ -181,7 +180,7 @@ -  Attributes + Attributes @if (!slimSidebarEnabled) {
@@ -233,7 +232,7 @@ -  Saved Views + Saved Views @@ -273,21 +272,21 @@ -  Settings + Settings } @@ -318,7 +317,7 @@ target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> -  Documentation + Documentation
@@ -22,7 +22,7 @@ } diff --git a/src-ui/src/app/components/common/input/text/text.component.html b/src-ui/src/app/components/common/input/text/text.component.html index 5bf4dd93a..240393441 100644 --- a/src-ui/src/app/components/common/input/text/text.component.html +++ b/src-ui/src/app/components/common/input/text/text.component.html @@ -6,7 +6,7 @@ } @if (removable) { }
diff --git a/src-ui/src/app/components/common/input/textarea/textarea.component.html b/src-ui/src/app/components/common/input/textarea/textarea.component.html index 60469ff08..32a3bce88 100644 --- a/src-ui/src/app/components/common/input/textarea/textarea.component.html +++ b/src-ui/src/app/components/common/input/textarea/textarea.component.html @@ -6,7 +6,7 @@ } @if (removable) { }
diff --git a/src-ui/src/app/components/common/input/url/url.component.html b/src-ui/src/app/components/common/input/url/url.component.html index 0fcd76d99..f305d6620 100644 --- a/src-ui/src/app/components/common/input/url/url.component.html +++ b/src-ui/src/app/components/common/input/url/url.component.html @@ -4,7 +4,7 @@ @if (removable) { } diff --git a/src-ui/src/app/components/common/page-header/page-header.component.html b/src-ui/src/app/components/common/page-header/page-header.component.html index 4b2a35425..6ee0388cd 100644 --- a/src-ui/src/app/components/common/page-header/page-header.component.html +++ b/src-ui/src/app/components/common/page-header/page-header.component.html @@ -5,7 +5,7 @@ @if (id) { @if (copied) { -  Copied! + Copied! } @else { ID: {{id}} } diff --git a/src-ui/src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html b/src-ui/src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html index ef2ac224d..d40b5817a 100644 --- a/src-ui/src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html +++ b/src-ui/src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html @@ -1,7 +1,6 @@