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 1/9] 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 2/9] 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 3/9] 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 afaf39e43aebf5fa810fe3c620dfb116085f6e11 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:02:15 -0800 Subject: [PATCH 4/9] Fix/GHSA-x395-6h48-wr8v --- docs/advanced_usage.md | 6 +- src/documents/serialisers.py | 18 +- src/documents/templating/filepath.py | 48 +++- src/documents/tests/test_api_objects.py | 289 ++++++++++++++++++++++ src/documents/tests/test_file_handling.py | 6 +- src/documents/views.py | 5 +- 6 files changed, 364 insertions(+), 8 deletions(-) diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index de1068864..0b5a7b601 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -431,8 +431,10 @@ This allows for complex logic to be included in the format, including [logical s and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11) to manipulate the [variables](#filename-format-variables) provided. The template is provided as a string, potentially multiline, and rendered into a single line. -In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed -with more complex logic. +In addition, a limited `document` object is available for advanced templates. +This object includes common metadata fields such as `id`, `pk`, `title`, `content`, `page_count`, `created`, `added`, `modified`, `mime_type`, +`checksum`, `archive_checksum`, `archive_serial_number`, `filename`, `archive_filename`, and `original_filename`. +Related values are available as nested objects with limited fields, for example document.correspondent.name, etc. #### Custom Jinja2 Filters diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index a7d852fb8..bec1254c8 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -6,6 +6,7 @@ import re from datetime import datetime from decimal import Decimal from typing import TYPE_CHECKING +from typing import Any from typing import Literal import magic @@ -73,6 +74,7 @@ from documents.models import WorkflowTrigger from documents.parsers import is_mime_type_supported from documents.permissions import get_document_count_filter_for_user from documents.permissions import get_groups_with_only_permission +from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import set_permissions_for_object from documents.regex import validate_regex_pattern from documents.templating.filepath import validate_filepath_template_and_render @@ -2753,8 +2755,22 @@ class StoragePathTestSerializer(SerializerWithPerms): ) document = serializers.PrimaryKeyRelatedField( - queryset=Document.objects.all(), + queryset=Document.objects.none(), required=True, label="Document", write_only=True, ) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + request = self.context.get("request") + user = getattr(request, "user", None) if request else None + if user is not None and user.is_authenticated: + document_field = self.fields.get("document") + if not isinstance(document_field, serializers.PrimaryKeyRelatedField): + return + document_field.queryset = get_objects_for_user_owner_aware( + user, + "documents.view_document", + Document, + ) diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py index 805cefbdb..b4dd367fb 100644 --- a/src/documents/templating/filepath.py +++ b/src/documents/templating/filepath.py @@ -193,6 +193,52 @@ def get_basic_metadata_context( } +def get_safe_document_context( + document: Document, + tags: Iterable[Tag], +) -> dict[str, object]: + """ + Build a document context object to avoid supplying entire model instance. + """ + return { + "id": document.pk, + "pk": document.pk, + "title": document.title, + "content": document.content, + "page_count": document.page_count, + "created": document.created, + "added": document.added, + "modified": document.modified, + "archive_serial_number": document.archive_serial_number, + "mime_type": document.mime_type, + "checksum": document.checksum, + "archive_checksum": document.archive_checksum, + "filename": document.filename, + "archive_filename": document.archive_filename, + "original_filename": document.original_filename, + "owner": {"username": document.owner.username, "id": document.owner.id} + if document.owner + else None, + "tags": [{"name": tag.name, "id": tag.id} for tag in tags], + "correspondent": ( + {"name": document.correspondent.name, "id": document.correspondent.id} + if document.correspondent + else None + ), + "document_type": ( + {"name": document.document_type.name, "id": document.document_type.id} + if document.document_type + else None + ), + "storage_path": { + "path": document.storage_path.path, + "id": document.storage_path.id, + } + if document.storage_path + else None, + } + + def get_tags_context(tags: Iterable[Tag]) -> dict[str, str | list[str]]: """ Given an Iterable of tags, constructs some context from them for usage @@ -303,7 +349,7 @@ def validate_filepath_template_and_render( # Build the context dictionary context = ( - {"document": document} + {"document": get_safe_document_context(document, tags=tags_list)} | get_basic_metadata_context(document, no_value_default=NO_VALUE_PLACEHOLDER) | get_creation_date_context(document) | get_added_date_context(document) diff --git a/src/documents/tests/test_api_objects.py b/src/documents/tests/test_api_objects.py index 0eb99f023..12d4918c5 100644 --- a/src/documents/tests/test_api_objects.py +++ b/src/documents/tests/test_api_objects.py @@ -5,10 +5,13 @@ from unittest import mock from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.test import override_settings +from guardian.shortcuts import assign_perm from rest_framework import status from rest_framework.test import APITestCase 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 StoragePath @@ -398,6 +401,292 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, "folder/Something") + def test_test_storage_path_requires_document_view_permission(self) -> None: + owner = User.objects.create_user(username="owner") + unprivileged = User.objects.create_user(username="unprivileged") + document = Document.objects.create( + mime_type="application/pdf", + owner=owner, + title="Sensitive", + checksum="123", + ) + self.client.force_authenticate(user=unprivileged) + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": "path/{{ title }}", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("document", response.data) + + def test_test_storage_path_allows_shared_document_view_permission(self) -> None: + owner = User.objects.create_user(username="owner") + viewer = User.objects.create_user(username="viewer") + document = Document.objects.create( + mime_type="application/pdf", + owner=owner, + title="Shared", + checksum="123", + ) + assign_perm("view_document", viewer, document) + + self.client.force_authenticate(user=viewer) + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": "path/{{ title }}", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "path/Shared") + + def test_test_storage_path_exposes_basic_document_context_but_not_sensitive_owner_data( + self, + ) -> None: + owner = User.objects.create_user( + username="owner", + password="password", + email="owner@example.com", + ) + document = Document.objects.create( + mime_type="application/pdf", + owner=owner, + title="Document", + content="Top secret content", + page_count=2, + checksum="123", + ) + self.client.force_authenticate(user=owner) + + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": "{{ document.owner.username }}", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "owner") + + for expression, expected in ( + ("{{ document.content }}", "Top secret content"), + ("{{ document.id }}", str(document.id)), + ("{{ document.page_count }}", "2"), + ): + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": expression, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, expected) + + for expression in ( + "{{ document.owner.password }}", + "{{ document.owner.email }}", + ): + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": expression, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data) + + def test_test_storage_path_includes_related_objects_for_visible_document( + self, + ) -> None: + owner = User.objects.create_user(username="owner") + viewer = User.objects.create_user(username="viewer") + private_correspondent = Correspondent.objects.create( + name="Private Correspondent", + owner=owner, + ) + document = Document.objects.create( + mime_type="application/pdf", + owner=owner, + correspondent=private_correspondent, + title="Document", + checksum="123", + ) + assign_perm("view_document", viewer, document) + + self.client.force_authenticate(user=viewer) + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": "{{ correspondent }}", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "Private Correspondent") + + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": ( + "{{ document.correspondent.name if document.correspondent else 'none' }}" + ), + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "Private Correspondent") + + def test_test_storage_path_superuser_can_view_private_related_objects(self) -> None: + owner = User.objects.create_user(username="owner") + private_correspondent = Correspondent.objects.create( + name="Private Correspondent", + owner=owner, + ) + document = Document.objects.create( + mime_type="application/pdf", + owner=owner, + correspondent=private_correspondent, + title="Document", + checksum="123", + ) + + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": ( + "{{ document.correspondent.name if document.correspondent else 'none' }}" + ), + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "Private Correspondent") + + def test_test_storage_path_includes_doc_type_storage_path_and_tags( + self, + ) -> None: + owner = User.objects.create_user(username="owner") + viewer = User.objects.create_user(username="viewer") + private_document_type = DocumentType.objects.create( + name="Private Type", + owner=owner, + ) + private_storage_path = StoragePath.objects.create( + name="Private Storage Path", + path="private/path", + owner=owner, + ) + private_tag = Tag.objects.create( + name="Private Tag", + owner=owner, + ) + document = Document.objects.create( + mime_type="application/pdf", + owner=owner, + document_type=private_document_type, + storage_path=private_storage_path, + title="Document", + checksum="123", + ) + document.tags.add(private_tag) + assign_perm("view_document", viewer, document) + + self.client.force_authenticate(user=viewer) + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": ( + "{{ document.document_type.name if document.document_type else 'none' }}/" + "{{ document.storage_path.path if document.storage_path else 'none' }}/" + "{{ document.tags[0].name if document.tags else 'none' }}" + ), + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "Private Type/private/path/Private Tag") + + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": "{{ document_type }}/{{ tag_list if tag_list else 'none' }}", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "Private Type/Private Tag") + + def test_test_storage_path_includes_custom_fields_for_visible_document( + self, + ) -> None: + owner = User.objects.create_user(username="owner") + viewer = User.objects.create_user(username="viewer") + document = Document.objects.create( + mime_type="application/pdf", + owner=owner, + title="Document", + checksum="123", + ) + custom_field = CustomField.objects.create( + name="Secret Number", + data_type=CustomField.FieldDataType.INT, + ) + CustomFieldInstance.objects.create( + document=document, + field=custom_field, + value_int=42, + ) + assign_perm("view_document", viewer, document) + + self.client.force_authenticate(user=viewer) + response = self.client.post( + f"{self.ENDPOINT}test/", + json.dumps( + { + "document": document.id, + "path": "{{ custom_fields | get_cf_value('Secret Number', 'none') }}", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "42") + class TestBulkEditObjects(APITestCase): # See test_api_permissions.py for bulk tests on permissions diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py index befc7050f..186483655 100644 --- a/src/documents/tests/test_file_handling.py +++ b/src/documents/tests/test_file_handling.py @@ -1382,11 +1382,11 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): def test_template_with_security(self): """ GIVEN: - - Filename format with one or more undefined variables + - Filename format with an unavailable document attribute WHEN: - Filepath for a document with this format is called THEN: - - The first undefined variable is logged + - The missing attribute is logged - The default format is used """ doc_a = Document.objects.create( @@ -1408,7 +1408,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): self.assertEqual(len(capture.output), 1) self.assertEqual( capture.output[0], - "WARNING:paperless.templating:Template attempted restricted operation: > is not safely callable", + "ERROR:paperless.templating:Template variable error: 'dict object' has no attribute 'save'", ) def test_template_with_custom_fields(self): diff --git a/src/documents/views.py b/src/documents/views.py index babc4e9aa..2ce12c330 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -2411,7 +2411,10 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet): """ Test storage path against a document """ - serializer = StoragePathTestSerializer(data=request.data) + serializer = StoragePathTestSerializer( + data=request.data, + context={"request": request}, + ) serializer.is_valid(raise_exception=True) document = serializer.validated_data.get("document") From dd3ec83569dc38a6aee283e173cf8482d8c0c1a5 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 5/9] 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 1eca34767..24fcba925 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 @@ -281,7 +281,7 @@ main { .navbar .dropdown-menu { font-size: 0.875rem; // body size - a i-bs { + a i-bs, button i-bs { opacity: 0.6; } } From e63b62d531dbca98366cb7379f01b305d8d56114 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 16 Feb 2026 07:26:59 -0800 Subject: [PATCH 6/9] Bump version to 2.20.7 --- pyproject.toml | 2 +- src-ui/package.json | 2 +- src-ui/src/environments/environment.prod.ts | 2 +- src/paperless/version.py | 2 +- uv.lock | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e91ff0283..5fffad81c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paperless-ngx" -version = "2.20.6" +version = "2.20.7" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" readme = "README.md" requires-python = ">=3.10" diff --git a/src-ui/package.json b/src-ui/package.json index dbd26cb8c..aa1a0e71c 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -1,6 +1,6 @@ { "name": "paperless-ngx-ui", - "version": "2.20.6", + "version": "2.20.7", "scripts": { "preinstall": "npx only-allow pnpm", "ng": "ng", diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index 3ce1d16cc..819bbed0c 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -6,7 +6,7 @@ export const environment = { apiVersion: '9', // match src/paperless/settings.py appTitle: 'Paperless-ngx', tag: 'prod', - version: '2.20.6', + version: '2.20.7', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', diff --git a/src/paperless/version.py b/src/paperless/version.py index ec6eaed08..7515698ad 100644 --- a/src/paperless/version.py +++ b/src/paperless/version.py @@ -1,6 +1,6 @@ from typing import Final -__version__: Final[tuple[int, int, int]] = (2, 20, 6) +__version__: Final[tuple[int, int, int]] = (2, 20, 7) # Version string like X.Y.Z __full_version_str__: Final[str] = ".".join(map(str, __version__)) # Version string like X.Y diff --git a/uv.lock b/uv.lock index e287d13bc..f595c3dac 100644 --- a/uv.lock +++ b/uv.lock @@ -1991,7 +1991,7 @@ wheels = [ [[package]] name = "paperless-ngx" -version = "2.20.6" +version = "2.20.7" source = { virtual = "." } dependencies = [ { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, From 5ecbfc9df7b8748209c9c8510ba4c47cc24eabab Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 16 Feb 2026 07:46:09 -0800 Subject: [PATCH 7/9] Split build vs deploy docs --- .github/workflows/ci.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6133475d..6842ac0f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,13 +79,10 @@ jobs: - name: Check files uses: pre-commit/action@v3.0.1 documentation: - name: "Build & Deploy Documentation" + name: "Build Documentation" runs-on: ubuntu-24.04 needs: - pre-commit - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/configure-pages@v5 - name: Checkout @@ -111,12 +108,26 @@ jobs: --dev \ --frozen \ zensical build --clean + - name: Upload documentation artifact + uses: actions/upload-artifact@v5 + with: + name: documentation + path: site/ - uses: actions/upload-pages-artifact@v4 with: path: site name: github-pages-${{ github.run_id }}-${{ github.run_attempt }} + deploy-documentation: + name: "Deploy Documentation" + runs-on: ubuntu-24.04 + needs: + - documentation + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: - uses: actions/deploy-pages@v4 - if: github.event_name == 'push' && github.ref == 'refs/heads/main' id: deployment with: artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }} From bc734798e37a25d9e2c37150c62a33821a70c656 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 16 Feb 2026 08:12:45 -0800 Subject: [PATCH 8/9] Add permissions --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6842ac0f3..cea2e84af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -359,6 +359,9 @@ jobs: build-docker-image: name: Build Docker image for ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }} runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write if: (github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_'))) || (github.event_name == 'pull_request' && (startsWith(github.head_ref, 'feature-') || startsWith(github.head_ref, 'fix-') || github.head_ref == 'dev' || github.head_ref == 'beta' || contains(github.head_ref, 'beta.rc') || startsWith(github.head_ref, 'l10n_'))) concurrency: group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }} @@ -597,6 +600,8 @@ jobs: publish-release: name: "Publish Release" runs-on: ubuntu-24.04 + permissions: + contents: write outputs: prerelease: ${{ steps.get_version.outputs.prerelease }} changelog: ${{ steps.create-release.outputs.body }} @@ -643,6 +648,10 @@ jobs: append-changelog: name: "Append Changelog" runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: write + issues: write needs: - publish-release if: needs.publish-release.outputs.prerelease == 'false' From 5c1bbcd06d579fff45760a20d2ecf2dfc583cccf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:00:19 -0800 Subject: [PATCH 9/9] Documentation: Add v2.20.7 changelog (#12100) --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/changelog.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index d6a87e299..fc66aead3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,23 @@ # Changelog +## paperless-ngx 2.20.7 + +### Bug Fixes + +- Performance fix: use subqueries to improve object retrieval in large installs [@shamoon](https://github.com/shamoon) ([#11950](https://github.com/paperless-ngx/paperless-ngx/pull/11950)) +- Fix: correct user dropdown button icon styling [@shamoon](https://github.com/shamoon) ([#12092](https://github.com/paperless-ngx/paperless-ngx/issues/12092)) +- Fix: fix broken docker create_classifier command in 2.20.6 [@shamoon](https://github.com/shamoon) ([#11965](https://github.com/paperless-ngx/paperless-ngx/issues/11965)) + +### All App Changes + +
+3 changes + +- Performance fix: use subqueries to improve object retrieval in large installs [@shamoon](https://github.com/shamoon) ([#11950](https://github.com/paperless-ngx/paperless-ngx/pull/11950)) +- Fix: correct user dropdown button icon styling [@shamoon](https://github.com/shamoon) ([#12092](https://github.com/paperless-ngx/paperless-ngx/issues/12092)) +- Fix: fix broken docker create_classifier command in 2.20.6 [@shamoon](https://github.com/shamoon) ([#11965](https://github.com/paperless-ngx/paperless-ngx/issues/11965)) +
+ ## paperless-ngx 2.20.6 ### Bug Fixes