diff --git a/.github/actions/apt-install/install.sh b/.github/actions/apt-install/install.sh index 11447785f3..4071649ea2 100644 --- a/.github/actions/apt-install/install.sh +++ b/.github/actions/apt-install/install.sh @@ -6,9 +6,9 @@ if [ "${1:-}" = download ]; then download_archive_dir=$2 shift 2 apt-get update -q - apt-get install -q --yes --no-install-recommends --download-only \ + # Keep apt itself under timeout supervision, including the kill grace period. + exec apt-get install -q --yes --no-install-recommends --download-only \ -o "Dir::Cache::archives=$download_archive_dir" "$@" - exit 0 fi SUDO=() @@ -34,7 +34,8 @@ use_ubuntu_mirrors() { for attempt in $(seq 1 "$ATTEMPTS"); do echo "::group::apt download attempt ${attempt}/${ATTEMPTS}" - if timeout --kill-after=10 "$ATTEMPT_TIMEOUT" "${SUDO[@]}" bash "$0" download "$CACHE_DIR" "${requested_packages[@]}"; then + # Run timeout as root so it can signal the root-owned apt processes. + if "${SUDO[@]}" timeout --kill-after=10 "$ATTEMPT_TIMEOUT" bash "$0" download "$CACHE_DIR" "${requested_packages[@]}"; then echo "::endgroup::" # Apt handles aliases (e.g. libmagic1 -> libmagic1t64) and dependencies. "${SUDO[@]}" apt-get install -q --yes --no-install-recommends --no-download \ diff --git a/SECURITY.md b/SECURITY.md index b08aaabae2..3f07f82d3c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,6 +56,9 @@ We may close reports that are: The following are not generally considered vulnerabilities unless accompanied by a concrete, reproducible impact in Paperless-ngx: +- password length inferred from masked mail-account passwords by users who already have permission to view the account, without disclosure of the password or token itself +- disclosure limited to document IDs, existence, page counts, checksums, or modification/deletion timestamps, without access to document contents or an unintended privileged action +- authenticated access to non-secret application settings, such as service endpoint URLs, model names, or configured environment variable names, without disclosure of credentials or secret values - large uploads or resource usage that do not bypass documented limits or privileges - IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior. - claims based solely on the presence of a library, framework feature or code pattern without a working exploit diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/init-compile-bytecode/run b/docker/rootfs/etc/s6-overlay/s6-rc.d/init-compile-bytecode/run index 1d7ba32ec2..d9ed0c2a58 100755 --- a/docker/rootfs/etc/s6-overlay/s6-rc.d/init-compile-bytecode/run +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/init-compile-bytecode/run @@ -39,7 +39,6 @@ declare -a scope=( "${site_packages}/allauth" "${site_packages}/drf_spectacular" "${site_packages}/drf_spectacular_sidecar" - "${site_packages}/treenode" "${site_packages}/compression_middleware" ) diff --git a/pyproject.toml b/pyproject.toml index 17d9e92a31..5eb0f59ed2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,6 @@ dependencies = [ "django-multiselectfield~=1.0.1", "django-rich~=2.2.0", "django-soft-delete~=1.0.18", - "django-treenode>=0.24", "djangorestframework~=3.16", "drf-spectacular~=0.30", "drf-spectacular-sidecar>=2026.10.1,<2026.11", diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index bc7c63a91e..134be4c5de 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -4030,14 +4030,21 @@ Sources src/app/components/chat/chat/chat.component.html - 46,47 + 47,48 + + + + + more + + src/app/components/chat/chat/chat.component.html + 49,50 Message src/app/components/chat/chat/chat.component.html - 61,62 + 66,67 src/app/components/common/email-document-dialog/email-document-dialog.component.html @@ -4048,22 +4055,22 @@ Stop src/app/components/chat/chat/chat.component.html - 74 + 79 src/app/components/chat/chat/chat.component.html - 74 + 79 Send src/app/components/chat/chat/chat.component.html - 78 + 83 src/app/components/chat/chat/chat.component.html - 78 + 83 src/app/components/document-detail/document-detail.component.html @@ -4090,14 +4097,14 @@ Ask a question about this document... src/app/components/chat/chat/chat.component.ts - 61 + 83 Ask a question about a document... src/app/components/chat/chat/chat.component.ts - 62 + 84 @@ -6984,7 +6991,7 @@ src/app/components/common/input/select/select.component.ts - 179 + 185 @@ -7091,7 +7098,7 @@ Private src/app/components/common/input/select/select.component.ts - 72 + 73 src/app/components/common/tag/tag.component.html @@ -7114,7 +7121,7 @@ No items found src/app/components/common/input/select/select.component.ts - 113 + 114 diff --git a/src-ui/src/app/components/chat/chat/chat.component.html b/src-ui/src/app/components/chat/chat/chat.component.html index f0e08782e0..f05a3ed949 100644 --- a/src-ui/src/app/components/chat/chat/chat.component.html +++ b/src-ui/src/app/components/chat/chat/chat.component.html @@ -42,11 +42,16 @@ {{ message.content }} } @if (message.role === 'assistant' && !message.isStreaming && message.references?.length) { -
-
Sources
- @for (reference of message.references; track reference.id) { - - {{ reference.title }} + diff --git a/src-ui/src/app/components/chat/chat/chat.component.scss b/src-ui/src/app/components/chat/chat/chat.component.scss index 64c3737340..3431e7e9b6 100644 --- a/src-ui/src/app/components/chat/chat/chat.component.scss +++ b/src-ui/src/app/components/chat/chat/chat.component.scss @@ -12,6 +12,10 @@ display: none; } +.message .flex-grow-1 { + min-width: 0; +} + .dropdown-item { white-space: initial; } diff --git a/src-ui/src/app/components/chat/chat/chat.component.ts b/src-ui/src/app/components/chat/chat/chat.component.ts index f4fbaafb0f..cdd3f8473d 100644 --- a/src-ui/src/app/components/chat/chat/chat.component.ts +++ b/src-ui/src/app/components/chat/chat/chat.component.ts @@ -14,6 +14,7 @@ import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon import { MarkdownPipe } from 'src/app/pipes/markdown.pipe' import { ChatMessage, + ChatReference, ChatService, parseChatResponse, } from 'src/app/services/chat.service' @@ -40,8 +41,29 @@ export class ChatComponent implements OnInit { readonly input = signal('') readonly documentId = signal(undefined) readonly slowResponse = signal(false) + readonly expandedReferences = signal>(new Set()) public popperOptions = pngxPopperOptions + static readonly COLLAPSED_REFERENCE_COUNT = 2 + + public visibleReferences(message: ChatMessage): ChatReference[] { + if (this.expandedReferences().has(message)) { + return message.references + } + return message.references.slice(0, ChatComponent.COLLAPSED_REFERENCE_COUNT) + } + + public hiddenReferenceCount(message: ChatMessage): number { + return Math.max( + 0, + message.references.length - ChatComponent.COLLAPSED_REFERENCE_COUNT + ) + } + + public expandReferences(message: ChatMessage): void { + this.expandedReferences.update((expanded) => new Set(expanded).add(message)) + } + private chatService: ChatService = inject(ChatService) private router: Router = inject(Router) diff --git a/src-ui/src/app/components/common/input/document-link/document-link.component.html b/src-ui/src/app/components/common/input/document-link/document-link.component.html index c050b91303..7668d2fdf2 100644 --- a/src-ui/src/app/components/common/input/document-link/document-link.component.html +++ b/src-ui/src/app/components/common/input/document-link/document-link.component.html @@ -36,7 +36,7 @@ [appendTo]="appendTo" [loading]="loading()" [typeahead]="documentsInput$" - (mousedown)="$event.stopImmediatePropagation()" + (mousedown)="appendTo && $event.stopImmediatePropagation()" (change)="onChange(selectedDocumentIDs)">
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" },