diff --git a/src-ui/src/app/components/common/input/select/select.component.spec.ts b/src-ui/src/app/components/common/input/select/select.component.spec.ts
index a71f37afb7..23a32f85f8 100644
--- a/src-ui/src/app/components/common/input/select/select.component.spec.ts
+++ b/src-ui/src/app/components/common/input/select/select.component.spec.ts
@@ -97,6 +97,7 @@ describe('SelectComponent', () => {
let createNewVal
component.createNew.subscribe((v) => (createNewVal = v))
expect(component.allowCreateNew).toBeTruthy()
+ const closeSpy = jest.spyOn(component.ngSelectComponent, 'close')
component.onSearch({ term: 'foo' })
component.addItem(undefined)
expect(createNewVal).toEqual('foo')
@@ -105,6 +106,8 @@ describe('SelectComponent', () => {
component.onSearch({ term: 'baz' })
component.clickNew()
expect(createNewVal).toEqual('baz')
+ // dropdown should close when creating a new item so it isn't left open behind the modal
+ expect(closeSpy).toHaveBeenCalled()
})
it('should search items by independent normalized terms', () => {
diff --git a/src-ui/src/app/components/common/input/select/select.component.ts b/src-ui/src/app/components/common/input/select/select.component.ts
index 1fd2db3b7b..f3d8b39fdd 100644
--- a/src-ui/src/app/components/common/input/select/select.component.ts
+++ b/src-ui/src/app/components/common/input/select/select.component.ts
@@ -4,6 +4,7 @@ import {
forwardRef,
Input,
Output,
+ ViewChild,
} from '@angular/core'
import {
FormsModule,
@@ -11,7 +12,7 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
import { RouterModule } from '@angular/router'
-import { NgSelectModule } from '@ng-select/ng-select'
+import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { matchesSearchText } from 'src/app/utils/text-search'
import { AbstractInputComponent } from '../abstract-input'
@@ -124,6 +125,9 @@ export class SelectComponent extends AbstractInputComponent
{
@Output()
filterDocuments = new EventEmitter()
+ @ViewChild(NgSelectComponent)
+ ngSelectComponent: NgSelectComponent
+
public addItemRef: (name) => void
private _lastSearchTerm: string
@@ -150,11 +154,13 @@ export class SelectComponent extends AbstractInputComponent {
if (name) this.createNew.next(name)
else this.createNew.next(this._lastSearchTerm)
this.clearLastSearchTerm()
+ this.ngSelectComponent?.close()
}
clickNew() {
this.createNew.next(this._lastSearchTerm)
this.clearLastSearchTerm()
+ this.ngSelectComponent?.close()
}
clearLastSearchTerm() {
diff --git a/src/documents/admin.py b/src/documents/admin.py
index 719d33c161..dba82c4a22 100644
--- a/src/documents/admin.py
+++ b/src/documents/admin.py
@@ -1,7 +1,6 @@
from django.conf import settings
from django.contrib import admin
from guardian.admin import GuardedModelAdmin
-from treenode.admin import TreeNodeModelAdmin
from documents.models import Correspondent
from documents.models import CustomField
@@ -29,7 +28,7 @@ class CorrespondentAdmin(GuardedModelAdmin):
list_editable = ("match", "matching_algorithm")
-class TagAdmin(GuardedModelAdmin, TreeNodeModelAdmin):
+class TagAdmin(GuardedModelAdmin):
list_display = ("name", "color", "match", "matching_algorithm")
list_filter = ("matching_algorithm",)
list_editable = ("color", "match", "matching_algorithm")
diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py
index 0476ed98d8..8dd4d93fe7 100644
--- a/src/documents/bulk_edit.py
+++ b/src/documents/bulk_edit.py
@@ -257,19 +257,13 @@ def modify_tags(
affected_docs = list(qs.values_list("pk", flat=True))
DocumentTagRelationship = Document.tags.through
- # add with all ancestors
- expanded_add_tags: set[int] = set()
- add_tag_objects = Tag.objects.filter(pk__in=add_tags)
- for t in add_tag_objects:
- expanded_add_tags.add(int(t.id))
- expanded_add_tags.update(int(pk) for pk in t.get_ancestors_pks())
-
- # remove with all descendants
- expanded_remove_tags: set[int] = set()
- remove_tag_objects = Tag.objects.filter(pk__in=remove_tags)
- for t in remove_tag_objects:
- expanded_remove_tags.add(int(t.id))
- expanded_remove_tags.update(int(pk) for pk in t.get_descendants_pks())
+ hierarchy = Tag.get_hierarchy()
+ expanded_add_tags = hierarchy.with_ancestors(
+ Tag.objects.filter(pk__in=add_tags).values_list("pk", flat=True),
+ )
+ expanded_remove_tags = hierarchy.with_descendants(
+ Tag.objects.filter(pk__in=remove_tags).values_list("pk", flat=True),
+ )
with transaction.atomic():
if expanded_remove_tags:
diff --git a/src/documents/management/commands/document_importer.py b/src/documents/management/commands/document_importer.py
index fa0e4bd330..67618bf2e9 100644
--- a/src/documents/management/commands/document_importer.py
+++ b/src/documents/management/commands/document_importer.py
@@ -108,6 +108,24 @@ def _deserialize_record(
) from e
for field_name, field_value in record.get("fields", {}).items():
+ # Older exports include django-treenode's derived bookkeeping. The
+ # parent FK is authoritative; accept only these known removed fields.
+ if Model is Tag and field_name in {
+ "tn_ancestors_count",
+ "tn_ancestors_pks",
+ "tn_children_count",
+ "tn_children_pks",
+ "tn_depth",
+ "tn_descendants_count",
+ "tn_descendants_pks",
+ "tn_index",
+ "tn_level",
+ "tn_order",
+ "tn_priority",
+ "tn_siblings_count",
+ "tn_siblings_pks",
+ }:
+ continue
field = Model._meta.get_field(field_name)
remote = field.remote_field
diff --git a/src/documents/migrations/0028_replace_tag_tree_fields.py b/src/documents/migrations/0028_replace_tag_tree_fields.py
new file mode 100644
index 0000000000..b6fd95e182
--- /dev/null
+++ b/src/documents/migrations/0028_replace_tag_tree_fields.py
@@ -0,0 +1,64 @@
+# Generated by Django 5.2.17 on 2026-10-28 22:54
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documents", "0027_documentbarcode"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_ancestors_count",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_ancestors_pks",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_children_count",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_children_pks",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_depth",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_descendants_count",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_descendants_pks",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_index",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_level",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_order",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_priority",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_siblings_count",
+ ),
+ migrations.RemoveField(
+ model_name="tag",
+ name="tn_siblings_pks",
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 8d888129ba..4acd402ef4 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -1,6 +1,7 @@
import datetime
import uuid
from pathlib import Path
+from typing import TYPE_CHECKING
from typing import Final
import pathvalidate
@@ -14,7 +15,6 @@ from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from multiselectfield import MultiSelectField
-from treenode.models import TreeNodeModel
if settings.AUDIT_LOG_ENABLED:
from auditlog.registry import auditlog
@@ -29,6 +29,9 @@ from django_softdelete.models import SoftDeleteModel
from documents.data_models import DocumentSource
from documents.parsers import get_default_file_extension
+if TYPE_CHECKING:
+ from documents.tag_hierarchy import TagHierarchy
+
class ModelWithOwner(models.Model):
owner = models.ForeignKey(
@@ -100,7 +103,17 @@ class Correspondent(MatchingModel):
verbose_name_plural = _("correspondents")
-class Tag(MatchingModel, TreeNodeModel):
+class Tag(MatchingModel):
+ # Keep the existing column and reverse relation during the transition.
+ tn_parent = models.ForeignKey(
+ "self",
+ blank=True,
+ null=True,
+ on_delete=models.CASCADE,
+ related_name="tn_children",
+ verbose_name=_("Parent"),
+ )
+
color = models.CharField(_("color"), max_length=7, default="#a6cee3")
# Maximum allowed nesting depth for tags (root = 1, max depth = 5)
MAX_NESTING_DEPTH: Final[int] = 5
@@ -114,27 +127,72 @@ class Tag(MatchingModel, TreeNodeModel):
),
)
- class Meta(MatchingModel.Meta, TreeNodeModel.Meta):
+ class Meta(MatchingModel.Meta):
verbose_name = _("tag")
verbose_name_plural = _("tags")
+ @classmethod
+ def get_hierarchy(cls) -> "TagHierarchy":
+ """Read the tag hierarchy in one query, ready to use across multiple tags."""
+ from documents.tag_hierarchy import TagHierarchy
+
+ return TagHierarchy(cls.objects.values_list("pk", "tn_parent_id"))
+
+ @property
+ def parent_pk(self) -> int | None:
+ return self.tn_parent_id
+
+ def get_parent(self) -> "Tag | None":
+ return self.tn_parent
+
+ def get_ancestors_pks(self) -> list[int]:
+ """Return ancestor IDs ordered from root to parent.
+
+ Each call reads the full hierarchy. Use Tag.get_hierarchy() once and
+ reuse it when working with several tags.
+ """
+ return self.get_hierarchy().ancestors(self.tn_parent_id)
+
+ def get_ancestors(self) -> list["Tag"]:
+ """Return ancestors ordered from root to parent.
+
+ Each call reads the full hierarchy. Use Tag.get_hierarchy() once and
+ reuse it when working with several tags.
+ """
+ ids = self.get_ancestors_pks()
+ tags = self.__class__.objects.in_bulk(ids)
+ return [tags[pk] for pk in ids]
+
+ def get_descendants_pks(self) -> set[int]:
+ """Return all descendant IDs.
+
+ Each call reads the full hierarchy. Use Tag.get_hierarchy() once and
+ reuse it when working with several tags.
+ """
+ return self.get_hierarchy().descendants([self.pk])
+
+ def get_descendants(self) -> "models.QuerySet[Tag]":
+ """Return a queryset of all descendants.
+
+ Each call reads the full hierarchy. Use Tag.get_hierarchy() once and
+ reuse it when working with several tags.
+ """
+ return self.__class__.objects.filter(pk__in=self.get_descendants_pks())
+
+ def get_children_queryset(self) -> "models.QuerySet[Tag]":
+ return self.__class__.objects.filter(tn_parent_id=self.pk)
+
def clean(self) -> None:
- # Prevent self-parenting and assigning a descendant as parent
- parent = self.get_parent()
- if parent == self:
- raise ValidationError({"parent": _("Cannot set itself as parent.")})
- if parent and self.pk is not None and self.is_ancestor_of(parent):
- raise ValidationError({"parent": _("Cannot set parent to a descendant.")})
-
- # Enforce maximum nesting depth
- new_parent_depth = 0
- if parent:
- new_parent_depth = parent.get_ancestors_count() + 1
-
- height = 0 if self.pk is None else self.get_depth()
- deepest_new_depth = (new_parent_depth + 1) + height
- if deepest_new_depth > self.MAX_NESTING_DEPTH:
- raise ValidationError({"parent": _("Maximum nesting depth exceeded.")})
+ hierarchy = self.get_hierarchy()
+ if self.pk is not None and self.tn_parent_id == self.pk:
+ raise ValidationError({"tn_parent": _("Cannot set itself as parent.")})
+ ancestors = hierarchy.ancestors(self.tn_parent_id)
+ if self.pk is not None and self.pk in ancestors:
+ raise ValidationError(
+ {"tn_parent": _("Cannot set parent to a descendant.")},
+ )
+ if len(ancestors) + 1 + hierarchy.height(self.pk) > self.MAX_NESTING_DEPTH:
+ raise ValidationError({"tn_parent": _("Maximum nesting depth exceeded.")})
return super().clean()
@@ -539,10 +597,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
return self.created
def add_nested_tags(self, tags) -> None:
- tag_ids = set()
- for tag in tags:
- tag_ids.add(tag.id)
- tag_ids.update(tag.get_ancestors_pks())
+ tag_ids = Tag.get_hierarchy().with_ancestors(tag.pk for tag in tags)
tags_to_add = self.tags.model.objects.filter(id__in=tag_ids)
self.tags.add(*tags_to_add)
diff --git a/src/documents/permissions.py b/src/documents/permissions.py
index ea6b1bc672..9a48e3a809 100644
--- a/src/documents/permissions.py
+++ b/src/documents/permissions.py
@@ -31,6 +31,7 @@ from guardian.core import ObjectPermissionChecker
from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission
from rest_framework.permissions import BasePermission
+from rest_framework.permissions import DjangoModelPermissions
from rest_framework.permissions import DjangoObjectPermissions
from documents.models import Document
@@ -63,6 +64,17 @@ class PermissionGranteesUpdate(TypedDict, total=False):
PermissionsSpec: TypeAlias = Mapping[PermissionAction, PermissionGranteesUpdate]
+class PaperlessModelPermissions(DjangoModelPermissions):
+ """Require basic global model permissions."""
+
+ perms_map = {
+ **DjangoModelPermissions.perms_map,
+ "GET": ["%(app_label)s.view_%(model_name)s"],
+ "OPTIONS": ["%(app_label)s.view_%(model_name)s"],
+ "HEAD": ["%(app_label)s.view_%(model_name)s"],
+ }
+
+
class PaperlessObjectPermissions(DjangoObjectPermissions):
"""
A permissions backend that checks for object-level permissions
@@ -576,12 +588,9 @@ def annotate_document_count_by_ids(
or CustomFieldInstance)
related_object_field: field on the relation pointing back to queryset pk
document_ids: the document ids to count against -- a concrete list/set,
- or a simple (already resolved) queryset of ids. Callers
- that need this filtered by a complex condition (e.g. a
- permission check) should resolve it to a concrete list
- first if the same ids will be reused across multiple
- calls, rather than passing the complex queryset itself
- into each -- see ``_get_selection_data_for_queryset``.
+ or a simple (already resolved) queryset of ids. Keep
+ the list small: it is embedded in the statement as
+ literal parameters.
target_field: field on the relation pointing to Document id
"""
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 206428620f..641aab3e43 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -593,7 +593,7 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
text_color = serializers.SerializerMethodField()
- # map to treenode's tn_parent
+ # Preserve the public API parent field.
parent = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(),
allow_null=True,
@@ -696,7 +696,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
self.instance.clean()
except ValidationError as e:
logger.debug("Tag parent validation failed: %s", e)
- raise e
+ raise serializers.ValidationError(
+ {"parent": e.message_dict["tn_parent"]},
+ ) from e
finally:
self.instance.tn_parent = original_parent
else:
@@ -706,7 +708,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
temp.clean()
except ValidationError as e:
logger.debug("Tag parent validation failed: %s", e)
- raise e
+ raise serializers.ValidationError(
+ {"parent": e.message_dict["tn_parent"]},
+ ) from e
return super().validate(attrs)
@@ -1202,61 +1206,35 @@ class DocumentSerializer(
doc_id,
)
if "tags" in validated_data:
- # Respect tag hierarchy on updates:
- # - Adding a child adds its ancestors
- # - Removing a parent removes all its descendants
- prev_tags = set(instance.tags.all())
- requested_tags = set(validated_data["tags"])
+ hierarchy = Tag.get_hierarchy()
+ previous_ids = set(instance.tags.values_list("pk", flat=True))
+ requested_ids = {tag.pk for tag in validated_data["tags"]}
+ required_ids = hierarchy.with_ancestors(requested_ids - previous_ids)
+ blocked_ids = hierarchy.with_descendants(previous_ids - requested_ids)
+ blocked_ids.difference_update(required_ids)
+ final_ids = hierarchy.with_ancestors(requested_ids) - blocked_ids
+ validated_data["tags"] = list(Tag.objects.filter(pk__in=final_ids))
- # Tags newly added in this update and the ancestors they require
- added_tags = requested_tags - prev_tags
- required_by_add_tags = set(added_tags)
- for t in added_tags:
- required_by_add_tags.update(t.get_ancestors())
-
- # Tags being removed in this update and all descendants, except
- # those required by a tag that is being added in this same update
- removed_tags = prev_tags - requested_tags
- blocked_tags = set(removed_tags)
- for t in removed_tags:
- blocked_tags.update(t.get_descendants())
- blocked_tags.difference_update(required_by_add_tags)
-
- # Add all parent tags
- final_tags = set(requested_tags)
- for t in requested_tags:
- final_tags.update(t.get_ancestors())
-
- # Drop removed parents and their descendants
- final_tags.difference_update(blocked_tags)
-
- validated_data["tags"] = list(final_tags)
if validated_data.get("remove_inbox_tags"):
- current_tag_ids = {t.pk for t in instance.tags.all()}
+ hierarchy = Tag.get_hierarchy()
tags = (
validated_data["tags"]
if "tags" in validated_data
else list(instance.tags.all())
)
-
- # Tags newly added in this update, plus their ancestors, are kept
- keep_ids: set[int] = set()
- for tag in tags:
- if tag.pk not in current_tag_ids:
- keep_ids.add(tag.pk)
- keep_ids.update(int(pk) for pk in tag.get_ancestors_pks())
-
- # Remove inbox tags and their descendants, except those being kept
- remove_ids: set[int] = set()
- for inbox_tag in (
+ current_ids = set(instance.tags.values_list("pk", flat=True))
+ keep_ids = hierarchy.with_ancestors(
+ tag.pk for tag in tags if tag.pk not in current_ids
+ )
+ inbox_ids = (
Tag.objects.filter(is_inbox_tag=True)
- .exclude(pk__in=keep_ids)
- .only("pk", "tn_descendants_pks")
- ):
- remove_ids.add(inbox_tag.pk)
- remove_ids.update(int(pk) for pk in inbox_tag.get_descendants_pks())
-
- validated_data["tags"] = [t for t in tags if t.pk not in remove_ids]
+ .exclude(
+ pk__in=keep_ids,
+ )
+ .values_list("pk", flat=True)
+ )
+ remove_ids = hierarchy.with_descendants(inbox_ids) - keep_ids
+ validated_data["tags"] = [tag for tag in tags if tag.pk not in remove_ids]
if settings.AUDIT_LOG_ENABLED:
with set_actor(self.user):
diff --git a/src/documents/tag_hierarchy.py b/src/documents/tag_hierarchy.py
new file mode 100644
index 0000000000..8b63b42b63
--- /dev/null
+++ b/src/documents/tag_hierarchy.py
@@ -0,0 +1,61 @@
+"""An operation-local snapshot of tag edges, with no persistent derived fields.
+
+Never cache this globally: tasks, bulk updates and other processes can change
+parents. Callers traversing multiple tags should share one snapshot.
+"""
+
+from collections import defaultdict
+from collections.abc import Iterable
+
+
+class TagHierarchy:
+ def __init__(self, edges: Iterable[tuple[int, int | None]]) -> None:
+ self.parents = dict(edges)
+ self.children: dict[int | None, list[int]] = defaultdict(list)
+ for pk, parent in self.parents.items():
+ self.children[parent].append(pk)
+
+ def ancestors(self, parent_id: int | None) -> list[int]:
+ result = []
+ seen = set()
+ while parent_id is not None and parent_id not in seen:
+ seen.add(parent_id)
+ result.append(parent_id)
+ parent_id = self.parents.get(parent_id)
+ return result[::-1]
+
+ def with_ancestors(self, roots: Iterable[int]) -> set[int]:
+ result = set(roots)
+ for pk in list(result):
+ result.update(self.ancestors(self.parents.get(pk)))
+ return result
+
+ def with_descendants(self, roots: Iterable[int]) -> set[int]:
+ roots = set(roots)
+ return roots | self.descendants(roots)
+
+ def descendants(self, roots: Iterable[int]) -> set[int]:
+ roots = {pk for pk in roots if pk is not None}
+ seen = set(roots)
+ pending = list(roots)
+ while pending:
+ for child in self.children.get(pending.pop(), ()):
+ if child not in seen:
+ seen.add(child)
+ pending.append(child)
+ return seen - roots
+
+ def height(self, pk: int | None) -> int:
+ if pk is None:
+ return 0
+ seen = {pk}
+ pending = [(pk, 0)]
+ height = 0
+ while pending:
+ parent, depth = pending.pop()
+ height = max(height, depth)
+ for child in self.children.get(parent, ()):
+ if child not in seen:
+ seen.add(child)
+ pending.append((child, depth + 1))
+ return height
diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py
index b06c6f67d0..109a196619 100644
--- a/src/documents/tests/test_api_app_config.py
+++ b/src/documents/tests/test_api_app_config.py
@@ -15,6 +15,7 @@ from paperless.models import ColorConvertChoices
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import UserFactory
from paperless_testing.http import read_streaming_response
+from paperless_testing.permissions import grant_global
class TestApiAppConfig(DirectoriesMixin, APITestCase):
@@ -26,6 +27,67 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
user = UserFactory(username="temp_admin", superuser=True)
self.client.force_authenticate(user=user)
+ def test_api_read_config_requires_view_permission(self) -> None:
+ for permissions in (
+ (),
+ ("change_applicationconfiguration",),
+ ("view_document",),
+ ):
+ user = UserFactory()
+ grant_global(user, *permissions)
+ self.client.force_authenticate(user=user)
+
+ for endpoint in (self.ENDPOINT, f"{self.ENDPOINT}1/"):
+ for method in ("get", "head", "options"):
+ with self.subTest(
+ permissions=permissions,
+ endpoint=endpoint,
+ method=method,
+ ):
+ response = getattr(self.client, method)(endpoint)
+ self.assertEqual(
+ response.status_code,
+ status.HTTP_403_FORBIDDEN,
+ )
+
+ def test_api_read_config_with_view_permission(self) -> None:
+ user = UserFactory()
+ grant_global(user, "view_applicationconfiguration")
+ self.client.force_authenticate(user=user)
+
+ for endpoint in (self.ENDPOINT, f"{self.ENDPOINT}1/"):
+ for method in ("get", "head", "options"):
+ with self.subTest(endpoint=endpoint, method=method):
+ response = getattr(self.client, method)(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ def test_api_view_config_permission_does_not_allow_writes(self) -> None:
+ user = UserFactory()
+ grant_global(user, "view_applicationconfiguration")
+ self.client.force_authenticate(user=user)
+
+ for method in ("put", "patch", "delete"):
+ with self.subTest(method=method):
+ response = getattr(self.client, method)(f"{self.ENDPOINT}1/")
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_api_change_config_permission_allows_updates(self) -> None:
+ user = UserFactory()
+ grant_global(user, "change_applicationconfiguration")
+ self.client.force_authenticate(user=user)
+
+ response = self.client.patch(
+ f"{self.ENDPOINT}1/",
+ {"app_title": "Updated title"},
+ format="json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ ApplicationConfiguration.objects.get().app_title,
+ "Updated title",
+ )
+
def test_api_get_config(self) -> None:
"""
GIVEN:
diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py
index a1524413d7..9009b72a0e 100644
--- a/src/documents/tests/test_api_bulk_edit.py
+++ b/src/documents/tests/test_api_bulk_edit.py
@@ -1223,8 +1223,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertCountEqual(
response.data["selected_document_types"],
[
- {"id": self.c1.id, "document_count": 1},
- {"id": self.c2.id, "document_count": 0},
+ {"id": self.dt1.id, "document_count": 1},
+ {"id": self.dt2.id, "document_count": 0},
],
)
diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py
index 9a502b2cbc..9599167e24 100644
--- a/src/documents/tests/test_api_documents.py
+++ b/src/documents/tests/test_api_documents.py
@@ -49,6 +49,7 @@ from documents.models import WorkflowTrigger
from documents.search import get_backend
from documents.signals.handlers import run_workflows
from documents.tests.utils import ConsumeTaskMixin
+from documents.views import DocumentViewSet
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import TagFactory
@@ -1755,12 +1756,113 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
}
self.assertEqual(counts_by_field[custom_field.id], 1)
+ def test_selection_data_counts_with_shared_by_filter(self) -> None:
+ """
+ GIVEN:
+ - Several documents owned by one user with the same correspondent
+ and document type, only one of which is shared
+ WHEN:
+ - Requesting the document list filtered by shared_by__id with
+ include_selection_data=true
+ THEN:
+ - Counts reflect only the shared document, not every document the
+ owner has with that correspondent or document type, regardless of
+ the batch size
+ """
+ owner = User.objects.create_user(username="owner")
+ other = User.objects.create_user(username="other")
+ correspondent = Correspondent.objects.create(name="c1")
+ doc_type = DocumentType.objects.create(name="dt1")
+ tag = Tag.objects.create(name="tag")
+ docs = [
+ Document.objects.create(
+ checksum=str(i),
+ owner=owner,
+ correspondent=correspondent,
+ document_type=doc_type,
+ )
+ for i in range(3)
+ ]
+ for doc in docs:
+ doc.tags.add(tag)
+ grant_object(other, docs[0], "view_document")
+
+ for batch_size in (10_000, 1):
+ with (
+ self.subTest(batch_size=batch_size),
+ mock.patch.object(
+ DocumentViewSet,
+ "selection_data_batch_size",
+ batch_size,
+ ),
+ ):
+ response = self.client.get(
+ f"/api/documents/?shared_by__id={owner.id}"
+ "&include_selection_data=true",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["count"], 1)
+ selection_data = response.data["selection_data"]
+ for key, obj in (
+ ("selected_correspondents", correspondent),
+ ("selected_document_types", doc_type),
+ ("selected_tags", tag),
+ ):
+ counts = {
+ item["id"]: item["document_count"]
+ for item in selection_data[key]
+ }
+ self.assertEqual(counts[obj.id], 1, key)
+
+ def test_selection_data_counts_documents_once_when_filter_joins(self) -> None:
+ """
+ GIVEN:
+ - A document matching a multi-valued filter through two tags
+ WHEN:
+ - Requesting the document list with include_selection_data=true
+ THEN:
+ - The document is counted once per correspondent, not once per
+ matching tag, regardless of the batch size
+ """
+ correspondent = Correspondent.objects.create(name="c1")
+ tag_a = Tag.objects.create(name="a")
+ tag_b = Tag.objects.create(name="b")
+ both = Document.objects.create(checksum="1", correspondent=correspondent)
+ both.tags.add(tag_a, tag_b)
+ only_a = Document.objects.create(checksum="2", correspondent=correspondent)
+ only_a.tags.add(tag_a)
+
+ for batch_size in (10_000, 1):
+ with (
+ self.subTest(batch_size=batch_size),
+ mock.patch.object(
+ DocumentViewSet,
+ "selection_data_batch_size",
+ batch_size,
+ ),
+ ):
+ response = self.client.get(
+ f"/api/documents/?tags__id__in={tag_a.id},{tag_b.id}"
+ "&include_selection_data=true",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ counts = {
+ item["id"]: item["document_count"]
+ for item in response.data["selection_data"][
+ "selected_correspondents"
+ ]
+ }
+ self.assertEqual(counts[correspondent.id], 2)
+
def test_statistics(self) -> None:
doc1 = Document.objects.create(
title="none1",
checksum="A",
mime_type="application/pdf",
content="abc",
+ archive_serial_number=42,
)
Document.objects.create(
title="none2",
@@ -1804,6 +1906,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.assertEqual(response.data["correspondent_count"], 2)
self.assertEqual(response.data["document_type_count"], 1)
self.assertEqual(response.data["storage_path_count"], 2)
+ self.assertEqual(response.data["current_asn"], 42)
def test_statistics_excludes_document_versions(self) -> None:
root = Document.objects.create(
@@ -1811,6 +1914,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
checksum="A",
mime_type="application/pdf",
content="root",
+ archive_serial_number=42,
)
version = Document.objects.create(
title="version",
@@ -1819,6 +1923,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
content="version",
root_document=root,
version_index=1,
+ archive_serial_number=99,
)
tag_inbox = Tag.objects.create(name="t1", is_inbox_tag=True)
version.tags.add(tag_inbox)
@@ -1829,6 +1934,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.assertEqual(response.data["documents_total"], 1)
self.assertEqual(response.data["documents_inbox"], 0)
self.assertEqual(response.data["character_count"], 4)
+ self.assertEqual(response.data["current_asn"], 42)
self.assertEqual(
response.data["document_file_type_counts"][0]["mime_type_count"],
1,
@@ -1842,6 +1948,45 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.assertEqual(response.data["documents_inbox"], None)
self.assertEqual(response.data["inbox_tags"], None)
+ def test_statistics_current_asn_respects_document_permissions(self) -> None:
+ viewer = UserFactory()
+ grant_global(viewer, "view_document")
+ DocumentFactory(owner=viewer, archive_serial_number=12)
+ shared = DocumentFactory(owner=self.user, archive_serial_number=23)
+ grant_object(viewer, shared, "view_document")
+ DocumentFactory(owner=self.user, archive_serial_number=99)
+
+ self.client.force_authenticate(user=viewer)
+ response = self.client.get("/api/statistics/")
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["current_asn"], 23)
+
+ def test_statistics_current_asn_without_visible_documents(self) -> None:
+ DocumentFactory(owner=self.user, archive_serial_number=99)
+
+ for permissions in ((), ("view_document",)):
+ viewer = UserFactory()
+ grant_global(viewer, *permissions)
+ self.client.force_authenticate(user=viewer)
+
+ with self.subTest(permissions=permissions):
+ response = self.client.get("/api/statistics/")
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["current_asn"], 0)
+
+ def test_statistics_current_asn_with_global_statistics_permission(self) -> None:
+ viewer = UserFactory()
+ grant_global(viewer, "view_global_statistics")
+ DocumentFactory(owner=self.user, archive_serial_number=99)
+
+ self.client.force_authenticate(user=viewer)
+ response = self.client.get("/api/statistics/")
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["current_asn"], 99)
+
def test_statistics_multiple_users(self) -> None:
"""
GIVEN:
diff --git a/src/documents/tests/test_api_search.py b/src/documents/tests/test_api_search.py
index 9c7ff158df..ebcd20280b 100644
--- a/src/documents/tests/test_api_search.py
+++ b/src/documents/tests/test_api_search.py
@@ -25,6 +25,7 @@ from documents.models import Tag
from documents.models import Workflow
from documents.search import get_backend
from documents.search import reset_backend
+from documents.views import DocumentViewSet
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
from paperless_testing.dirs import DirectoriesMixin
@@ -345,38 +346,39 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
storage_path = StoragePath.objects.create(name="sp1")
tag = Tag.objects.create(name="tag")
- matching_doc = Document.objects.create(
- title="bank statement",
- content="bank content",
- checksum="A",
- correspondent=correspondent,
- document_type=doc_type,
- storage_path=storage_path,
- )
- matching_doc.tags.add(tag)
+ for checksum, title in (
+ ("A", "bank statement"),
+ ("B", "bank account"),
+ ("C", "credit card"),
+ ):
+ doc = Document.objects.create(
+ title=title,
+ checksum=checksum,
+ correspondent=correspondent,
+ document_type=doc_type,
+ storage_path=storage_path,
+ )
+ doc.tags.add(tag)
+ get_backend().add_or_update(doc)
- get_backend().add_or_update(matching_doc)
-
- response = self.client.get(
- "/api/documents/?query=bank&include_selection_data=true",
- )
+ with mock.patch.object(DocumentViewSet, "selection_data_batch_size", 1):
+ response = self.client.get(
+ "/api/documents/?query=bank&page_size=1&include_selection_data=true",
+ )
self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertIn("selection_data", response.data)
-
- selected_correspondent = next(
- item
- for item in response.data["selection_data"]["selected_correspondents"]
- if item["id"] == correspondent.id
- )
- selected_tag = next(
- item
- for item in response.data["selection_data"]["selected_tags"]
- if item["id"] == tag.id
- )
-
- self.assertEqual(selected_correspondent["document_count"], 1)
- self.assertEqual(selected_tag["document_count"], 1)
+ self.assertEqual(response.data["count"], 2)
+ self.assertEqual(len(response.data["results"]), 1)
+ for key, pk in (
+ ("selected_correspondents", correspondent.pk),
+ ("selected_document_types", doc_type.pk),
+ ("selected_storage_paths", storage_path.pk),
+ ("selected_tags", tag.pk),
+ ):
+ self.assertEqual(
+ response.data["selection_data"][key],
+ [{"id": pk, "document_count": 2}],
+ )
def test_search_custom_field_ordering(self) -> None:
custom_field = CustomField.objects.create(
diff --git a/src/documents/tests/test_tag_adjacency.py b/src/documents/tests/test_tag_adjacency.py
new file mode 100644
index 0000000000..8219c4435e
--- /dev/null
+++ b/src/documents/tests/test_tag_adjacency.py
@@ -0,0 +1,123 @@
+from typing import TYPE_CHECKING
+
+import pytest
+from django.core.exceptions import FieldDoesNotExist
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+
+from documents.management.commands.document_importer import _deserialize_record
+from documents.models import Tag
+from documents.tag_hierarchy import TagHierarchy
+from paperless_testing.factories import TagFactory
+from paperless_testing.migrations import TestMigrations
+
+if TYPE_CHECKING:
+ from django.apps.registry import Apps
+ from pytest_django.fixtures import DjangoAssertNumQueries
+
+
+@pytest.mark.django_db
+class TestTagAdjacency:
+ def test_held_instance_uses_current_parent_edges(self) -> None:
+ """Descendant lookups reflect parent changes made after loading a tag."""
+ first = TagFactory(name="First")
+ second = TagFactory(name="Second")
+ child = TagFactory(name="Child", tn_parent=first)
+ Tag.objects.filter(pk=child.pk).update(tn_parent=second)
+ assert first.get_descendants_pks() == set()
+ assert second.get_descendants_pks() == {child.pk}
+
+ def test_queryset_delete_preserves_cascade(self) -> None:
+ """Deleting a parent through a queryset deletes only its subtree."""
+ root = TagFactory(name="Root")
+ child = TagFactory(name="Child", tn_parent=root)
+ TagFactory(name="Grandchild", tn_parent=child)
+ survivor = TagFactory(name="Other root")
+ Tag.objects.filter(pk=root.pk).delete()
+ assert list(Tag.objects.values_list("pk", flat=True)) == [survivor.pk]
+
+ def test_flat_writes_do_not_update_other_tags(self) -> None:
+ """Creating and renaming flat tags never rewrite unrelated tag rows."""
+ TagFactory.create_batch(100)
+ with CaptureQueriesContext(connection) as queries:
+ new = TagFactory(name="New")
+ assert not any(q["sql"].startswith("UPDATE") for q in queries)
+ with CaptureQueriesContext(connection) as queries:
+ new.name = "Renamed"
+ new.save()
+ tag_updates = [
+ q["sql"] for q in queries if q["sql"].startswith('UPDATE "documents_tag"')
+ ]
+ assert len(tag_updates) == 1
+ assert 'WHERE "documents_tag"."id" =' in tag_updates[0]
+
+ def test_batch_snapshot_is_one_query_and_includes_overlapping_roots(
+ self,
+ django_assert_num_queries: "DjangoAssertNumQueries",
+ ) -> None:
+ """One edge query supports multiple traversals with overlapping roots."""
+ root = TagFactory(name="Root")
+ child = TagFactory(name="Child", tn_parent=root)
+ leaf = TagFactory(name="Leaf", tn_parent=child)
+ with django_assert_num_queries(1):
+ hierarchy = Tag.get_hierarchy()
+ assert hierarchy.with_ancestors([leaf.pk]) == {root.pk, child.pk, leaf.pk}
+ assert hierarchy.with_descendants([root.pk, child.pk]) == {
+ root.pk,
+ child.pk,
+ leaf.pk,
+ }
+ assert hierarchy.height(None) == 0
+
+ def test_legacy_export_fields_are_ignored_but_parent_is_preserved(self) -> None:
+ """Legacy bookkeeping is ignored while parent links and field checks survive."""
+ model, tag, m2m = _deserialize_record(
+ {
+ "model": "documents.tag",
+ "pk": 2,
+ "fields": {
+ "name": "Child",
+ "tn_parent": 1,
+ "tn_order": 3,
+ "tn_siblings_pks": "4,5",
+ "tn_descendants_pks": "6",
+ },
+ },
+ )
+ assert model is Tag
+ assert tag.tn_parent_id == 1
+ assert tag.name == "Child"
+ assert not m2m
+ with pytest.raises(FieldDoesNotExist):
+ _deserialize_record(
+ {"model": "documents.tag", "pk": 3, "fields": {"tn_typo": "bad"}},
+ )
+
+ def test_malformed_cycles_terminate(self) -> None:
+ """Traversal terminates even when stored parent edges contain a cycle."""
+ hierarchy = TagHierarchy([(1, 2), (2, 1)])
+ assert hierarchy.with_descendants([1]) == {1, 2}
+ assert set(hierarchy.ancestors(2)) == {1, 2}
+
+
+class TestTagAdjacencyMigration(TestMigrations):
+ migrate_from = "0027_documentbarcode"
+ migrate_to = "0028_replace_tag_tree_fields"
+
+ def setUpBeforeMigration(self, apps: "Apps") -> None:
+ """Seed parent links using the model from the pre-migration state."""
+ tag = apps.get_model("documents", "Tag")
+ root = tag.objects.create(name="Root")
+ child = tag.objects.create(name="Child", tn_parent=root)
+ self.edges = [(root.pk, None), (child.pk, root.pk)]
+
+ def test_parent_edges_survive(self) -> None:
+ """Removing derived tree columns preserves the existing parent links."""
+ tag = self.apps.get_model("documents", "Tag")
+ assert (
+ list(tag.objects.order_by("pk").values_list("pk", "tn_parent_id"))
+ == self.edges
+ )
+ assert {
+ field.name for field in tag._meta.fields if field.name.startswith("tn_")
+ } == {"tn_parent"}
diff --git a/src/documents/views.py b/src/documents/views.py
index f000f6ade2..e516a8cd09 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -6,12 +6,14 @@ import re
import subprocess
import tempfile
import zipfile
+from collections import Counter
from collections import deque
from collections.abc import Callable
from dataclasses import asdict
from datetime import datetime
from datetime import timedelta
from http import HTTPStatus
+from itertools import islice
from pathlib import Path
from time import mktime
from time import sleep
@@ -172,7 +174,6 @@ from documents.permissions import PaperlessNotePermissions
from documents.permissions import PaperlessObjectPermissions
from documents.permissions import TrashPermissions
from documents.permissions import ViewDocumentsPermissions
-from documents.permissions import annotate_document_count_by_ids
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_permission_grantees
@@ -641,7 +642,7 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
queryset = queryset.order_by(*ordering)
all_tags = list(queryset)
- descendant_pks = {pk for tag in all_tags for pk in tag.get_descendants_pks()}
+ descendant_pks = Tag.get_hierarchy().descendants(t.pk for t in all_tags)
if descendant_pks:
user = getattr(getattr(self, "request", None), "user", None)
@@ -1023,71 +1024,80 @@ class DocumentViewSet(
"custom_field_",
)
- def _get_selection_data_for_queryset(self, queryset):
- # Resolve once instead of once per model below. `queryset` can carry an
- # arbitrarily expensive WHERE clause (user filters plus the permission
- # filter); re-embedding it as a subquery inside 5 separate Count(...)
- # calls forces the database to re-evaluate that whole thing 5 times, and
- # -- for FK relations especially -- can defeat semi-join planning
- # entirely at scale. A concrete id list is cheap to reuse.
- # order_by() drops the default/user ordering -- irrelevant for a plain
- # id list, but left in place it forces a sort over the full filtered
- # set before the ids can even be collected.
- document_ids = list(queryset.order_by().values_list("pk", flat=True))
+ # Bound the IN clauses used for search results and through-table lookups.
+ selection_data_batch_size = 10_000
- correspondents = Correspondent.objects.annotate(
- document_count=Count(
- "documents",
- filter=Q(documents__id__in=document_ids),
- distinct=True,
- ),
- )
- document_types = DocumentType.objects.annotate(
- document_count=Count(
- "documents",
- filter=Q(documents__id__in=document_ids),
- distinct=True,
- ),
- )
- storage_paths = StoragePath.objects.annotate(
- document_count=Count(
- "documents",
- filter=Q(documents__id__in=document_ids),
- distinct=True,
- ),
- )
- # Tag and CustomField reach Document through an M2M/through-model table;
- # a plain Count(filter=...) there is a much more expensive plan than the
- # FK relations above once the bridge table is large -- see
- # annotate_document_count_by_ids() for why.
- tags = annotate_document_count_by_ids(
- Tag.objects.all(),
- through_model=Document.tags.through,
- related_object_field="tag_id",
- document_ids=document_ids,
- )
- custom_fields = annotate_document_count_by_ids(
- CustomField.objects.all(),
- through_model=CustomFieldInstance,
- related_object_field="field_id",
- document_ids=document_ids,
+ def _get_selection_data_for_queryset(
+ self,
+ queryset: QuerySet[Document],
+ *,
+ search_ids: list[int] | None = None,
+ ) -> dict[str, list[dict[str, int]]]:
+ # Keep permission and filter predicates at document granularity.
+ # Reusing the filtered queryset as a subquery for each count is slow.
+ rows = (
+ queryset.order_by()
+ .prefetch_related(None)
+ .values_list(
+ "pk",
+ "correspondent_id",
+ "document_type_id",
+ "storage_path_id",
+ )
)
+ batch_size = self.selection_data_batch_size
+
+ def document_rows():
+ if search_ids is None:
+ yield from rows.iterator()
+ else:
+ for start in range(0, len(search_ids), batch_size):
+ yield from rows.filter(
+ pk__in=search_ids[start : start + batch_size],
+ ).iterator()
+
+ correspondents: Counter[int | None] = Counter()
+ document_types: Counter[int | None] = Counter()
+ storage_paths: Counter[int | None] = Counter()
+ document_ids: set[int] = set()
+ for pk, correspondent, document_type, storage_path in document_rows():
+ # Filters that join multi-valued relations can repeat a document.
+ if pk in document_ids:
+ continue
+ document_ids.add(pk)
+ correspondents[correspondent] += 1
+ document_types[document_type] += 1
+ storage_paths[storage_path] += 1
+
+ tags: Counter[int | None] = Counter()
+ custom_fields: Counter[int | None] = Counter()
+ ids = iter(document_ids)
+ while batch := list(islice(ids, batch_size)):
+ for model, field, counts in (
+ (Document.tags.through, "tag_id", tags),
+ (CustomFieldInstance, "field_id", custom_fields),
+ ):
+ counts.update(
+ dict(
+ model.objects.filter(document_id__in=batch)
+ .order_by()
+ .values_list(field)
+ .annotate(document_count=Count("document_id")),
+ ),
+ )
+
+ def selection(model: type[Model], counts: Counter[int | None]):
+ return [
+ {"id": pk, "document_count": counts[pk]}
+ for pk in model._default_manager.values_list("pk", flat=True)
+ ]
+
return {
- "selected_correspondents": [
- {"id": t.id, "document_count": t.document_count} for t in correspondents
- ],
- "selected_tags": [
- {"id": t.id, "document_count": t.document_count} for t in tags
- ],
- "selected_document_types": [
- {"id": t.id, "document_count": t.document_count} for t in document_types
- ],
- "selected_storage_paths": [
- {"id": t.id, "document_count": t.document_count} for t in storage_paths
- ],
- "selected_custom_fields": [
- {"id": t.id, "document_count": t.document_count} for t in custom_fields
- ],
+ "selected_correspondents": selection(Correspondent, correspondents),
+ "selected_tags": selection(Tag, tags),
+ "selected_document_types": selection(DocumentType, document_types),
+ "selected_storage_paths": selection(StoragePath, storage_paths),
+ "selected_custom_fields": selection(CustomField, custom_fields),
}
@classmethod
@@ -2804,13 +2814,10 @@ class UnifiedSearchViewSet(DocumentViewSet):
if get_boolean(
str(request.query_params.get("include_selection_data", "false")),
):
- # NOTE: pk__in=ordered_ids generates a large SQL IN clause
- # for big result sets. Acceptable today but may need a temp
- # table or chunked approach if selection_data becomes slow
- # at scale (tens of thousands of matching documents).
response.data["selection_data"] = (
self._get_selection_data_for_queryset(
- filtered_qs.filter(pk__in=result.ordered_ids),
+ filtered_qs,
+ search_ids=result.ordered_ids,
)
)
return response
@@ -4184,7 +4191,7 @@ class StatisticsView(GenericAPIView[Any]):
for row in mime_type_stats
]
- current_asn = Document.objects.aggregate(
+ current_asn = documents.aggregate(
Max("archive_serial_number", default=0),
).get(
"archive_serial_number__max",
@@ -5191,12 +5198,7 @@ class BulkEditObjectsView(PassUserMixin):
if object_type == "tags":
editable_ids = set(user_permitted_objects.values_list("pk", flat=True))
all_ids = set(objs.values_list("pk", flat=True))
- for tag in objs:
- all_ids.update(
- descendant.pk
- for descendant in tag.get_descendants()
- if descendant.pk in editable_ids
- )
+ all_ids.update(Tag.get_hierarchy().descendants(all_ids) & editable_ids)
objs = object_class.objects.filter(pk__in=all_ids)
objs = objs.select_related("owner")
object_ids = list(objs.values_list("pk", flat=True))
diff --git a/src/documents/workflows/mutations.py b/src/documents/workflows/mutations.py
index 33982ef5fd..1291b2d649 100644
--- a/src/documents/workflows/mutations.py
+++ b/src/documents/workflows/mutations.py
@@ -6,6 +6,7 @@ from guardian.shortcuts import remove_perm
from documents.data_models import DocumentMetadataOverrides
from documents.models import CustomFieldInstance
from documents.models import Document
+from documents.models import Tag
from documents.models import WorkflowAction
from documents.permissions import PermissionsSpec
from documents.permissions import set_permissions_for_objects
@@ -134,10 +135,9 @@ def apply_assignment_to_overrides(
if action.has_assign_tags:
if overrides.tag_ids is None:
overrides.tag_ids = []
- tag_ids_to_add: set[int] = set()
- for tag in action.assign_tags.all():
- tag_ids_to_add.add(tag.pk)
- tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
+ tag_ids_to_add = Tag.get_hierarchy().with_ancestors(
+ tag.pk for tag in action.assign_tags.all()
+ )
overrides.tag_ids = list(set(overrides.tag_ids) | tag_ids_to_add)
@@ -216,10 +216,9 @@ def apply_removal_to_document(
if action.remove_all_tags:
Document.objects.get(pk=document.pk).tags.clear()
else:
- tag_ids_to_remove: set[int] = set()
- for tag in action.remove_tags.all():
- tag_ids_to_remove.add(tag.pk)
- tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
+ tag_ids_to_remove = Tag.get_hierarchy().with_descendants(
+ tag.pk for tag in action.remove_tags.all()
+ )
if tag_ids_to_remove:
Document.objects.get(pk=document.pk).tags.remove(*tag_ids_to_remove)
@@ -297,10 +296,9 @@ def apply_removal_to_overrides(
if action.remove_all_tags:
overrides.tag_ids = None
elif overrides.tag_ids:
- tag_ids_to_remove: set[int] = set()
- for tag in action.remove_tags.all():
- tag_ids_to_remove.add(tag.pk)
- tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
+ tag_ids_to_remove = Tag.get_hierarchy().with_descendants(
+ tag.pk for tag in action.remove_tags.all()
+ )
overrides.tag_ids = [t for t in overrides.tag_ids if t not in tag_ids_to_remove]
diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po
index 4cbf9d18d7..d72de6e62a 100644
--- a/src/locale/en_US/LC_MESSAGES/django.po
+++ b/src/locale/en_US/LC_MESSAGES/django.po
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-11-04 19:06+0000\n"
+"POT-Creation-Date: 2026-11-07 02:45+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "{data_type} does not support query expr {expr!r}."
msgstr ""
-#: documents/filters.py:802 documents/models.py:137
+#: documents/filters.py:802 documents/models.py:195
msgid "Maximum nesting depth exceeded."
msgstr ""
@@ -61,1604 +61,1608 @@ msgstr ""
msgid "Custom field not found"
msgstr ""
-#: documents/models.py:40 documents/models.py:1122 documents/models.py:1170
+#: documents/models.py:43 documents/models.py:1177 documents/models.py:1225
msgid "owner"
msgstr ""
-#: documents/models.py:57 documents/models.py:1468
+#: documents/models.py:60 documents/models.py:1523
msgid "None"
msgstr ""
-#: documents/models.py:58 documents/models.py:1469
+#: documents/models.py:61 documents/models.py:1524
msgid "Any word"
msgstr ""
-#: documents/models.py:59 documents/models.py:1470
+#: documents/models.py:62 documents/models.py:1525
msgid "All words"
msgstr ""
-#: documents/models.py:60 documents/models.py:1471
+#: documents/models.py:63 documents/models.py:1526
msgid "Exact match"
msgstr ""
-#: documents/models.py:61 documents/models.py:1472
+#: documents/models.py:64 documents/models.py:1527
msgid "Regular expression"
msgstr ""
-#: documents/models.py:62 documents/models.py:1473
+#: documents/models.py:65 documents/models.py:1528
msgid "Fuzzy word"
msgstr ""
-#: documents/models.py:63
+#: documents/models.py:66
msgid "Automatic"
msgstr ""
-#: documents/models.py:66 documents/models.py:654 documents/models.py:2094
+#: documents/models.py:69 documents/models.py:709 documents/models.py:2149
#: paperless_mail/models.py:23 paperless_mail/models.py:143
msgid "name"
msgstr ""
-#: documents/models.py:68 documents/models.py:1537
+#: documents/models.py:71 documents/models.py:1592
msgid "match"
msgstr ""
-#: documents/models.py:71 documents/models.py:1540
+#: documents/models.py:74 documents/models.py:1595
msgid "matching algorithm"
msgstr ""
-#: documents/models.py:76 documents/models.py:1545
+#: documents/models.py:79 documents/models.py:1600
msgid "is insensitive"
msgstr ""
-#: documents/models.py:99 documents/models.py:169
+#: documents/models.py:102 documents/models.py:227
msgid "correspondent"
msgstr ""
-#: documents/models.py:100
+#: documents/models.py:103
msgid "correspondents"
msgstr ""
-#: documents/models.py:104
+#: documents/models.py:114
+msgid "Parent"
+msgstr ""
+
+#: documents/models.py:117
msgid "color"
msgstr ""
-#: documents/models.py:109
+#: documents/models.py:122
msgid "is inbox tag"
msgstr ""
-#: documents/models.py:112
+#: documents/models.py:125
msgid ""
"Marks this tag as an inbox tag: All newly consumed documents will be tagged "
"with inbox tags."
msgstr ""
-#: documents/models.py:118
+#: documents/models.py:131
msgid "tag"
msgstr ""
-#: documents/models.py:119 documents/models.py:216
+#: documents/models.py:132 documents/models.py:274
msgid "tags"
msgstr ""
-#: documents/models.py:125
+#: documents/models.py:188
msgid "Cannot set itself as parent."
msgstr ""
-#: documents/models.py:127
+#: documents/models.py:192
msgid "Cannot set parent to a descendant."
msgstr ""
-#: documents/models.py:144 documents/models.py:189
+#: documents/models.py:202 documents/models.py:247
msgid "document type"
msgstr ""
-#: documents/models.py:145
+#: documents/models.py:203
msgid "document types"
msgstr ""
-#: documents/models.py:150
+#: documents/models.py:208
msgid "path"
msgstr ""
-#: documents/models.py:154 documents/models.py:178
+#: documents/models.py:212 documents/models.py:236
msgid "storage path"
msgstr ""
-#: documents/models.py:155
+#: documents/models.py:213
msgid "storage paths"
msgstr ""
-#: documents/models.py:181
+#: documents/models.py:239
msgid "title"
msgstr ""
-#: documents/models.py:193 documents/models.py:955
+#: documents/models.py:251 documents/models.py:1010
msgid "content"
msgstr ""
-#: documents/models.py:196
+#: documents/models.py:254
msgid ""
"The raw, text-only data of the document. This field is primarily used for "
"searching."
msgstr ""
-#: documents/models.py:210
+#: documents/models.py:268
msgid "mime type"
msgstr ""
-#: documents/models.py:220
+#: documents/models.py:278
msgid "checksum"
msgstr ""
-#: documents/models.py:224
+#: documents/models.py:282
msgid "The checksum of the original document."
msgstr ""
-#: documents/models.py:228
+#: documents/models.py:286
msgid "archive checksum"
msgstr ""
-#: documents/models.py:234
+#: documents/models.py:292
msgid "The checksum of the archived document."
msgstr ""
-#: documents/models.py:238
+#: documents/models.py:296
msgid "page count"
msgstr ""
-#: documents/models.py:245
+#: documents/models.py:303
msgid "The number of pages of the document."
msgstr ""
-#: documents/models.py:250 documents/models.py:961 documents/models.py:1080
-#: documents/models.py:1142 documents/models.py:1270 documents/models.py:1329
+#: documents/models.py:308 documents/models.py:1016 documents/models.py:1135
+#: documents/models.py:1197 documents/models.py:1325 documents/models.py:1384
msgid "created"
msgstr ""
-#: documents/models.py:256
+#: documents/models.py:314
msgid "modified"
msgstr ""
-#: documents/models.py:263
+#: documents/models.py:321
msgid "added"
msgstr ""
-#: documents/models.py:270
+#: documents/models.py:328
msgid "filename"
msgstr ""
-#: documents/models.py:276
+#: documents/models.py:334
msgid "Current filename in storage"
msgstr ""
-#: documents/models.py:280
+#: documents/models.py:338
msgid "archive filename"
msgstr ""
-#: documents/models.py:286
+#: documents/models.py:344
msgid "Current archive filename in storage"
msgstr ""
-#: documents/models.py:290
+#: documents/models.py:348
msgid "original filename"
msgstr ""
-#: documents/models.py:296
+#: documents/models.py:354
msgid "The original name of the file when it was uploaded"
msgstr ""
-#: documents/models.py:303
+#: documents/models.py:361
msgid "archive serial number"
msgstr ""
-#: documents/models.py:313
+#: documents/models.py:371
msgid "The position of this document in your physical document archive."
msgstr ""
-#: documents/models.py:323
+#: documents/models.py:381
msgid "root document for this version"
msgstr ""
-#: documents/models.py:327
+#: documents/models.py:385
msgid "version index"
msgstr ""
-#: documents/models.py:331
+#: documents/models.py:389
msgid "Index of this version within the root document."
msgstr ""
-#: documents/models.py:335
+#: documents/models.py:393
msgid "version label"
msgstr ""
-#: documents/models.py:339
+#: documents/models.py:397
msgid "Optional short label for a document version."
msgstr ""
-#: documents/models.py:344 documents/models.py:972 documents/models.py:1003
-#: documents/models.py:1107 documents/models.py:2137
+#: documents/models.py:402 documents/models.py:1027 documents/models.py:1058
+#: documents/models.py:1162 documents/models.py:2192
msgid "document"
msgstr ""
-#: documents/models.py:345 documents/models.py:1213
+#: documents/models.py:403 documents/models.py:1268
msgid "documents"
msgstr ""
-#: documents/models.py:573 documents/models.py:1076
+#: documents/models.py:628 documents/models.py:1131
msgid "Archive"
msgstr ""
-#: documents/models.py:574
+#: documents/models.py:629
msgid "Bank"
msgstr ""
-#: documents/models.py:575
+#: documents/models.py:630
msgid "Basket"
msgstr ""
-#: documents/models.py:576
+#: documents/models.py:631
msgid "Bell"
msgstr ""
-#: documents/models.py:577
+#: documents/models.py:632
msgid "Bookmark"
msgstr ""
-#: documents/models.py:578
+#: documents/models.py:633
msgid "Boxes"
msgstr ""
-#: documents/models.py:579
+#: documents/models.py:634
msgid "Briefcase"
msgstr ""
-#: documents/models.py:580
+#: documents/models.py:635
msgid "Building"
msgstr ""
-#: documents/models.py:581
+#: documents/models.py:636
msgid "Calculator"
msgstr ""
-#: documents/models.py:582
+#: documents/models.py:637
msgid "Calendar"
msgstr ""
-#: documents/models.py:583
+#: documents/models.py:638
msgid "Camera"
msgstr ""
-#: documents/models.py:584
+#: documents/models.py:639
msgid "Checklist"
msgstr ""
-#: documents/models.py:585
+#: documents/models.py:640
msgid "Cash"
msgstr ""
-#: documents/models.py:586
+#: documents/models.py:641
msgid "Chat"
msgstr ""
-#: documents/models.py:587
+#: documents/models.py:642
msgid "Check"
msgstr ""
-#: documents/models.py:588
+#: documents/models.py:643
msgid "Clipboard"
msgstr ""
-#: documents/models.py:589
+#: documents/models.py:644
msgid "Clock"
msgstr ""
-#: documents/models.py:590
+#: documents/models.py:645
msgid "Credit card"
msgstr ""
-#: documents/models.py:591
+#: documents/models.py:646
msgid "Download"
msgstr ""
-#: documents/models.py:592
+#: documents/models.py:647
msgid "Envelope"
msgstr ""
-#: documents/models.py:593
+#: documents/models.py:648
msgid "Warning"
msgstr ""
-#: documents/models.py:594
+#: documents/models.py:649
msgid "File"
msgstr ""
-#: documents/models.py:595
+#: documents/models.py:650
msgid "Checked file"
msgstr ""
-#: documents/models.py:596
+#: documents/models.py:651
msgid "Locked file"
msgstr ""
-#: documents/models.py:597
+#: documents/models.py:652
msgid "Medical file"
msgstr ""
-#: documents/models.py:598
+#: documents/models.py:653
msgid "Person file"
msgstr ""
-#: documents/models.py:601
+#: documents/models.py:656
msgid "Spreadsheet"
msgstr ""
-#: documents/models.py:603
+#: documents/models.py:658
msgid "Text file"
msgstr ""
-#: documents/models.py:604
+#: documents/models.py:659
msgid "Files"
msgstr ""
-#: documents/models.py:605
+#: documents/models.py:660
msgid "Folder"
msgstr ""
-#: documents/models.py:606 paperless_mail/admin.py:58
+#: documents/models.py:661 paperless_mail/admin.py:58
msgid "Filter"
msgstr ""
-#: documents/models.py:607
+#: documents/models.py:662
msgid "Gear"
msgstr ""
-#: documents/models.py:608
+#: documents/models.py:663
msgid "Globe"
msgstr ""
-#: documents/models.py:609
+#: documents/models.py:664
msgid "Hash"
msgstr ""
-#: documents/models.py:610
+#: documents/models.py:665
msgid "Heart"
msgstr ""
-#: documents/models.py:611
+#: documents/models.py:666
msgid "House"
msgstr ""
-#: documents/models.py:612
+#: documents/models.py:667
msgid "Inbox"
msgstr ""
-#: documents/models.py:613
+#: documents/models.py:668
msgid "Journals"
msgstr ""
-#: documents/models.py:614
+#: documents/models.py:669
msgid "Task list"
msgstr ""
-#: documents/models.py:615
+#: documents/models.py:670
msgid "Newspaper"
msgstr ""
-#: documents/models.py:616
+#: documents/models.py:671
msgid "Attachment"
msgstr ""
-#: documents/models.py:617
+#: documents/models.py:672
msgid "People"
msgstr ""
-#: documents/models.py:618
+#: documents/models.py:673
msgid "Person"
msgstr ""
-#: documents/models.py:619
+#: documents/models.py:674
msgid "Printer"
msgstr ""
-#: documents/models.py:620
+#: documents/models.py:675
msgid "Receipt"
msgstr ""
-#: documents/models.py:621
+#: documents/models.py:676
msgid "Safe"
msgstr ""
-#: documents/models.py:622
+#: documents/models.py:677
msgid "Search"
msgstr ""
-#: documents/models.py:623
+#: documents/models.py:678
msgid "Send"
msgstr ""
-#: documents/models.py:624
+#: documents/models.py:679
msgid "Shop"
msgstr ""
-#: documents/models.py:625
+#: documents/models.py:680
msgid "Stack"
msgstr ""
-#: documents/models.py:626
+#: documents/models.py:681
msgid "Stars"
msgstr ""
-#: documents/models.py:627
+#: documents/models.py:682
msgid "Tag"
msgstr ""
-#: documents/models.py:628 documents/models.py:643 documents/models.py:1815
+#: documents/models.py:683 documents/models.py:698 documents/models.py:1870
msgid "Tags"
msgstr ""
-#: documents/models.py:629
+#: documents/models.py:684
msgid "Telephone"
msgstr ""
-#: documents/models.py:630
+#: documents/models.py:685
msgid "Truck"
msgstr ""
-#: documents/models.py:631
+#: documents/models.py:686
msgid "Barcode"
msgstr ""
-#: documents/models.py:632
+#: documents/models.py:687
msgid "Wallet"
msgstr ""
-#: documents/models.py:635
+#: documents/models.py:690
msgid "Table"
msgstr ""
-#: documents/models.py:636
+#: documents/models.py:691
msgid "Small Cards"
msgstr ""
-#: documents/models.py:637
+#: documents/models.py:692
msgid "Large Cards"
msgstr ""
-#: documents/models.py:640 documents/models.py:1814
+#: documents/models.py:695 documents/models.py:1869
msgid "Title"
msgstr ""
-#: documents/models.py:641 documents/models.py:871 documents/models.py:1489
+#: documents/models.py:696 documents/models.py:926 documents/models.py:1544
msgid "Created"
msgstr ""
-#: documents/models.py:642 documents/models.py:1488
+#: documents/models.py:697 documents/models.py:1543
msgid "Added"
msgstr ""
-#: documents/models.py:644 documents/models.py:1816
+#: documents/models.py:699 documents/models.py:1871
msgid "Correspondent"
msgstr ""
-#: documents/models.py:645
+#: documents/models.py:700
msgid "Document Type"
msgstr ""
-#: documents/models.py:646
+#: documents/models.py:701
msgid "Storage Path"
msgstr ""
-#: documents/models.py:647
+#: documents/models.py:702
msgid "Note"
msgstr ""
-#: documents/models.py:648
+#: documents/models.py:703
msgid "Owner"
msgstr ""
-#: documents/models.py:649
+#: documents/models.py:704
msgid "Shared"
msgstr ""
-#: documents/models.py:650
+#: documents/models.py:705
msgid "ASN"
msgstr ""
-#: documents/models.py:651
+#: documents/models.py:706
msgid "Pages"
msgstr ""
-#: documents/models.py:657
+#: documents/models.py:712
msgid "icon"
msgstr ""
-#: documents/models.py:664
+#: documents/models.py:719
msgid "sort field"
msgstr ""
-#: documents/models.py:669
+#: documents/models.py:724
msgid "sort reverse"
msgstr ""
-#: documents/models.py:672
+#: documents/models.py:727
msgid "View page size"
msgstr ""
-#: documents/models.py:680
+#: documents/models.py:735
msgid "View display mode"
msgstr ""
-#: documents/models.py:687
+#: documents/models.py:742
msgid "Document display fields"
msgstr ""
-#: documents/models.py:694 documents/models.py:760
+#: documents/models.py:749 documents/models.py:815
msgid "saved view"
msgstr ""
-#: documents/models.py:695
+#: documents/models.py:750
msgid "saved views"
msgstr ""
-#: documents/models.py:703
+#: documents/models.py:758
msgid "title contains"
msgstr ""
-#: documents/models.py:704
+#: documents/models.py:759
msgid "content contains"
msgstr ""
-#: documents/models.py:705
+#: documents/models.py:760
msgid "ASN is"
msgstr ""
-#: documents/models.py:706
+#: documents/models.py:761
msgid "correspondent is"
msgstr ""
-#: documents/models.py:707
+#: documents/models.py:762
msgid "document type is"
msgstr ""
-#: documents/models.py:708
+#: documents/models.py:763
msgid "is in inbox"
msgstr ""
-#: documents/models.py:709
+#: documents/models.py:764
msgid "has tag"
msgstr ""
-#: documents/models.py:710
+#: documents/models.py:765
msgid "has any tag"
msgstr ""
-#: documents/models.py:711
+#: documents/models.py:766
msgid "created before"
msgstr ""
-#: documents/models.py:712
+#: documents/models.py:767
msgid "created after"
msgstr ""
-#: documents/models.py:713
+#: documents/models.py:768
msgid "created year is"
msgstr ""
-#: documents/models.py:714
+#: documents/models.py:769
msgid "created month is"
msgstr ""
-#: documents/models.py:715
+#: documents/models.py:770
msgid "created day is"
msgstr ""
-#: documents/models.py:716
+#: documents/models.py:771
msgid "added before"
msgstr ""
-#: documents/models.py:717
+#: documents/models.py:772
msgid "added after"
msgstr ""
-#: documents/models.py:718
+#: documents/models.py:773
msgid "modified before"
msgstr ""
-#: documents/models.py:719
+#: documents/models.py:774
msgid "modified after"
msgstr ""
-#: documents/models.py:720
+#: documents/models.py:775
msgid "does not have tag"
msgstr ""
-#: documents/models.py:721
+#: documents/models.py:776
msgid "does not have ASN"
msgstr ""
-#: documents/models.py:722
+#: documents/models.py:777
msgid "title or content contains"
msgstr ""
-#: documents/models.py:723
+#: documents/models.py:778
msgid "fulltext query"
msgstr ""
-#: documents/models.py:724
+#: documents/models.py:779
msgid "more like this"
msgstr ""
-#: documents/models.py:725
+#: documents/models.py:780
msgid "has tags in"
msgstr ""
-#: documents/models.py:726
+#: documents/models.py:781
msgid "ASN greater than"
msgstr ""
-#: documents/models.py:727
+#: documents/models.py:782
msgid "ASN less than"
msgstr ""
-#: documents/models.py:728
+#: documents/models.py:783
msgid "storage path is"
msgstr ""
-#: documents/models.py:729
+#: documents/models.py:784
msgid "has correspondent in"
msgstr ""
-#: documents/models.py:730
+#: documents/models.py:785
msgid "does not have correspondent in"
msgstr ""
-#: documents/models.py:731
+#: documents/models.py:786
msgid "has document type in"
msgstr ""
-#: documents/models.py:732
+#: documents/models.py:787
msgid "does not have document type in"
msgstr ""
-#: documents/models.py:733
+#: documents/models.py:788
msgid "has storage path in"
msgstr ""
-#: documents/models.py:734
+#: documents/models.py:789
msgid "does not have storage path in"
msgstr ""
-#: documents/models.py:735
+#: documents/models.py:790
msgid "owner is"
msgstr ""
-#: documents/models.py:736
+#: documents/models.py:791
msgid "has owner in"
msgstr ""
-#: documents/models.py:737
+#: documents/models.py:792
msgid "does not have owner"
msgstr ""
-#: documents/models.py:738
+#: documents/models.py:793
msgid "does not have owner in"
msgstr ""
-#: documents/models.py:739
+#: documents/models.py:794
msgid "has custom field value"
msgstr ""
-#: documents/models.py:740
+#: documents/models.py:795
msgid "is shared by me"
msgstr ""
-#: documents/models.py:741
+#: documents/models.py:796
msgid "has custom fields"
msgstr ""
-#: documents/models.py:742
+#: documents/models.py:797
msgid "has custom field in"
msgstr ""
-#: documents/models.py:743
+#: documents/models.py:798
msgid "does not have custom field in"
msgstr ""
-#: documents/models.py:744
+#: documents/models.py:799
msgid "does not have custom field"
msgstr ""
-#: documents/models.py:745
+#: documents/models.py:800
msgid "custom fields query"
msgstr ""
-#: documents/models.py:746
+#: documents/models.py:801
msgid "created to"
msgstr ""
-#: documents/models.py:747
+#: documents/models.py:802
msgid "created from"
msgstr ""
-#: documents/models.py:748
+#: documents/models.py:803
msgid "added to"
msgstr ""
-#: documents/models.py:749
+#: documents/models.py:804
msgid "added from"
msgstr ""
-#: documents/models.py:750
+#: documents/models.py:805
msgid "mime type is"
msgstr ""
-#: documents/models.py:751
+#: documents/models.py:806
msgid "simple title search"
msgstr ""
-#: documents/models.py:752
+#: documents/models.py:807
msgid "simple text search"
msgstr ""
-#: documents/models.py:753
+#: documents/models.py:808
msgid "has duplicates"
msgstr ""
-#: documents/models.py:763
+#: documents/models.py:818
msgid "rule type"
msgstr ""
-#: documents/models.py:765 documents/models.py:1011
+#: documents/models.py:820 documents/models.py:1066
msgid "value"
msgstr ""
-#: documents/models.py:768
+#: documents/models.py:823
msgid "filter rule"
msgstr ""
-#: documents/models.py:769
+#: documents/models.py:824
msgid "filter rules"
msgstr ""
-#: documents/models.py:799 documents/models.py:1136
+#: documents/models.py:854 documents/models.py:1191
msgid "Pending"
msgstr ""
-#: documents/models.py:800 documents/models.py:878
+#: documents/models.py:855 documents/models.py:933
msgid "Started"
msgstr ""
-#: documents/models.py:801
+#: documents/models.py:856
msgid "Success"
msgstr ""
-#: documents/models.py:802
+#: documents/models.py:857
msgid "Failure"
msgstr ""
-#: documents/models.py:803
+#: documents/models.py:858
msgid "Revoked"
msgstr ""
-#: documents/models.py:806
+#: documents/models.py:861
msgid "Consume File"
msgstr ""
-#: documents/models.py:807
+#: documents/models.py:862
msgid "Train Classifier"
msgstr ""
-#: documents/models.py:808
+#: documents/models.py:863
msgid "Sanity Check"
msgstr ""
-#: documents/models.py:809
+#: documents/models.py:864
msgid "Index Optimize"
msgstr ""
-#: documents/models.py:810 documents/models.py:1484
+#: documents/models.py:865 documents/models.py:1539
msgid "Mail Fetch"
msgstr ""
-#: documents/models.py:811
+#: documents/models.py:866
msgid "LLM Index"
msgstr ""
-#: documents/models.py:812
+#: documents/models.py:867
msgid "Empty Trash"
msgstr ""
-#: documents/models.py:813
+#: documents/models.py:868
msgid "Check Workflows"
msgstr ""
-#: documents/models.py:814
+#: documents/models.py:869
msgid "Bulk Update"
msgstr ""
-#: documents/models.py:815
+#: documents/models.py:870
msgid "Reprocess Document"
msgstr ""
-#: documents/models.py:816
+#: documents/models.py:871
msgid "Build Share Link"
msgstr ""
-#: documents/models.py:817
+#: documents/models.py:872
msgid "Bulk Delete"
msgstr ""
-#: documents/models.py:818
+#: documents/models.py:873
msgid "Apply AI Suggestions"
msgstr ""
-#: documents/models.py:827 documents/models.py:1479
+#: documents/models.py:882 documents/models.py:1534
msgid "Scheduled"
msgstr ""
-#: documents/models.py:828 documents/models.py:1485
+#: documents/models.py:883 documents/models.py:1540
msgid "Web UI"
msgstr ""
-#: documents/models.py:829
+#: documents/models.py:884
msgid "API Upload"
msgstr ""
-#: documents/models.py:830
+#: documents/models.py:885
msgid "Folder Consume"
msgstr ""
-#: documents/models.py:831
+#: documents/models.py:886
msgid "Email Consume"
msgstr ""
-#: documents/models.py:832
+#: documents/models.py:887
msgid "System"
msgstr ""
-#: documents/models.py:833
+#: documents/models.py:888
msgid "Manual"
msgstr ""
-#: documents/models.py:839
+#: documents/models.py:894
msgid "Task ID"
msgstr ""
-#: documents/models.py:840
+#: documents/models.py:895
msgid "Celery task ID"
msgstr ""
-#: documents/models.py:846
+#: documents/models.py:901
msgid "Task Type"
msgstr ""
-#: documents/models.py:847
+#: documents/models.py:902
msgid "The kind of work being performed"
msgstr ""
-#: documents/models.py:854
+#: documents/models.py:909
msgid "Trigger Source"
msgstr ""
-#: documents/models.py:855
+#: documents/models.py:910
msgid "What initiated this task"
msgstr ""
-#: documents/models.py:864
+#: documents/models.py:919
msgid "Status"
msgstr ""
-#: documents/models.py:884
+#: documents/models.py:939
msgid "Completed"
msgstr ""
-#: documents/models.py:892
+#: documents/models.py:947
msgid "Duration (seconds)"
msgstr ""
-#: documents/models.py:893
+#: documents/models.py:948
msgid "Elapsed time from start to completion"
msgstr ""
-#: documents/models.py:899
+#: documents/models.py:954
msgid "Wait Time (seconds)"
msgstr ""
-#: documents/models.py:900
+#: documents/models.py:955
msgid "Time from task creation to worker pickup"
msgstr ""
-#: documents/models.py:907
+#: documents/models.py:962
msgid "Input Data"
msgstr ""
-#: documents/models.py:908
+#: documents/models.py:963
msgid "Structured input parameters for the task"
msgstr ""
-#: documents/models.py:914
+#: documents/models.py:969
msgid "Result Data"
msgstr ""
-#: documents/models.py:915
+#: documents/models.py:970
msgid "Structured result data from task execution"
msgstr ""
-#: documents/models.py:921
+#: documents/models.py:976
msgid "Acknowledged"
msgstr ""
-#: documents/models.py:926
+#: documents/models.py:981
msgid "Task"
msgstr ""
-#: documents/models.py:927
+#: documents/models.py:982
msgid "Tasks"
msgstr ""
-#: documents/models.py:957
+#: documents/models.py:1012
msgid "Note for the document"
msgstr ""
-#: documents/models.py:981
+#: documents/models.py:1036
msgid "user"
msgstr ""
-#: documents/models.py:986
+#: documents/models.py:1041
msgid "note"
msgstr ""
-#: documents/models.py:987
+#: documents/models.py:1042
msgid "notes"
msgstr ""
-#: documents/models.py:1007
+#: documents/models.py:1062
msgid "page"
msgstr ""
-#: documents/models.py:1008
+#: documents/models.py:1063
msgid "Page of the original file, starting at 1"
msgstr ""
-#: documents/models.py:1063
+#: documents/models.py:1118
msgid "format"
msgstr ""
-#: documents/models.py:1067
+#: documents/models.py:1122
msgid "document barcode"
msgstr ""
-#: documents/models.py:1068
+#: documents/models.py:1123
msgid "document barcodes"
msgstr ""
-#: documents/models.py:1077
+#: documents/models.py:1132
msgid "Original"
msgstr ""
-#: documents/models.py:1088 documents/models.py:1150
+#: documents/models.py:1143 documents/models.py:1205
#: paperless_mail/models.py:75
msgid "expiration"
msgstr ""
-#: documents/models.py:1095 documents/models.py:1157
+#: documents/models.py:1150 documents/models.py:1212
msgid "slug"
msgstr ""
-#: documents/models.py:1127
+#: documents/models.py:1182
msgid "share link"
msgstr ""
-#: documents/models.py:1128
+#: documents/models.py:1183
msgid "share links"
msgstr ""
-#: documents/models.py:1137
+#: documents/models.py:1192
msgid "Processing"
msgstr ""
-#: documents/models.py:1138
+#: documents/models.py:1193
msgid "Ready"
msgstr ""
-#: documents/models.py:1139
+#: documents/models.py:1194
msgid "Failed"
msgstr ""
-#: documents/models.py:1186
+#: documents/models.py:1241
msgid "size (bytes)"
msgstr ""
-#: documents/models.py:1192
+#: documents/models.py:1247
msgid "last error"
msgstr ""
-#: documents/models.py:1199
+#: documents/models.py:1254
msgid "file path"
msgstr ""
-#: documents/models.py:1205
+#: documents/models.py:1260
msgid "built at"
msgstr ""
-#: documents/models.py:1218
+#: documents/models.py:1273
msgid "share link bundle"
msgstr ""
-#: documents/models.py:1219
+#: documents/models.py:1274
msgid "share link bundles"
msgstr ""
-#: documents/models.py:1222
+#: documents/models.py:1277
#, python-format
msgid "Share link bundle %(slug)s"
msgstr ""
-#: documents/models.py:1258
+#: documents/models.py:1313
msgid "String"
msgstr ""
-#: documents/models.py:1259
+#: documents/models.py:1314
msgid "URL"
msgstr ""
-#: documents/models.py:1260
+#: documents/models.py:1315
msgid "Date"
msgstr ""
-#: documents/models.py:1261
+#: documents/models.py:1316
msgid "Boolean"
msgstr ""
-#: documents/models.py:1262
+#: documents/models.py:1317
msgid "Integer"
msgstr ""
-#: documents/models.py:1263
+#: documents/models.py:1318
msgid "Float"
msgstr ""
-#: documents/models.py:1264
+#: documents/models.py:1319
msgid "Monetary"
msgstr ""
-#: documents/models.py:1265
+#: documents/models.py:1320
msgid "Document Link"
msgstr ""
-#: documents/models.py:1266
+#: documents/models.py:1321
msgid "Select"
msgstr ""
-#: documents/models.py:1267
+#: documents/models.py:1322
msgid "Long Text"
msgstr ""
-#: documents/models.py:1279
+#: documents/models.py:1334
msgid "data type"
msgstr ""
-#: documents/models.py:1286
+#: documents/models.py:1341
msgid "extra data"
msgstr ""
-#: documents/models.py:1290
+#: documents/models.py:1345
msgid "Extra data for the custom field, such as select options"
msgstr ""
-#: documents/models.py:1296
+#: documents/models.py:1351
msgid "custom field"
msgstr ""
-#: documents/models.py:1297
+#: documents/models.py:1352
msgid "custom fields"
msgstr ""
-#: documents/models.py:1397
+#: documents/models.py:1452
msgid "custom field instance"
msgstr ""
-#: documents/models.py:1398
+#: documents/models.py:1453
msgid "custom field instances"
msgstr ""
-#: documents/models.py:1476
+#: documents/models.py:1531
msgid "Consumption Started"
msgstr ""
-#: documents/models.py:1477
+#: documents/models.py:1532
msgid "Document Added"
msgstr ""
-#: documents/models.py:1478
+#: documents/models.py:1533
msgid "Document Updated"
msgstr ""
-#: documents/models.py:1482
+#: documents/models.py:1537
msgid "Consume Folder"
msgstr ""
-#: documents/models.py:1483
+#: documents/models.py:1538
msgid "Api Upload"
msgstr ""
-#: documents/models.py:1490
+#: documents/models.py:1545
msgid "Modified"
msgstr ""
-#: documents/models.py:1491
+#: documents/models.py:1546
msgid "Custom Field"
msgstr ""
-#: documents/models.py:1494
+#: documents/models.py:1549
msgid "Workflow Trigger Type"
msgstr ""
-#: documents/models.py:1506
+#: documents/models.py:1561
msgid "filter path"
msgstr ""
-#: documents/models.py:1511
+#: documents/models.py:1566
msgid ""
"Only consume documents with a path that matches this if specified. Wildcards "
"specified as * are allowed. Case insensitive."
msgstr ""
-#: documents/models.py:1518
+#: documents/models.py:1573
msgid "filter filename"
msgstr ""
-#: documents/models.py:1523 paperless_mail/models.py:200
+#: documents/models.py:1578 paperless_mail/models.py:200
msgid ""
"Only consume documents which entirely match this filename if specified. "
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
msgstr ""
-#: documents/models.py:1534
+#: documents/models.py:1589
msgid "filter documents from this mail rule"
msgstr ""
-#: documents/models.py:1550
+#: documents/models.py:1605
msgid "has these tag(s)"
msgstr ""
-#: documents/models.py:1557
+#: documents/models.py:1612
msgid "has all of these tag(s)"
msgstr ""
-#: documents/models.py:1564
+#: documents/models.py:1619
msgid "does not have these tag(s)"
msgstr ""
-#: documents/models.py:1572
+#: documents/models.py:1627
msgid "has this document type"
msgstr ""
-#: documents/models.py:1579
+#: documents/models.py:1634
msgid "has one of these document types"
msgstr ""
-#: documents/models.py:1586
+#: documents/models.py:1641
msgid "does not have these document type(s)"
msgstr ""
-#: documents/models.py:1594
+#: documents/models.py:1649
msgid "has this correspondent"
msgstr ""
-#: documents/models.py:1601
+#: documents/models.py:1656
msgid "does not have these correspondent(s)"
msgstr ""
-#: documents/models.py:1608
+#: documents/models.py:1663
msgid "has one of these correspondents"
msgstr ""
-#: documents/models.py:1616
+#: documents/models.py:1671
msgid "has this storage path"
msgstr ""
-#: documents/models.py:1623
+#: documents/models.py:1678
msgid "has one of these storage paths"
msgstr ""
-#: documents/models.py:1630
+#: documents/models.py:1685
msgid "does not have these storage path(s)"
msgstr ""
-#: documents/models.py:1634
+#: documents/models.py:1689
msgid "filter custom field query"
msgstr ""
-#: documents/models.py:1637
+#: documents/models.py:1692
msgid "JSON-encoded custom field query expression."
msgstr ""
-#: documents/models.py:1641
+#: documents/models.py:1696
msgid "schedule offset days"
msgstr ""
-#: documents/models.py:1644
+#: documents/models.py:1699
msgid "The number of days to offset the schedule trigger by."
msgstr ""
-#: documents/models.py:1649
+#: documents/models.py:1704
msgid "schedule is recurring"
msgstr ""
-#: documents/models.py:1652
+#: documents/models.py:1707
msgid "If the schedule should be recurring."
msgstr ""
-#: documents/models.py:1657
+#: documents/models.py:1712
msgid "schedule recurring delay in days"
msgstr ""
-#: documents/models.py:1661
+#: documents/models.py:1716
msgid "The number of days between recurring schedule triggers."
msgstr ""
-#: documents/models.py:1666
+#: documents/models.py:1721
msgid "schedule date field"
msgstr ""
-#: documents/models.py:1671
+#: documents/models.py:1726
msgid "The field to check for a schedule trigger."
msgstr ""
-#: documents/models.py:1680
+#: documents/models.py:1735
msgid "schedule date custom field"
msgstr ""
-#: documents/models.py:1684
+#: documents/models.py:1739
msgid "workflow trigger"
msgstr ""
-#: documents/models.py:1685
+#: documents/models.py:1740
msgid "workflow triggers"
msgstr ""
-#: documents/models.py:1693
+#: documents/models.py:1748
msgid "email subject"
msgstr ""
-#: documents/models.py:1697
+#: documents/models.py:1752
msgid ""
"The subject of the email, can include some placeholders, see documentation."
msgstr ""
-#: documents/models.py:1703
+#: documents/models.py:1758
msgid "email body"
msgstr ""
-#: documents/models.py:1706
+#: documents/models.py:1761
msgid ""
"The body (message) of the email, can include some placeholders, see "
"documentation."
msgstr ""
-#: documents/models.py:1712
+#: documents/models.py:1767
msgid "emails to"
msgstr ""
-#: documents/models.py:1715
+#: documents/models.py:1770
msgid "The destination email addresses, comma separated."
msgstr ""
-#: documents/models.py:1721
+#: documents/models.py:1776
msgid "include document in email"
msgstr ""
-#: documents/models.py:1732
+#: documents/models.py:1787
msgid "webhook url"
msgstr ""
-#: documents/models.py:1735
+#: documents/models.py:1790
msgid "The destination URL for the notification."
msgstr ""
-#: documents/models.py:1740
+#: documents/models.py:1795
msgid "use parameters"
msgstr ""
-#: documents/models.py:1745
+#: documents/models.py:1800
msgid "send as JSON"
msgstr ""
-#: documents/models.py:1749
+#: documents/models.py:1804
msgid "webhook parameters"
msgstr ""
-#: documents/models.py:1752
+#: documents/models.py:1807
msgid "The parameters to send with the webhook URL if body not used."
msgstr ""
-#: documents/models.py:1756
+#: documents/models.py:1811
msgid "webhook body"
msgstr ""
-#: documents/models.py:1759
+#: documents/models.py:1814
msgid "The body to send with the webhook URL if parameters not used."
msgstr ""
-#: documents/models.py:1763
+#: documents/models.py:1818
msgid "webhook headers"
msgstr ""
-#: documents/models.py:1766
+#: documents/models.py:1821
msgid "The headers to send with the webhook URL."
msgstr ""
-#: documents/models.py:1771
+#: documents/models.py:1826
msgid "include document in webhook"
msgstr ""
-#: documents/models.py:1782
+#: documents/models.py:1837
msgid "Assignment"
msgstr ""
-#: documents/models.py:1786
+#: documents/models.py:1841
msgid "Removal"
msgstr ""
-#: documents/models.py:1790 documents/templates/account/password_reset.html:15
+#: documents/models.py:1845 documents/templates/account/password_reset.html:15
msgid "Email"
msgstr ""
-#: documents/models.py:1794
+#: documents/models.py:1849
msgid "Webhook"
msgstr ""
-#: documents/models.py:1798
+#: documents/models.py:1853
msgid "Password removal"
msgstr ""
-#: documents/models.py:1802
+#: documents/models.py:1857
msgid "Move to trash"
msgstr ""
-#: documents/models.py:1806
+#: documents/models.py:1861
msgid "Remote OCR"
msgstr ""
-#: documents/models.py:1810
+#: documents/models.py:1865
msgid "Apply AI suggestions"
msgstr ""
-#: documents/models.py:1817
+#: documents/models.py:1872
msgid "Document type"
msgstr ""
-#: documents/models.py:1818
+#: documents/models.py:1873
msgid "Storage path"
msgstr ""
-#: documents/models.py:1819
+#: documents/models.py:1874
msgid "Created date"
msgstr ""
-#: documents/models.py:1822
+#: documents/models.py:1877
msgid "Workflow Action Type"
msgstr ""
-#: documents/models.py:1827 documents/models.py:2096
+#: documents/models.py:1882 documents/models.py:2151
#: paperless_mail/models.py:145
msgid "order"
msgstr ""
-#: documents/models.py:1830
+#: documents/models.py:1885
msgid "assign title"
msgstr ""
-#: documents/models.py:1834
+#: documents/models.py:1889
msgid "Assign a document title, must be a Jinja2 template, see documentation."
msgstr ""
-#: documents/models.py:1842 paperless_mail/models.py:274
+#: documents/models.py:1897 paperless_mail/models.py:274
msgid "assign this tag"
msgstr ""
-#: documents/models.py:1851 paperless_mail/models.py:282
+#: documents/models.py:1906 paperless_mail/models.py:282
msgid "assign this document type"
msgstr ""
-#: documents/models.py:1860 paperless_mail/models.py:296
+#: documents/models.py:1915 paperless_mail/models.py:296
msgid "assign this correspondent"
msgstr ""
-#: documents/models.py:1869
+#: documents/models.py:1924
msgid "assign this storage path"
msgstr ""
-#: documents/models.py:1878
+#: documents/models.py:1933
msgid "assign this owner"
msgstr ""
-#: documents/models.py:1885
+#: documents/models.py:1940
msgid "grant view permissions to these users"
msgstr ""
-#: documents/models.py:1892
+#: documents/models.py:1947
msgid "grant view permissions to these groups"
msgstr ""
-#: documents/models.py:1899
+#: documents/models.py:1954
msgid "grant change permissions to these users"
msgstr ""
-#: documents/models.py:1906
+#: documents/models.py:1961
msgid "grant change permissions to these groups"
msgstr ""
-#: documents/models.py:1913
+#: documents/models.py:1968
msgid "assign these custom fields"
msgstr ""
-#: documents/models.py:1917
+#: documents/models.py:1972
msgid "custom field values"
msgstr ""
-#: documents/models.py:1921
+#: documents/models.py:1976
msgid "Optional values to assign to the custom fields."
msgstr ""
-#: documents/models.py:1930
+#: documents/models.py:1985
msgid "remove these tag(s)"
msgstr ""
-#: documents/models.py:1935
+#: documents/models.py:1990
msgid "remove all tags"
msgstr ""
-#: documents/models.py:1942
+#: documents/models.py:1997
msgid "remove these document type(s)"
msgstr ""
-#: documents/models.py:1947
+#: documents/models.py:2002
msgid "remove all document types"
msgstr ""
-#: documents/models.py:1954
+#: documents/models.py:2009
msgid "remove these correspondent(s)"
msgstr ""
-#: documents/models.py:1959
+#: documents/models.py:2014
msgid "remove all correspondents"
msgstr ""
-#: documents/models.py:1966
+#: documents/models.py:2021
msgid "remove these storage path(s)"
msgstr ""
-#: documents/models.py:1971
+#: documents/models.py:2026
msgid "remove all storage paths"
msgstr ""
-#: documents/models.py:1978
+#: documents/models.py:2033
msgid "remove these owner(s)"
msgstr ""
-#: documents/models.py:1983
+#: documents/models.py:2038
msgid "remove all owners"
msgstr ""
-#: documents/models.py:1990
+#: documents/models.py:2045
msgid "remove view permissions for these users"
msgstr ""
-#: documents/models.py:1997
+#: documents/models.py:2052
msgid "remove view permissions for these groups"
msgstr ""
-#: documents/models.py:2004
+#: documents/models.py:2059
msgid "remove change permissions for these users"
msgstr ""
-#: documents/models.py:2011
+#: documents/models.py:2066
msgid "remove change permissions for these groups"
msgstr ""
-#: documents/models.py:2016
+#: documents/models.py:2071
msgid "remove all permissions"
msgstr ""
-#: documents/models.py:2023
+#: documents/models.py:2078
msgid "remove these custom fields"
msgstr ""
-#: documents/models.py:2028
+#: documents/models.py:2083
msgid "remove all custom fields"
msgstr ""
-#: documents/models.py:2037
+#: documents/models.py:2092
msgid "email"
msgstr ""
-#: documents/models.py:2046
+#: documents/models.py:2101
msgid "webhook"
msgstr ""
-#: documents/models.py:2050
+#: documents/models.py:2105
msgid "passwords"
msgstr ""
-#: documents/models.py:2054
+#: documents/models.py:2109
msgid ""
"Passwords to try when removing PDF protection. Separate with commas or new "
"lines."
msgstr ""
-#: documents/models.py:2059
+#: documents/models.py:2114
msgid "AI suggestion fields"
msgstr ""
-#: documents/models.py:2063
+#: documents/models.py:2118
msgid "Which of the AI-suggested fields to apply to the document."
msgstr ""
-#: documents/models.py:2068
+#: documents/models.py:2123
msgid "create missing objects"
msgstr ""
-#: documents/models.py:2071
+#: documents/models.py:2126
msgid ""
"Create suggested tags, correspondents, document types and storage paths that "
"do not already exist instead of skipping them."
msgstr ""
-#: documents/models.py:2077
+#: documents/models.py:2132
msgid "overwrite existing values"
msgstr ""
-#: documents/models.py:2080
+#: documents/models.py:2135
msgid ""
"Apply suggestions even if the document already has a value for that field. "
"Tags are always added to, never replaced."
msgstr ""
-#: documents/models.py:2086
+#: documents/models.py:2141
msgid "workflow action"
msgstr ""
-#: documents/models.py:2087
+#: documents/models.py:2142
msgid "workflow actions"
msgstr ""
-#: documents/models.py:2102
+#: documents/models.py:2157
msgid "triggers"
msgstr ""
-#: documents/models.py:2109
+#: documents/models.py:2164
msgid "actions"
msgstr ""
-#: documents/models.py:2112 paperless_mail/models.py:154
+#: documents/models.py:2167 paperless_mail/models.py:154
msgid "enabled"
msgstr ""
-#: documents/models.py:2123
+#: documents/models.py:2178
msgid "workflow"
msgstr ""
-#: documents/models.py:2127
+#: documents/models.py:2182
msgid "workflow trigger type"
msgstr ""
-#: documents/models.py:2141
+#: documents/models.py:2196
msgid "date run"
msgstr ""
-#: documents/models.py:2147
+#: documents/models.py:2202
msgid "workflow run"
msgstr ""
-#: documents/models.py:2148
+#: documents/models.py:2203
msgid "workflow runs"
msgstr ""
-#: documents/serialisers.py:483 documents/serialisers.py:840
-#: documents/serialisers.py:2872 documents/views.py:348 documents/views.py:614
-#: documents/views.py:2748 paperless_mail/serialisers.py:156
+#: documents/serialisers.py:483 documents/serialisers.py:844
+#: documents/serialisers.py:2850 documents/views.py:349 documents/views.py:615
+#: documents/views.py:2755 paperless_mail/serialisers.py:156
msgid "Insufficient permissions."
msgstr ""
@@ -1666,39 +1670,39 @@ msgstr ""
msgid "Invalid color."
msgstr ""
-#: documents/serialisers.py:2339
+#: documents/serialisers.py:2317
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
-#: documents/serialisers.py:2383
+#: documents/serialisers.py:2361
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
-#: documents/serialisers.py:2390
+#: documents/serialisers.py:2368
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
-#: documents/serialisers.py:2407 documents/serialisers.py:2417
+#: documents/serialisers.py:2385 documents/serialisers.py:2395
msgid ""
"Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
-#: documents/serialisers.py:2412
+#: documents/serialisers.py:2390
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
-#: documents/serialisers.py:2559
+#: documents/serialisers.py:2537
msgid "Invalid variable detected."
msgstr ""
-#: documents/serialisers.py:2928
+#: documents/serialisers.py:2906
msgid "Duplicate document identifiers are not allowed."
msgstr ""
-#: documents/serialisers.py:2958 documents/views.py:4798
+#: documents/serialisers.py:2936 documents/views.py:4805
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1949,40 +1953,40 @@ msgstr ""
msgid ", "
msgstr ""
-#: documents/views.py:303
+#: documents/views.py:304
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
-#: documents/views.py:341 documents/views.py:2745
+#: documents/views.py:342 documents/views.py:2752
msgid "Invalid more_like_id"
msgstr ""
-#: documents/views.py:1669
+#: documents/views.py:1679
msgid "Invalid AI configuration."
msgstr ""
-#: documents/views.py:1680
+#: documents/views.py:1690
msgid "AI backend request timed out."
msgstr ""
-#: documents/views.py:1692
+#: documents/views.py:1702
msgid "AI backend rejected the request. Check logs for details."
msgstr ""
-#: documents/views.py:4814
+#: documents/views.py:4821
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
-#: documents/views.py:4861
+#: documents/views.py:4868
msgid "Bundle is already being processed."
msgstr ""
-#: documents/views.py:4927
+#: documents/views.py:4934
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
-#: documents/views.py:4941
+#: documents/views.py:4948
msgid "The share link bundle is unavailable."
msgstr ""
@@ -2274,151 +2278,151 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
-#: paperless/settings/__init__.py:571
+#: paperless/settings/__init__.py:570
msgid "English (US)"
msgstr ""
-#: paperless/settings/__init__.py:572
+#: paperless/settings/__init__.py:571
msgid "Arabic"
msgstr ""
-#: paperless/settings/__init__.py:573
+#: paperless/settings/__init__.py:572
msgid "Afrikaans"
msgstr ""
-#: paperless/settings/__init__.py:574
+#: paperless/settings/__init__.py:573
msgid "Belarusian"
msgstr ""
-#: paperless/settings/__init__.py:575
+#: paperless/settings/__init__.py:574
msgid "Bulgarian"
msgstr ""
-#: paperless/settings/__init__.py:576
+#: paperless/settings/__init__.py:575
msgid "Catalan"
msgstr ""
-#: paperless/settings/__init__.py:577
+#: paperless/settings/__init__.py:576
msgid "Czech"
msgstr ""
-#: paperless/settings/__init__.py:578
+#: paperless/settings/__init__.py:577
msgid "Danish"
msgstr ""
-#: paperless/settings/__init__.py:579
+#: paperless/settings/__init__.py:578
msgid "German"
msgstr ""
-#: paperless/settings/__init__.py:580
+#: paperless/settings/__init__.py:579
msgid "Greek"
msgstr ""
-#: paperless/settings/__init__.py:581
+#: paperless/settings/__init__.py:580
msgid "English (GB)"
msgstr ""
-#: paperless/settings/__init__.py:582
+#: paperless/settings/__init__.py:581
msgid "Spanish"
msgstr ""
-#: paperless/settings/__init__.py:583
+#: paperless/settings/__init__.py:582
msgid "Persian"
msgstr ""
-#: paperless/settings/__init__.py:584
+#: paperless/settings/__init__.py:583
msgid "Finnish"
msgstr ""
-#: paperless/settings/__init__.py:585
+#: paperless/settings/__init__.py:584
msgid "French"
msgstr ""
-#: paperless/settings/__init__.py:586
+#: paperless/settings/__init__.py:585
msgid "Hungarian"
msgstr ""
-#: paperless/settings/__init__.py:587
+#: paperless/settings/__init__.py:586
msgid "Indonesian"
msgstr ""
-#: paperless/settings/__init__.py:588
+#: paperless/settings/__init__.py:587
msgid "Italian"
msgstr ""
-#: paperless/settings/__init__.py:589
+#: paperless/settings/__init__.py:588
msgid "Japanese"
msgstr ""
-#: paperless/settings/__init__.py:590
+#: paperless/settings/__init__.py:589
msgid "Korean"
msgstr ""
-#: paperless/settings/__init__.py:591
+#: paperless/settings/__init__.py:590
msgid "Luxembourgish"
msgstr ""
-#: paperless/settings/__init__.py:592
+#: paperless/settings/__init__.py:591
msgid "Norwegian"
msgstr ""
-#: paperless/settings/__init__.py:593
+#: paperless/settings/__init__.py:592
msgid "Dutch"
msgstr ""
-#: paperless/settings/__init__.py:594
+#: paperless/settings/__init__.py:593
msgid "Polish"
msgstr ""
-#: paperless/settings/__init__.py:595
+#: paperless/settings/__init__.py:594
msgid "Portuguese (Brazil)"
msgstr ""
-#: paperless/settings/__init__.py:596
+#: paperless/settings/__init__.py:595
msgid "Portuguese"
msgstr ""
-#: paperless/settings/__init__.py:597
+#: paperless/settings/__init__.py:596
msgid "Romanian"
msgstr ""
-#: paperless/settings/__init__.py:598
+#: paperless/settings/__init__.py:597
msgid "Russian"
msgstr ""
-#: paperless/settings/__init__.py:599
+#: paperless/settings/__init__.py:598
msgid "Slovak"
msgstr ""
-#: paperless/settings/__init__.py:600
+#: paperless/settings/__init__.py:599
msgid "Slovenian"
msgstr ""
-#: paperless/settings/__init__.py:601
+#: paperless/settings/__init__.py:600
msgid "Serbian"
msgstr ""
-#: paperless/settings/__init__.py:602
+#: paperless/settings/__init__.py:601
msgid "Swedish"
msgstr ""
-#: paperless/settings/__init__.py:603
+#: paperless/settings/__init__.py:602
msgid "Turkish"
msgstr ""
-#: paperless/settings/__init__.py:604
+#: paperless/settings/__init__.py:603
msgid "Ukrainian"
msgstr ""
-#: paperless/settings/__init__.py:605
+#: paperless/settings/__init__.py:604
msgid "Vietnamese"
msgstr ""
-#: paperless/settings/__init__.py:606
+#: paperless/settings/__init__.py:605
msgid "Chinese Simplified"
msgstr ""
-#: paperless/settings/__init__.py:607
+#: paperless/settings/__init__.py:606
msgid "Chinese Traditional"
msgstr ""
diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py
index eaef4708ef..1c035844ac 100644
--- a/src/paperless/settings/__init__.py
+++ b/src/paperless/settings/__init__.py
@@ -157,7 +157,6 @@ INSTALLED_APPS = [
"allauth.headless",
"drf_spectacular",
"drf_spectacular_sidecar",
- "treenode",
*env_apps,
]
diff --git a/src/paperless/views.py b/src/paperless/views.py
index 62b6242944..cfb1d7ebbb 100644
--- a/src/paperless/views.py
+++ b/src/paperless/views.py
@@ -32,13 +32,13 @@ from rest_framework.fields import BooleanField
from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView
from rest_framework.pagination import PageNumberPagination
-from rest_framework.permissions import DjangoModelPermissions
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.viewsets import ModelViewSet
from documents.models import PaperlessTask
+from documents.permissions import PaperlessModelPermissions
from documents.permissions import PaperlessObjectPermissions
from documents.tasks import llmindex_index
from paperless.filters import GroupFilterSet
@@ -414,7 +414,7 @@ class ApplicationConfigurationViewSet(ModelViewSet[ApplicationConfiguration]):
queryset = ApplicationConfiguration.objects
serializer_class = ApplicationConfigurationSerializer
- permission_classes = (IsAuthenticated, DjangoModelPermissions)
+ permission_classes = (IsAuthenticated, PaperlessModelPermissions)
@extend_schema(exclude=True)
def create(self, request, *args, **kwargs):
diff --git a/src/paperless_ai/chat.py b/src/paperless_ai/chat.py
index df6ea7b638..088a5b285f 100644
--- a/src/paperless_ai/chat.py
+++ b/src/paperless_ai/chat.py
@@ -22,8 +22,8 @@ logger = logging.getLogger("paperless_ai.chat")
CHAT_METADATA_DELIMITER = "\n\n__PAPERLESS_CHAT_METADATA__"
CHAT_ERROR_MESSAGE = "Sorry, something went wrong while generating a response."
CHAT_NO_CONTENT_MESSAGE = "Sorry, I couldn't find any content to answer your question."
-MAX_CHAT_REFERENCES = 3
CHAT_RETRIEVER_TOP_K = 5
+MAX_CHAT_REFERENCES = CHAT_RETRIEVER_TOP_K
def _build_chat_prompt(output_language: str | None) -> str:
diff --git a/src/paperless_ai/tests/test_vector_store.py b/src/paperless_ai/tests/test_vector_store.py
index 1c4dbf2f94..9981b89a74 100644
--- a/src/paperless_ai/tests/test_vector_store.py
+++ b/src/paperless_ai/tests/test_vector_store.py
@@ -20,8 +20,10 @@ from paperless_ai.tables import DocumentChunksTable
from paperless_ai.tables import DocumentMetaTable
from paperless_ai.vector_store import _MAX_IN_VALUES
from paperless_ai.vector_store import DB_FILENAME
+from paperless_ai.vector_store import DB_PAGE_SIZE
from paperless_ai.vector_store import DEFAULT_TABLE_NAME
from paperless_ai.vector_store import SCHEMA_VERSION
+from paperless_ai.vector_store import VEC0_CHUNK_SIZE
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
from paperless_ai.vector_store import _build_where
from paperless_ai.vector_store import _pack
@@ -1128,3 +1130,127 @@ class TestV1ToV2Migration:
assert "DocumentChunksTable.create" not in source
assert "DocumentMetaTable.create" not in source
assert "_create_vec_table(" not in source
+
+
+LEGACY_PAGE_SIZE = 4096
+
+
+def vec_table_sql(db_path: Path) -> str:
+ conn = sqlite3.connect(db_path)
+ try:
+ return conn.execute(
+ "SELECT sql FROM sqlite_master WHERE name = ?",
+ (DEFAULT_TABLE_NAME,),
+ ).fetchone()[0]
+ finally:
+ conn.close()
+
+
+def page_size_of(db_path: Path) -> int:
+ conn = sqlite3.connect(db_path)
+ try:
+ return conn.execute("PRAGMA page_size").fetchone()[0]
+ finally:
+ conn.close()
+
+
+def create_legacy_vec_table(conn: sqlite3.Connection, dim: int) -> None:
+ """The table definition from before chunk_size was set explicitly."""
+ conn.execute(
+ f"CREATE VIRTUAL TABLE {DEFAULT_TABLE_NAME} USING vec0("
+ "id TEXT PRIMARY KEY, document_id INTEGER, +node_content TEXT, "
+ f"embedding float[{dim}] distance_metric=cosine)",
+ )
+
+
+@pytest.fixture
+def legacy_page_size_db(tmp_path: Path) -> Path:
+ """An existing index file created before the page size was set."""
+ db_path = tmp_path / DB_FILENAME
+ conn = sqlite3.connect(db_path)
+ conn.execute(f"PRAGMA page_size={LEGACY_PAGE_SIZE}")
+ conn.execute("CREATE TABLE _seed(x)")
+ conn.execute("DROP TABLE _seed")
+ conn.close()
+ assert page_size_of(db_path) == LEGACY_PAGE_SIZE
+ return db_path
+
+
+class TestVecChunkSize:
+ def test_new_table_uses_configured_chunk_size(
+ self,
+ store: PaperlessSqliteVecVectorStore,
+ tmp_path: Path,
+ ) -> None:
+ """
+ GIVEN an empty store
+ WHEN the first document is indexed and the vec0 table is created
+ THEN the table definition carries the configured chunk size
+ """
+ store.upsert_document(1, [make_node("1-a", 1)])
+
+ assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
+
+ def test_compact_moves_legacy_table_to_configured_chunk_size(
+ self,
+ store: PaperlessSqliteVecVectorStore,
+ tmp_path: Path,
+ mocker: MockerFixture,
+ ) -> None:
+ """
+ GIVEN an index whose vec0 table was created with the default chunk size
+ WHEN the index is compacted
+ THEN the rebuilt table uses the configured chunk size
+ AND no indexed chunks are lost
+ """
+ legacy = mocker.patch.object(
+ PaperlessSqliteVecVectorStore,
+ "_create_vec_table",
+ staticmethod(create_legacy_vec_table),
+ )
+ store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
+ store.upsert_document(2, [make_node("2-a", 2)])
+ mocker.stop(legacy)
+ assert "chunk_size" not in vec_table_sql(tmp_path / DB_FILENAME)
+
+ store.compact(force=True)
+
+ assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
+ assert DocumentChunksTable.count(store.client) == 3
+
+
+class TestIndexPageSize:
+ def test_new_index_file_uses_configured_page_size(
+ self,
+ store: PaperlessSqliteVecVectorStore,
+ tmp_path: Path,
+ ) -> None:
+ """
+ GIVEN no existing index file
+ WHEN a store is opened and a document is indexed
+ THEN the database file uses the configured page size
+ """
+ store.upsert_document(1, [make_node("1-a", 1)])
+
+ assert page_size_of(tmp_path / DB_FILENAME) == DB_PAGE_SIZE
+
+ def test_compact_moves_legacy_file_to_configured_page_size(
+ self,
+ legacy_page_size_db: Path,
+ ) -> None:
+ """
+ GIVEN an existing index file created with a different page size
+ WHEN the index is compacted
+ THEN the rebuilt file uses the configured page size
+ AND no indexed chunks are lost
+ """
+ with PaperlessSqliteVecVectorStore(
+ uri=str(legacy_page_size_db.parent),
+ ) as store:
+ store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
+ assert page_size_of(legacy_page_size_db) == LEGACY_PAGE_SIZE
+
+ store.compact(force=True)
+
+ assert page_size_of(legacy_page_size_db) == DB_PAGE_SIZE
+ assert DocumentChunksTable.count(store.client) == 2
diff --git a/src/paperless_ai/vector_store.py b/src/paperless_ai/vector_store.py
index c7675261d6..7b48934247 100644
--- a/src/paperless_ai/vector_store.py
+++ b/src/paperless_ai/vector_store.py
@@ -47,6 +47,20 @@ SCHEMA_VERSION = 2
# a rebuild copies the live rows into a fresh table.
COMPACT_BLOAT_RATIO = 2.0
+# Rows per vec0 vector chunk (vec0's default is 1024, which makes each chunk a
+# multi-megabyte blob). On spinning disks, inserts and re-indexing were dozens
+# of times faster with small chunks, at no cost in query speed or file size.
+# Only applies to tables created from here on; an existing index picks it up
+# the next time it is rebuilt (compact or rebuild).
+VEC0_CHUNK_SIZE = 32
+
+# SQLite page size for the index file, in bytes. With the small vec0 chunks above,
+# 64 KiB pages made cold scans about 35% faster and warm ones about 45% faster,
+# at the cost of a larger write-ahead log (up to about 70 MB). Only takes effect
+# on a newly created file, so like the chunk size it reaches an existing index
+# when it is rebuilt.
+DB_PAGE_SIZE = 65536
+
# Number of rows fetched/copied per batch whenever this module streams rows
# instead of materializing them all at once, keeping memory bounded regardless
# of index size -- used by compact()'s rebuild, m0001_v1_to_v2's migration
@@ -222,6 +236,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
conn.enable_load_extension(True) # noqa: FBT003
sqlite_vec.load(conn)
conn.enable_load_extension(False) # noqa: FBT003
+ conn.execute(f"PRAGMA page_size={DB_PAGE_SIZE}")
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
IndexMetaTable.create(conn)
@@ -329,7 +344,8 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
+ " +node_content TEXT,"
+ " embedding float["
+ str(int(dim))
- + "] distance_metric=cosine"
+ + "] distance_metric=cosine, chunk_size="
+ + str(VEC0_CHUNK_SIZE)
+ ")",
)
diff --git a/uv.lock b/uv.lock
index da56c52cd8..99fc677d7e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1210,15 +1210,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/c3/305a9f7611c0dc96d2a638bc3a67bdcde5a7faa5f4669188db018b8af6fc/django_stubs_ext-6.1.1-py3-none-any.whl", hash = "sha256:64aebb7ce2c327a4a2b75207d67034e879fd7edf027c93487be69da363dc79fa", size = 10853, upload-time = "2026-09-14T08:45:51.289Z" },
]
-[[package]]
-name = "django-treenode"
-version = "0.25.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cb/2b/09f04b44b3583a911ae4f5d0e3360a23d529704c9e9115fce2520b7d4420/django_treenode-0.25.0.tar.gz", hash = "sha256:d14d0eb3571c227ce11147210d62a8f87b568bbb30fa76a5be9b9e6d514157c3", size = 29951, upload-time = "2026-08-26T00:20:13.489Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e1/ac/2140e8c20e2a47457ec23302273527d3430bf5c09edb8940d74c0725c138/django_treenode-0.25.0-py3-none-any.whl", hash = "sha256:f47caab5440420870b6287602b3ee401ca354416d44b24ab97579be5ea658927", size = 22343, upload-time = "2026-08-26T00:20:12.078Z" },
-]
-
[[package]]
name = "djangorestframework"
version = "3.18.3"
@@ -3107,7 +3098,6 @@ dependencies = [
{ name = "django-multiselectfield" },
{ name = "django-rich" },
{ name = "django-soft-delete" },
- { name = "django-treenode" },
{ name = "djangorestframework" },
{ name = "drf-spectacular" },
{ name = "drf-spectacular-sidecar" },
@@ -3258,7 +3248,6 @@ requires-dist = [
{ name = "django-multiselectfield", specifier = "~=1.0.1" },
{ name = "django-rich", specifier = "~=2.2.0" },
{ name = "django-soft-delete", specifier = "~=1.0.18" },
- { name = "django-treenode", specifier = ">=0.24" },
{ name = "djangorestframework", specifier = "~=3.16" },
{ name = "drf-spectacular", specifier = "~=0.30" },
{ name = "drf-spectacular-sidecar", specifier = ">=2026.10.1,<2026.11" },