mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-09 19:27:59 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f8cf4cd6e |
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -380,7 +379,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
)
|
)
|
||||||
delete_ids = list({*doc_ids, *version_ids})
|
delete_ids = list({*doc_ids, *version_ids})
|
||||||
|
|
||||||
Document.objects.filter(id__in=delete_ids).delete(transaction_id=uuid.uuid4())
|
Document.objects.filter(id__in=delete_ids).delete()
|
||||||
|
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from documents.templating.workflows import parse_w_workflow_placeholders
|
|||||||
from documents.utils import compute_checksum
|
from documents.utils import compute_checksum
|
||||||
from documents.utils import copy_basic_file_stats
|
from documents.utils import copy_basic_file_stats
|
||||||
from documents.utils import copy_file_with_basic_stats
|
from documents.utils import copy_file_with_basic_stats
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
from documents.utils import run_subprocess
|
from documents.utils import run_subprocess
|
||||||
from paperless.config import OcrConfig
|
from paperless.config import OcrConfig
|
||||||
from paperless.config import RemoteOCRConfig
|
from paperless.config import RemoteOCRConfig
|
||||||
@@ -201,7 +202,9 @@ class ConsumerPluginMixin:
|
|||||||
|
|
||||||
self.renew_logging_group()
|
self.renew_logging_group()
|
||||||
|
|
||||||
self.filename = self.metadata.filename or self.input_doc.original_file.name
|
self.filename = normalize_unicode(
|
||||||
|
self.metadata.filename or self.input_doc.original_file.name,
|
||||||
|
)
|
||||||
|
|
||||||
def _send_progress(
|
def _send_progress(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -156,15 +156,6 @@ class FileStabilityTracker:
|
|||||||
logger.debug(f"File disappeared during stability check: {path}")
|
logger.debug(f"File disappeared during stability check: {path}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Stable, but empty: some scanners create a zero byte placeholder
|
|
||||||
# and only write the page some time later. Consuming it now can
|
|
||||||
# only fail so drop it and let the writer's next event
|
|
||||||
# (or the periodic rescan) bring it back once it has content
|
|
||||||
if not tracked.last_size:
|
|
||||||
to_remove.append(path)
|
|
||||||
logger.debug("Ignoring stable but empty file: %s", path)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# File is stable, we can return it
|
# File is stable, we can return it
|
||||||
to_yield.append(path)
|
to_yield.append(path)
|
||||||
logger.info(f"File is stable: {path}")
|
logger.info(f"File is stable: {path}")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from documents.models import Workflow
|
|||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.regex import safe_regex_search
|
from documents.regex import safe_regex_search
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
@@ -311,11 +312,12 @@ def consumable_document_matches_workflow(
|
|||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
# Document filename vs trigger filename
|
# Document filename vs trigger filename
|
||||||
|
document_filename = normalize_unicode(document.original_file.name)
|
||||||
if (
|
if (
|
||||||
trigger.filter_filename is not None
|
trigger.filter_filename is not None
|
||||||
and len(trigger.filter_filename) > 0
|
and len(trigger.filter_filename) > 0
|
||||||
and not fnmatch(
|
and not fnmatch(
|
||||||
document.original_file.name.lower(),
|
document_filename.lower(),
|
||||||
trigger.filter_filename.lower(),
|
trigger.filter_filename.lower(),
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
@@ -328,10 +330,12 @@ def consumable_document_matches_workflow(
|
|||||||
# Document path vs trigger path
|
# Document path vs trigger path
|
||||||
|
|
||||||
# Use the original_path if set, else us the original_file
|
# Use the original_path if set, else us the original_file
|
||||||
match_against = (
|
match_against = normalize_unicode(
|
||||||
document.original_path
|
str(
|
||||||
if document.original_path is not None
|
document.original_path
|
||||||
else document.original_file
|
if document.original_path is not None
|
||||||
|
else document.original_file,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -536,7 +540,7 @@ def existing_document_matches_workflow(
|
|||||||
and len(trigger.filter_filename) > 0
|
and len(trigger.filter_filename) > 0
|
||||||
and document.original_filename is not None
|
and document.original_filename is not None
|
||||||
and not fnmatch(
|
and not fnmatch(
|
||||||
document.original_filename.lower(),
|
normalize_unicode(document.original_filename).lower(),
|
||||||
trigger.filter_filename.lower(),
|
trigger.filter_filename.lower(),
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
|
|||||||
+4
-11
@@ -1,5 +1,4 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -28,6 +27,7 @@ from django_softdelete.models import SoftDeleteModel
|
|||||||
|
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
from documents.parsers import get_default_file_extension
|
from documents.parsers import get_default_file_extension
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
|
|
||||||
|
|
||||||
class ModelWithOwner(models.Model):
|
class ModelWithOwner(models.Model):
|
||||||
@@ -468,7 +468,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
context_document = (
|
context_document = (
|
||||||
self.root_document if self.root_document_id is not None else self
|
self.root_document if self.root_document_id is not None else self
|
||||||
)
|
)
|
||||||
result = str(context_document)
|
result = normalize_unicode(str(context_document))
|
||||||
|
|
||||||
if counter:
|
if counter:
|
||||||
result += f"_{counter:02}"
|
result += f"_{counter:02}"
|
||||||
@@ -515,20 +515,13 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
*args,
|
*args,
|
||||||
transaction_id=None,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
# Versions must share the root's transaction ID so they are restored
|
# If deleting a root document, move all its versions to trash as well.
|
||||||
# together by django-softdelete.
|
|
||||||
if transaction_id is None:
|
|
||||||
transaction_id = uuid.uuid4()
|
|
||||||
if self.root_document_id is None:
|
if self.root_document_id is None:
|
||||||
Document.objects.filter(root_document=self).delete(
|
Document.objects.filter(root_document=self).delete()
|
||||||
transaction_id=transaction_id,
|
|
||||||
)
|
|
||||||
return super().delete(
|
return super().delete(
|
||||||
*args,
|
*args,
|
||||||
transaction_id=transaction_id,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ from documents.regex import validate_regex_pattern
|
|||||||
from documents.templating.filepath import validate_filepath_template_and_render
|
from documents.templating.filepath import validate_filepath_template_and_render
|
||||||
from documents.templating.utils import convert_format_str_to_template_format
|
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.utils import normalize_unicode
|
||||||
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 sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
@@ -3120,6 +3121,13 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer[WorkflowTrigger]):
|
|||||||
):
|
):
|
||||||
attrs["filter_path"] = None
|
attrs["filter_path"] = None
|
||||||
|
|
||||||
|
# Normalize once at write time, since these are matched against many
|
||||||
|
# documents but edited rarely
|
||||||
|
if attrs.get("filter_filename") is not None:
|
||||||
|
attrs["filter_filename"] = normalize_unicode(attrs["filter_filename"])
|
||||||
|
if attrs.get("filter_path") is not None:
|
||||||
|
attrs["filter_path"] = normalize_unicode(attrs["filter_path"])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
"filter_custom_field_query" in attrs
|
"filter_custom_field_query" in attrs
|
||||||
and attrs["filter_custom_field_query"] is not None
|
and attrs["filter_custom_field_query"] is not None
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from pathlib import PurePath
|
from pathlib import PurePath
|
||||||
|
|
||||||
@@ -26,6 +25,7 @@ from documents.templating.environment import _template_environment
|
|||||||
from documents.templating.filters import format_datetime
|
from documents.templating.filters import format_datetime
|
||||||
from documents.templating.filters import get_cf_value
|
from documents.templating.filters import get_cf_value
|
||||||
from documents.templating.filters import localize_date
|
from documents.templating.filters import localize_date
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.templating")
|
logger = logging.getLogger("paperless.templating")
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ class FilePathTemplate(Template):
|
|||||||
3. Removing extra spaces before and after forward slashes
|
3. Removing extra spaces before and after forward slashes
|
||||||
4. Preserving spaces in other parts of the path
|
4. Preserving spaces in other parts of the path
|
||||||
"""
|
"""
|
||||||
value = unicodedata.normalize("NFC", value)
|
value = normalize_unicode(value)
|
||||||
value = value.replace("\n", "").replace("\r", "")
|
value = value.replace("\n", "").replace("\r", "")
|
||||||
value = re.sub(r"\s*/\s*", "/", value)
|
value = re.sub(r"\s*/\s*", "/", value)
|
||||||
|
|
||||||
@@ -184,17 +184,17 @@ def get_basic_metadata_context(
|
|||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"title": pathvalidate.sanitize_filename(
|
"title": pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", document.title),
|
normalize_unicode(document.title),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
),
|
),
|
||||||
"correspondent": pathvalidate.sanitize_filename(
|
"correspondent": pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", document.correspondent.name),
|
normalize_unicode(document.correspondent.name),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
)
|
)
|
||||||
if document.correspondent
|
if document.correspondent
|
||||||
else no_value_default,
|
else no_value_default,
|
||||||
"document_type": pathvalidate.sanitize_filename(
|
"document_type": pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", document.document_type.name),
|
normalize_unicode(document.document_type.name),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
)
|
)
|
||||||
if document.document_type
|
if document.document_type
|
||||||
@@ -205,8 +205,7 @@ def get_basic_metadata_context(
|
|||||||
"owner_username": document.owner.username
|
"owner_username": document.owner.username
|
||||||
if document.owner
|
if document.owner
|
||||||
else no_value_default,
|
else no_value_default,
|
||||||
"original_name": unicodedata.normalize(
|
"original_name": normalize_unicode(
|
||||||
"NFC",
|
|
||||||
PurePath(document.original_filename).with_suffix("").name,
|
PurePath(document.original_filename).with_suffix("").name,
|
||||||
)
|
)
|
||||||
if document.original_filename
|
if document.original_filename
|
||||||
@@ -275,12 +274,12 @@ def get_tags_context(tags: Iterable[Tag]) -> dict[str, str | list[str]]:
|
|||||||
return {
|
return {
|
||||||
"tag_list": pathvalidate.sanitize_filename(
|
"tag_list": pathvalidate.sanitize_filename(
|
||||||
",".join(
|
",".join(
|
||||||
sorted(unicodedata.normalize("NFC", tag.name) for tag in tags),
|
sorted(normalize_unicode(tag.name) for tag in tags),
|
||||||
),
|
),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
),
|
),
|
||||||
# Assumed to be ordered, but a template could loop through to find what they want
|
# Assumed to be ordered, but a template could loop through to find what they want
|
||||||
"tag_name_list": [unicodedata.normalize("NFC", x.name) for x in tags],
|
"tag_name_list": [normalize_unicode(x.name) for x in tags],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -307,7 +306,7 @@ def get_custom_fields_context(
|
|||||||
CustomField.FieldDataType.LONG_TEXT,
|
CustomField.FieldDataType.LONG_TEXT,
|
||||||
}:
|
}:
|
||||||
value = pathvalidate.sanitize_filename(
|
value = pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", field_instance.value),
|
normalize_unicode(field_instance.value),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
@@ -316,8 +315,7 @@ def get_custom_fields_context(
|
|||||||
):
|
):
|
||||||
options = field_instance.field.extra_data["select_options"]
|
options = field_instance.field.extra_data["select_options"]
|
||||||
value = pathvalidate.sanitize_filename(
|
value = pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize(
|
normalize_unicode(
|
||||||
"NFC",
|
|
||||||
next(
|
next(
|
||||||
option["label"]
|
option["label"]
|
||||||
for option in options
|
for option in options
|
||||||
@@ -330,7 +328,7 @@ def get_custom_fields_context(
|
|||||||
value = field_instance.value
|
value = field_instance.value
|
||||||
field_data["custom_fields"][
|
field_data["custom_fields"][
|
||||||
pathvalidate.sanitize_filename(
|
pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", field_instance.field.name),
|
normalize_unicode(field_instance.field.name),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
)
|
)
|
||||||
] = {
|
] = {
|
||||||
|
|||||||
@@ -207,65 +207,3 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("have not yet been deleted", resp.data["documents"][0])
|
self.assertIn("have not yet been deleted", resp.data["documents"][0])
|
||||||
|
|
||||||
def _make_versioned_document(self) -> tuple[Document, list[Document]]:
|
|
||||||
root = Document.objects.create(
|
|
||||||
title="root",
|
|
||||||
content="root-content",
|
|
||||||
checksum="root",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
versions = [
|
|
||||||
Document.objects.create(
|
|
||||||
title=f"v{index}",
|
|
||||||
content=f"v{index}-content",
|
|
||||||
checksum=f"v{index}",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
root_document=root,
|
|
||||||
version_index=index,
|
|
||||||
)
|
|
||||||
for index in range(1, 3)
|
|
||||||
]
|
|
||||||
return root, versions
|
|
||||||
|
|
||||||
def test_api_trash_restore_document_restores_its_versions(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Existing document with two versions
|
|
||||||
WHEN:
|
|
||||||
- API request to delete the document
|
|
||||||
- API request to restore it from the trash
|
|
||||||
THEN:
|
|
||||||
- Only the document itself is listed in the trash
|
|
||||||
- A version cannot be restored without its root
|
|
||||||
- The document is restored together with all of its versions
|
|
||||||
"""
|
|
||||||
root, versions = self._make_versioned_document()
|
|
||||||
|
|
||||||
self.client.force_login(user=self.user)
|
|
||||||
self.client.delete(f"/api/documents/{root.pk}/")
|
|
||||||
self.assertEqual(Document.deleted_objects.count(), 3)
|
|
||||||
|
|
||||||
resp = self.client.get("/api/trash/")
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(resp.data["count"], 1)
|
|
||||||
self.assertEqual(resp.data["results"][0]["id"], root.pk)
|
|
||||||
|
|
||||||
# A version cannot be restored while its root remains in the trash.
|
|
||||||
resp = self.client.post(
|
|
||||||
"/api/trash/",
|
|
||||||
{"action": "restore", "documents": [versions[0].pk]},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
self.assertIn("Restore the root document", resp.data["documents"][0])
|
|
||||||
|
|
||||||
resp = self.client.post(
|
|
||||||
"/api/trash/",
|
|
||||||
{"action": "restore", "documents": [root.pk]},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(Document.deleted_objects.count(), 0)
|
|
||||||
self.assertCountEqual(
|
|
||||||
Document.objects.filter(root_document=root).values_list("id", flat=True),
|
|
||||||
[version.pk for version in versions],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import unicodedata
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import celery.result
|
||||||
|
import pytest
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.data_models import ConsumableDocument
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def consume_file_mock():
|
||||||
|
with mock.patch("documents.tasks.consume_file.apply_async") as m:
|
||||||
|
m.return_value = celery.result.AsyncResult(id="test-task-id")
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def directories(tmp_path, settings, _media_settings):
|
||||||
|
scratch = tmp_path / "scratch"
|
||||||
|
scratch.mkdir()
|
||||||
|
settings.SCRATCH_DIR = scratch
|
||||||
|
return scratch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestUpdateVersionNFCNormalization:
|
||||||
|
def test_nfd_filename_normalized_to_nfc(
|
||||||
|
self,
|
||||||
|
admin_client,
|
||||||
|
consume_file_mock: mock.MagicMock,
|
||||||
|
directories,
|
||||||
|
):
|
||||||
|
"""Uploaded new-version file with NFD filename must have its temp name stored as NFC."""
|
||||||
|
document = Document.objects.create(
|
||||||
|
title="Test",
|
||||||
|
content="content",
|
||||||
|
checksum="checksum",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
nfd = unicodedata.normalize("NFD", "Rechnung März.pdf")
|
||||||
|
nfc = unicodedata.normalize("NFC", "Rechnung März.pdf")
|
||||||
|
|
||||||
|
assert nfd != nfc
|
||||||
|
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
nfd,
|
||||||
|
b"%PDF-1.4 test",
|
||||||
|
content_type="application/pdf",
|
||||||
|
)
|
||||||
|
response = admin_client.post(
|
||||||
|
f"/api/documents/{document.pk}/update_version/",
|
||||||
|
{"document": uploaded},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
task_kwargs = consume_file_mock.call_args.kwargs["kwargs"]
|
||||||
|
input_doc: ConsumableDocument = task_kwargs["input_doc"]
|
||||||
|
|
||||||
|
assert input_doc.original_file.name == nfc, (
|
||||||
|
f"Expected NFC filename {nfc!r}, got {input_doc.original_file.name!r}"
|
||||||
|
)
|
||||||
|
assert unicodedata.is_normalized("NFC", input_doc.original_file.name)
|
||||||
@@ -392,11 +392,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
|
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
|
||||||
self.assertFalse(Document.objects.filter(id=version.id).exists())
|
self.assertFalse(Document.objects.filter(id=version.id).exists())
|
||||||
|
|
||||||
Document.deleted_objects.get(id=self.doc1.id).restore(strict=False)
|
|
||||||
|
|
||||||
self.assertTrue(Document.objects.filter(id=self.doc1.id).exists())
|
|
||||||
self.assertTrue(Document.objects.filter(id=version.id).exists())
|
|
||||||
|
|
||||||
def test_delete_version_document_keeps_root(self) -> None:
|
def test_delete_version_document_keeps_root(self) -> None:
|
||||||
version = Document.objects.create(
|
version = Document.objects.create(
|
||||||
checksum="A-v1",
|
checksum="A-v1",
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class TestDocument(TestCase):
|
|||||||
checksum="checksum",
|
checksum="checksum",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
version = Document.objects.create(
|
Document.objects.create(
|
||||||
root_document=root,
|
root_document=root,
|
||||||
correspondent=root.correspondent,
|
correspondent=root.correspondent,
|
||||||
title="Version",
|
title="Version",
|
||||||
@@ -124,10 +124,6 @@ class TestDocument(TestCase):
|
|||||||
self.assertEqual(Document.objects.count(), 0)
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
self.assertEqual(Document.deleted_objects.count(), 2)
|
self.assertEqual(Document.deleted_objects.count(), 2)
|
||||||
|
|
||||||
root.restore(strict=False)
|
|
||||||
|
|
||||||
self.assertTrue(Document.objects.filter(pk=version.pk).exists())
|
|
||||||
|
|
||||||
def test_file_name(self) -> None:
|
def test_file_name(self) -> None:
|
||||||
doc = Document(
|
doc = Document(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import unicodedata
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Correspondent
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestGetPublicFilenameNfc:
|
||||||
|
def test_normalizes_nfd_title_to_nfc(self) -> None:
|
||||||
|
nfd_title = unicodedata.normalize("NFD", "Gehaltserhöhung")
|
||||||
|
assert not unicodedata.is_normalized("NFC", nfd_title)
|
||||||
|
|
||||||
|
doc = Document(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
title=nfd_title,
|
||||||
|
created=date(2025, 10, 17),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = doc.get_public_filename()
|
||||||
|
|
||||||
|
assert unicodedata.is_normalized("NFC", result)
|
||||||
|
assert (
|
||||||
|
result
|
||||||
|
== "2025-10-17 "
|
||||||
|
+ unicodedata.normalize(
|
||||||
|
"NFC",
|
||||||
|
nfd_title,
|
||||||
|
)
|
||||||
|
+ ".pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_normalizes_nfd_correspondent_name_to_nfc(self) -> None:
|
||||||
|
nfd_name = unicodedata.normalize("NFD", "Müller GmbH")
|
||||||
|
correspondent = Correspondent.objects.create(name=nfd_name)
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
title="Rechnung",
|
||||||
|
created=date(2025, 10, 17),
|
||||||
|
correspondent=correspondent,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = doc.get_public_filename()
|
||||||
|
|
||||||
|
assert unicodedata.is_normalized("NFC", result)
|
||||||
@@ -136,23 +136,6 @@ def wait_for_mock_call(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def sleep_past_stability(
|
|
||||||
owner: FileStabilityTracker | ConsumerThread,
|
|
||||||
*,
|
|
||||||
windows: float = 1.5,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Block until a tracked file's stability window has certainly elapsed.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
owner: The tracker, or the consumer thread running one, whose
|
|
||||||
configured stability delay sets the wait.
|
|
||||||
windows: How many stability windows to wait, giving slop for a slow
|
|
||||||
or loaded test runner.
|
|
||||||
"""
|
|
||||||
sleep(owner.stability_delay * windows)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTrackedFile:
|
class TestTrackedFile:
|
||||||
"""Tests for the TrackedFile dataclass."""
|
"""Tests for the TrackedFile dataclass."""
|
||||||
|
|
||||||
@@ -278,56 +261,6 @@ class TestFileStabilityTracker:
|
|||||||
assert len(stable) == 0
|
assert len(stable) == 0
|
||||||
assert stability_tracker.pending_count == 1
|
assert stability_tracker.pending_count == 1
|
||||||
|
|
||||||
def test_get_stable_files_skips_empty_file(
|
|
||||||
self,
|
|
||||||
stability_tracker: FileStabilityTracker,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A zero byte file, tracked and past its stability delay
|
|
||||||
WHEN:
|
|
||||||
- Stable files are collected
|
|
||||||
THEN:
|
|
||||||
- The file is not yielded for consumption
|
|
||||||
- The file is dropped from tracking rather than held, so an
|
|
||||||
abandoned placeholder does not keep the watch loop awake
|
|
||||||
"""
|
|
||||||
empty = tmp_path / "scan.pdf"
|
|
||||||
empty.write_bytes(b"")
|
|
||||||
stability_tracker.track(empty, Change.added)
|
|
||||||
sleep_past_stability(stability_tracker)
|
|
||||||
|
|
||||||
stable = list(stability_tracker.get_stable_files())
|
|
||||||
|
|
||||||
assert stable == []
|
|
||||||
assert stability_tracker.pending_count == 0
|
|
||||||
|
|
||||||
def test_empty_file_is_yielded_once_content_arrives(
|
|
||||||
self,
|
|
||||||
stability_tracker: FileStabilityTracker,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A zero byte file which was dropped from tracking while empty
|
|
||||||
WHEN:
|
|
||||||
- The writer fills the file and a new event re-tracks it
|
|
||||||
THEN:
|
|
||||||
- The file is yielded for consumption once it is stable
|
|
||||||
"""
|
|
||||||
target = tmp_path / "scan.pdf"
|
|
||||||
target.write_bytes(b"")
|
|
||||||
stability_tracker.track(target, Change.added)
|
|
||||||
sleep_past_stability(stability_tracker)
|
|
||||||
assert list(stability_tracker.get_stable_files()) == []
|
|
||||||
|
|
||||||
target.write_bytes(b"%PDF-1.4 content")
|
|
||||||
stability_tracker.track(target, Change.modified)
|
|
||||||
sleep_past_stability(stability_tracker)
|
|
||||||
|
|
||||||
assert list(stability_tracker.get_stable_files()) == [target]
|
|
||||||
|
|
||||||
def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None:
|
def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None:
|
||||||
"""Test deleted file is not returned during stability check."""
|
"""Test deleted file is not returned during stability check."""
|
||||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||||
@@ -946,51 +879,6 @@ class TestCommandWatch:
|
|||||||
|
|
||||||
mock_consume_file_delay.apply_async.assert_called()
|
mock_consume_file_delay.apply_async.assert_called()
|
||||||
|
|
||||||
def test_scanner_placeholder_is_not_consumed_while_empty(
|
|
||||||
self,
|
|
||||||
consumption_dir: Path,
|
|
||||||
sample_pdf: Path,
|
|
||||||
mock_consume_file_delay: MagicMock,
|
|
||||||
start_consumer: Callable[..., ConsumerThread],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A scanner which creates a zero byte placeholder and only writes
|
|
||||||
the page some time later (GH discussion #13969)
|
|
||||||
WHEN:
|
|
||||||
- The placeholder sits untouched well past the stability delay
|
|
||||||
- The scanner then writes the real content
|
|
||||||
THEN:
|
|
||||||
- The empty placeholder is never queued, as it could only fail
|
|
||||||
with "Unsupported mime type inode/x-empty"
|
|
||||||
- The file is queued exactly once, when the content lands
|
|
||||||
"""
|
|
||||||
thread = start_consumer(stability_delay=0.2)
|
|
||||||
|
|
||||||
target = consumption_dir / "scan.pdf"
|
|
||||||
target.write_bytes(b"") # the scanner's placeholder
|
|
||||||
|
|
||||||
# Well past the stability delay: the old behaviour queued it here.
|
|
||||||
sleep_past_stability(thread, windows=5)
|
|
||||||
if thread.exception:
|
|
||||||
raise thread.exception
|
|
||||||
assert mock_consume_file_delay.apply_async.call_count == 0
|
|
||||||
|
|
||||||
shutil.copy(sample_pdf, target) # the scanner finishes the page
|
|
||||||
|
|
||||||
assert wait_for_mock_call(
|
|
||||||
mock_consume_file_delay.apply_async,
|
|
||||||
timeout_s=5.0,
|
|
||||||
)
|
|
||||||
if thread.exception:
|
|
||||||
raise thread.exception
|
|
||||||
|
|
||||||
assert mock_consume_file_delay.apply_async.call_count == 1
|
|
||||||
queued_doc = mock_consume_file_delay.apply_async.call_args.kwargs["kwargs"][
|
|
||||||
"input_doc"
|
|
||||||
]
|
|
||||||
assert queued_doc.original_file.name == "scan.pdf"
|
|
||||||
|
|
||||||
def test_ignores_macos_files(
|
def test_ignores_macos_files(
|
||||||
self,
|
self,
|
||||||
consumption_dir: Path,
|
consumption_dir: Path,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import unicodedata
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.data_models import ConsumableDocument
|
||||||
|
from documents.data_models import DocumentSource
|
||||||
|
from documents.matching import consumable_document_matches_workflow
|
||||||
|
from documents.matching import existing_document_matches_workflow
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import Workflow
|
||||||
|
from documents.models import WorkflowTrigger
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestMatchingNfcNormalization:
|
||||||
|
def test_consumable_document_filename_nfd_matches_nfc_pattern(
|
||||||
|
self,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A file on disk whose name is NFD-normalized
|
||||||
|
- A workflow trigger filename filter typed as NFC
|
||||||
|
WHEN:
|
||||||
|
- The consumable document is checked against the trigger
|
||||||
|
THEN:
|
||||||
|
- It matches, because both sides are normalized before comparing
|
||||||
|
"""
|
||||||
|
nfd_name = unicodedata.normalize("NFD", "Gehaltserhöhung.pdf")
|
||||||
|
nfc_pattern = unicodedata.normalize("NFC", "*Gehaltserhöhung*")
|
||||||
|
assert nfd_name != unicodedata.normalize("NFC", nfd_name)
|
||||||
|
|
||||||
|
file_path = tmp_path / nfd_name
|
||||||
|
file_path.write_bytes(b"%PDF-1.4 test")
|
||||||
|
|
||||||
|
document = ConsumableDocument(
|
||||||
|
source=DocumentSource.ConsumeFolder,
|
||||||
|
original_file=file_path,
|
||||||
|
)
|
||||||
|
trigger = WorkflowTrigger(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
filter_filename=nfc_pattern,
|
||||||
|
sources=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
matched, reason = consumable_document_matches_workflow(document, trigger)
|
||||||
|
|
||||||
|
assert matched, reason
|
||||||
|
|
||||||
|
def test_existing_document_filename_nfd_matches_nfc_pattern(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A Document whose original_filename is NFD-normalized (e.g. from
|
||||||
|
before normalization was applied at consumption time)
|
||||||
|
- A workflow trigger filename filter typed as NFC
|
||||||
|
WHEN:
|
||||||
|
- The document is checked against the trigger
|
||||||
|
THEN:
|
||||||
|
- It matches, because both sides are normalized before comparing
|
||||||
|
"""
|
||||||
|
nfd_name = unicodedata.normalize("NFD", "Gehaltserhöhung.pdf")
|
||||||
|
nfc_pattern = unicodedata.normalize("NFC", "*Gehaltserhöhung*")
|
||||||
|
|
||||||
|
document = Document.objects.create(
|
||||||
|
title="Test",
|
||||||
|
content="content",
|
||||||
|
checksum="checksum",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
original_filename=nfd_name,
|
||||||
|
)
|
||||||
|
workflow = Workflow.objects.create(name="Test workflow", order=0)
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
filter_filename=nfc_pattern,
|
||||||
|
)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
|
||||||
|
matched, reason = existing_document_matches_workflow(document, trigger)
|
||||||
|
|
||||||
|
assert matched, reason
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from documents.utils import normalize_unicode
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeUnicode:
|
||||||
|
def test_none_passes_through(self) -> None:
|
||||||
|
assert normalize_unicode(None) is None
|
||||||
@@ -32,7 +32,6 @@ from documents.signals.handlers import update_llm_suggestions_cache
|
|||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
from documents.tests.utils import read_streaming_response
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
|
|
||||||
@@ -738,38 +737,6 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
|
||||||
@override_settings(
|
|
||||||
AI_ENABLED=True,
|
|
||||||
LLM_BACKEND="openai-like",
|
|
||||||
)
|
|
||||||
def test_ai_suggestions_with_llm_provider_error(
|
|
||||||
self,
|
|
||||||
mock_get_ai_classification,
|
|
||||||
) -> None:
|
|
||||||
mock_get_ai_classification.side_effect = LLMProviderError(
|
|
||||||
"confidential provider response",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_login(user=self.user)
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY)
|
|
||||||
self.assertEqual(
|
|
||||||
response.json(),
|
|
||||||
{
|
|
||||||
"ai": [
|
|
||||||
"AI backend rejected the request. Check logs for details.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertNotIn("confidential provider response", response.content.decode())
|
|
||||||
self.assertIsNone(
|
|
||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("documents.views.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import unicodedata
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@@ -31,6 +32,25 @@ def identity(iterable: Iterable[_T]) -> Iterable[_T]:
|
|||||||
return iterable
|
return iterable
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_unicode(value: str | None) -> str | None:
|
||||||
|
"""
|
||||||
|
Normalize a string to Unicode NFC form, or return None unchanged.
|
||||||
|
|
||||||
|
This is the single normalization pass for any user- or filesystem-supplied
|
||||||
|
text that ends up in a filename, path, or is compared/matched against one
|
||||||
|
(titles, correspondent/tag/type names, uploaded filenames, workflow and
|
||||||
|
mail rule filename/path filters). Composed (NFC) and decomposed (NFD)
|
||||||
|
forms of the same visible text are different byte sequences, which breaks
|
||||||
|
exact comparisons and filesystem lookups even though the text looks
|
||||||
|
identical. Always normalize through this function rather than calling
|
||||||
|
unicodedata.normalize() directly, so every call site agrees on the same
|
||||||
|
form.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return unicodedata.normalize("NFC", value)
|
||||||
|
|
||||||
|
|
||||||
class QuerySetStream(Generic[_M]):
|
class QuerySetStream(Generic[_M]):
|
||||||
"""Stream a QuerySet via .iterator(chunk_size=...) instead of
|
"""Stream a QuerySet via .iterator(chunk_size=...) instead of
|
||||||
materializing it (plus any prefetch caches) all at once, while still
|
materializing it (plus any prefetch caches) all at once, while still
|
||||||
|
|||||||
+5
-31
@@ -231,6 +231,7 @@ from documents.tasks import sanity_check
|
|||||||
from documents.tasks import train_classifier
|
from documents.tasks import train_classifier
|
||||||
from documents.tasks import update_document_parent_tags
|
from documents.tasks import update_document_parent_tags
|
||||||
from documents.utils import get_boolean
|
from documents.utils import get_boolean
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
from documents.versioning import VersionResolutionError
|
from documents.versioning import VersionResolutionError
|
||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
from documents.versioning import get_latest_version_for_root
|
from documents.versioning import get_latest_version_for_root
|
||||||
@@ -252,7 +253,6 @@ from paperless.views import StandardPagination
|
|||||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||||
from paperless_ai.ai_classifier import get_llm_output_language
|
from paperless_ai.ai_classifier import get_llm_output_language
|
||||||
from paperless_ai.chat import stream_chat_with_documents
|
from paperless_ai.chat import stream_chat_with_documents
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
from paperless_ai.matching import extract_unmatched_names
|
from paperless_ai.matching import extract_unmatched_names
|
||||||
from paperless_ai.matching import match_correspondents_by_name
|
from paperless_ai.matching import match_correspondents_by_name
|
||||||
@@ -1604,22 +1604,6 @@ class DocumentViewSet(
|
|||||||
{"ai": [_("AI backend request timed out.")]},
|
{"ai": [_("AI backend request timed out.")]},
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
except LLMProviderError:
|
|
||||||
logger.exception(
|
|
||||||
"AI backend rejected the request for document %s",
|
|
||||||
doc.pk,
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"ai": [
|
|
||||||
_(
|
|
||||||
"AI backend rejected the request. "
|
|
||||||
"Check logs for details.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
status=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
)
|
|
||||||
set_llm_suggestions_cache(
|
set_llm_suggestions_cache(
|
||||||
doc.pk,
|
doc.pk,
|
||||||
llm_suggestions,
|
llm_suggestions,
|
||||||
@@ -2085,6 +2069,7 @@ class DocumentViewSet(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
doc_name, doc_data = serializer.validated_data.get("document")
|
doc_name, doc_data = serializer.validated_data.get("document")
|
||||||
|
doc_name = normalize_unicode(doc_name)
|
||||||
version_label = serializer.validated_data.get("version_label")
|
version_label = serializer.validated_data.get("version_label")
|
||||||
|
|
||||||
t = int(mktime(datetime.now().timetuple()))
|
t = int(mktime(datetime.now().timetuple()))
|
||||||
@@ -3351,7 +3336,7 @@ class PostDocumentView(GenericAPIView[Any]):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
doc_name, doc_data = serializer.validated_data.get("document")
|
doc_name, doc_data = serializer.validated_data.get("document")
|
||||||
doc_name = normalize("NFC", doc_name)
|
doc_name = normalize_unicode(doc_name)
|
||||||
correspondent_id = serializer.validated_data.get("correspondent")
|
correspondent_id = serializer.validated_data.get("correspondent")
|
||||||
document_type_id = serializer.validated_data.get("document_type")
|
document_type_id = serializer.validated_data.get("document_type")
|
||||||
storage_path_id = serializer.validated_data.get("storage_path")
|
storage_path_id = serializer.validated_data.get("storage_path")
|
||||||
@@ -5454,10 +5439,7 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
|
|
||||||
model = Document
|
model = Document
|
||||||
|
|
||||||
# A version is listed separately only when its root is not in the trash.
|
queryset = Document.deleted_objects.all()
|
||||||
queryset = Document.deleted_objects.exclude(
|
|
||||||
root_document_id__in=Document.deleted_objects.values("id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, request: Request, format: str | None = None) -> Response:
|
def get(self, request: Request, format: str | None = None) -> Response:
|
||||||
self.serializer_class = DocumentSerializer
|
self.serializer_class = DocumentSerializer
|
||||||
@@ -5488,15 +5470,7 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
action = serializer.validated_data.get("action")
|
action = serializer.validated_data.get("action")
|
||||||
if action == "restore":
|
if action == "restore":
|
||||||
restored = list(self.get_queryset().filter(id__in=doc_ids))
|
restored = list(Document.deleted_objects.filter(id__in=doc_ids))
|
||||||
if len(restored) != len(doc_ids):
|
|
||||||
raise ValidationError(
|
|
||||||
{
|
|
||||||
"documents": [
|
|
||||||
"Restore the root document instead of one of its versions.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
for doc in restored:
|
for doc in restored:
|
||||||
doc.restore(strict=False)
|
doc.restore(strict=False)
|
||||||
if restored:
|
if restored:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,24 +4,21 @@ from django.conf import settings
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
|
||||||
from documents.permissions import user_is_unrestricted
|
|
||||||
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
|
||||||
from paperless_ai.base_model import classification_suggestions_to_model
|
from paperless_ai.base_model import classification_suggestions_to_model
|
||||||
from paperless_ai.client import AIClient
|
from paperless_ai.client import AIClient
|
||||||
from paperless_ai.db import db_connection_released
|
from paperless_ai.db import db_connection_released
|
||||||
|
from paperless_ai.indexing import _node_document_ids
|
||||||
from paperless_ai.indexing import retrieve_similar_nodes
|
from paperless_ai.indexing import retrieve_similar_nodes
|
||||||
from paperless_ai.indexing import truncate_content
|
from paperless_ai.indexing import truncate_content
|
||||||
from paperless_ai.prompts.context import ClassificationPromptContext
|
from paperless_ai.prompts.context import ClassificationPromptContext
|
||||||
from paperless_ai.prompts.context import LocalizationPromptContext
|
from paperless_ai.prompts.context import LocalizationPromptContext
|
||||||
from paperless_ai.prompts.context import RagContextPromptContext
|
from paperless_ai.prompts.context import RagContextPromptContext
|
||||||
from paperless_ai.prompts.render import render_prompt
|
from paperless_ai.prompts.render import render_prompt
|
||||||
from paperless_ai.taxonomy import SimilarDocument
|
|
||||||
from paperless_ai.taxonomy import TaxonomyCandidates
|
from paperless_ai.taxonomy import TaxonomyCandidates
|
||||||
from paperless_ai.taxonomy import _node_document_weights
|
|
||||||
from paperless_ai.taxonomy import build_taxonomy_candidates
|
from paperless_ai.taxonomy import build_taxonomy_candidates
|
||||||
from paperless_ai.taxonomy import empty_taxonomy_candidates
|
from paperless_ai.taxonomy import empty_taxonomy_candidates
|
||||||
from paperless_ai.taxonomy import format_taxonomy_for_prompt
|
from paperless_ai.taxonomy import format_taxonomy_for_prompt
|
||||||
@@ -40,48 +37,6 @@ logger = logging.getLogger("paperless_ai.rag_classifier")
|
|||||||
TAXONOMY_CANDIDATE_TOP_K = 15
|
TAXONOMY_CANDIDATE_TOP_K = 15
|
||||||
|
|
||||||
|
|
||||||
def _fulltext_similar_documents(
|
|
||||||
document: Document,
|
|
||||||
user: User | None,
|
|
||||||
top_k: int,
|
|
||||||
) -> list[SimilarDocument]:
|
|
||||||
"""Rank-based fallback when no embedding backend is configured. Uses
|
|
||||||
Tantivy's "More Like This" (term-overlap similarity) instead of vector
|
|
||||||
similarity - cruder, but far better than no candidates at all.
|
|
||||||
more_like_this_ids returns only a ranked ID list, no scores, so weight is
|
|
||||||
synthesized from rank (descending from top_k) rather than claiming a
|
|
||||||
similarity magnitude that doesn't exist. An unrestricted user (none, or an
|
|
||||||
active superuser - see user_is_unrestricted) is normalized to ``None``
|
|
||||||
before calling, since the backend's permission filter has no superuser
|
|
||||||
short-circuit of its own. Results are re-checked with
|
|
||||||
restrict_queryset_to_visible() since Tantivy's indexed permission fields
|
|
||||||
lag the DB via async reindexing.
|
|
||||||
"""
|
|
||||||
from documents.search import get_backend
|
|
||||||
|
|
||||||
unrestricted = user_is_unrestricted(user)
|
|
||||||
search_user = None if unrestricted else user
|
|
||||||
backend = get_backend()
|
|
||||||
similar_ids = backend.more_like_this_ids(
|
|
||||||
document.pk,
|
|
||||||
user=search_user,
|
|
||||||
limit=top_k,
|
|
||||||
)
|
|
||||||
if not unrestricted:
|
|
||||||
allowed_ids = set(
|
|
||||||
restrict_queryset_to_visible(
|
|
||||||
Document.objects.filter(pk__in=similar_ids),
|
|
||||||
user,
|
|
||||||
"view_document",
|
|
||||||
).values_list("pk", flat=True),
|
|
||||||
)
|
|
||||||
similar_ids = [doc_id for doc_id in similar_ids if doc_id in allowed_ids]
|
|
||||||
return [
|
|
||||||
SimilarDocument(document_id=doc_id, weight=float(top_k - rank))
|
|
||||||
for rank, doc_id in enumerate(similar_ids)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_language_name(language_code: str) -> str:
|
def get_language_name(language_code: str) -> str:
|
||||||
normalized_language_code = language_code.lower()
|
normalized_language_code = language_code.lower()
|
||||||
for code, name in settings.LANGUAGES:
|
for code, name in settings.LANGUAGES:
|
||||||
@@ -181,52 +136,43 @@ def get_taxonomy_context(
|
|||||||
user: User | None = None,
|
user: User | None = None,
|
||||||
max_docs: int = 5,
|
max_docs: int = 5,
|
||||||
) -> tuple[TaxonomyCandidates, str]:
|
) -> tuple[TaxonomyCandidates, str]:
|
||||||
"""One retrieval feeds both taxonomy candidates and RAG text context. Uses
|
"""One retrieval feeds both taxonomy candidates and RAG text context.
|
||||||
vector similarity when an embedding backend is configured, otherwise
|
On any retrieval failure, degrades to empty candidates/context rather than
|
||||||
falls back to Tantivy full-text "More Like This" similarity - see
|
propagating the exception - a vector-store outage should not block
|
||||||
_fulltext_similar_documents. On any retrieval failure, degrades to empty
|
classification, only its RAG-assisted enrichment.
|
||||||
candidates/context rather than propagating the exception - neither a
|
|
||||||
vector-store outage nor a search-index issue should block classification,
|
|
||||||
only its context-assisted enrichment.
|
|
||||||
"""
|
"""
|
||||||
ai_config = AIConfig()
|
|
||||||
try:
|
try:
|
||||||
if ai_config.llm_embedding_backend:
|
# None means "no restriction" to retrieve_similar_nodes. A superuser
|
||||||
# None means "no restriction" to retrieve_similar_nodes. An
|
# (like no user at all) can see every document, so skip materializing
|
||||||
# unrestricted user (no user at all, or an active superuser -- see
|
# every visible pk into a Python list and passing it through as an IN
|
||||||
# user_is_unrestricted) can see every document, so skip
|
# filter: for a large library that is a wasted quadratic scan in the
|
||||||
# materializing every visible pk into a Python list and passing it
|
# vector store at best, and past ~32,763 documents a hard
|
||||||
# through as an IN filter: for a large library that is a wasted
|
# sqlite3.OperationalError (SQLite's bound-parameter limit) at worst.
|
||||||
# quadratic scan in the vector store at best, and past ~32,763
|
# get_objects_for_user_owner_aware() would return every Document for a
|
||||||
# documents a hard sqlite3.OperationalError (SQLite's
|
# superuser anyway (guardian's own with_superuser shortcut), so this
|
||||||
# bound-parameter limit) at worst.
|
# changes nothing about which documents are considered -- only how we
|
||||||
# permitted_object_ids() has its own superuser shortcut that would
|
# get there.
|
||||||
# return every Document's id anyway, so this changes nothing about
|
visible_document_ids = (
|
||||||
# which documents are considered -- only how we get there.
|
None
|
||||||
visible_document_ids = (
|
if user is None or user.is_superuser
|
||||||
None
|
else list(
|
||||||
if user_is_unrestricted(user)
|
get_objects_for_user_owner_aware(
|
||||||
else list(permitted_object_ids(user, Document, "view_document"))
|
user,
|
||||||
)
|
"view_document",
|
||||||
nodes = retrieve_similar_nodes(
|
Document,
|
||||||
document,
|
).values_list("pk", flat=True),
|
||||||
top_k=TAXONOMY_CANDIDATE_TOP_K,
|
|
||||||
document_ids=visible_document_ids,
|
|
||||||
)
|
|
||||||
similar_documents = _node_document_weights(nodes)
|
|
||||||
else:
|
|
||||||
# See _fulltext_similar_documents: it applies its own permission
|
|
||||||
# filter via `user`, so no visible-document-id list is needed here.
|
|
||||||
similar_documents = _fulltext_similar_documents(
|
|
||||||
document,
|
|
||||||
user,
|
|
||||||
top_k=TAXONOMY_CANDIDATE_TOP_K,
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
nodes = retrieve_similar_nodes(
|
||||||
|
document,
|
||||||
|
top_k=TAXONOMY_CANDIDATE_TOP_K,
|
||||||
|
document_ids=visible_document_ids,
|
||||||
|
)
|
||||||
|
|
||||||
candidates = build_taxonomy_candidates(similar_documents, user)
|
candidates = build_taxonomy_candidates(nodes, user)
|
||||||
|
|
||||||
# similar_documents is already ordered by descending weight; don't lose it.
|
# ``nodes`` are already ordered by descending vector similarity; don't lose it.
|
||||||
similar_document_ids = [s["document_id"] for s in similar_documents]
|
similar_document_ids = list(dict.fromkeys(_node_document_ids(nodes)))
|
||||||
similar_documents_by_id = Document.objects.in_bulk(similar_document_ids)
|
similar_documents_by_id = Document.objects.in_bulk(similar_document_ids)
|
||||||
similar_docs = [
|
similar_docs = [
|
||||||
similar_documents_by_id[document_id]
|
similar_documents_by_id[document_id]
|
||||||
@@ -240,8 +186,8 @@ def get_taxonomy_context(
|
|||||||
context_blocks.append(f"TITLE: {title}\n{text}")
|
context_blocks.append(f"TITLE: {title}\n{text}")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to retrieve similar-document context for document %s; "
|
"Failed to retrieve RAG neighbours for document %s; continuing "
|
||||||
"continuing without taxonomy candidates or similar-document context.",
|
"without taxonomy candidates or similar-document context.",
|
||||||
document.pk,
|
document.pk,
|
||||||
)
|
)
|
||||||
return empty_taxonomy_candidates(), ""
|
return empty_taxonomy_candidates(), ""
|
||||||
@@ -295,13 +241,17 @@ def get_ai_document_classification(
|
|||||||
) -> ClassificationSuggestions:
|
) -> ClassificationSuggestions:
|
||||||
ai_config = AIConfig()
|
ai_config = AIConfig()
|
||||||
|
|
||||||
candidates, context = get_taxonomy_context(document, user)
|
if ai_config.llm_embedding_backend:
|
||||||
prompt = build_prompt_with_rag(
|
candidates, context = get_taxonomy_context(document, user)
|
||||||
document,
|
prompt = build_prompt_with_rag(
|
||||||
ai_config,
|
document,
|
||||||
candidates=candidates,
|
ai_config,
|
||||||
context=context,
|
candidates=candidates,
|
||||||
)
|
context=context,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
candidates = empty_taxonomy_candidates()
|
||||||
|
prompt = build_prompt_without_rag(document, ai_config, candidates=candidates)
|
||||||
|
|
||||||
client = AIClient()
|
client = AIClient()
|
||||||
# Hand the pooled DB connection back while the (slow) LLM query runs so it
|
# Hand the pooled DB connection back while the (slow) LLM query runs so it
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from paperless.network import validate_outbound_http_url
|
|||||||
from paperless_ai.base_model import ClassificationSuggestions
|
from paperless_ai.base_model import ClassificationSuggestions
|
||||||
from paperless_ai.base_model import DocumentClassifierSchema
|
from paperless_ai.base_model import DocumentClassifierSchema
|
||||||
from paperless_ai.base_model import model_to_classification_suggestions
|
from paperless_ai.base_model import model_to_classification_suggestions
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
logger = logging.getLogger("paperless_ai.client")
|
logger = logging.getLogger("paperless_ai.client")
|
||||||
@@ -133,7 +132,7 @@ class AIClient:
|
|||||||
from llama_index.core.llms import ChatMessage
|
from llama_index.core.llms import ChatMessage
|
||||||
|
|
||||||
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
||||||
with self._normalize_errors():
|
with self._normalize_timeouts():
|
||||||
result = self.llm.chat(
|
result = self.llm.chat(
|
||||||
[ChatMessage(role="user", content=prompt)],
|
[ChatMessage(role="user", content=prompt)],
|
||||||
format=DocumentClassifierSchema.model_json_schema(),
|
format=DocumentClassifierSchema.model_json_schema(),
|
||||||
@@ -154,7 +153,7 @@ class AIClient:
|
|||||||
content=f"{prompt}\n\n"
|
content=f"{prompt}\n\n"
|
||||||
f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.",
|
f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.",
|
||||||
)
|
)
|
||||||
with self._normalize_errors():
|
with self._normalize_timeouts():
|
||||||
result = self.llm.chat_with_tools(
|
result = self.llm.chat_with_tools(
|
||||||
tools=[tool],
|
tools=[tool],
|
||||||
user_msg=user_msg,
|
user_msg=user_msg,
|
||||||
@@ -174,7 +173,7 @@ class AIClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _normalize_errors(self) -> Iterator[None]:
|
def _normalize_timeouts(self) -> Iterator[None]:
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
@@ -182,23 +181,8 @@ class AIClient:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if self._is_openai_timeout(exc):
|
if self._is_openai_timeout(exc):
|
||||||
raise LLMTimeoutError from exc
|
raise LLMTimeoutError from exc
|
||||||
if self._is_provider_error(exc):
|
|
||||||
raise LLMProviderError from exc
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _is_provider_error(self, exc: Exception) -> bool:
|
|
||||||
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
|
||||||
from ollama import ResponseError
|
|
||||||
|
|
||||||
return isinstance(exc, ResponseError)
|
|
||||||
|
|
||||||
if self.settings.llm_backend == LLMBackend.OPENAI_LIKE:
|
|
||||||
from openai import APIStatusError
|
|
||||||
|
|
||||||
return isinstance(exc, APIStatusError)
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _is_openai_timeout(self, exc: Exception) -> bool:
|
def _is_openai_timeout(self, exc: Exception) -> bool:
|
||||||
if self.settings.llm_backend != LLMBackend.OPENAI_LIKE:
|
if self.settings.llm_backend != LLMBackend.OPENAI_LIKE:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
class LLMTimeoutError(Exception):
|
class LLMTimeoutError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LLMProviderError(Exception):
|
|
||||||
"""The LLM backend rejected the request."""
|
|
||||||
|
|||||||
@@ -721,3 +721,20 @@ def retrieve_similar_nodes(
|
|||||||
continue
|
continue
|
||||||
filtered.append(node)
|
filtered.append(node)
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _node_document_ids(nodes: list["NodeWithScore"]) -> list[int]:
|
||||||
|
document_ids: list[int] = []
|
||||||
|
for node in nodes:
|
||||||
|
document_id = node.metadata.get("document_id")
|
||||||
|
if document_id is None: # pragma: no cover
|
||||||
|
# See the matching guard in retrieve_similar_nodes() above.
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
document_ids.append(int(document_id))
|
||||||
|
except ValueError: # pragma: no cover
|
||||||
|
logger.warning(
|
||||||
|
"Skipping LLM index result with invalid document_id %r.",
|
||||||
|
document_id,
|
||||||
|
)
|
||||||
|
return document_ids
|
||||||
|
|||||||
@@ -31,11 +31,6 @@ class TaxonomyCandidate(TypedDict):
|
|||||||
weight: float
|
weight: float
|
||||||
|
|
||||||
|
|
||||||
class SimilarDocument(TypedDict):
|
|
||||||
document_id: int
|
|
||||||
weight: float
|
|
||||||
|
|
||||||
|
|
||||||
class TaxonomyCandidates(TypedDict):
|
class TaxonomyCandidates(TypedDict):
|
||||||
tags: list[TaxonomyCandidate]
|
tags: list[TaxonomyCandidate]
|
||||||
document_types: list[TaxonomyCandidate]
|
document_types: list[TaxonomyCandidate]
|
||||||
@@ -54,10 +49,10 @@ def empty_taxonomy_candidates() -> TaxonomyCandidates:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _node_document_weights(nodes: list["NodeWithScore"]) -> list[SimilarDocument]:
|
def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]:
|
||||||
"""Sum each node's similarity score into its document_id (a document can
|
"""document_id -> that node's similarity score, summed if a document_id
|
||||||
appear via multiple chunks/nodes) and return one SimilarDocument per
|
appears more than once across the retrieved nodes (e.g. multiple chunks
|
||||||
distinct document_id."""
|
of the same source document)."""
|
||||||
weights: dict[int, float] = defaultdict(float)
|
weights: dict[int, float] = defaultdict(float)
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
document_id = node.metadata.get("document_id")
|
document_id = node.metadata.get("document_id")
|
||||||
@@ -70,14 +65,7 @@ def _node_document_weights(nodes: list["NodeWithScore"]) -> list[SimilarDocument
|
|||||||
weights[int(document_id)] += float(node.score or 0.0)
|
weights[int(document_id)] += float(node.score or 0.0)
|
||||||
except (TypeError, ValueError): # pragma: no cover
|
except (TypeError, ValueError): # pragma: no cover
|
||||||
continue
|
continue
|
||||||
return sorted(
|
return weights
|
||||||
(
|
|
||||||
SimilarDocument(document_id=document_id, weight=weight)
|
|
||||||
for document_id, weight in weights.items()
|
|
||||||
),
|
|
||||||
key=lambda similar: similar["weight"],
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _visible_ranked_candidates(
|
def _visible_ranked_candidates(
|
||||||
@@ -113,25 +101,20 @@ def _visible_ranked_candidates(
|
|||||||
|
|
||||||
|
|
||||||
def build_taxonomy_candidates(
|
def build_taxonomy_candidates(
|
||||||
similar_documents: list[SimilarDocument],
|
nodes: list["NodeWithScore"],
|
||||||
user: User | None,
|
user: User | None,
|
||||||
) -> TaxonomyCandidates:
|
) -> TaxonomyCandidates:
|
||||||
"""Resolve each similar document's id to a live Document, read its
|
"""Resolve each neighbour node's document_id to a live Document, read its
|
||||||
*current* tags/type/correspondent/storage_path via the ORM (never any
|
*current* tags/type/correspondent/storage_path via the ORM (never the
|
||||||
possibly-stale names an adapter's source might have cached), weight each
|
possibly-stale names cached in vector-index node metadata), weight each
|
||||||
distinct taxonomy object by aggregate similarity weight, permission-filter
|
distinct taxonomy object by aggregate neighbour similarity, permission-filter
|
||||||
against what ``user`` can see, and return each category ranked by weight
|
against what ``user`` can see, and return each category ranked by weight
|
||||||
and capped. ``similar_documents`` may come from either the vector-RAG
|
and capped.
|
||||||
adapter or the full-text fallback adapter - both produce this same shape.
|
|
||||||
"""
|
"""
|
||||||
if not similar_documents:
|
|
||||||
return empty_taxonomy_candidates()
|
|
||||||
|
|
||||||
# Both adapters guarantee at most one SimilarDocument per document_id, so
|
document_weights = _node_document_weights(nodes)
|
||||||
# this never silently drops a duplicate's weight.
|
if not document_weights:
|
||||||
document_weights: dict[int, float] = {
|
return empty_taxonomy_candidates()
|
||||||
s["document_id"]: s["weight"] for s in similar_documents
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only .tags.all() needs prefetching (a reverse M2M, one extra query for
|
# Only .tags.all() needs prefetching (a reverse M2M, one extra query for
|
||||||
# the whole batch). document_type/correspondent/storage_path are read
|
# the whole batch). document_type/correspondent/storage_path are read
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from collections.abc import Generator
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -7,24 +6,18 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_mock
|
import pytest_mock
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from guardian.shortcuts import remove_perm
|
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.search import TantivyBackend
|
|
||||||
from documents.tests.factories import DocumentFactory
|
from documents.tests.factories import DocumentFactory
|
||||||
from documents.tests.factories import TagFactory
|
from documents.tests.factories import TagFactory
|
||||||
from documents.tests.factories import UserFactory
|
from documents.tests.factories import UserFactory
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless_ai.ai_classifier import TAXONOMY_CANDIDATE_TOP_K
|
|
||||||
from paperless_ai.ai_classifier import _fulltext_similar_documents
|
|
||||||
from paperless_ai.ai_classifier import build_localization_prompt
|
from paperless_ai.ai_classifier import build_localization_prompt
|
||||||
from paperless_ai.ai_classifier import build_prompt_with_rag
|
from paperless_ai.ai_classifier import build_prompt_with_rag
|
||||||
from paperless_ai.ai_classifier import build_prompt_without_rag
|
from paperless_ai.ai_classifier import build_prompt_without_rag
|
||||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||||
from paperless_ai.ai_classifier import get_language_name
|
from paperless_ai.ai_classifier import get_language_name
|
||||||
from paperless_ai.ai_classifier import get_taxonomy_context
|
from paperless_ai.ai_classifier import get_taxonomy_context
|
||||||
from paperless_ai.taxonomy import SimilarDocument
|
|
||||||
from paperless_ai.taxonomy import TaxonomyCandidate
|
from paperless_ai.taxonomy import TaxonomyCandidate
|
||||||
from paperless_ai.taxonomy import TaxonomyCandidates
|
from paperless_ai.taxonomy import TaxonomyCandidates
|
||||||
|
|
||||||
@@ -227,10 +220,12 @@ def test_use_rag_if_configured(
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@patch("paperless_ai.client.AIClient.run_llm_query")
|
@patch("paperless_ai.client.AIClient.run_llm_query")
|
||||||
@patch("paperless_ai.ai_classifier.build_prompt_with_rag")
|
@patch("paperless_ai.ai_classifier.build_prompt_without_rag")
|
||||||
|
@patch("paperless_ai.ai_classifier.AIConfig")
|
||||||
@override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model")
|
@override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model")
|
||||||
def test_use_rag_prompt_even_without_embedding_backend(
|
def test_use_without_rag_if_not_configured(
|
||||||
mock_build_prompt_with_rag,
|
mock_ai_config,
|
||||||
|
mock_build_prompt_without_rag,
|
||||||
mock_run_llm_query,
|
mock_run_llm_query,
|
||||||
mock_document,
|
mock_document,
|
||||||
):
|
):
|
||||||
@@ -240,13 +235,13 @@ def test_use_rag_prompt_even_without_embedding_backend(
|
|||||||
WHEN:
|
WHEN:
|
||||||
- get_ai_document_classification() is called
|
- get_ai_document_classification() is called
|
||||||
THEN:
|
THEN:
|
||||||
- The RAG-context prompt builder is still used (fed by the full-text
|
- The non-RAG prompt builder is used
|
||||||
fallback's context/candidates instead of the vector store's)
|
|
||||||
"""
|
"""
|
||||||
mock_build_prompt_with_rag.return_value = "Prompt with RAG"
|
mock_ai_config.return_value.llm_embedding_backend = None
|
||||||
|
mock_build_prompt_without_rag.return_value = "Prompt without RAG"
|
||||||
mock_run_llm_query.return_value = NESTED_SUGGESTIONS
|
mock_run_llm_query.return_value = NESTED_SUGGESTIONS
|
||||||
get_ai_document_classification(mock_document)
|
get_ai_document_classification(mock_document)
|
||||||
mock_build_prompt_with_rag.assert_called_once()
|
mock_build_prompt_without_rag.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -325,7 +320,6 @@ def test_build_localization_prompt_preserves_unicode_characters():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
def test_get_taxonomy_context_assembles_rag_text_and_candidates():
|
def test_get_taxonomy_context_assembles_rag_text_and_candidates():
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -360,7 +354,6 @@ def test_get_taxonomy_context_assembles_rag_text_and_candidates():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents():
|
def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents():
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -431,7 +424,6 @@ def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
def test_get_taxonomy_context_no_similar_docs():
|
def test_get_taxonomy_context_no_similar_docs():
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -455,67 +447,6 @@ def test_get_taxonomy_context_no_similar_docs():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_get_taxonomy_context_uses_fulltext_fallback_when_no_embedding_backend(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- No LLM embedding backend is configured (the default test settings)
|
|
||||||
WHEN:
|
|
||||||
- get_taxonomy_context() is called
|
|
||||||
THEN:
|
|
||||||
- _fulltext_similar_documents() is called with the document, the user
|
|
||||||
and TAXONOMY_CANDIDATE_TOP_K
|
|
||||||
- retrieve_similar_nodes() (the vector path) is never called
|
|
||||||
"""
|
|
||||||
document = DocumentFactory.create(content="Some content")
|
|
||||||
mock_fulltext = mocker.patch(
|
|
||||||
"paperless_ai.ai_classifier._fulltext_similar_documents",
|
|
||||||
return_value=[],
|
|
||||||
)
|
|
||||||
mock_retrieve = mocker.patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
|
|
||||||
|
|
||||||
get_taxonomy_context(document, user=None)
|
|
||||||
|
|
||||||
mock_fulltext.assert_called_once_with(
|
|
||||||
document,
|
|
||||||
None,
|
|
||||||
top_k=TAXONOMY_CANDIDATE_TOP_K,
|
|
||||||
)
|
|
||||||
mock_retrieve.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
def test_get_taxonomy_context_uses_vector_path_when_embedding_backend_configured(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An LLM embedding backend is configured
|
|
||||||
WHEN:
|
|
||||||
- get_taxonomy_context() is called
|
|
||||||
THEN:
|
|
||||||
- retrieve_similar_nodes() (the vector path) is called
|
|
||||||
- _fulltext_similar_documents() (the no-embedding-backend fallback)
|
|
||||||
is never called
|
|
||||||
"""
|
|
||||||
document = DocumentFactory.create(content="Some content")
|
|
||||||
mock_retrieve = mocker.patch(
|
|
||||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
|
||||||
return_value=[],
|
|
||||||
)
|
|
||||||
mock_fulltext = mocker.patch(
|
|
||||||
"paperless_ai.ai_classifier._fulltext_similar_documents",
|
|
||||||
)
|
|
||||||
|
|
||||||
get_taxonomy_context(document, user=None)
|
|
||||||
|
|
||||||
mock_retrieve.assert_called_once()
|
|
||||||
mock_fulltext.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetTaxonomyContextVisibility:
|
class TestGetTaxonomyContextVisibility:
|
||||||
"""get_taxonomy_context must not materialize every visible document id
|
"""get_taxonomy_context must not materialize every visible document id
|
||||||
for a user who can already see the whole library: a superuser (like no
|
for a user who can already see the whole library: a superuser (like no
|
||||||
@@ -528,7 +459,6 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
def test_skips_permission_lookup_for_superuser(
|
def test_skips_permission_lookup_for_superuser(
|
||||||
self,
|
self,
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
@@ -547,18 +477,17 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||||
return_value=[],
|
return_value=[],
|
||||||
)
|
)
|
||||||
mock_permitted = mocker.patch(
|
mock_get_objects = mocker.patch(
|
||||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
|
||||||
)
|
)
|
||||||
user = UserFactory.create(is_superuser=True)
|
user = UserFactory.create(is_superuser=True)
|
||||||
|
|
||||||
get_taxonomy_context(document, user)
|
get_taxonomy_context(document, user)
|
||||||
|
|
||||||
mock_permitted.assert_not_called()
|
mock_get_objects.assert_not_called()
|
||||||
assert mock_retrieve.call_args.kwargs["document_ids"] is None
|
assert mock_retrieve.call_args.kwargs["document_ids"] is None
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
def test_skips_permission_lookup_when_no_user(
|
def test_skips_permission_lookup_when_no_user(
|
||||||
self,
|
self,
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
@@ -577,17 +506,16 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||||
return_value=[],
|
return_value=[],
|
||||||
)
|
)
|
||||||
mock_permitted = mocker.patch(
|
mock_get_objects = mocker.patch(
|
||||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
|
||||||
)
|
)
|
||||||
|
|
||||||
get_taxonomy_context(document, None)
|
get_taxonomy_context(document, None)
|
||||||
|
|
||||||
mock_permitted.assert_not_called()
|
mock_get_objects.assert_not_called()
|
||||||
assert mock_retrieve.call_args.kwargs["document_ids"] is None
|
assert mock_retrieve.call_args.kwargs["document_ids"] is None
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
def test_restricts_to_visible_documents_for_non_superuser(
|
def test_restricts_to_visible_documents_for_non_superuser(
|
||||||
self,
|
self,
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
@@ -598,7 +526,7 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
WHEN:
|
WHEN:
|
||||||
- get_taxonomy_context() is called
|
- get_taxonomy_context() is called
|
||||||
THEN:
|
THEN:
|
||||||
- The user's permitted document ids are looked up and passed to
|
- The user's visible document ids are looked up and passed to
|
||||||
retrieve_similar_nodes() as a restriction
|
retrieve_similar_nodes() as a restriction
|
||||||
"""
|
"""
|
||||||
document = DocumentFactory.create(content="Some content")
|
document = DocumentFactory.create(content="Some content")
|
||||||
@@ -606,232 +534,21 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||||
return_value=[],
|
return_value=[],
|
||||||
)
|
)
|
||||||
mock_permitted = mocker.patch(
|
mock_queryset = mocker.MagicMock()
|
||||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
mock_queryset.values_list.return_value = [1, 2, 3]
|
||||||
return_value=[1, 2, 3],
|
mock_get_objects = mocker.patch(
|
||||||
|
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
|
||||||
|
return_value=mock_queryset,
|
||||||
)
|
)
|
||||||
user = UserFactory.create(is_superuser=False)
|
user = UserFactory.create(is_superuser=False)
|
||||||
|
|
||||||
get_taxonomy_context(document, user)
|
get_taxonomy_context(document, user)
|
||||||
|
|
||||||
mock_permitted.assert_called_once_with(user, Document, "view_document")
|
mock_get_objects.assert_called_once_with(user, "view_document", Document)
|
||||||
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
|
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestFulltextSimilarDocuments:
|
|
||||||
"""_fulltext_similar_documents is the no-embedding-backend fallback: it
|
|
||||||
asks the Tantivy full-text index for "More Like This" neighbours instead
|
|
||||||
of the vector store, and synthesizes a rank-based weight since Tantivy's
|
|
||||||
more_like_this_ids returns only an ordered id list, no scores.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fulltext_backend(
|
|
||||||
self,
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> Generator[TantivyBackend, None, None]:
|
|
||||||
"""An in-memory Tantivy backend, wired up as the module-level
|
|
||||||
singleton _fulltext_similar_documents resolves via get_backend()."""
|
|
||||||
backend = TantivyBackend(path=None)
|
|
||||||
backend.open()
|
|
||||||
mocker.patch("documents.search.get_backend", return_value=backend)
|
|
||||||
try:
|
|
||||||
yield backend
|
|
||||||
finally:
|
|
||||||
backend.close()
|
|
||||||
|
|
||||||
def test_ranks_by_rank_based_weight_descending(
|
|
||||||
self,
|
|
||||||
fulltext_backend: TantivyBackend,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A source document and two similar documents indexed in Tantivy
|
|
||||||
WHEN:
|
|
||||||
- _fulltext_similar_documents() is called
|
|
||||||
THEN:
|
|
||||||
- Each result's weight reflects its rank (first result weighted
|
|
||||||
higher than the second), not a raw similarity score
|
|
||||||
"""
|
|
||||||
source = DocumentFactory.create(content="quarterly financial report details")
|
|
||||||
first = DocumentFactory.create(content="quarterly financial report details")
|
|
||||||
second = DocumentFactory.create(content="financial report")
|
|
||||||
for doc in (source, first, second):
|
|
||||||
fulltext_backend.add_or_update(doc)
|
|
||||||
|
|
||||||
result = _fulltext_similar_documents(source, user=None, top_k=5)
|
|
||||||
|
|
||||||
assert len(result) == 2
|
|
||||||
weight_by_id = {s["document_id"]: s["weight"] for s in result}
|
|
||||||
assert weight_by_id[first.pk] > weight_by_id[second.pk]
|
|
||||||
|
|
||||||
def test_excludes_source_document(
|
|
||||||
self,
|
|
||||||
fulltext_backend: TantivyBackend,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A source document indexed in Tantivy with no other documents
|
|
||||||
WHEN:
|
|
||||||
- _fulltext_similar_documents() is called
|
|
||||||
THEN:
|
|
||||||
- An empty list is returned - the source document is never its
|
|
||||||
own similar document
|
|
||||||
"""
|
|
||||||
source = DocumentFactory.create(content="unique unrelated content")
|
|
||||||
fulltext_backend.add_or_update(source)
|
|
||||||
|
|
||||||
result = _fulltext_similar_documents(source, user=None, top_k=5)
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_empty_index_returns_empty_list(
|
|
||||||
self,
|
|
||||||
fulltext_backend: TantivyBackend,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document that has never been indexed (fresh/empty Tantivy index)
|
|
||||||
WHEN:
|
|
||||||
- _fulltext_similar_documents() is called
|
|
||||||
THEN:
|
|
||||||
- An empty list is returned rather than raising
|
|
||||||
"""
|
|
||||||
source = DocumentFactory.create(content="never indexed")
|
|
||||||
|
|
||||||
result = _fulltext_similar_documents(source, user=None, top_k=5)
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_respects_top_k_limit(
|
|
||||||
self,
|
|
||||||
fulltext_backend: TantivyBackend,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A source document and four similar documents indexed
|
|
||||||
WHEN:
|
|
||||||
- _fulltext_similar_documents() is called with top_k=2
|
|
||||||
THEN:
|
|
||||||
- At most 2 results are returned
|
|
||||||
"""
|
|
||||||
source = DocumentFactory.create(content="shared overlapping keyword text")
|
|
||||||
fulltext_backend.add_or_update(source)
|
|
||||||
for _ in range(4):
|
|
||||||
fulltext_backend.add_or_update(
|
|
||||||
DocumentFactory.create(content="shared overlapping keyword text"),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = _fulltext_similar_documents(source, user=None, top_k=2)
|
|
||||||
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_result_shape_is_similar_document(
|
|
||||||
self,
|
|
||||||
fulltext_backend: TantivyBackend,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A source document and one similar document indexed
|
|
||||||
WHEN:
|
|
||||||
- _fulltext_similar_documents() is called
|
|
||||||
THEN:
|
|
||||||
- Each result is a SimilarDocument (document_id + weight only)
|
|
||||||
"""
|
|
||||||
source = DocumentFactory.create(content="shared content phrase")
|
|
||||||
other = DocumentFactory.create(content="shared content phrase")
|
|
||||||
fulltext_backend.add_or_update(source)
|
|
||||||
fulltext_backend.add_or_update(other)
|
|
||||||
|
|
||||||
result = _fulltext_similar_documents(source, user=None, top_k=5)
|
|
||||||
|
|
||||||
# rank 0 (the only/best result) with top_k=5 -> weight = top_k - rank = 5.0,
|
|
||||||
# per the "first result gets top_k, the last gets 1" formula.
|
|
||||||
assert result == [SimilarDocument(document_id=other.pk, weight=5.0)]
|
|
||||||
|
|
||||||
def test_superuser_sees_other_users_documents(
|
|
||||||
self,
|
|
||||||
fulltext_backend: TantivyBackend,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A source document owned by one user and a similar document
|
|
||||||
owned by a different user, with no sharing between them
|
|
||||||
WHEN:
|
|
||||||
- _fulltext_similar_documents() is called with a superuser
|
|
||||||
THEN:
|
|
||||||
- The other user's document is still returned as a similar
|
|
||||||
document - a superuser must not be narrowed by the backend's
|
|
||||||
owner-based permission filter
|
|
||||||
"""
|
|
||||||
owner = UserFactory.create()
|
|
||||||
other_owner = UserFactory.create()
|
|
||||||
superuser = UserFactory.create(is_superuser=True)
|
|
||||||
source = DocumentFactory.create(
|
|
||||||
content="shared content phrase",
|
|
||||||
owner=owner,
|
|
||||||
)
|
|
||||||
other = DocumentFactory.create(
|
|
||||||
content="shared content phrase",
|
|
||||||
owner=other_owner,
|
|
||||||
)
|
|
||||||
fulltext_backend.add_or_update(source)
|
|
||||||
fulltext_backend.add_or_update(other)
|
|
||||||
|
|
||||||
result = _fulltext_similar_documents(source, user=superuser, top_k=5)
|
|
||||||
|
|
||||||
assert [s["document_id"] for s in result] == [other.pk]
|
|
||||||
|
|
||||||
def test_excludes_stale_permitted_document_for_regular_user(
|
|
||||||
self,
|
|
||||||
fulltext_backend: TantivyBackend,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A regular (non-superuser) user
|
|
||||||
- A similar document the user is permitted to view, and another
|
|
||||||
similar document indexed while the user still had view
|
|
||||||
permission but which has since had that permission revoked in
|
|
||||||
the database, i.e. the Tantivy index has stale permission data
|
|
||||||
WHEN:
|
|
||||||
- _fulltext_similar_documents() is called with that user
|
|
||||||
THEN:
|
|
||||||
- Only the still-permitted document is returned - the DB
|
|
||||||
re-check via restrict_queryset_to_visible() must catch the
|
|
||||||
document Tantivy's stale index still thinks is visible
|
|
||||||
"""
|
|
||||||
owner = UserFactory.create()
|
|
||||||
viewer = UserFactory.create(is_superuser=False)
|
|
||||||
source = DocumentFactory.create(
|
|
||||||
content="shared content phrase",
|
|
||||||
owner=owner,
|
|
||||||
)
|
|
||||||
permitted = DocumentFactory.create(
|
|
||||||
content="shared content phrase",
|
|
||||||
owner=owner,
|
|
||||||
)
|
|
||||||
now_private = DocumentFactory.create(
|
|
||||||
content="shared content phrase",
|
|
||||||
owner=owner,
|
|
||||||
)
|
|
||||||
assign_perm("view_document", viewer, permitted)
|
|
||||||
assign_perm("view_document", viewer, now_private)
|
|
||||||
fulltext_backend.add_or_update(source)
|
|
||||||
fulltext_backend.add_or_update(permitted)
|
|
||||||
fulltext_backend.add_or_update(now_private)
|
|
||||||
|
|
||||||
# Revoke access after indexing, without reindexing: the index still
|
|
||||||
# carries viewer as a permitted viewer for `now_private`.
|
|
||||||
remove_perm("view_document", viewer, now_private)
|
|
||||||
|
|
||||||
result = _fulltext_similar_documents(source, user=viewer, top_k=5)
|
|
||||||
|
|
||||||
assert [s["document_id"] for s in result] == [permitted.pk]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
|
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
|
||||||
def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrieve):
|
def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrieve):
|
||||||
"""
|
"""
|
||||||
@@ -858,7 +575,6 @@ def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrie
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
@patch("paperless_ai.ai_classifier.build_taxonomy_candidates")
|
@patch("paperless_ai.ai_classifier.build_taxonomy_candidates")
|
||||||
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
|
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
|
||||||
def test_get_taxonomy_context_candidate_building_failure_degrades_to_no_hints(
|
def test_get_taxonomy_context_candidate_building_failure_degrades_to_no_hints(
|
||||||
|
|||||||
@@ -1188,7 +1188,9 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
|
|||||||
|
|
||||||
nodes = indexing.retrieve_similar_nodes(a, document_ids=[b.id])
|
nodes = indexing.retrieve_similar_nodes(a, document_ids=[b.id])
|
||||||
|
|
||||||
assert all(int(node.metadata["document_id"]) == b.id for node in nodes)
|
assert all(
|
||||||
|
document_id == b.id for document_id in indexing._node_document_ids(nodes)
|
||||||
|
)
|
||||||
|
|
||||||
def test_excludes_self(
|
def test_excludes_self(
|
||||||
self,
|
self,
|
||||||
@@ -1210,7 +1212,7 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
|
|||||||
|
|
||||||
nodes = indexing.retrieve_similar_nodes(a, top_k=5)
|
nodes = indexing.retrieve_similar_nodes(a, top_k=5)
|
||||||
|
|
||||||
assert {int(node.metadata["document_id"]) for node in nodes} == {b.id}
|
assert set(indexing._node_document_ids(nodes)) == {b.id}
|
||||||
|
|
||||||
def test_excludes_self_with_multiple_chunks(
|
def test_excludes_self_with_multiple_chunks(
|
||||||
self,
|
self,
|
||||||
@@ -1233,4 +1235,4 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
|
|||||||
|
|
||||||
nodes = indexing.retrieve_similar_nodes(a, top_k=3)
|
nodes = indexing.retrieve_similar_nodes(a, top_k=3)
|
||||||
|
|
||||||
assert {int(node.metadata["document_id"]) for node in nodes} == {b.id}
|
assert set(indexing._node_document_ids(nodes)) == {b.id}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from unittest.mock import MagicMock
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import ollama
|
|
||||||
import openai
|
import openai
|
||||||
import pytest
|
import pytest
|
||||||
from llama_index.core.llms.llm import ToolSelection
|
from llama_index.core.llms.llm import ToolSelection
|
||||||
@@ -12,7 +11,6 @@ from llama_index.core.llms.llm import ToolSelection
|
|||||||
from paperless_ai.client import LLM_SYSTEM_PROMPT
|
from paperless_ai.client import LLM_SYSTEM_PROMPT
|
||||||
from paperless_ai.client import PLACEHOLDER_API_KEY
|
from paperless_ai.client import PLACEHOLDER_API_KEY
|
||||||
from paperless_ai.client import AIClient
|
from paperless_ai.client import AIClient
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
|
|
||||||
@@ -216,52 +214,6 @@ def test_run_llm_query_openai_timeout_raises_local_error(
|
|||||||
client.run_llm_query("test_prompt")
|
client.run_llm_query("test_prompt")
|
||||||
|
|
||||||
|
|
||||||
def test_run_llm_query_openai_status_error_raises_provider_error(
|
|
||||||
mock_ai_config,
|
|
||||||
mock_openai_llm,
|
|
||||||
):
|
|
||||||
mock_ai_config.llm_backend = "openai-like"
|
|
||||||
mock_ai_config.llm_model = "test_model"
|
|
||||||
mock_ai_config.llm_endpoint = "http://test-url"
|
|
||||||
|
|
||||||
request = httpx.Request("POST", "http://test-url/v1/chat/completions")
|
|
||||||
body = {"error": {"message": "Thinking mode does not support this tool_choice"}}
|
|
||||||
mock_openai_llm.return_value.chat_with_tools.side_effect = openai.BadRequestError(
|
|
||||||
"Error code: 400",
|
|
||||||
response=httpx.Response(400, request=request, json=body),
|
|
||||||
body=body,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = AIClient()
|
|
||||||
|
|
||||||
with pytest.raises(LLMProviderError) as exc_info:
|
|
||||||
client.run_llm_query("test_prompt")
|
|
||||||
assert str(exc_info.value) == ""
|
|
||||||
assert isinstance(exc_info.value.__cause__, openai.BadRequestError)
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_llm_query_ollama_response_error_raises_provider_error(
|
|
||||||
mock_ai_config,
|
|
||||||
mock_ollama_llm,
|
|
||||||
):
|
|
||||||
mock_ai_config.llm_backend = "ollama"
|
|
||||||
mock_ai_config.llm_model = "test_model"
|
|
||||||
mock_ai_config.llm_endpoint = "http://test-url"
|
|
||||||
|
|
||||||
response_error = ollama.ResponseError(
|
|
||||||
"confidential provider response",
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
mock_ollama_llm.return_value.chat.side_effect = response_error
|
|
||||||
|
|
||||||
client = AIClient()
|
|
||||||
|
|
||||||
with pytest.raises(LLMProviderError) as exc_info:
|
|
||||||
client.run_llm_query("test_prompt")
|
|
||||||
assert str(exc_info.value) == ""
|
|
||||||
assert exc_info.value.__cause__ is response_error
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_llm_query_httpx_timeout_raises_local_error(
|
def test_run_llm_query_httpx_timeout_raises_local_error(
|
||||||
mock_ai_config,
|
mock_ai_config,
|
||||||
mock_ollama_llm,
|
mock_ollama_llm,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_mock
|
import pytest_mock
|
||||||
@@ -9,14 +10,14 @@ from documents.tests.factories import DocumentTypeFactory
|
|||||||
from documents.tests.factories import StoragePathFactory
|
from documents.tests.factories import StoragePathFactory
|
||||||
from documents.tests.factories import TagFactory
|
from documents.tests.factories import TagFactory
|
||||||
from documents.tests.factories import UserFactory
|
from documents.tests.factories import UserFactory
|
||||||
from paperless_ai.taxonomy import SimilarDocument
|
|
||||||
from paperless_ai.taxonomy import TaxonomyCandidates
|
from paperless_ai.taxonomy import TaxonomyCandidates
|
||||||
from paperless_ai.taxonomy import build_taxonomy_candidates
|
from paperless_ai.taxonomy import build_taxonomy_candidates
|
||||||
from paperless_ai.taxonomy import format_taxonomy_for_prompt
|
from paperless_ai.taxonomy import format_taxonomy_for_prompt
|
||||||
|
|
||||||
|
|
||||||
def make_similar(document_id: int, weight: float) -> SimilarDocument:
|
def make_node(document_id: int, score: float) -> SimpleNamespace:
|
||||||
return SimilarDocument(document_id=document_id, weight=weight)
|
"""A stand-in for NodeWithScore: only ``.metadata``/``.score`` are read."""
|
||||||
|
return SimpleNamespace(metadata={"document_id": str(document_id)}, score=score)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -52,9 +53,9 @@ class TestBuildTaxonomyCandidates:
|
|||||||
doc_a.tags.add(tag)
|
doc_a.tags.add(tag)
|
||||||
doc_b = DocumentFactory.create()
|
doc_b = DocumentFactory.create()
|
||||||
doc_b.tags.add(tag)
|
doc_b.tags.add(tag)
|
||||||
similar_documents = [make_similar(doc_a.pk, 0.9), make_similar(doc_b.pk, 0.4)]
|
nodes = [make_node(doc_a.pk, 0.9), make_node(doc_b.pk, 0.4)]
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert len(result["tags"]) == 1
|
assert len(result["tags"]) == 1
|
||||||
assert result["tags"][0]["id"] == tag.pk
|
assert result["tags"][0]["id"] == tag.pk
|
||||||
@@ -79,9 +80,9 @@ class TestBuildTaxonomyCandidates:
|
|||||||
document.tags.add(tag)
|
document.tags.add(tag)
|
||||||
tag.name = "New Name"
|
tag.name = "New Name"
|
||||||
tag.save()
|
tag.save()
|
||||||
similar_documents = [make_similar(document.pk, 0.5)]
|
nodes = [make_node(document.pk, 0.5)]
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert result["tags"][0]["name"] == "New Name"
|
assert result["tags"][0]["name"] == "New Name"
|
||||||
|
|
||||||
@@ -101,9 +102,9 @@ class TestBuildTaxonomyCandidates:
|
|||||||
document = DocumentFactory.create()
|
document = DocumentFactory.create()
|
||||||
document.tags.add(tag)
|
document.tags.add(tag)
|
||||||
tag.delete()
|
tag.delete()
|
||||||
similar_documents = [make_similar(document.pk, 0.5)]
|
nodes = [make_node(document.pk, 0.5)]
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert result["tags"] == []
|
assert result["tags"] == []
|
||||||
|
|
||||||
@@ -122,12 +123,9 @@ class TestBuildTaxonomyCandidates:
|
|||||||
strong_doc.tags.add(strong_tag)
|
strong_doc.tags.add(strong_tag)
|
||||||
weak_doc = DocumentFactory.create()
|
weak_doc = DocumentFactory.create()
|
||||||
weak_doc.tags.add(weak_tag)
|
weak_doc.tags.add(weak_tag)
|
||||||
similar_documents = [
|
nodes = [make_node(strong_doc.pk, 0.9), make_node(weak_doc.pk, 0.1)]
|
||||||
make_similar(strong_doc.pk, 0.9),
|
|
||||||
make_similar(weak_doc.pk, 0.1),
|
|
||||||
]
|
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"]
|
assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"]
|
||||||
|
|
||||||
@@ -143,9 +141,9 @@ class TestBuildTaxonomyCandidates:
|
|||||||
document = DocumentFactory.create()
|
document = DocumentFactory.create()
|
||||||
for i in range(15):
|
for i in range(15):
|
||||||
document.tags.add(TagFactory.create(name=f"Tag{i}"))
|
document.tags.add(TagFactory.create(name=f"Tag{i}"))
|
||||||
similar_documents = [make_similar(document.pk, 0.5)]
|
nodes = [make_node(document.pk, 0.5)]
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert len(result["tags"]) == 10
|
assert len(result["tags"]) == 10
|
||||||
|
|
||||||
@@ -159,12 +157,12 @@ class TestBuildTaxonomyCandidates:
|
|||||||
- Only 5 correspondents are returned
|
- Only 5 correspondents are returned
|
||||||
"""
|
"""
|
||||||
correspondents = CorrespondentFactory.create_batch(7)
|
correspondents = CorrespondentFactory.create_batch(7)
|
||||||
similar_documents = [
|
nodes = [
|
||||||
make_similar(DocumentFactory.create(correspondent=c).pk, 0.5)
|
make_node(DocumentFactory.create(correspondent=c).pk, 0.5)
|
||||||
for c in correspondents
|
for c in correspondents
|
||||||
]
|
]
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert len(result["correspondents"]) == 5
|
assert len(result["correspondents"]) == 5
|
||||||
|
|
||||||
@@ -179,9 +177,9 @@ class TestBuildTaxonomyCandidates:
|
|||||||
"""
|
"""
|
||||||
document_type = DocumentTypeFactory.create(name="Invoice")
|
document_type = DocumentTypeFactory.create(name="Invoice")
|
||||||
document = DocumentFactory.create(document_type=document_type)
|
document = DocumentFactory.create(document_type=document_type)
|
||||||
similar_documents = [make_similar(document.pk, 0.5)]
|
nodes = [make_node(document.pk, 0.5)]
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert len(result["document_types"]) == 1
|
assert len(result["document_types"]) == 1
|
||||||
assert result["document_types"][0]["id"] == document_type.pk
|
assert result["document_types"][0]["id"] == document_type.pk
|
||||||
@@ -197,12 +195,12 @@ class TestBuildTaxonomyCandidates:
|
|||||||
- Only 5 document_types are returned
|
- Only 5 document_types are returned
|
||||||
"""
|
"""
|
||||||
document_types = DocumentTypeFactory.create_batch(7)
|
document_types = DocumentTypeFactory.create_batch(7)
|
||||||
similar_documents = [
|
nodes = [
|
||||||
make_similar(DocumentFactory.create(document_type=dt).pk, 0.5)
|
make_node(DocumentFactory.create(document_type=dt).pk, 0.5)
|
||||||
for dt in document_types
|
for dt in document_types
|
||||||
]
|
]
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert len(result["document_types"]) == 5
|
assert len(result["document_types"]) == 5
|
||||||
|
|
||||||
@@ -217,9 +215,9 @@ class TestBuildTaxonomyCandidates:
|
|||||||
"""
|
"""
|
||||||
storage_path = StoragePathFactory.create(name="Invoices")
|
storage_path = StoragePathFactory.create(name="Invoices")
|
||||||
document = DocumentFactory.create(storage_path=storage_path)
|
document = DocumentFactory.create(storage_path=storage_path)
|
||||||
similar_documents = [make_similar(document.pk, 0.5)]
|
nodes = [make_node(document.pk, 0.5)]
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert len(result["storage_paths"]) == 1
|
assert len(result["storage_paths"]) == 1
|
||||||
assert result["storage_paths"][0]["id"] == storage_path.pk
|
assert result["storage_paths"][0]["id"] == storage_path.pk
|
||||||
@@ -235,12 +233,12 @@ class TestBuildTaxonomyCandidates:
|
|||||||
- Only 5 storage_paths are returned
|
- Only 5 storage_paths are returned
|
||||||
"""
|
"""
|
||||||
storage_paths = StoragePathFactory.create_batch(7)
|
storage_paths = StoragePathFactory.create_batch(7)
|
||||||
similar_documents = [
|
nodes = [
|
||||||
make_similar(DocumentFactory.create(storage_path=sp).pk, 0.5)
|
make_node(DocumentFactory.create(storage_path=sp).pk, 0.5)
|
||||||
for sp in storage_paths
|
for sp in storage_paths
|
||||||
]
|
]
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert len(result["storage_paths"]) == 5
|
assert len(result["storage_paths"]) == 5
|
||||||
|
|
||||||
@@ -260,14 +258,14 @@ class TestBuildTaxonomyCandidates:
|
|||||||
tag = TagFactory.create(name="Restricted")
|
tag = TagFactory.create(name="Restricted")
|
||||||
document = DocumentFactory.create()
|
document = DocumentFactory.create()
|
||||||
document.tags.add(tag)
|
document.tags.add(tag)
|
||||||
similar_documents = [make_similar(document.pk, 0.5)]
|
nodes = [make_node(document.pk, 0.5)]
|
||||||
user = UserFactory.create()
|
user = UserFactory.create()
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"documents.permissions.permitted_object_ids",
|
"documents.permissions.permitted_object_ids",
|
||||||
return_value=[], # user cannot see this tag
|
return_value=[], # user cannot see this tag
|
||||||
)
|
)
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=user)
|
result = build_taxonomy_candidates(nodes, user=user)
|
||||||
|
|
||||||
assert result["tags"] == []
|
assert result["tags"] == []
|
||||||
|
|
||||||
@@ -297,10 +295,10 @@ class TestBuildTaxonomyCandidates:
|
|||||||
tag.save()
|
tag.save()
|
||||||
document = DocumentFactory.create()
|
document = DocumentFactory.create()
|
||||||
document.tags.add(tag)
|
document.tags.add(tag)
|
||||||
similar_documents = [make_similar(document.pk, 0.5)]
|
nodes = [make_node(document.pk, 0.5)]
|
||||||
spy = mocker.patch("documents.permissions.permitted_object_ids")
|
spy = mocker.patch("documents.permissions.permitted_object_ids")
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(nodes, user=None)
|
||||||
|
|
||||||
assert result["tags"][0]["name"] == "Owned"
|
assert result["tags"][0]["name"] == "Owned"
|
||||||
spy.assert_not_called()
|
spy.assert_not_called()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import socket
|
|||||||
import ssl
|
import ssl
|
||||||
import tempfile
|
import tempfile
|
||||||
import traceback
|
import traceback
|
||||||
import unicodedata
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from fnmatch import fnmatch
|
from fnmatch import fnmatch
|
||||||
@@ -45,6 +44,7 @@ from documents.models import Correspondent
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.parsers import is_mime_type_supported
|
from documents.parsers import is_mime_type_supported
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
from paperless.network import is_public_ip
|
from paperless.network import is_public_ip
|
||||||
from paperless.network import resolve_hostname_ips
|
from paperless.network import resolve_hostname_ips
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
@@ -617,10 +617,10 @@ class MailAccountHandler(LoggingMixin):
|
|||||||
rule: MailRule,
|
rule: MailRule,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
if rule.assign_title_from == MailRule.TitleSource.FROM_SUBJECT:
|
if rule.assign_title_from == MailRule.TitleSource.FROM_SUBJECT:
|
||||||
return unicodedata.normalize("NFC", message.subject)
|
return normalize_unicode(message.subject)
|
||||||
|
|
||||||
elif rule.assign_title_from == MailRule.TitleSource.FROM_FILENAME:
|
elif rule.assign_title_from == MailRule.TitleSource.FROM_FILENAME:
|
||||||
return unicodedata.normalize("NFC", Path(att.filename).stem)
|
return normalize_unicode(Path(att.filename).stem)
|
||||||
|
|
||||||
elif rule.assign_title_from == MailRule.TitleSource.NONE:
|
elif rule.assign_title_from == MailRule.TitleSource.NONE:
|
||||||
return None
|
return None
|
||||||
@@ -1004,6 +1004,8 @@ class MailAccountHandler(LoggingMixin):
|
|||||||
consume_tasks = []
|
consume_tasks = []
|
||||||
|
|
||||||
for att in message.attachments:
|
for att in message.attachments:
|
||||||
|
attachment_filename = normalize_unicode(att.filename)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
att.content_disposition != "attachment"
|
att.content_disposition != "attachment"
|
||||||
and rule.attachment_type
|
and rule.attachment_type
|
||||||
@@ -1018,7 +1020,7 @@ class MailAccountHandler(LoggingMixin):
|
|||||||
|
|
||||||
if not self.filename_inclusion_matches(
|
if not self.filename_inclusion_matches(
|
||||||
rule.filter_attachment_filename_include,
|
rule.filter_attachment_filename_include,
|
||||||
att.filename,
|
attachment_filename,
|
||||||
):
|
):
|
||||||
# Force the filename and pattern to the lowercase
|
# Force the filename and pattern to the lowercase
|
||||||
# as this is system dependent otherwise
|
# as this is system dependent otherwise
|
||||||
@@ -1030,7 +1032,7 @@ class MailAccountHandler(LoggingMixin):
|
|||||||
continue
|
continue
|
||||||
elif self.filename_exclusion_matches(
|
elif self.filename_exclusion_matches(
|
||||||
rule.filter_attachment_filename_exclude,
|
rule.filter_attachment_filename_exclude,
|
||||||
att.filename,
|
attachment_filename,
|
||||||
):
|
):
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
f"Rule {rule}: "
|
f"Rule {rule}: "
|
||||||
@@ -1064,7 +1066,7 @@ class MailAccountHandler(LoggingMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
attachment_name = pathvalidate.sanitize_filename(
|
attachment_name = pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", att.filename),
|
attachment_filename,
|
||||||
)
|
)
|
||||||
if attachment_name:
|
if attachment_name:
|
||||||
temp_filename = temp_dir / attachment_name
|
temp_filename = temp_dir / attachment_name
|
||||||
@@ -1175,7 +1177,7 @@ class MailAccountHandler(LoggingMixin):
|
|||||||
doc_overrides = DocumentMetadataOverrides(
|
doc_overrides = DocumentMetadataOverrides(
|
||||||
title=message.subject,
|
title=message.subject,
|
||||||
filename=pathvalidate.sanitize_filename(
|
filename=pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", f"{message.subject}.eml"),
|
normalize_unicode(f"{message.subject}.eml"),
|
||||||
),
|
),
|
||||||
correspondent_id=correspondent.id if correspondent else None,
|
correspondent_id=correspondent.id if correspondent else None,
|
||||||
document_type_id=doc_type.id if doc_type else None,
|
document_type_id=doc_type.id if doc_type else None,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from documents.serialisers import CorrespondentField
|
|||||||
from documents.serialisers import DocumentTypeField
|
from documents.serialisers import DocumentTypeField
|
||||||
from documents.serialisers import OwnedObjectSerializer
|
from documents.serialisers import OwnedObjectSerializer
|
||||||
from documents.serialisers import TagsField
|
from documents.serialisers import TagsField
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_mail.models import ProcessedMail
|
from paperless_mail.models import ProcessedMail
|
||||||
@@ -161,6 +162,12 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
|||||||
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def validate_filter_attachment_filename_include(self, value):
|
||||||
|
return normalize_unicode(value)
|
||||||
|
|
||||||
|
def validate_filter_attachment_filename_exclude(self, value):
|
||||||
|
return normalize_unicode(value)
|
||||||
|
|
||||||
|
|
||||||
class ProcessedMailSerializer(OwnedObjectSerializer):
|
class ProcessedMailSerializer(OwnedObjectSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from celery import Task
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
from documents.models import PaperlessTask
|
|
||||||
from paperless_mail.mail import MailAccountHandler
|
from paperless_mail.mail import MailAccountHandler
|
||||||
from paperless_mail.mail import MailError
|
from paperless_mail.mail import MailError
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
@@ -12,26 +10,8 @@ from paperless_mail.models import MailRule
|
|||||||
logger = logging.getLogger("paperless.mail.tasks")
|
logger = logging.getLogger("paperless.mail.tasks")
|
||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True)
|
@shared_task
|
||||||
def process_mail_accounts(self: Task, account_ids: list[int] | None = None) -> str:
|
def process_mail_accounts(account_ids: list[int] | None = None) -> str:
|
||||||
# A scheduled check can still be running (or queued) when the next one
|
|
||||||
# ProcessedMail dedup only records a message once its
|
|
||||||
# handling has finished, so an overlapping run can still pick up the same
|
|
||||||
# not-yet-recorded message. Skip outright rather than race it.
|
|
||||||
other_mail_fetch_running = (
|
|
||||||
PaperlessTask.objects.filter(
|
|
||||||
task_type=PaperlessTask.TaskType.MAIL_FETCH,
|
|
||||||
status__in=[PaperlessTask.Status.PENDING, PaperlessTask.Status.STARTED],
|
|
||||||
)
|
|
||||||
.exclude(task_id=self.request.id)
|
|
||||||
.exists()
|
|
||||||
)
|
|
||||||
if other_mail_fetch_running:
|
|
||||||
logger.info(
|
|
||||||
"Mail account processing is already running; skipping this run.",
|
|
||||||
)
|
|
||||||
return "Skipped: mail account processing already in progress."
|
|
||||||
|
|
||||||
total_new_documents = 0
|
total_new_documents = 0
|
||||||
accounts = (
|
accounts = (
|
||||||
MailAccount.objects.filter(pk__in=account_ids)
|
MailAccount.objects.filter(pk__in=account_ids)
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
from typing import Final
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_mock
|
|
||||||
|
|
||||||
from documents.models import PaperlessTask
|
|
||||||
from documents.tests.factories import PaperlessTaskFactory
|
|
||||||
from paperless_mail import tasks
|
|
||||||
from paperless_mail.tests.factories import MailAccountFactory
|
|
||||||
from paperless_mail.tests.factories import MailRuleFactory
|
|
||||||
|
|
||||||
NO_DOCUMENTS_ADDED: Final = "No new documents were added."
|
|
||||||
SKIPPED: Final = "Skipped: mail account processing already in progress."
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@pytest.mark.usefixtures("account_with_rule")
|
|
||||||
class TestProcessMailAccountsOverlap:
|
|
||||||
@pytest.fixture
|
|
||||||
def account_with_rule(self) -> None:
|
|
||||||
"""An enabled mail account with a single enabled rule."""
|
|
||||||
account = MailAccountFactory.create()
|
|
||||||
MailRuleFactory.create(account=account, enabled=True)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("status", "expected_result", "expected_call_count"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
PaperlessTask.Status.PENDING,
|
|
||||||
SKIPPED,
|
|
||||||
0,
|
|
||||||
id="pending-task-blocks",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
PaperlessTask.Status.STARTED,
|
|
||||||
SKIPPED,
|
|
||||||
0,
|
|
||||||
id="started-task-blocks",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
PaperlessTask.Status.SUCCESS,
|
|
||||||
NO_DOCUMENTS_ADDED,
|
|
||||||
1,
|
|
||||||
id="finished-task-does-not-block",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_skips_only_while_another_mail_fetch_task_runs(
|
|
||||||
self,
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
status: PaperlessTask.Status,
|
|
||||||
expected_result: str,
|
|
||||||
expected_call_count: int,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An enabled mail account with a rule
|
|
||||||
- Another mail fetch task row in the given status
|
|
||||||
WHEN:
|
|
||||||
- Mail accounts are processed
|
|
||||||
THEN:
|
|
||||||
- Processing is skipped only if that other task is pending or running
|
|
||||||
"""
|
|
||||||
PaperlessTaskFactory.create(
|
|
||||||
task_type=PaperlessTask.TaskType.MAIL_FETCH,
|
|
||||||
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
|
|
||||||
mocked_handle = mocker.patch.object(
|
|
||||||
tasks.MailAccountHandler,
|
|
||||||
"handle_mail_account",
|
|
||||||
return_value=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = tasks.process_mail_accounts()
|
|
||||||
|
|
||||||
assert mocked_handle.call_count == expected_call_count
|
|
||||||
assert result == expected_result
|
|
||||||
|
|
||||||
def test_runs_when_no_other_mail_fetch_task_exists(
|
|
||||||
self,
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An enabled mail account with a rule
|
|
||||||
- No other mail fetch task rows
|
|
||||||
WHEN:
|
|
||||||
- Mail accounts are processed
|
|
||||||
THEN:
|
|
||||||
- The account is handled
|
|
||||||
"""
|
|
||||||
mocked_handle = mocker.patch.object(
|
|
||||||
tasks.MailAccountHandler,
|
|
||||||
"handle_mail_account",
|
|
||||||
return_value=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = tasks.process_mail_accounts()
|
|
||||||
|
|
||||||
mocked_handle.assert_called_once()
|
|
||||||
assert result == NO_DOCUMENTS_ADDED
|
|
||||||
|
|
||||||
def test_does_not_skip_due_to_its_own_task_row(
|
|
||||||
self,
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An enabled mail account with a rule
|
|
||||||
- A running mail fetch task row belonging to this very task
|
|
||||||
WHEN:
|
|
||||||
- Mail accounts are processed under that task id
|
|
||||||
THEN:
|
|
||||||
- The task does not skip itself and handles the account
|
|
||||||
"""
|
|
||||||
PaperlessTaskFactory.create(
|
|
||||||
task_id="self-task-id",
|
|
||||||
task_type=PaperlessTask.TaskType.MAIL_FETCH,
|
|
||||||
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
|
|
||||||
status=PaperlessTask.Status.STARTED,
|
|
||||||
)
|
|
||||||
|
|
||||||
mocked_handle = mocker.patch.object(
|
|
||||||
tasks.MailAccountHandler,
|
|
||||||
"handle_mail_account",
|
|
||||||
return_value=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = tasks.process_mail_accounts.apply(task_id="self-task-id").result
|
|
||||||
|
|
||||||
mocked_handle.assert_called_once()
|
|
||||||
assert result == NO_DOCUMENTS_ADDED
|
|
||||||
Reference in New Issue
Block a user