mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-12 19:17:13 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551ebad6f1 | ||
|
|
4171ecf166 | ||
|
|
8289dd0bae | ||
|
|
b195423f1d | ||
|
|
9f2f809131 | ||
|
|
96535991a3 | ||
|
|
c2deb69ac5 | ||
|
|
070f614929 | ||
|
|
6f496f741b | ||
|
|
1fb463b55c | ||
|
|
9a136ecb62 |
No files matched your search
@@ -66,7 +66,7 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "gotenberg"
|
- "gotenberg"
|
||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=^file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:3.3.1.0
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -38,16 +38,6 @@ jobs:
|
|||||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Label by size
|
|
||||||
uses: Gascon1/pr-size-labeler@deff8ed00a76639a7c0f197525bafa3350ba4c36 # v1.3.0
|
|
||||||
with:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
xs_label: 'small-change'
|
|
||||||
xs_diff: '9'
|
|
||||||
s_label: 'non-trivial'
|
|
||||||
s_diff: '99999'
|
|
||||||
fail_if_xl: 'false'
|
|
||||||
excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$
|
|
||||||
- name: Label by PR title
|
- name: Label by PR title
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
+4
-13
@@ -43,26 +43,17 @@ Once you have submitted a **P**ull **R**equest it will be reviewed, approved, an
|
|||||||
|
|
||||||
Important: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Instead of opening a PR which does not meet this requirement, please open a feature request instead, to gather feedback from both users and the project maintainers.
|
Important: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Instead of opening a PR which does not meet this requirement, please open a feature request instead, to gather feedback from both users and the project maintainers.
|
||||||
|
|
||||||
## Non-Trivial Requests
|
## PR Review Process
|
||||||
|
|
||||||
PRs deemed `non-trivial` will go through a stricter review process before being merged into `dev`. This is to ensure code quality and complete functionality (free of side effects).
|
Our community review process for PRs is the following:
|
||||||
|
|
||||||
Examples of `non-trivial` PRs might include:
|
|
||||||
|
|
||||||
- Additional features
|
|
||||||
- Large changes to many distinct files
|
|
||||||
- Breaking or deprecation of existing features
|
|
||||||
|
|
||||||
Our community review process for `non-trivial` PRs is the following:
|
|
||||||
|
|
||||||
1. Must pass usual automated code tests and formatting checks.
|
1. Must pass usual automated code tests and formatting checks.
|
||||||
2. The PR will be assigned and pinged to the appropriately experienced team (i.e. @paperless-ngx/backend for backend changes).
|
2. The PR will be assigned and pinged to the appropriately experienced team (i.e. @paperless-ngx/backend for backend changes).
|
||||||
3. Development team will check and test code manually (possibly over several days).
|
3. Development team will check and test code manually (possibly over several days).
|
||||||
- You may be asked to make changes or rebase.
|
- You may be asked to make changes or rebase.
|
||||||
- The team may ask for additional testing done by @paperless-ngx/test
|
4. One or more members of the team will approve and finally merge the request into `dev` 🎉.
|
||||||
4. **At least two** members of the team will approve and finally merge the request into `dev` 🎉.
|
|
||||||
|
|
||||||
This process might be slow as community members have different schedules and time to dedicate to the Paperless project. However it ensures community code reviews are as brilliantly thorough as they once were with @jonaswinkler.
|
This process might be slow as community members have different schedules and time to dedicate to the Paperless project. However it ensures community code reviews are thorough and of high quality.
|
||||||
|
|
||||||
# Use of AI Tools
|
# Use of AI Tools
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "gotenberg"
|
- "gotenberg"
|
||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=^file:///tmp/.*"
|
||||||
- "--log-level=warn"
|
- "--log-level=warn"
|
||||||
- "--log-format=text"
|
- "--log-format=text"
|
||||||
tika:
|
tika:
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "gotenberg"
|
- "gotenberg"
|
||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=^file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:3.3.1.0
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "gotenberg"
|
- "gotenberg"
|
||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=^file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:3.3.1.0
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "gotenberg"
|
- "gotenberg"
|
||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=^file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:3.3.1.0
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ change in the `docker-compose.yml` file:
|
|||||||
command:
|
command:
|
||||||
- 'gotenberg'
|
- 'gotenberg'
|
||||||
- '--chromium-disable-javascript=true'
|
- '--chromium-disable-javascript=true'
|
||||||
- '--chromium-allow-list=file:///tmp/.*'
|
- '--chromium-allow-list=^file:///tmp/.*'
|
||||||
- '--api-timeout=60s'
|
- '--api-timeout=60s'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
|
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
|
||||||
docker run -d -p 6379:6379 redis:latest
|
docker run -d -p 6379:6379 redis:latest
|
||||||
docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*"
|
docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="^file:///tmp/.*"
|
||||||
docker run -p 9998:9998 -d docker.io/apache/tika:latest
|
docker run -p 9998:9998 -d docker.io/apache/tika:latest
|
||||||
@@ -50,6 +50,7 @@ from documents.models import ShareLink
|
|||||||
from documents.models import ShareLinkBundle
|
from documents.models import ShareLinkBundle
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.permissions import annotate_authorizing_fields
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
@@ -1075,6 +1076,9 @@ class PermittedObjectsFilter(BaseFilterBackend):
|
|||||||
|
|
||||||
include_granted: bool = True
|
include_granted: bool = True
|
||||||
perm_codename: str | None = None
|
perm_codename: str | None = None
|
||||||
|
# A self-referencing foreign key whose target authorizes a row, so a row is
|
||||||
|
# judged by its parent's owner and grants (``Document.root_document``).
|
||||||
|
parent_field: str | None = None
|
||||||
|
|
||||||
def filter_queryset(self, request, queryset, view):
|
def filter_queryset(self, request, queryset, view):
|
||||||
# Before the superuser and owner-only paths, neither of which consults
|
# Before the superuser and owner-only paths, neither of which consults
|
||||||
@@ -1086,11 +1090,23 @@ class PermittedObjectsFilter(BaseFilterBackend):
|
|||||||
if request.user.is_superuser:
|
if request.user.is_superuser:
|
||||||
return queryset
|
return queryset
|
||||||
if not self.include_granted:
|
if not self.include_granted:
|
||||||
return queryset.filter(Q(owner=request.user) | Q(owner__isnull=True))
|
owner_field = "owner"
|
||||||
|
if self.parent_field is not None:
|
||||||
|
queryset = annotate_authorizing_fields(queryset, self.parent_field)
|
||||||
|
owner_field = "authorizing_owner"
|
||||||
|
return queryset.filter(
|
||||||
|
Q(**{owner_field: request.user.pk})
|
||||||
|
| Q(**{f"{owner_field}__isnull": True}),
|
||||||
|
)
|
||||||
model = queryset.model
|
model = queryset.model
|
||||||
perm = self.perm_codename or f"view_{model._meta.model_name}"
|
perm = self.perm_codename or f"view_{model._meta.model_name}"
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
id__in=permitted_object_ids(request.user, model, perm),
|
id__in=permitted_object_ids(
|
||||||
|
request.user,
|
||||||
|
model,
|
||||||
|
perm,
|
||||||
|
parent_field=self.parent_field,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,18 +67,22 @@ class Command(PaperlessCommand):
|
|||||||
if options.get("recreate"):
|
if options.get("recreate"):
|
||||||
wipe_index(settings.INDEX_DIR)
|
wipe_index(settings.INDEX_DIR)
|
||||||
|
|
||||||
documents = Document.objects.select_related(
|
documents = (
|
||||||
"correspondent",
|
Document.objects.filter(root_document__isnull=True)
|
||||||
"document_type",
|
.select_related(
|
||||||
"storage_path",
|
"correspondent",
|
||||||
"owner",
|
"document_type",
|
||||||
).prefetch_related(
|
"storage_path",
|
||||||
"tags",
|
"owner",
|
||||||
"notes__user",
|
)
|
||||||
"custom_fields__field",
|
.prefetch_related(
|
||||||
"versions",
|
"tags",
|
||||||
"barcodes",
|
"notes__user",
|
||||||
"versions__barcodes",
|
"custom_fields__field",
|
||||||
|
"versions",
|
||||||
|
"barcodes",
|
||||||
|
"versions__barcodes",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
total = documents.count()
|
total = documents.count()
|
||||||
rebuild_kwargs = {}
|
rebuild_kwargs = {}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from rest_framework.permissions import BasePermission
|
|||||||
from rest_framework.permissions import DjangoObjectPermissions
|
from rest_framework.permissions import DjangoObjectPermissions
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from documents.versioning import get_root_document
|
||||||
|
|
||||||
|
|
||||||
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
||||||
@@ -388,14 +389,7 @@ def permitted_object_ids(
|
|||||||
owner_field, key_field = "owner", "pk"
|
owner_field, key_field = "owner", "pk"
|
||||||
if parent_field is not None:
|
if parent_field is not None:
|
||||||
owner_field, key_field = "authorizing_owner", "authorizing_id"
|
owner_field, key_field = "authorizing_owner", "authorizing_id"
|
||||||
base_qs = base_qs.annotate(
|
base_qs = annotate_authorizing_fields(base_qs, parent_field)
|
||||||
authorizing_id=Coalesce(f"{parent_field}_id", "id"),
|
|
||||||
authorizing_owner=Case(
|
|
||||||
When(**{f"{parent_field}_id__isnull": True}, then=F("owner_id")),
|
|
||||||
default=F(f"{parent_field}__owner_id"),
|
|
||||||
output_field=IntegerField(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
unowned = Q(**{f"{owner_field}__isnull": True})
|
unowned = Q(**{f"{owner_field}__isnull": True})
|
||||||
|
|
||||||
if user is None or not getattr(user, "is_authenticated", False):
|
if user is None or not getattr(user, "is_authenticated", False):
|
||||||
@@ -448,6 +442,26 @@ def permitted_object_ids(
|
|||||||
ModelT = TypeVar("ModelT", bound=Model)
|
ModelT = TypeVar("ModelT", bound=Model)
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_authorizing_fields(
|
||||||
|
queryset: QuerySet[ModelT],
|
||||||
|
parent_field: str,
|
||||||
|
) -> QuerySet[ModelT]:
|
||||||
|
"""
|
||||||
|
Annotate each row with ``authorizing_id`` and ``authorizing_owner``: the id
|
||||||
|
and owner of the row that authorizes it. A row with a parent (the
|
||||||
|
self-referencing foreign key ``parent_field``) is authorized by its parent,
|
||||||
|
any other row by itself.
|
||||||
|
"""
|
||||||
|
return queryset.annotate(
|
||||||
|
authorizing_id=Coalesce(f"{parent_field}_id", "id"),
|
||||||
|
authorizing_owner=Case(
|
||||||
|
When(**{f"{parent_field}_id__isnull": True}, then=F("owner_id")),
|
||||||
|
default=F(f"{parent_field}__owner_id"),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def user_is_unrestricted(user: User | None) -> bool:
|
def user_is_unrestricted(user: User | None) -> bool:
|
||||||
"""
|
"""
|
||||||
True when ``user`` means "no restriction at all" (an absent user, or an
|
True when ``user`` means "no restriction at all" (an absent user, or an
|
||||||
@@ -679,7 +693,14 @@ def has_perms_owner_aware(user, perms, obj):
|
|||||||
single-object check still has many production callers. Several callers
|
single-object check still has many production callers. Several callers
|
||||||
remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/``
|
remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/``
|
||||||
-- grep for this function name before removing it.
|
-- grep for this function name before removing it.
|
||||||
|
|
||||||
|
A document version is authorized by its root document, like in
|
||||||
|
``permitted_document_ids``, so a version's own owner and grants never
|
||||||
|
matter. Fetch the root with ``select_related("root_document__owner")`` to
|
||||||
|
avoid extra queries.
|
||||||
"""
|
"""
|
||||||
|
if isinstance(obj, Document):
|
||||||
|
obj = get_root_document(obj)
|
||||||
checker = ObjectPermissionChecker(user)
|
checker = ObjectPermissionChecker(user)
|
||||||
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
||||||
|
|
||||||
|
|||||||
@@ -284,9 +284,14 @@ class WriteBatch:
|
|||||||
and adding the new version. This ensures stale document data (e.g., after
|
and adding the new version. This ensures stale document data (e.g., after
|
||||||
permission changes) doesn't persist in the index.
|
permission changes) doesn't persist in the index.
|
||||||
|
|
||||||
|
Only root documents are indexed, with their effective content, so a
|
||||||
|
version is indexed as its root document.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
document: Django Document instance to index
|
document: Django Document instance to index
|
||||||
"""
|
"""
|
||||||
|
if document.root_document_id is not None:
|
||||||
|
document = document.root_document
|
||||||
self.remove(document.pk)
|
self.remove(document.pk)
|
||||||
doc = self._backend._build_tantivy_doc(document)
|
doc = self._backend._build_tantivy_doc(document)
|
||||||
self._writer.add_document(doc)
|
self._writer.add_document(doc)
|
||||||
@@ -311,20 +316,22 @@ class WriteBatch:
|
|||||||
An id with no matching document (e.g. deleted between the caller
|
An id with no matching document (e.g. deleted between the caller
|
||||||
collecting ids and the batch running) is silently skipped, matching
|
collecting ids and the batch running) is silently skipped, matching
|
||||||
``add_or_update()``'s existing single-document deferred-task behavior
|
``add_or_update()``'s existing single-document deferred-task behavior
|
||||||
rather than erroring or leaving a stale index entry.
|
rather than erroring or leaving a stale index entry. The id of a
|
||||||
|
version stands for its root document.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ids: Primary keys of Document instances to index
|
ids: Primary keys of Document instances to index
|
||||||
"""
|
"""
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
|
from documents.versioning import root_document_ids
|
||||||
|
|
||||||
ids = list(ids)
|
ids = list(ids)
|
||||||
if not ids:
|
if not ids:
|
||||||
return
|
return
|
||||||
|
|
||||||
queryset = annotate_effective_content(
|
queryset = annotate_effective_content(
|
||||||
Document.objects.filter(pk__in=ids)
|
Document.objects.filter(pk__in=root_document_ids(ids))
|
||||||
.select_related("correspondent", "document_type", "storage_path", "owner")
|
.select_related("correspondent", "document_type", "storage_path", "owner")
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
"tags",
|
"tags",
|
||||||
@@ -1036,16 +1043,21 @@ class TantivyBackend:
|
|||||||
excluded from results.
|
excluded from results.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
doc_id: Primary key of the reference document
|
doc_id: Primary key of the reference document, or of one of its
|
||||||
|
versions
|
||||||
user: User for permission filtering (None for no filtering)
|
user: User for permission filtering (None for no filtering)
|
||||||
limit: Maximum number of IDs to return (None = all matching docs)
|
limit: Maximum number of IDs to return (None = all matching docs)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of similar document IDs (excluding the original)
|
List of similar document IDs (excluding the original)
|
||||||
"""
|
"""
|
||||||
|
from documents.versioning import root_document_ids
|
||||||
|
|
||||||
self._ensure_open()
|
self._ensure_open()
|
||||||
searcher = self._index.searcher()
|
searcher = self._index.searcher()
|
||||||
|
|
||||||
|
# Only root documents are indexed, so a version stands for its root
|
||||||
|
doc_id = next(iter(root_document_ids([doc_id])), doc_id)
|
||||||
id_query = tantivy.Query.term_query(self._schema, "id", doc_id)
|
id_query = tantivy.Query.term_query(self._schema, "id", doc_id)
|
||||||
results = searcher.search(id_query, limit=1)
|
results = searcher.search(id_query, limit=1)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ logger = logging.getLogger("paperless.search")
|
|||||||
# columns dropped. tantivy compares schemas by ordered field list, so an
|
# columns dropped. tantivy compares schemas by ordered field list, so an
|
||||||
# index built by v1 rejects every write against the v2 schema.
|
# index built by v1 rejects every write against the v2 schema.
|
||||||
# v3 - barcodes JSON field for stored barcode contents
|
# v3 - barcodes JSON field for stored barcode contents
|
||||||
SCHEMA_VERSION: Final[int] = 3
|
# v4 - root documents only. Earlier indexes may hold document versions under
|
||||||
|
# their own id and metadata, so they are rebuilt without them.
|
||||||
|
SCHEMA_VERSION: Final[int] = 4
|
||||||
|
|
||||||
# Present in the index directory from the moment a full rebuild starts until it
|
# Present in the index directory from the moment a full rebuild starts until it
|
||||||
# finishes. If a rebuild is interrupted it is left behind, so the half-built
|
# finishes. If a rebuild is interrupted it is left behind, so the half-built
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ from documents.templating.utils import convert_format_str_to_template_format
|
|||||||
from documents.templating.workflows import validate_workflow_template
|
from documents.templating.workflows import validate_workflow_template
|
||||||
from documents.validators import uri_validator
|
from documents.validators import uri_validator
|
||||||
from documents.validators import url_validator
|
from documents.validators import url_validator
|
||||||
from documents.versioning import get_root_document
|
|
||||||
from documents.versioning import has_prefetched_effective_content
|
from documents.versioning import has_prefetched_effective_content
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
|
|
||||||
@@ -2895,7 +2894,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
|||||||
and has_perms_owner_aware(
|
and has_perms_owner_aware(
|
||||||
self.user,
|
self.user,
|
||||||
"view_document",
|
"view_document",
|
||||||
get_root_document(document),
|
document,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
return document
|
return document
|
||||||
|
|||||||
@@ -490,23 +490,20 @@ def update_document_content_maybe_archive_file(
|
|||||||
shutil.move(thumbnail, document.thumbnail_path)
|
shutil.move(thumbnail, document.thumbnail_path)
|
||||||
|
|
||||||
document.refresh_from_db()
|
document.refresh_from_db()
|
||||||
root_document = (
|
|
||||||
document.root_document if document.root_document_id else document
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Updating index for document {root_document.pk} ({document.archive_checksum})",
|
f"Updating index for document {document_id} ({document.archive_checksum})",
|
||||||
)
|
)
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
|
||||||
get_backend().add_or_update(root_document)
|
get_backend().add_or_update(document)
|
||||||
|
|
||||||
ai_config = AIConfig()
|
ai_config = AIConfig()
|
||||||
if ai_config.llm_index_enabled:
|
if ai_config.llm_index_enabled:
|
||||||
llm_index_add_or_update_document(root_document)
|
llm_index_add_or_update_document(document)
|
||||||
|
|
||||||
clear_document_caches(document.pk)
|
clear_document_caches(document.pk)
|
||||||
if root_document.pk != document.pk:
|
if document.root_document_id is not None:
|
||||||
clear_document_caches(root_document.pk)
|
clear_document_caches(document.root_document_id)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|||||||
@@ -293,6 +293,80 @@ class TestAddOrUpdateIds:
|
|||||||
assert backend.search_ids("updated", user=None) == [doc.pk]
|
assert backend.search_ids("updated", user=None) == [doc.pk]
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionsAreIndexedAsTheirRoot:
|
||||||
|
"""Only root documents are indexed, with their effective content, so
|
||||||
|
every write path that is handed a version indexes its root instead."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _root_with_version() -> tuple[Document, Document]:
|
||||||
|
root = DocumentFactory(title="Statement", content="stale text")
|
||||||
|
version = DocumentFactory(
|
||||||
|
title="Statement",
|
||||||
|
content="latest text",
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
return root, version
|
||||||
|
|
||||||
|
def test_add_or_update_indexes_the_root_of_a_version(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a version
|
||||||
|
WHEN:
|
||||||
|
- The version is passed to add_or_update
|
||||||
|
THEN:
|
||||||
|
- The root is indexed with the version's text, and the version is not
|
||||||
|
"""
|
||||||
|
root, version = self._root_with_version()
|
||||||
|
|
||||||
|
backend.add_or_update(version)
|
||||||
|
|
||||||
|
assert backend.search_ids("latest", user=None) == [root.pk]
|
||||||
|
assert backend.search_ids("stale", user=None) == []
|
||||||
|
|
||||||
|
def test_add_or_update_ids_indexes_each_root_once(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a version
|
||||||
|
WHEN:
|
||||||
|
- Both ids are passed to add_or_update_ids
|
||||||
|
THEN:
|
||||||
|
- The root is indexed once and the version is not indexed
|
||||||
|
"""
|
||||||
|
root, version = self._root_with_version()
|
||||||
|
|
||||||
|
with backend.batch_update() as batch:
|
||||||
|
batch.add_or_update_ids([version.pk, root.pk])
|
||||||
|
|
||||||
|
assert backend.search_ids("Statement", user=None) == [root.pk]
|
||||||
|
assert backend.search_ids("latest", user=None) == [root.pk]
|
||||||
|
|
||||||
|
def test_add_or_update_ids_resolves_a_lone_version(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a version
|
||||||
|
WHEN:
|
||||||
|
- Only the version's id is passed to add_or_update_ids
|
||||||
|
THEN:
|
||||||
|
- The root is indexed
|
||||||
|
"""
|
||||||
|
root, version = self._root_with_version()
|
||||||
|
|
||||||
|
with backend.batch_update() as batch:
|
||||||
|
batch.add_or_update_ids([version.pk])
|
||||||
|
|
||||||
|
assert backend.search_ids("latest", user=None) == [root.pk]
|
||||||
|
|
||||||
|
|
||||||
class TestSearch:
|
class TestSearch:
|
||||||
"""Test search query parsing and matching via search_ids."""
|
"""Test search query parsing and matching via search_ids."""
|
||||||
|
|
||||||
@@ -1062,6 +1136,34 @@ class TestMoreLikeThis:
|
|||||||
assert 150 not in ids
|
assert 150 not in ids
|
||||||
assert 151 in ids
|
assert 151 in ids
|
||||||
|
|
||||||
|
def test_more_like_this_ids_seeded_by_version_uses_root(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""A version is not indexed, so it must be looked up as its root."""
|
||||||
|
root = DocumentFactory.create(
|
||||||
|
title="Important document",
|
||||||
|
content="financial information report",
|
||||||
|
)
|
||||||
|
version = DocumentFactory.create(
|
||||||
|
title="Important document",
|
||||||
|
content="financial information report",
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
other = DocumentFactory.create(
|
||||||
|
title="Another document",
|
||||||
|
content="financial information report",
|
||||||
|
)
|
||||||
|
backend.add_or_update(root)
|
||||||
|
backend.add_or_update(other)
|
||||||
|
|
||||||
|
ids = backend.more_like_this_ids(doc_id=version.pk, user=None)
|
||||||
|
|
||||||
|
assert other.pk in ids
|
||||||
|
assert root.pk not in ids
|
||||||
|
assert version.pk not in ids
|
||||||
|
|
||||||
|
|
||||||
class TestSingleton:
|
class TestSingleton:
|
||||||
"""Test get_backend() and reset_backend() singleton lifecycle."""
|
"""Test get_backend() and reset_backend() singleton lifecycle."""
|
||||||
|
|||||||
@@ -1196,6 +1196,59 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(d3.id, result_ids)
|
self.assertIn(d3.id, result_ids)
|
||||||
self.assertNotIn(d4.id, result_ids)
|
self.assertNotIn(d4.id, result_ids)
|
||||||
|
|
||||||
|
def test_search_more_like_version_uses_its_root(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document similar in content to a root document, and one that is not
|
||||||
|
- A version of the root document, which is never indexed
|
||||||
|
WHEN:
|
||||||
|
- API request for more like the version
|
||||||
|
THEN:
|
||||||
|
- The documents similar to the root are returned, not the version
|
||||||
|
"""
|
||||||
|
indexed = {}
|
||||||
|
for name, title, content, day in (
|
||||||
|
("root", "bank statement 1", "things i paid for in august", (2019, 3, 4)),
|
||||||
|
(
|
||||||
|
"similar",
|
||||||
|
"bank statement 3",
|
||||||
|
"things i paid for in september",
|
||||||
|
(2020, 7, 9),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"other",
|
||||||
|
"Quarterly Report",
|
||||||
|
"quarterly revenue profit margin",
|
||||||
|
(2021, 11, 30),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with time_machine.travel(
|
||||||
|
timezone.make_aware(datetime.datetime(*day)),
|
||||||
|
tick=False,
|
||||||
|
):
|
||||||
|
indexed[name] = DocumentFactory(
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
created=datetime.date(*day),
|
||||||
|
added=timezone.make_aware(datetime.datetime(*day)),
|
||||||
|
)
|
||||||
|
version = DocumentFactory(
|
||||||
|
root_document=indexed["root"],
|
||||||
|
version_index=1,
|
||||||
|
content="things i paid for in august",
|
||||||
|
)
|
||||||
|
backend = get_backend()
|
||||||
|
for document in indexed.values():
|
||||||
|
backend.add_or_update(document)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/documents/?more_like_id={version.id}")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
result_ids = [r["id"] for r in response.data["results"]]
|
||||||
|
self.assertIn(indexed["similar"].id, result_ids)
|
||||||
|
self.assertNotIn(indexed["other"].id, result_ids)
|
||||||
|
self.assertNotIn(version.id, result_ids)
|
||||||
|
|
||||||
def test_more_like_requires_id_of_existing_document(self) -> None:
|
def test_more_like_requires_id_of_existing_document(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1261,6 +1314,39 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
self.assertEqual(response.content, b"Insufficient permissions.")
|
self.assertEqual(response.content, b"Insufficient permissions.")
|
||||||
|
|
||||||
|
def test_search_more_like_version_requires_view_permission_on_its_root(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Another user's private root document with an unowned version
|
||||||
|
WHEN:
|
||||||
|
- A user without access requests more-like-this for the version
|
||||||
|
THEN:
|
||||||
|
- The request is rejected, the version's missing owner grants nothing
|
||||||
|
"""
|
||||||
|
owner = UserFactory(username="owner")
|
||||||
|
attacker = UserFactory(username="attacker")
|
||||||
|
grant_global(attacker, "view_document")
|
||||||
|
private_root = DocumentFactory(
|
||||||
|
title="private bank statement",
|
||||||
|
content="quarterly treasury bank statement wire transfer",
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
version = DocumentFactory(
|
||||||
|
root_document=private_root,
|
||||||
|
version_index=1,
|
||||||
|
owner=None,
|
||||||
|
)
|
||||||
|
get_backend().add_or_update(private_root)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=attacker)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/documents/?more_like_id={version.id}")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(response.content, b"Insufficient permissions.")
|
||||||
|
|
||||||
def test_search_filtering(self) -> None:
|
def test_search_filtering(self) -> None:
|
||||||
t = Tag.objects.create(name="tag")
|
t = Tag.objects.create(name="tag")
|
||||||
t2 = Tag.objects.create(name="tag2")
|
t2 = Tag.objects.create(name="tag2")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from documents.models import Document
|
|||||||
from documents.tasks import update_document_content_maybe_archive_file
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
sample_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
|
sample_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
|
||||||
|
|
||||||
@@ -116,6 +117,27 @@ class TestMakeIndex:
|
|||||||
call_command("document_index", "reindex", skip_checks=True)
|
call_command("document_index", "reindex", skip_checks=True)
|
||||||
mock_get_backend.return_value.rebuild.assert_called_once()
|
mock_get_backend.return_value.rebuild.assert_called_once()
|
||||||
|
|
||||||
|
def test_reindex_skips_versions(self, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a version
|
||||||
|
WHEN:
|
||||||
|
- The reindex command runs
|
||||||
|
THEN:
|
||||||
|
- Only the root document is handed to the rebuild, since a version
|
||||||
|
is indexed as its root
|
||||||
|
"""
|
||||||
|
root = DocumentFactory()
|
||||||
|
DocumentFactory(root_document=root, version_index=1)
|
||||||
|
mock_get_backend = mocker.patch(
|
||||||
|
"documents.management.commands.document_index.get_backend",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_command("document_index", "reindex", skip_checks=True)
|
||||||
|
|
||||||
|
documents = mock_get_backend.return_value.rebuild.call_args.args[0]
|
||||||
|
assert list(documents.values_list("pk", flat=True)) == [root.pk]
|
||||||
|
|
||||||
def test_optimize(self) -> None:
|
def test_optimize(self) -> None:
|
||||||
"""Optimize command must execute without error (Tantivy handles optimization automatically)."""
|
"""Optimize command must execute without error (Tantivy handles optimization automatically)."""
|
||||||
call_command("document_index", "optimize", skip_checks=True)
|
call_command("document_index", "optimize", skip_checks=True)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from documents.models import Correspondent
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.permissions import has_perms_owner_aware
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
@@ -470,6 +471,92 @@ class TestPermittedDocumentIdsVersions:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestHasPermsOwnerAwareVersions:
|
||||||
|
"""
|
||||||
|
The single-object check agrees with permitted_document_ids: a version is
|
||||||
|
authorized by its root document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("root_owner", "version_owner", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
"other",
|
||||||
|
"nobody",
|
||||||
|
False,
|
||||||
|
id="unowned-version-of-private-root",
|
||||||
|
),
|
||||||
|
pytest.param("other", "user", False, id="own-version-of-private-root"),
|
||||||
|
pytest.param("user", "other", True, id="foreign-version-of-own-root"),
|
||||||
|
pytest.param("nobody", "other", True, id="private-version-of-unowned-root"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_version_follows_root_owner(
|
||||||
|
self,
|
||||||
|
root_owner: str,
|
||||||
|
version_owner: str,
|
||||||
|
*,
|
||||||
|
expected: bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document and a version with differing owners
|
||||||
|
WHEN:
|
||||||
|
- The single-object check runs for the version
|
||||||
|
THEN:
|
||||||
|
- The version is allowed exactly when its root is
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
owners = {"user": user, "other": UserFactory(), "nobody": None}
|
||||||
|
root = DocumentFactory(owner=owners[root_owner])
|
||||||
|
version = DocumentFactory(root_document=root, owner=owners[version_owner])
|
||||||
|
|
||||||
|
assert has_perms_owner_aware(user, "view_document", version) is expected
|
||||||
|
assert has_perms_owner_aware(user, "view_document", root) is expected
|
||||||
|
|
||||||
|
def test_grant_on_root_applies_and_grant_on_version_does_not(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A private root with a version, and a second private root with a version
|
||||||
|
- The user may change only the first root, and was granted the second
|
||||||
|
root's version directly
|
||||||
|
WHEN:
|
||||||
|
- The single-object check runs for each version
|
||||||
|
THEN:
|
||||||
|
- Only the first root's version is allowed
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
shared_root = DocumentFactory(owner=UserFactory())
|
||||||
|
shared_version = DocumentFactory(root_document=shared_root, owner=UserFactory())
|
||||||
|
private_root = DocumentFactory(owner=UserFactory())
|
||||||
|
private_version = DocumentFactory(
|
||||||
|
root_document=private_root,
|
||||||
|
owner=UserFactory(),
|
||||||
|
)
|
||||||
|
grant_object(user, shared_root, "change_document")
|
||||||
|
grant_object(user, private_version, "change_document")
|
||||||
|
|
||||||
|
assert has_perms_owner_aware(user, "change_document", shared_version)
|
||||||
|
assert not has_perms_owner_aware(user, "change_document", private_version)
|
||||||
|
|
||||||
|
def test_other_models_use_their_own_owner(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A tag owned by someone else, and one owned by the user
|
||||||
|
WHEN:
|
||||||
|
- The single-object check runs for each
|
||||||
|
THEN:
|
||||||
|
- Only the user's own tag is allowed without a grant
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
mine = TagFactory(owner=user)
|
||||||
|
theirs = TagFactory(owner=UserFactory())
|
||||||
|
|
||||||
|
assert has_perms_owner_aware(user, "view_tag", mine)
|
||||||
|
assert not has_perms_owner_aware(user, "view_tag", theirs)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestAiChatAllDocumentsPermissionBoundary:
|
class TestAiChatAllDocumentsPermissionBoundary:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.tasks.clear_document_caches")
|
@mock.patch("documents.tasks.clear_document_caches")
|
||||||
@mock.patch("documents.search.get_backend")
|
@mock.patch("documents.search.get_backend")
|
||||||
def test_update_content_version_indexes_root(
|
def test_update_content_version_clears_caches_for_root(
|
||||||
self,
|
self,
|
||||||
mock_get_backend: mock.Mock,
|
mock_get_backend: mock.Mock,
|
||||||
mock_clear_caches: mock.Mock,
|
mock_clear_caches: mock.Mock,
|
||||||
@@ -321,8 +321,8 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
|
|||||||
WHEN:
|
WHEN:
|
||||||
- Update content task is called for the version
|
- Update content task is called for the version
|
||||||
THEN:
|
THEN:
|
||||||
- The version's content is updated
|
- The version's content is updated, not the root's
|
||||||
- The root document is indexed rather than the version
|
- The document is indexed
|
||||||
- Caches are cleared for both
|
- Caches are cleared for both
|
||||||
"""
|
"""
|
||||||
root, version = self._create_root_with_version()
|
root, version = self._create_root_with_version()
|
||||||
@@ -334,8 +334,7 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
|
|||||||
"my document",
|
"my document",
|
||||||
)
|
)
|
||||||
self.assertEqual(Document.objects.get(pk=root.pk).content, "root content")
|
self.assertEqual(Document.objects.get(pk=root.pk).content, "root content")
|
||||||
indexed = mock_get_backend.return_value.add_or_update.call_args.args[0]
|
mock_get_backend.return_value.add_or_update.assert_called_once()
|
||||||
self.assertEqual(indexed.pk, root.pk)
|
|
||||||
mock_clear_caches.assert_has_calls(
|
mock_clear_caches.assert_has_calls(
|
||||||
[mock.call(version.pk), mock.call(root.pk)],
|
[mock.call(version.pk), mock.call(root.pk)],
|
||||||
)
|
)
|
||||||
@@ -343,7 +342,7 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
|
|||||||
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="huggingface")
|
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
@mock.patch("documents.tasks.llm_index_add_or_update_document")
|
@mock.patch("documents.tasks.llm_index_add_or_update_document")
|
||||||
@mock.patch("documents.search.get_backend")
|
@mock.patch("documents.search.get_backend")
|
||||||
def test_update_content_version_updates_llm_index_for_root(
|
def test_update_content_version_updates_llm_index(
|
||||||
self,
|
self,
|
||||||
mock_get_backend: mock.Mock,
|
mock_get_backend: mock.Mock,
|
||||||
mock_llm_index: mock.Mock,
|
mock_llm_index: mock.Mock,
|
||||||
@@ -355,14 +354,13 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
|
|||||||
WHEN:
|
WHEN:
|
||||||
- Update content task is called for the version
|
- Update content task is called for the version
|
||||||
THEN:
|
THEN:
|
||||||
- The LLM index is updated for the root document, not the version
|
- The LLM index is updated
|
||||||
"""
|
"""
|
||||||
root, version = self._create_root_with_version()
|
_, version = self._create_root_with_version()
|
||||||
|
|
||||||
tasks.update_document_content_maybe_archive_file(version.pk)
|
tasks.update_document_content_maybe_archive_file(version.pk)
|
||||||
|
|
||||||
mock_llm_index.assert_called_once()
|
mock_llm_index.assert_called_once()
|
||||||
self.assertEqual(mock_llm_index.call_args.args[0].pk, root.pk)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
|
class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
|
||||||
|
|||||||
@@ -17,9 +17,26 @@ from django.db.models.functions import RowNumber
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
|
|
||||||
|
|
||||||
|
def root_document_ids(ids: Iterable[int]) -> QuerySet[int]:
|
||||||
|
"""
|
||||||
|
The ids of the root documents of the given documents: a root stands for
|
||||||
|
itself and a version for its root. Only the indexes' bookkeeping needs
|
||||||
|
this, since they hold root documents only.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
Document.objects.filter(pk__in=ids)
|
||||||
|
.annotate(root_id=Coalesce("root_document_id", "id"))
|
||||||
|
.order_by()
|
||||||
|
.values_list("root_id", flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
|
def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||||
"""
|
"""
|
||||||
Sorts versions so the newest one comes first using version_index and not on id,
|
Sorts versions so the newest one comes first using version_index and not on id,
|
||||||
|
|||||||
+14
-28
@@ -329,9 +329,10 @@ def _get_tantivy_query_and_mode(params):
|
|||||||
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
||||||
try:
|
try:
|
||||||
more_like_doc_id = int(query_params["more_like_id"])
|
more_like_doc_id = int(query_params["more_like_id"])
|
||||||
more_like_doc = Document.objects.select_related("owner").get(
|
more_like_doc = Document.objects.select_related(
|
||||||
pk=more_like_doc_id,
|
"owner",
|
||||||
)
|
"root_document__owner",
|
||||||
|
).get(pk=more_like_doc_id)
|
||||||
except (TypeError, ValueError, Document.DoesNotExist):
|
except (TypeError, ValueError, Document.DoesNotExist):
|
||||||
raise PermissionDenied(_("Invalid more_like_id"))
|
raise PermissionDenied(_("Invalid more_like_id"))
|
||||||
|
|
||||||
@@ -342,7 +343,8 @@ def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
|||||||
):
|
):
|
||||||
raise PermissionDenied(_("Insufficient permissions."))
|
raise PermissionDenied(_("Insufficient permissions."))
|
||||||
|
|
||||||
return more_like_doc_id
|
# Only root documents are indexed, a version stands for its root
|
||||||
|
return more_like_doc.root_document_id or more_like_doc.pk
|
||||||
|
|
||||||
|
|
||||||
class SearchParams(NamedTuple):
|
class SearchParams(NamedTuple):
|
||||||
@@ -1559,7 +1561,7 @@ class DocumentViewSet(
|
|||||||
if request.user is not None and not has_perms_owner_aware(
|
if request.user is not None and not has_perms_owner_aware(
|
||||||
request.user,
|
request.user,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
@@ -1622,7 +1624,7 @@ class DocumentViewSet(
|
|||||||
if request.user is not None and not has_perms_owner_aware(
|
if request.user is not None and not has_perms_owner_aware(
|
||||||
request.user,
|
request.user,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
@@ -1875,7 +1877,7 @@ class DocumentViewSet(
|
|||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"view_document",
|
"view_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions to view notes")
|
return HttpResponseForbidden("Insufficient permissions to view notes")
|
||||||
except Document.DoesNotExist:
|
except Document.DoesNotExist:
|
||||||
@@ -1897,7 +1899,7 @@ class DocumentViewSet(
|
|||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
"Insufficient permissions to create notes",
|
"Insufficient permissions to create notes",
|
||||||
@@ -1940,7 +1942,7 @@ class DocumentViewSet(
|
|||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions to delete notes")
|
return HttpResponseForbidden("Insufficient permissions to delete notes")
|
||||||
|
|
||||||
@@ -1990,7 +1992,7 @@ class DocumentViewSet(
|
|||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
"Insufficient permissions to add share link",
|
"Insufficient permissions to add share link",
|
||||||
@@ -2451,7 +2453,7 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
if not has_perms_owner_aware(
|
if not has_perms_owner_aware(
|
||||||
request.user,
|
request.user,
|
||||||
"view_document",
|
"view_document",
|
||||||
get_root_document(document),
|
document,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
@@ -5600,23 +5602,7 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
|
|
||||||
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
||||||
include_granted = False
|
include_granted = False
|
||||||
|
parent_field = "root_document"
|
||||||
def filter_queryset(self, request, queryset, view):
|
|
||||||
if request.user.is_superuser or not request.user.is_active:
|
|
||||||
return super().filter_queryset(request, queryset, view)
|
|
||||||
|
|
||||||
# A version belongs to whoever owns its root
|
|
||||||
def owned_or_unowned(prefix: str) -> Q:
|
|
||||||
return Q(**{f"{prefix}owner": request.user}) | Q(
|
|
||||||
**{f"{prefix}owner__isnull": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
return queryset.filter(
|
|
||||||
(Q(root_document__isnull=True) & owned_or_unowned(""))
|
|
||||||
| (
|
|
||||||
Q(root_document__isnull=False) & owned_or_unowned("root_document__")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
filter_backends = (_TrashPermittedObjectsFilter,)
|
filter_backends = (_TrashPermittedObjectsFilter,)
|
||||||
pagination_class = StandardPagination
|
pagination_class = StandardPagination
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-10-10 22:04+0000\n"
|
"POT-Creation-Date: 2026-10-12 19:12+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -21,39 +21,39 @@ msgstr ""
|
|||||||
msgid "Documents"
|
msgid "Documents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:463
|
#: documents/filters.py:464
|
||||||
msgid "Value must be valid JSON."
|
msgid "Value must be valid JSON."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:482
|
#: documents/filters.py:483
|
||||||
msgid "Invalid custom field query expression"
|
msgid "Invalid custom field query expression"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:492
|
#: documents/filters.py:493
|
||||||
msgid "Invalid expression list. Must be nonempty."
|
msgid "Invalid expression list. Must be nonempty."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:513
|
#: documents/filters.py:514
|
||||||
msgid "Invalid logical operator {op!r}"
|
msgid "Invalid logical operator {op!r}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:527
|
#: documents/filters.py:528
|
||||||
msgid "Maximum number of query conditions exceeded."
|
msgid "Maximum number of query conditions exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:591
|
#: documents/filters.py:592
|
||||||
msgid "{name!r} is not a valid custom field."
|
msgid "{name!r} is not a valid custom field."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:628
|
#: documents/filters.py:629
|
||||||
msgid "{data_type} does not support query expr {expr!r}."
|
msgid "{data_type} does not support query expr {expr!r}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:747 documents/models.py:137
|
#: documents/filters.py:748 documents/models.py:137
|
||||||
msgid "Maximum nesting depth exceeded."
|
msgid "Maximum nesting depth exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:1109
|
#: documents/filters.py:1125
|
||||||
msgid "Custom field not found"
|
msgid "Custom field not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1652,49 +1652,49 @@ msgstr ""
|
|||||||
msgid "workflow runs"
|
msgid "workflow runs"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:516 documents/serialisers.py:873
|
#: documents/serialisers.py:515 documents/serialisers.py:872
|
||||||
#: documents/serialisers.py:2903 documents/views.py:343 documents/views.py:2750
|
#: documents/serialisers.py:2902 documents/views.py:344 documents/views.py:2752
|
||||||
#: paperless_mail/serialisers.py:156
|
#: paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:709
|
#: documents/serialisers.py:708
|
||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2370
|
#: documents/serialisers.py:2369
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2414
|
#: documents/serialisers.py:2413
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2421
|
#: documents/serialisers.py:2420
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2438 documents/serialisers.py:2448
|
#: documents/serialisers.py:2437 documents/serialisers.py:2447
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2443
|
#: documents/serialisers.py:2442
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2590
|
#: documents/serialisers.py:2589
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2959
|
#: documents/serialisers.py:2958
|
||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2989 documents/views.py:4797
|
#: documents/serialisers.py:2988 documents/views.py:4799
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1945,40 +1945,40 @@ msgstr ""
|
|||||||
msgid ", "
|
msgid ", "
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:336 documents/views.py:2747
|
#: documents/views.py:337 documents/views.py:2749
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1682
|
#: documents/views.py:1684
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1693
|
#: documents/views.py:1695
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1705
|
#: documents/views.py:1707
|
||||||
msgid "AI backend rejected the request. Check logs for details."
|
msgid "AI backend rejected the request. Check logs for details."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2572 documents/views.py:2888
|
#: documents/views.py:2574 documents/views.py:2890
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4813
|
#: documents/views.py:4815
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4860
|
#: documents/views.py:4862
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4924
|
#: documents/views.py:4926
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4938
|
#: documents/views.py:4940
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2270,151 +2270,151 @@ msgstr ""
|
|||||||
msgid "paperless application settings"
|
msgid "paperless application settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:561
|
#: paperless/settings/__init__.py:562
|
||||||
msgid "English (US)"
|
msgid "English (US)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:562
|
#: paperless/settings/__init__.py:563
|
||||||
msgid "Arabic"
|
msgid "Arabic"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:563
|
#: paperless/settings/__init__.py:564
|
||||||
msgid "Afrikaans"
|
msgid "Afrikaans"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:564
|
#: paperless/settings/__init__.py:565
|
||||||
msgid "Belarusian"
|
msgid "Belarusian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:565
|
#: paperless/settings/__init__.py:566
|
||||||
msgid "Bulgarian"
|
msgid "Bulgarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:566
|
#: paperless/settings/__init__.py:567
|
||||||
msgid "Catalan"
|
msgid "Catalan"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:567
|
#: paperless/settings/__init__.py:568
|
||||||
msgid "Czech"
|
msgid "Czech"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:568
|
#: paperless/settings/__init__.py:569
|
||||||
msgid "Danish"
|
msgid "Danish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:569
|
#: paperless/settings/__init__.py:570
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:570
|
#: paperless/settings/__init__.py:571
|
||||||
msgid "Greek"
|
msgid "Greek"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:571
|
#: paperless/settings/__init__.py:572
|
||||||
msgid "English (GB)"
|
msgid "English (GB)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:572
|
#: paperless/settings/__init__.py:573
|
||||||
msgid "Spanish"
|
msgid "Spanish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:573
|
#: paperless/settings/__init__.py:574
|
||||||
msgid "Persian"
|
msgid "Persian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:574
|
#: paperless/settings/__init__.py:575
|
||||||
msgid "Finnish"
|
msgid "Finnish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:575
|
#: paperless/settings/__init__.py:576
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:576
|
#: paperless/settings/__init__.py:577
|
||||||
msgid "Hungarian"
|
msgid "Hungarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:577
|
#: paperless/settings/__init__.py:578
|
||||||
msgid "Indonesian"
|
msgid "Indonesian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:578
|
#: paperless/settings/__init__.py:579
|
||||||
msgid "Italian"
|
msgid "Italian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:579
|
#: paperless/settings/__init__.py:580
|
||||||
msgid "Japanese"
|
msgid "Japanese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:580
|
#: paperless/settings/__init__.py:581
|
||||||
msgid "Korean"
|
msgid "Korean"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:581
|
#: paperless/settings/__init__.py:582
|
||||||
msgid "Luxembourgish"
|
msgid "Luxembourgish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:582
|
#: paperless/settings/__init__.py:583
|
||||||
msgid "Norwegian"
|
msgid "Norwegian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:583
|
#: paperless/settings/__init__.py:584
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:584
|
#: paperless/settings/__init__.py:585
|
||||||
msgid "Polish"
|
msgid "Polish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:585
|
#: paperless/settings/__init__.py:586
|
||||||
msgid "Portuguese (Brazil)"
|
msgid "Portuguese (Brazil)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:586
|
#: paperless/settings/__init__.py:587
|
||||||
msgid "Portuguese"
|
msgid "Portuguese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:587
|
#: paperless/settings/__init__.py:588
|
||||||
msgid "Romanian"
|
msgid "Romanian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:588
|
#: paperless/settings/__init__.py:589
|
||||||
msgid "Russian"
|
msgid "Russian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:589
|
#: paperless/settings/__init__.py:590
|
||||||
msgid "Slovak"
|
msgid "Slovak"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:590
|
#: paperless/settings/__init__.py:591
|
||||||
msgid "Slovenian"
|
msgid "Slovenian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:591
|
#: paperless/settings/__init__.py:592
|
||||||
msgid "Serbian"
|
msgid "Serbian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:592
|
#: paperless/settings/__init__.py:593
|
||||||
msgid "Swedish"
|
msgid "Swedish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:593
|
#: paperless/settings/__init__.py:594
|
||||||
msgid "Turkish"
|
msgid "Turkish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:594
|
#: paperless/settings/__init__.py:595
|
||||||
msgid "Ukrainian"
|
msgid "Ukrainian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:595
|
#: paperless/settings/__init__.py:596
|
||||||
msgid "Vietnamese"
|
msgid "Vietnamese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:596
|
#: paperless/settings/__init__.py:597
|
||||||
msgid "Chinese Simplified"
|
msgid "Chinese Simplified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:597
|
#: paperless/settings/__init__.py:598
|
||||||
msgid "Chinese Traditional"
|
msgid "Chinese Traditional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import logging.config
|
import logging.config
|
||||||
import math
|
import math
|
||||||
@@ -7,7 +6,6 @@ import multiprocessing
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
from typing import Final
|
from typing import Final
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -25,6 +23,7 @@ from paperless.settings.parsers import get_bool_from_env
|
|||||||
from paperless.settings.parsers import get_choice_from_env
|
from paperless.settings.parsers import get_choice_from_env
|
||||||
from paperless.settings.parsers import get_float_from_env
|
from paperless.settings.parsers import get_float_from_env
|
||||||
from paperless.settings.parsers import get_int_from_env
|
from paperless.settings.parsers import get_int_from_env
|
||||||
|
from paperless.settings.parsers import get_json_from_env
|
||||||
from paperless.settings.parsers import get_list_from_env
|
from paperless.settings.parsers import get_list_from_env
|
||||||
from paperless.settings.parsers import get_path_from_env
|
from paperless.settings.parsers import get_path_from_env
|
||||||
|
|
||||||
@@ -331,8 +330,10 @@ SOCIALACCOUNT_ALLOW_SIGNUPS = get_bool_from_env(
|
|||||||
"yes",
|
"yes",
|
||||||
)
|
)
|
||||||
SOCIALACCOUNT_AUTO_SIGNUP = get_bool_from_env("PAPERLESS_SOCIAL_AUTO_SIGNUP")
|
SOCIALACCOUNT_AUTO_SIGNUP = get_bool_from_env("PAPERLESS_SOCIAL_AUTO_SIGNUP")
|
||||||
SOCIALACCOUNT_PROVIDERS = json.loads(
|
SOCIALACCOUNT_PROVIDERS = get_json_from_env(
|
||||||
os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
|
"PAPERLESS_SOCIALACCOUNT_PROVIDERS",
|
||||||
|
{},
|
||||||
|
dict,
|
||||||
)
|
)
|
||||||
SOCIAL_ACCOUNT_DEFAULT_GROUPS = get_list_from_env(
|
SOCIAL_ACCOUNT_DEFAULT_GROUPS = get_list_from_env(
|
||||||
"PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS",
|
"PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS",
|
||||||
@@ -488,7 +489,7 @@ ALLAUTH_TRUSTED_CLIENT_IP_HEADER = os.getenv(
|
|||||||
USE_X_FORWARDED_HOST = get_bool_from_env("PAPERLESS_USE_X_FORWARD_HOST", "false")
|
USE_X_FORWARDED_HOST = get_bool_from_env("PAPERLESS_USE_X_FORWARD_HOST", "false")
|
||||||
USE_X_FORWARDED_PORT = get_bool_from_env("PAPERLESS_USE_X_FORWARD_PORT", "false")
|
USE_X_FORWARDED_PORT = get_bool_from_env("PAPERLESS_USE_X_FORWARD_PORT", "false")
|
||||||
SECURE_PROXY_SSL_HEADER = (
|
SECURE_PROXY_SSL_HEADER = (
|
||||||
tuple(json.loads(os.environ["PAPERLESS_PROXY_SSL_HEADER"]))
|
tuple(get_json_from_env("PAPERLESS_PROXY_SSL_HEADER", [], list))
|
||||||
if "PAPERLESS_PROXY_SSL_HEADER" in os.environ
|
if "PAPERLESS_PROXY_SSL_HEADER" in os.environ
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -828,23 +829,17 @@ CONSUMER_DELETE_DUPLICATES = get_bool_from_env("PAPERLESS_CONSUMER_DELETE_DUPLIC
|
|||||||
CONSUMER_RECURSIVE = get_bool_from_env("PAPERLESS_CONSUMER_RECURSIVE")
|
CONSUMER_RECURSIVE = get_bool_from_env("PAPERLESS_CONSUMER_RECURSIVE")
|
||||||
|
|
||||||
# Ignore regex patterns, matched against filename only
|
# Ignore regex patterns, matched against filename only
|
||||||
CONSUMER_IGNORE_PATTERNS = list(
|
CONSUMER_IGNORE_PATTERNS = get_json_from_env(
|
||||||
json.loads(
|
"PAPERLESS_CONSUMER_IGNORE_PATTERNS",
|
||||||
os.getenv(
|
[],
|
||||||
"PAPERLESS_CONSUMER_IGNORE_PATTERNS",
|
list,
|
||||||
json.dumps([]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Directories to always ignore. These are matched by directory name, not full path
|
# Directories to always ignore. These are matched by directory name, not full path
|
||||||
CONSUMER_IGNORE_DIRS = list(
|
CONSUMER_IGNORE_DIRS = get_json_from_env(
|
||||||
json.loads(
|
"PAPERLESS_CONSUMER_IGNORE_DIRS",
|
||||||
os.getenv(
|
[],
|
||||||
"PAPERLESS_CONSUMER_IGNORE_DIRS",
|
list,
|
||||||
json.dumps([]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_SUBDIRS_AS_TAGS = get_bool_from_env("PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS")
|
CONSUMER_SUBDIRS_AS_TAGS = get_bool_from_env("PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS")
|
||||||
@@ -894,13 +889,10 @@ CONSUMER_ENABLE_TAG_BARCODE: Final[bool] = get_bool_from_env(
|
|||||||
"PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE",
|
"PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE",
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_TAG_BARCODE_MAPPING = dict(
|
CONSUMER_TAG_BARCODE_MAPPING = get_json_from_env(
|
||||||
json.loads(
|
"PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING",
|
||||||
os.getenv(
|
{"TAG:(.*)": "\\g<1>"},
|
||||||
"PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING",
|
dict,
|
||||||
'{"TAG:(.*)": "\\\\g<1>"}',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
CONSUMER_TAG_BARCODE_SPLIT: Final[bool] = get_bool_from_env(
|
CONSUMER_TAG_BARCODE_SPLIT: Final[bool] = get_bool_from_env(
|
||||||
@@ -1088,25 +1080,6 @@ CLASSIFIER_LANGUAGES: Final[dict[str, str]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_llm_extra_params() -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Parse PAPERLESS_AI_LLM_EXTRA_PARAMS, a JSON object passed straight through
|
|
||||||
to the LLM backend's request body.
|
|
||||||
"""
|
|
||||||
raw = os.getenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", "{}")
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
"PAPERLESS_AI_LLM_EXTRA_PARAMS must be valid JSON",
|
|
||||||
) from e
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
"PAPERLESS_AI_LLM_EXTRA_PARAMS must be a JSON object",
|
|
||||||
)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _get_classifier_language_setting(ocr_lang: str) -> str | None:
|
def _get_classifier_language_setting(ocr_lang: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Maps the primary Tesseract language to the classifier's stemming
|
Maps the primary Tesseract language to the classifier's stemming
|
||||||
@@ -1268,4 +1241,5 @@ LLM_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env(
|
|||||||
"PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS",
|
"PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS",
|
||||||
"true",
|
"true",
|
||||||
)
|
)
|
||||||
LLM_EXTRA_PARAMS = _get_llm_extra_params()
|
# JSON object passed straight through to the LLM backend's request body
|
||||||
|
LLM_EXTRA_PARAMS = get_json_from_env("PAPERLESS_AI_LLM_EXTRA_PARAMS", {}, dict)
|
||||||
@@ -312,3 +312,58 @@ def get_choice_from_env(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_json_from_env(key: str, default: T, expected_type: type[T]) -> T: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_json_from_env(
|
||||||
|
key: str,
|
||||||
|
default: Any,
|
||||||
|
expected_type: None = None,
|
||||||
|
) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
def get_json_from_env(
|
||||||
|
key: str,
|
||||||
|
default: Any,
|
||||||
|
expected_type: type | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Gets an environment variable and parses it as JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The environment variable key
|
||||||
|
default: Python value returned when the variable is not set
|
||||||
|
expected_type: If given, the parsed value must be an instance of it
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The parsed JSON value, or the default if the variable is not set
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the value is not valid JSON or is not the expected type.
|
||||||
|
The message names the offending variable.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Environment variable '{key}' is not valid JSON: {e.msg} "
|
||||||
|
f"(line {e.lineno}, column {e.colno}). Value: {raw!r}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if expected_type is not None and not isinstance(parsed, expected_type):
|
||||||
|
raise ValueError(
|
||||||
|
f"Environment variable '{key}' must be a JSON {expected_type.__name__}, "
|
||||||
|
f"got {type(parsed).__name__}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parsed
|
||||||
@@ -8,6 +8,7 @@ from paperless.settings.parsers import get_bool_from_env
|
|||||||
from paperless.settings.parsers import get_choice_from_env
|
from paperless.settings.parsers import get_choice_from_env
|
||||||
from paperless.settings.parsers import get_float_from_env
|
from paperless.settings.parsers import get_float_from_env
|
||||||
from paperless.settings.parsers import get_int_from_env
|
from paperless.settings.parsers import get_int_from_env
|
||||||
|
from paperless.settings.parsers import get_json_from_env
|
||||||
from paperless.settings.parsers import get_list_from_env
|
from paperless.settings.parsers import get_list_from_env
|
||||||
from paperless.settings.parsers import get_path_from_env
|
from paperless.settings.parsers import get_path_from_env
|
||||||
from paperless.settings.parsers import parse_dict_from_str
|
from paperless.settings.parsers import parse_dict_from_str
|
||||||
@@ -611,3 +612,43 @@ class TestGetEnvChoice:
|
|||||||
result = get_choice_from_env("TEST_ENV", large_choices)
|
result = get_choice_from_env("TEST_ENV", large_choices)
|
||||||
|
|
||||||
assert result == "option_50"
|
assert result == "option_50"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetJsonFromEnv:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("env_value", "expected_type", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(None, list, ["default"], id="unset-uses-default"),
|
||||||
|
pytest.param('["a", "b"]', list, ["a", "b"], id="list"),
|
||||||
|
pytest.param('["a"]', None, ["a"], id="no-type-check"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_returns_value(
|
||||||
|
self,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
env_value: str | None,
|
||||||
|
expected_type: type | None,
|
||||||
|
expected: list[str],
|
||||||
|
) -> None:
|
||||||
|
env = {} if env_value is None else {"TEST_ENV": env_value}
|
||||||
|
mocker.patch.dict("os.environ", env, clear=True)
|
||||||
|
|
||||||
|
assert get_json_from_env("TEST_ENV", ["default"], expected_type) == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("env_value", "match"),
|
||||||
|
[
|
||||||
|
pytest.param('["a", , "b"]', "'TEST_ENV' is not valid JSON", id="invalid"),
|
||||||
|
pytest.param('{"a": 1}', "must be a JSON list, got dict", id="wrong-type"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_raises(
|
||||||
|
self,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
env_value: str,
|
||||||
|
match: str,
|
||||||
|
) -> None:
|
||||||
|
mocker.patch.dict("os.environ", {"TEST_ENV": env_value})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=match):
|
||||||
|
get_json_from_env("TEST_ENV", [], list)
|
||||||
@@ -7,7 +7,6 @@ from django.core.exceptions import ImproperlyConfigured
|
|||||||
|
|
||||||
from paperless.settings import _get_allauth_trusted_proxy_count
|
from paperless.settings import _get_allauth_trusted_proxy_count
|
||||||
from paperless.settings import _get_classifier_language_setting
|
from paperless.settings import _get_classifier_language_setting
|
||||||
from paperless.settings import _get_llm_extra_params
|
|
||||||
from paperless.settings import _get_search_language_setting
|
from paperless.settings import _get_search_language_setting
|
||||||
from paperless.settings import _parse_paperless_url
|
from paperless.settings import _parse_paperless_url
|
||||||
from paperless.settings import default_threads_per_worker
|
from paperless.settings import default_threads_per_worker
|
||||||
@@ -167,45 +166,3 @@ class TestPaperlessURLSettings(TestCase):
|
|||||||
|
|
||||||
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
|
self.assertIn(url, settings.CSRF_TRUSTED_ORIGINS)
|
||||||
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
|
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
|
||||||
|
|
||||||
|
|
||||||
class TestLlmExtraParams:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("env_value", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(None, {}, id="unset"),
|
|
||||||
pytest.param(
|
|
||||||
'{"reasoning_effort": "none"}',
|
|
||||||
{"reasoning_effort": "none"},
|
|
||||||
id="json-object",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_parses(
|
|
||||||
self,
|
|
||||||
monkeypatch,
|
|
||||||
env_value,
|
|
||||||
expected,
|
|
||||||
):
|
|
||||||
if env_value is None:
|
|
||||||
monkeypatch.delenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", raising=False)
|
|
||||||
else:
|
|
||||||
monkeypatch.setenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", env_value)
|
|
||||||
assert _get_llm_extra_params() == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("env_value", "match"),
|
|
||||||
[
|
|
||||||
pytest.param("reasoning_effort=none", "valid JSON", id="invalid-json"),
|
|
||||||
pytest.param('["none"]', "JSON object", id="not-an-object"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_invalid_raises(
|
|
||||||
self,
|
|
||||||
monkeypatch,
|
|
||||||
env_value,
|
|
||||||
match,
|
|
||||||
):
|
|
||||||
monkeypatch.setenv("PAPERLESS_AI_LLM_EXTRA_PARAMS", env_value)
|
|
||||||
with pytest.raises(ImproperlyConfigured, match=match):
|
|
||||||
_get_llm_extra_params()
|
|
||||||
@@ -6,6 +6,7 @@ from django.contrib.auth.models import User
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import user_is_unrestricted
|
from documents.permissions import user_is_unrestricted
|
||||||
|
from documents.versioning import annotate_effective_content
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless_ai.base_model import ClassificationSuggestions
|
from paperless_ai.base_model import ClassificationSuggestions
|
||||||
from paperless_ai.base_model import TaxonomyChoiceDict
|
from paperless_ai.base_model import TaxonomyChoiceDict
|
||||||
@@ -111,7 +112,7 @@ def build_prompt_without_rag(
|
|||||||
) -> str:
|
) -> str:
|
||||||
filename = document.filename or ""
|
filename = document.filename or ""
|
||||||
content = truncate_content(
|
content = truncate_content(
|
||||||
document.content[:4000] or "",
|
(document.get_effective_content() or "")[:4000],
|
||||||
chunk_size=config.llm_embedding_chunk_size,
|
chunk_size=config.llm_embedding_chunk_size,
|
||||||
context_size=config.llm_context_size,
|
context_size=config.llm_context_size,
|
||||||
)
|
)
|
||||||
@@ -225,7 +226,9 @@ def get_taxonomy_context(
|
|||||||
|
|
||||||
# similar_documents is already ordered by descending weight; don't lose it.
|
# similar_documents is already ordered by descending weight; don't lose it.
|
||||||
similar_document_ids = [s["document_id"] for s in similar_documents]
|
similar_document_ids = [s["document_id"] for s in similar_documents]
|
||||||
similar_documents_by_id = Document.objects.in_bulk(similar_document_ids)
|
similar_documents_by_id = annotate_effective_content(
|
||||||
|
Document.objects.all(),
|
||||||
|
).in_bulk(similar_document_ids)
|
||||||
similar_docs = [
|
similar_docs = [
|
||||||
similar_documents_by_id[document_id]
|
similar_documents_by_id[document_id]
|
||||||
for document_id in similar_document_ids
|
for document_id in similar_document_ids
|
||||||
@@ -233,7 +236,7 @@ def get_taxonomy_context(
|
|||||||
][:max_docs]
|
][:max_docs]
|
||||||
context_blocks = []
|
context_blocks = []
|
||||||
for similar in similar_docs:
|
for similar in similar_docs:
|
||||||
text = similar.content[:1000] or ""
|
text = (similar.get_effective_content() or "")[:1000]
|
||||||
title = similar.title or similar.filename or "Untitled"
|
title = similar.title or similar.filename or "Untitled"
|
||||||
context_blocks.append(f"TITLE: {title}\n{text}")
|
context_blocks.append(f"TITLE: {title}\n{text}")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -135,6 +135,6 @@ def build_llm_index_text(doc: Document) -> str:
|
|||||||
lines.append(f"Custom Field - {instance.field.name}: {instance}")
|
lines.append(f"Custom Field - {instance.field.name}: {instance}")
|
||||||
|
|
||||||
lines.append("\nContent:\n")
|
lines.append("\nContent:\n")
|
||||||
lines.append(doc.content or "")
|
lines.append(doc.get_effective_content() or "")
|
||||||
|
|
||||||
return _normalize_llm_index_text("\n".join(lines))
|
return _normalize_llm_index_text("\n".join(lines))
|
||||||
@@ -17,6 +17,8 @@ from documents.models import PaperlessTask
|
|||||||
from documents.utils import IterWrapper
|
from documents.utils import IterWrapper
|
||||||
from documents.utils import QuerySetStream
|
from documents.utils import QuerySetStream
|
||||||
from documents.utils import identity
|
from documents.utils import identity
|
||||||
|
from documents.versioning import annotate_effective_content
|
||||||
|
from documents.versioning import root_document_ids
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless_ai.db import db_connection_released
|
from paperless_ai.db import db_connection_released
|
||||||
from paperless_ai.embedding import build_llm_index_text
|
from paperless_ai.embedding import build_llm_index_text
|
||||||
@@ -443,11 +445,11 @@ def update_llm_index(
|
|||||||
"Skipping LLM index update: migration check deferred; "
|
"Skipping LLM index update: migration check deferred; "
|
||||||
"will retry next run."
|
"will retry next run."
|
||||||
)
|
)
|
||||||
documents = Document.objects.select_related(
|
documents = annotate_effective_content(
|
||||||
"correspondent",
|
Document.objects.filter(root_document__isnull=True)
|
||||||
"document_type",
|
.select_related("correspondent", "document_type", "storage_path")
|
||||||
"storage_path",
|
.prefetch_related("tags", "notes", "custom_fields__field"),
|
||||||
).prefetch_related("tags", "notes", "custom_fields__field")
|
)
|
||||||
no_documents = not documents.exists()
|
no_documents = not documents.exists()
|
||||||
|
|
||||||
# Fast exit before touching config: nothing to index and no existing index.
|
# Fast exit before touching config: nothing to index and no existing index.
|
||||||
@@ -483,7 +485,7 @@ def update_llm_index(
|
|||||||
msg = "LLM index rebuilt successfully."
|
msg = "LLM index rebuilt successfully."
|
||||||
else:
|
else:
|
||||||
scoped_documents = (
|
scoped_documents = (
|
||||||
documents.filter(id__in=document_ids)
|
documents.filter(id__in=root_document_ids(document_ids))
|
||||||
if document_ids is not None
|
if document_ids is not None
|
||||||
else documents
|
else documents
|
||||||
)
|
)
|
||||||
@@ -510,7 +512,12 @@ def update_llm_index(
|
|||||||
|
|
||||||
|
|
||||||
def llm_index_add_or_update_document(document: Document):
|
def llm_index_add_or_update_document(document: Document):
|
||||||
"""Add or atomically replace a document's chunks in the index."""
|
"""
|
||||||
|
Add or atomically replace a document's chunks in the index. Only root
|
||||||
|
documents are indexed, so a version is indexed as its root document.
|
||||||
|
"""
|
||||||
|
if document.root_document_id is not None:
|
||||||
|
document = document.root_document
|
||||||
config = AIConfig()
|
config = AIConfig()
|
||||||
new_nodes = build_document_node(
|
new_nodes = build_document_node(
|
||||||
document,
|
document,
|
||||||
@@ -688,7 +695,7 @@ def retrieve_similar_nodes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
query_text = truncate_embedding_query(
|
query_text = truncate_embedding_query(
|
||||||
(document.title or "") + "\n" + (document.content or ""),
|
(document.title or "") + "\n" + (document.get_effective_content() or ""),
|
||||||
chunk_size=config.llm_embedding_chunk_size,
|
chunk_size=config.llm_embedding_chunk_size,
|
||||||
)
|
)
|
||||||
# Hold the shared read lock for the whole retrieval so the connection is
|
# Hold the shared read lock for the whole retrieval so the connection is
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def mock_document():
|
|||||||
doc.storage_path = None
|
doc.storage_path = None
|
||||||
doc.archive_serial_number = "12345"
|
doc.archive_serial_number = "12345"
|
||||||
doc.content = "This is the document content."
|
doc.content = "This is the document content."
|
||||||
|
doc.get_effective_content.return_value = "This is the document content."
|
||||||
|
|
||||||
cf1 = MagicMock(__str__=lambda x: "Value1")
|
cf1 = MagicMock(__str__=lambda x: "Value1")
|
||||||
cf1.field = MagicMock()
|
cf1.field = MagicMock()
|
||||||
@@ -434,6 +435,55 @@ def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestClassifierEffectiveContent:
|
||||||
|
"""A root document's text for the LLM is its newest version's content."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _root_with_version() -> Document:
|
||||||
|
root = DocumentFactory(title="Statement", content="stale text")
|
||||||
|
DocumentFactory(root_document=root, version_index=1, content="latest text")
|
||||||
|
return root
|
||||||
|
|
||||||
|
def test_prompt_uses_the_newest_versions_content(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a version
|
||||||
|
WHEN:
|
||||||
|
- The classification prompt is built for the root
|
||||||
|
THEN:
|
||||||
|
- It contains the newest version's content
|
||||||
|
"""
|
||||||
|
prompt = build_prompt_without_rag(self._root_with_version(), AIConfig())
|
||||||
|
|
||||||
|
assert "latest text" in prompt
|
||||||
|
assert "stale text" not in prompt
|
||||||
|
|
||||||
|
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
|
def test_similar_document_context_uses_the_newest_versions_content(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A similar root document with a version
|
||||||
|
WHEN:
|
||||||
|
- The similar-document context is built
|
||||||
|
THEN:
|
||||||
|
- It contains the newest version's content
|
||||||
|
"""
|
||||||
|
similar = self._root_with_version()
|
||||||
|
document = DocumentFactory(content="Some content")
|
||||||
|
fake_nodes = [
|
||||||
|
SimpleNamespace(metadata={"document_id": str(similar.pk)}, score=0.9),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||||
|
return_value=fake_nodes,
|
||||||
|
):
|
||||||
|
_candidates, context = get_taxonomy_context(document, user=None)
|
||||||
|
|
||||||
|
assert context == "TITLE: Statement\nlatest text"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
def test_get_taxonomy_context_no_similar_docs():
|
def test_get_taxonomy_context_no_similar_docs():
|
||||||
|
|||||||
@@ -388,6 +388,108 @@ def test_update_llm_index_partial_update(
|
|||||||
assert after[str(doc2.pk)] == before[str(doc2.pk)]
|
assert after[str(doc2.pk)] == before[str(doc2.pk)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestLlmIndexVersions:
|
||||||
|
"""The LLM index holds root documents only: a version is indexed as its root."""
|
||||||
|
|
||||||
|
def test_add_or_update_document_indexes_a_version_as_its_root(
|
||||||
|
self,
|
||||||
|
temp_llm_index_dir: Path,
|
||||||
|
mock_embed_model: FakeEmbedding,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a version
|
||||||
|
WHEN:
|
||||||
|
- The version is passed to llm_index_add_or_update_document
|
||||||
|
THEN:
|
||||||
|
- Only the root document is in the index
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="root content")
|
||||||
|
version = DocumentFactory(root_document=root, version_index=1)
|
||||||
|
|
||||||
|
indexing.llm_index_add_or_update_document(version)
|
||||||
|
|
||||||
|
with indexing.get_vector_store() as store:
|
||||||
|
indexed = store.get_modified_times()
|
||||||
|
|
||||||
|
assert set(indexed) == {str(root.pk)}
|
||||||
|
|
||||||
|
def test_rebuild_skips_versions(
|
||||||
|
self,
|
||||||
|
temp_llm_index_dir: Path,
|
||||||
|
mock_embed_model: FakeEmbedding,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a version
|
||||||
|
WHEN:
|
||||||
|
- The LLM index is rebuilt
|
||||||
|
THEN:
|
||||||
|
- Only the root document is in the index
|
||||||
|
"""
|
||||||
|
root = DocumentFactory()
|
||||||
|
DocumentFactory(root_document=root, version_index=1)
|
||||||
|
|
||||||
|
indexing.update_llm_index(rebuild=True)
|
||||||
|
|
||||||
|
with indexing.get_vector_store() as store:
|
||||||
|
indexed = store.get_modified_times()
|
||||||
|
|
||||||
|
assert set(indexed) == {str(root.pk)}
|
||||||
|
|
||||||
|
def test_rebuild_indexes_the_newest_versions_content(
|
||||||
|
self,
|
||||||
|
temp_llm_index_dir: Path,
|
||||||
|
mock_embed_model: FakeEmbedding,
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a version
|
||||||
|
WHEN:
|
||||||
|
- The LLM index is rebuilt
|
||||||
|
THEN:
|
||||||
|
- The root's text for the index is the version's content, answered
|
||||||
|
from the query rather than a query per document
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="stale text")
|
||||||
|
DocumentFactory(root_document=root, version_index=1, content="latest text")
|
||||||
|
spy = mocker.spy(indexing, "build_document_node")
|
||||||
|
|
||||||
|
indexing.update_llm_index(rebuild=True)
|
||||||
|
|
||||||
|
indexed = spy.call_args.args[0]
|
||||||
|
assert indexed.pk == root.pk
|
||||||
|
assert indexed.effective_content == "latest text"
|
||||||
|
|
||||||
|
def test_incremental_update_by_version_id_refreshes_the_root(
|
||||||
|
self,
|
||||||
|
temp_llm_index_dir: Path,
|
||||||
|
mock_embed_model: FakeEmbedding,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An indexed root document with a version whose root was modified since
|
||||||
|
WHEN:
|
||||||
|
- An incremental update is scoped to the version's id
|
||||||
|
THEN:
|
||||||
|
- The root's entry is refreshed and no entry exists for the version
|
||||||
|
"""
|
||||||
|
root = DocumentFactory()
|
||||||
|
version = DocumentFactory(root_document=root, version_index=1)
|
||||||
|
indexing.update_llm_index(rebuild=True)
|
||||||
|
Document.objects.filter(pk=root.pk).update(modified=timezone.now())
|
||||||
|
root.refresh_from_db()
|
||||||
|
|
||||||
|
indexing.update_llm_index(document_ids=[version.pk])
|
||||||
|
|
||||||
|
with indexing.get_vector_store() as store:
|
||||||
|
indexed = store.get_modified_times()
|
||||||
|
|
||||||
|
assert indexed == {str(root.pk): root.modified.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_add_or_update_document_updates_existing_entry(
|
def test_add_or_update_document_updates_existing_entry(
|
||||||
temp_llm_index_dir: Path,
|
temp_llm_index_dir: Path,
|
||||||
@@ -524,6 +626,39 @@ def test_retrieve_similar_nodes_truncates_query_to_embedding_chunk_size(
|
|||||||
assert "word199" not in query_text
|
assert "word199" not in query_text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(
|
||||||
|
LLM_EMBEDDING_BACKEND="huggingface",
|
||||||
|
LLM_BACKEND="ollama",
|
||||||
|
)
|
||||||
|
def test_retrieve_similar_nodes_queries_with_newest_versions_content(
|
||||||
|
temp_llm_index_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document whose newest version carries different content
|
||||||
|
WHEN:
|
||||||
|
- Documents similar to the root are retrieved
|
||||||
|
THEN:
|
||||||
|
- The query text holds the newest version's content, not the root's own
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(title="Statement", content="stale root text")
|
||||||
|
DocumentFactory(root_document=root, version_index=1, content="latest version text")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("paperless_ai.indexing.load_or_build_index"),
|
||||||
|
patch("paperless_ai.indexing.llm_index_exists", return_value=True),
|
||||||
|
patch("llama_index.core.retrievers.VectorIndexRetriever") as mock_retriever_cls,
|
||||||
|
):
|
||||||
|
mock_retriever_cls.return_value.retrieve.return_value = []
|
||||||
|
|
||||||
|
indexing.retrieve_similar_nodes(root, top_k=3)
|
||||||
|
|
||||||
|
query_text = mock_retriever_cls.return_value.retrieve.call_args.args[0]
|
||||||
|
assert "latest version text" in query_text
|
||||||
|
assert "stale root text" not in query_text
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateLlmIndexEmptyDocumentSet:
|
class TestUpdateLlmIndexEmptyDocumentSet:
|
||||||
"""update_llm_index must clear the vector store table when all documents are deleted.
|
"""update_llm_index must clear the vector store table when all documents are deleted.
|
||||||
|
|
||||||
@@ -637,6 +772,7 @@ class TestLlmIndexAddOrUpdateDocumentEmptyContent:
|
|||||||
|
|
||||||
doc = MagicMock(spec=Document)
|
doc = MagicMock(spec=Document)
|
||||||
doc.id = 42
|
doc.id = 42
|
||||||
|
doc.root_document_id = None
|
||||||
# Must not raise
|
# Must not raise
|
||||||
indexing.llm_index_add_or_update_document(doc)
|
indexing.llm_index_add_or_update_document(doc)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from paperless_ai.embedding import _normalize_llm_index_text
|
|||||||
from paperless_ai.embedding import build_llm_index_text
|
from paperless_ai.embedding import build_llm_index_text
|
||||||
from paperless_ai.embedding import get_configured_model_name
|
from paperless_ai.embedding import get_configured_model_name
|
||||||
from paperless_ai.embedding import get_embedding_model
|
from paperless_ai.embedding import get_embedding_model
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -46,6 +47,7 @@ def mock_document():
|
|||||||
doc.correspondent.name = "Test Correspondent"
|
doc.correspondent.name = "Test Correspondent"
|
||||||
doc.archive_serial_number = "12345"
|
doc.archive_serial_number = "12345"
|
||||||
doc.content = "This is the document content."
|
doc.content = "This is the document content."
|
||||||
|
doc.get_effective_content.return_value = "This is the document content."
|
||||||
|
|
||||||
cf1 = MagicMock(__str__=lambda x: "Value1")
|
cf1 = MagicMock(__str__=lambda x: "Value1")
|
||||||
cf1.field = MagicMock()
|
cf1.field = MagicMock()
|
||||||
@@ -280,7 +282,7 @@ def test_build_llm_index_text(mock_document):
|
|||||||
|
|
||||||
|
|
||||||
def test_build_llm_index_text_normalizes_ocr_punctuation_runs(mock_document):
|
def test_build_llm_index_text_normalizes_ocr_punctuation_runs(mock_document):
|
||||||
mock_document.content = (
|
mock_document.get_effective_content.return_value = (
|
||||||
"Introduction ................................................ 7\n"
|
"Introduction ................................................ 7\n"
|
||||||
"Hardware Limitation ________________________________________ 9\n"
|
"Hardware Limitation ________________________________________ 9\n"
|
||||||
"Keep short punctuation like INV-100 and ellipses..."
|
"Keep short punctuation like INV-100 and ellipses..."
|
||||||
@@ -294,6 +296,43 @@ def test_build_llm_index_text_normalizes_ocr_punctuation_runs(mock_document):
|
|||||||
assert "ellipses..." in result
|
assert "ellipses..." in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBuildLlmIndexTextVersions:
|
||||||
|
"""A root document is indexed with its effective content, like in the search index."""
|
||||||
|
|
||||||
|
def test_root_uses_the_newest_versions_content(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with two versions
|
||||||
|
WHEN:
|
||||||
|
- The LLM index text is built for the root
|
||||||
|
THEN:
|
||||||
|
- It contains the newest version's content and not the others'
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="stale text")
|
||||||
|
DocumentFactory(root_document=root, version_index=1, content="older text")
|
||||||
|
DocumentFactory(root_document=root, version_index=2, content="latest text")
|
||||||
|
|
||||||
|
text = build_llm_index_text(root)
|
||||||
|
|
||||||
|
assert "latest text" in text
|
||||||
|
assert "stale text" not in text
|
||||||
|
assert "older text" not in text
|
||||||
|
|
||||||
|
def test_root_without_versions_uses_its_own_content(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document without versions
|
||||||
|
WHEN:
|
||||||
|
- The LLM index text is built for it
|
||||||
|
THEN:
|
||||||
|
- It contains the document's own content
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="own text")
|
||||||
|
|
||||||
|
assert "own text" in build_llm_index_text(root)
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_llm_index_text_collapses_ocr_leaders_without_joining_lines():
|
def test_normalize_llm_index_text_collapses_ocr_leaders_without_joining_lines():
|
||||||
assert _normalize_llm_index_text("A........B\nC____D----E") == "A B\nC D E"
|
assert _normalize_llm_index_text("A........B\nC____D----E") == "A B\nC D E"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user