mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-03 03:05:53 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be95f8fcc |
No files matched your search
+5
-5
@@ -10334,18 +10334,18 @@
|
|||||||
<context context-type="linenumber">294,295</context>
|
<context context-type="linenumber">294,295</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3420321797707163677" datatype="html">
|
<trans-unit id="2179847500064178686" datatype="html">
|
||||||
<source>Preview document</source>
|
<source>Edit document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||||
<context context-type="linenumber">328</context>
|
<context context-type="linenumber">328</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2179847500064178686" datatype="html">
|
<trans-unit id="3420321797707163677" datatype="html">
|
||||||
<source>Edit document</source>
|
<source>Preview document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||||
<context context-type="linenumber">331</context>
|
<context context-type="linenumber">329</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4512084577073831437" datatype="html">
|
<trans-unit id="4512084577073831437" datatype="html">
|
||||||
|
|||||||
@@ -465,7 +465,7 @@
|
|||||||
@switch (archiveContentRenderType) {
|
@switch (archiveContentRenderType) {
|
||||||
@case (ContentRenderType.PDF) {
|
@case (ContentRenderType.PDF) {
|
||||||
@if (!useNativePdfViewer) {
|
@if (!useNativePdfViewer) {
|
||||||
<div class="preview-sticky pdf-viewer-container" tabindex="-1">
|
<div class="preview-sticky pdf-viewer-container">
|
||||||
<pngx-pdf-viewer
|
<pngx-pdf-viewer
|
||||||
[src]="pdfSource()"
|
[src]="pdfSource()"
|
||||||
[sourceRevision]="previewRevision()"
|
[sourceRevision]="previewRevision()"
|
||||||
@@ -477,7 +477,7 @@
|
|||||||
(loadError)="onError($event)"
|
(loadError)="onError($event)"
|
||||||
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
||||||
</pngx-pdf-viewer>
|
</pngx-pdf-viewer>
|
||||||
<div class="preview-controls position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
|
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
|
||||||
@if (previewNumPages()) {
|
@if (previewNumPages()) {
|
||||||
<div class="input-group input-group-sm w-auto flex-nowrap">
|
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
|
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
|
||||||
|
|||||||
@@ -12,22 +12,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-controls {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.15s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .preview-controls,
|
|
||||||
&:focus-within .preview-controls {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-group .dropdown-toggle-split {
|
.btn-group .dropdown-toggle-split {
|
||||||
|
|||||||
@@ -208,7 +208,7 @@
|
|||||||
i18n>Correspondent</th>
|
i18n>Correspondent</th>
|
||||||
}
|
}
|
||||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||||
<th class="cursor-pointer ps-5"
|
<th class="cursor-pointer"
|
||||||
pngxSortable="title"
|
pngxSortable="title"
|
||||||
title="Sort by title" i18n-title
|
title="Sort by title" i18n-title
|
||||||
[currentSortField]="list.sortField"
|
[currentSortField]="list.sortField"
|
||||||
@@ -322,13 +322,13 @@
|
|||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
|
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||||
<td width="30%" class="position-relative" [class.ps-5]="activeDisplayFields.includes(DisplayField.TITLE)">
|
<td width="30%">
|
||||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||||
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
|
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
|
||||||
<pngx-preview-popup class="position-absolute start-0" [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
|
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||||
|
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
|
||||||
<i-bs name="eye"></i-bs>
|
<i-bs name="eye"></i-bs>
|
||||||
</pngx-preview-popup>
|
</pngx-preview-popup>
|
||||||
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
|
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||||
|
|||||||
@@ -87,10 +87,6 @@ $paperless-card-breakpoints: (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table .ps-5 {
|
|
||||||
padding-left: 2.2rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,30 @@ def _clear_django_caches() -> None:
|
|||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _keep_connection_open_in_atomic(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Make ``close_old_connections()`` a no-op inside the test's transaction.
|
||||||
|
|
||||||
|
The Celery signal handlers call it, as a worker would between tasks. Tests
|
||||||
|
run inside an open atomic block, where Postgres really closes the
|
||||||
|
connection, taking the test transaction and every later test on the
|
||||||
|
worker with it. SQLite in-memory ignores the close, which hid this.
|
||||||
|
"""
|
||||||
|
from django.db.backends.base.base import BaseDatabaseWrapper
|
||||||
|
|
||||||
|
original = BaseDatabaseWrapper.close_if_unusable_or_obsolete
|
||||||
|
|
||||||
|
def close_unless_atomic(self: BaseDatabaseWrapper) -> None:
|
||||||
|
if not self.in_atomic_block:
|
||||||
|
original(self)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
BaseDatabaseWrapper,
|
||||||
|
"close_if_unusable_or_obsolete",
|
||||||
|
close_unless_atomic,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def paperless_dirs(
|
def paperless_dirs(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -1062,7 +1062,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
"custom_fields": [
|
"custom_fields": [
|
||||||
{
|
{
|
||||||
"field": custom_field_doclink.id,
|
"field": custom_field_doclink.id,
|
||||||
"value": [2, 3, 4],
|
"value": [doc2.id, doc3.id, doc4.id],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1073,13 +1073,13 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(CustomFieldInstance.objects.count(), 4)
|
self.assertEqual(CustomFieldInstance.objects.count(), 4)
|
||||||
_cf_5 = doc2.custom_fields.first()
|
_cf_5 = doc2.custom_fields.first()
|
||||||
assert _cf_5 is not None
|
assert _cf_5 is not None
|
||||||
self.assertEqual(_cf_5.value, [1])
|
self.assertEqual(_cf_5.value, [doc1.id])
|
||||||
_cf_6 = doc3.custom_fields.first()
|
_cf_6 = doc3.custom_fields.first()
|
||||||
assert _cf_6 is not None
|
assert _cf_6 is not None
|
||||||
self.assertEqual(_cf_6.value, [1])
|
self.assertEqual(_cf_6.value, [doc1.id])
|
||||||
_cf_7 = doc4.custom_fields.first()
|
_cf_7 = doc4.custom_fields.first()
|
||||||
assert _cf_7 is not None
|
assert _cf_7 is not None
|
||||||
self.assertEqual(_cf_7.value, [1])
|
self.assertEqual(_cf_7.value, [doc1.id])
|
||||||
|
|
||||||
# Add links appends if necessary
|
# Add links appends if necessary
|
||||||
resp = self.client.patch(
|
resp = self.client.patch(
|
||||||
@@ -1088,7 +1088,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
"custom_fields": [
|
"custom_fields": [
|
||||||
{
|
{
|
||||||
"field": custom_field_doclink.id,
|
"field": custom_field_doclink.id,
|
||||||
"value": [1, 4],
|
"value": [doc1.id, doc4.id],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1098,7 +1098,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
_cf_8 = doc4.custom_fields.first()
|
_cf_8 = doc4.custom_fields.first()
|
||||||
assert _cf_8 is not None
|
assert _cf_8 is not None
|
||||||
self.assertEqual(_cf_8.value, [1, 3])
|
self.assertEqual(_cf_8.value, [doc1.id, doc3.id])
|
||||||
|
|
||||||
# Remove one of the links, removed on other doc
|
# Remove one of the links, removed on other doc
|
||||||
resp = self.client.patch(
|
resp = self.client.patch(
|
||||||
@@ -1107,7 +1107,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
"custom_fields": [
|
"custom_fields": [
|
||||||
{
|
{
|
||||||
"field": custom_field_doclink.id,
|
"field": custom_field_doclink.id,
|
||||||
"value": [2, 3],
|
"value": [doc2.id, doc3.id],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1117,13 +1117,13 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
_cf_9 = doc2.custom_fields.first()
|
_cf_9 = doc2.custom_fields.first()
|
||||||
assert _cf_9 is not None
|
assert _cf_9 is not None
|
||||||
self.assertEqual(_cf_9.value, [1])
|
self.assertEqual(_cf_9.value, [doc1.id])
|
||||||
_cf_10 = doc3.custom_fields.first()
|
_cf_10 = doc3.custom_fields.first()
|
||||||
assert _cf_10 is not None
|
assert _cf_10 is not None
|
||||||
self.assertEqual(_cf_10.value, [1, 4])
|
self.assertEqual(_cf_10.value, [doc1.id, doc4.id])
|
||||||
_cf_11 = doc4.custom_fields.first()
|
_cf_11 = doc4.custom_fields.first()
|
||||||
assert _cf_11 is not None
|
assert _cf_11 is not None
|
||||||
self.assertEqual(_cf_11.value, [3])
|
self.assertEqual(_cf_11.value, [doc3.id])
|
||||||
|
|
||||||
# Removes the field entirely
|
# Removes the field entirely
|
||||||
resp = self.client.patch(
|
resp = self.client.patch(
|
||||||
@@ -1140,10 +1140,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(_cf_12.value, [])
|
self.assertEqual(_cf_12.value, [])
|
||||||
_cf_13 = doc3.custom_fields.first()
|
_cf_13 = doc3.custom_fields.first()
|
||||||
assert _cf_13 is not None
|
assert _cf_13 is not None
|
||||||
self.assertEqual(_cf_13.value, [4])
|
self.assertEqual(_cf_13.value, [doc4.id])
|
||||||
_cf_14 = doc4.custom_fields.first()
|
_cf_14 = doc4.custom_fields.first()
|
||||||
assert _cf_14 is not None
|
assert _cf_14 is not None
|
||||||
self.assertEqual(_cf_14.value, [3])
|
self.assertEqual(_cf_14.value, [doc3.id])
|
||||||
|
|
||||||
# If field exists on target doc but value is None
|
# If field exists on target doc but value is None
|
||||||
doc5 = Document.objects.create(
|
doc5 = Document.objects.create(
|
||||||
@@ -1170,7 +1170,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
_cf_15 = doc5.custom_fields.first()
|
_cf_15 = doc5.custom_fields.first()
|
||||||
assert _cf_15 is not None
|
assert _cf_15 is not None
|
||||||
self.assertEqual(_cf_15.value, [1])
|
self.assertEqual(_cf_15.value, [doc1.id])
|
||||||
|
|
||||||
def test_documentlink_patch_requires_change_permission_on_target_documents(
|
def test_documentlink_patch_requires_change_permission_on_target_documents(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -628,22 +628,23 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
|||||||
# Subset check (document link field only) #
|
# Subset check (document link field only) #
|
||||||
# ==========================================================#
|
# ==========================================================#
|
||||||
def test_document_link_contains(self) -> None:
|
def test_document_link_contains(self) -> None:
|
||||||
|
linked = {self.documents[1].id, self.documents[2].id}
|
||||||
# Document link field "contains" performs a subset check.
|
# Document link field "contains" performs a subset check.
|
||||||
self._assert_query_match_predicate(
|
self._assert_query_match_predicate(
|
||||||
["documentlink_field", "contains", [1, 2]],
|
["documentlink_field", "contains", sorted(linked)],
|
||||||
lambda document: (
|
lambda document: (
|
||||||
"documentlink_field" in document
|
"documentlink_field" in document
|
||||||
and document["documentlink_field"] is not None
|
and document["documentlink_field"] is not None
|
||||||
and set(document["documentlink_field"]) >= {1, 2}
|
and set(document["documentlink_field"]) >= linked
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# The order of IDs don't matter - this is the same as above.
|
# The order of IDs don't matter - this is the same as above.
|
||||||
self._assert_query_match_predicate(
|
self._assert_query_match_predicate(
|
||||||
["documentlink_field", "contains", [2, 1]],
|
["documentlink_field", "contains", sorted(linked, reverse=True)],
|
||||||
lambda document: (
|
lambda document: (
|
||||||
"documentlink_field" in document
|
"documentlink_field" in document
|
||||||
and document["documentlink_field"] is not None
|
and document["documentlink_field"] is not None
|
||||||
and set(document["documentlink_field"]) >= {1, 2}
|
and set(document["documentlink_field"]) >= linked
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -944,7 +944,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual({r["id"] for r in results}, {1, 2})
|
self.assertEqual({r["id"] for r in results}, {d1.id, d2.id})
|
||||||
|
|
||||||
@mock.patch("documents.search._backend.parse_user_query")
|
@mock.patch("documents.search._backend.parse_user_query")
|
||||||
def test_search_parser_bug_surfaces_as_500_not_400(self, m) -> None:
|
def test_search_parser_bug_surfaces_as_500_not_400(self, m) -> None:
|
||||||
@@ -1801,6 +1801,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
def test_search_sorting(self) -> None:
|
def test_search_sorting(self) -> None:
|
||||||
u1 = UserFactory(username="user1")
|
u1 = UserFactory(username="user1")
|
||||||
u2 = UserFactory(username="user2")
|
u2 = UserFactory(username="user2")
|
||||||
|
u3 = UserFactory(username="user3")
|
||||||
c1 = Correspondent.objects.create(name="corres Ax")
|
c1 = Correspondent.objects.create(name="corres Ax")
|
||||||
c2 = Correspondent.objects.create(name="corres Cx")
|
c2 = Correspondent.objects.create(name="corres Cx")
|
||||||
c3 = Correspondent.objects.create(name="corres Bx")
|
c3 = Correspondent.objects.create(name="corres Bx")
|
||||||
@@ -1826,6 +1827,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
content="test",
|
content="test",
|
||||||
archive_serial_number=1,
|
archive_serial_number=1,
|
||||||
title="1",
|
title="1",
|
||||||
|
owner=u3,
|
||||||
)
|
)
|
||||||
Note.objects.create(
|
Note.objects.create(
|
||||||
note="This is a note.",
|
note="This is a note.",
|
||||||
@@ -1878,14 +1880,14 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
search_query("&ordering=-num_notes"),
|
search_query("&ordering=-num_notes"),
|
||||||
[d1.id, d3.id, d2.id],
|
[d1.id, d3.id, d2.id],
|
||||||
)
|
)
|
||||||
# owner sort: ORM orders by owner_id (integer); NULLs first in SQLite ASC
|
# owner sort: ORM orders by owner_id (integer)
|
||||||
self.assertListEqual(
|
self.assertListEqual(
|
||||||
search_query("&ordering=owner"),
|
search_query("&ordering=owner"),
|
||||||
[d3.id, d1.id, d2.id],
|
[d1.id, d2.id, d3.id],
|
||||||
)
|
)
|
||||||
self.assertListEqual(
|
self.assertListEqual(
|
||||||
search_query("&ordering=-owner"),
|
search_query("&ordering=-owner"),
|
||||||
[d2.id, d1.id, d3.id],
|
[d3.id, d2.id, d1.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_search_ordering_by_score(self) -> None:
|
def test_search_ordering_by_score(self) -> None:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from datetime import timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.db import connection
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -68,7 +69,7 @@ class TestSystemStatus(APITestCase):
|
|||||||
self.assertEqual(response.data["install_type"], "bare-metal")
|
self.assertEqual(response.data["install_type"], "bare-metal")
|
||||||
self.assertIsNotNone(response.data["storage"]["total"])
|
self.assertIsNotNone(response.data["storage"]["total"])
|
||||||
self.assertIsNotNone(response.data["storage"]["available"])
|
self.assertIsNotNone(response.data["storage"]["available"])
|
||||||
self.assertEqual(response.data["database"]["type"], "sqlite")
|
self.assertEqual(response.data["database"]["type"], connection.vendor)
|
||||||
self.assertIsNotNone(response.data["database"]["url"])
|
self.assertIsNotNone(response.data["database"]["url"])
|
||||||
self.assertEqual(response.data["database"]["status"], "OK")
|
self.assertEqual(response.data["database"]["status"], "OK")
|
||||||
self.assertIsNone(response.data["database"]["error"])
|
self.assertIsNone(response.data["database"]["error"])
|
||||||
|
|||||||
@@ -1031,9 +1031,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
bulk_edit.merge(doc_ids)
|
bulk_edit.merge(doc_ids)
|
||||||
error_str = cm.output[0]
|
error_str = cm.output[0]
|
||||||
expected_str = (
|
expected_str = f"Error merging document {self.doc2.id}, it will not be included in the merge"
|
||||||
"Error merging document 2, it will not be included in the merge"
|
|
||||||
)
|
|
||||||
self.assertIn(expected_str, error_str)
|
self.assertIn(expected_str, error_str)
|
||||||
|
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
@@ -1193,7 +1191,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
bulk_edit.split(doc_ids, pages)
|
bulk_edit.split(doc_ids, pages)
|
||||||
error_str = cm.output[0]
|
error_str = cm.output[0]
|
||||||
expected_str = "Error splitting document 2"
|
expected_str = f"Error splitting document {self.doc2.id}"
|
||||||
self.assertIn(expected_str, error_str)
|
self.assertIn(expected_str, error_str)
|
||||||
|
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
|
|||||||
@@ -248,8 +248,8 @@ class TestConsumer(
|
|||||||
)
|
)
|
||||||
self.assertIsNone(document.correspondent)
|
self.assertIsNone(document.correspondent)
|
||||||
self.assertIsNone(document.document_type)
|
self.assertIsNone(document.document_type)
|
||||||
self.assertEqual(document.filename, "0000001.pdf")
|
self.assertEqual(document.filename, f"{document.pk:07}.pdf")
|
||||||
self.assertEqual(document.archive_filename, "0000001.pdf")
|
self.assertEqual(document.archive_filename, f"{document.pk:07}.pdf")
|
||||||
|
|
||||||
self.assertIsFile(document.source_path)
|
self.assertIsFile(document.source_path)
|
||||||
|
|
||||||
@@ -1127,17 +1127,17 @@ class TestConsumer(
|
|||||||
with self.get_consumer(settings.CONSUMPTION_DIR / "simple.png") as consumer:
|
with self.get_consumer(settings.CONSUMPTION_DIR / "simple.png") as consumer:
|
||||||
consumer.run()
|
consumer.run()
|
||||||
|
|
||||||
doc1 = Document.objects.filter(pk=1).first()
|
doc1 = Document.objects.latest("pk")
|
||||||
|
|
||||||
with self.get_consumer(settings.CONSUMPTION_DIR / "simple.pdf") as consumer:
|
with self.get_consumer(settings.CONSUMPTION_DIR / "simple.pdf") as consumer:
|
||||||
consumer.run()
|
consumer.run()
|
||||||
|
|
||||||
doc2 = Document.objects.filter(pk=2).first()
|
doc2 = Document.objects.latest("pk")
|
||||||
|
|
||||||
with self.get_consumer(settings.CONSUMPTION_DIR / "simple.png.pdf") as consumer:
|
with self.get_consumer(settings.CONSUMPTION_DIR / "simple.png.pdf") as consumer:
|
||||||
consumer.run()
|
consumer.run()
|
||||||
|
|
||||||
doc3 = Document.objects.filter(pk=3).first()
|
doc3 = Document.objects.latest("pk")
|
||||||
|
|
||||||
self.assertEqual(doc1.filename, "simple.png")
|
self.assertEqual(doc1.filename, "simple.png")
|
||||||
self.assertEqual(doc1.archive_filename, "simple.pdf")
|
self.assertEqual(doc1.archive_filename, "simple.pdf")
|
||||||
|
|||||||
@@ -760,6 +760,9 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
|
|||||||
archive_checksum="B",
|
archive_checksum="B",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Without a format the files are renamed to match the primary key
|
||||||
|
original = doc.source_path
|
||||||
|
archive = doc.archive_path
|
||||||
self.assertIsFile(original)
|
self.assertIsFile(original)
|
||||||
self.assertIsFile(archive)
|
self.assertIsFile(archive)
|
||||||
self.assertIsFile(doc.source_path)
|
self.assertIsFile(doc.source_path)
|
||||||
@@ -978,6 +981,9 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
|
|||||||
archive_filename="0000001.pdf",
|
archive_filename="0000001.pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Without a format the files are renamed to match the primary key
|
||||||
|
original = doc.source_path
|
||||||
|
archive = doc.archive_path
|
||||||
self.assertIsFile(original)
|
self.assertIsFile(original)
|
||||||
self.assertIsFile(archive)
|
self.assertIsFile(archive)
|
||||||
self.assertIsFile(doc.source_path)
|
self.assertIsFile(doc.source_path)
|
||||||
|
|||||||
@@ -151,6 +151,20 @@ class TestExportImport(
|
|||||||
|
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
|
def _install_sample_files(self) -> None:
|
||||||
|
documents_dir = Path(self.dirs.media_dir) / "documents"
|
||||||
|
shutil.rmtree(documents_dir)
|
||||||
|
install_document_samples(documents_dir)
|
||||||
|
# Thumbnails are located by primary key, which the database sequence
|
||||||
|
# does not reset between tests on PostgreSQL
|
||||||
|
for number, document in enumerate(
|
||||||
|
(self.d1, self.d2, self.d3, self.d4),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
source = documents_dir / "thumbnails" / f"{number:07}.webp"
|
||||||
|
if source != document.thumbnail_path:
|
||||||
|
shutil.copy(source, document.thumbnail_path)
|
||||||
|
|
||||||
def _get_document_from_manifest(self, manifest, id):
|
def _get_document_from_manifest(self, manifest, id):
|
||||||
f = list(
|
f = list(
|
||||||
filter(
|
filter(
|
||||||
@@ -204,8 +218,7 @@ class TestExportImport(
|
|||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
def test_exporter(self, *, use_filename_format=False) -> None:
|
def test_exporter(self, *, use_filename_format=False) -> None:
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
num_permission_objects = Permission.objects.count()
|
num_permission_objects = Permission.objects.count()
|
||||||
|
|
||||||
@@ -308,8 +321,7 @@ class TestExportImport(
|
|||||||
self.assertEqual(messages.total_issue_count, 0)
|
self.assertEqual(messages.total_issue_count, 0)
|
||||||
|
|
||||||
def test_exporter_with_filename_format(self) -> None:
|
def test_exporter_with_filename_format(self) -> None:
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
with override_settings(
|
with override_settings(
|
||||||
FILENAME_FORMAT="{created_year}/{correspondent}/{title}",
|
FILENAME_FORMAT="{created_year}/{correspondent}/{title}",
|
||||||
@@ -317,8 +329,7 @@ class TestExportImport(
|
|||||||
self.test_exporter(use_filename_format=True)
|
self.test_exporter(use_filename_format=True)
|
||||||
|
|
||||||
def test_exporter_includes_share_links_and_bundles(self) -> None:
|
def test_exporter_includes_share_links_and_bundles(self) -> None:
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
share_link = ShareLink.objects.create(
|
share_link = ShareLink.objects.create(
|
||||||
slug="share-link-slug",
|
slug="share-link-slug",
|
||||||
@@ -416,8 +427,7 @@ class TestExportImport(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_update_export_changed_time(self) -> None:
|
def test_update_export_changed_time(self) -> None:
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
self._do_export()
|
self._do_export()
|
||||||
self.assertIsFile(self.target / "manifest.json")
|
self.assertIsFile(self.target / "manifest.json")
|
||||||
@@ -452,8 +462,7 @@ class TestExportImport(
|
|||||||
self.assertEqual(st_mtime_3, st_mtime_4)
|
self.assertEqual(st_mtime_3, st_mtime_4)
|
||||||
|
|
||||||
def test_update_export_changed_checksum(self) -> None:
|
def test_update_export_changed_checksum(self) -> None:
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
self._do_export()
|
self._do_export()
|
||||||
|
|
||||||
@@ -479,8 +488,7 @@ class TestExportImport(
|
|||||||
self.assertIsFile(self.target / "manifest.json")
|
self.assertIsFile(self.target / "manifest.json")
|
||||||
|
|
||||||
def test_update_export_deleted_document(self) -> None:
|
def test_update_export_deleted_document(self) -> None:
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
manifest = self._do_export()
|
manifest = self._do_export()
|
||||||
|
|
||||||
@@ -511,8 +519,7 @@ class TestExportImport(
|
|||||||
|
|
||||||
@override_settings(FILENAME_FORMAT="{title}/{correspondent}")
|
@override_settings(FILENAME_FORMAT="{title}/{correspondent}")
|
||||||
def test_update_export_changed_location(self) -> None:
|
def test_update_export_changed_location(self) -> None:
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
self._do_export(use_filename_format=True)
|
self._do_export(use_filename_format=True)
|
||||||
self.assertIsFile(self.target / "wow1" / "c.pdf")
|
self.assertIsFile(self.target / "wow1" / "c.pdf")
|
||||||
@@ -553,8 +560,7 @@ class TestExportImport(
|
|||||||
- Zipfile is created
|
- Zipfile is created
|
||||||
- Zipfile contains exported files
|
- Zipfile contains exported files
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
args = ["document_exporter", self.target, "--zip"]
|
args = ["document_exporter", self.target, "--zip"]
|
||||||
|
|
||||||
@@ -582,8 +588,7 @@ class TestExportImport(
|
|||||||
- Zipfile is created
|
- Zipfile is created
|
||||||
- Zipfile contains exported files
|
- Zipfile contains exported files
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
args = ["document_exporter", self.target, "--zip", "--use-filename-format"]
|
args = ["document_exporter", self.target, "--zip", "--use-filename-format"]
|
||||||
|
|
||||||
@@ -618,8 +623,7 @@ class TestExportImport(
|
|||||||
- Zipfile contains exported files
|
- Zipfile contains exported files
|
||||||
- The existing file and directory in target are removed
|
- The existing file and directory in target are removed
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
# Create stuff in target directory
|
# Create stuff in target directory
|
||||||
existing_file = self.target / "test.txt"
|
existing_file = self.target / "test.txt"
|
||||||
@@ -714,8 +718,7 @@ class TestExportImport(
|
|||||||
- Manifest.json doesn't contain information about archive files
|
- Manifest.json doesn't contain information about archive files
|
||||||
- Documents can be imported again
|
- Documents can be imported again
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
manifest = self._do_export()
|
manifest = self._do_export()
|
||||||
has_archive = False
|
has_archive = False
|
||||||
@@ -757,8 +760,7 @@ class TestExportImport(
|
|||||||
- Manifest.json doesn't contain information about thumbnails
|
- Manifest.json doesn't contain information about thumbnails
|
||||||
- Documents can be imported again
|
- Documents can be imported again
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
manifest = self._do_export()
|
manifest = self._do_export()
|
||||||
has_thumbnail = False
|
has_thumbnail = False
|
||||||
@@ -802,8 +804,7 @@ class TestExportImport(
|
|||||||
- Main manifest.json file doesn't contain information about documents
|
- Main manifest.json file doesn't contain information about documents
|
||||||
- Documents can be imported again
|
- Documents can be imported again
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
manifest = self._do_export(split_manifest=True)
|
manifest = self._do_export(split_manifest=True)
|
||||||
has_document = False
|
has_document = False
|
||||||
@@ -827,8 +828,7 @@ class TestExportImport(
|
|||||||
self.assertEqual(CustomFieldInstance.objects.count(), 1)
|
self.assertEqual(CustomFieldInstance.objects.count(), 1)
|
||||||
|
|
||||||
def _export_import_barcodes(self, *, split_manifest: bool) -> None:
|
def _export_import_barcodes(self, *, split_manifest: bool) -> None:
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
DocumentBarcodeFactory(document=self.d1, value="https://example.com")
|
DocumentBarcodeFactory(document=self.d1, value="https://example.com")
|
||||||
DocumentBarcodeFactory(document=self.d2, page=2, value="DE8937")
|
DocumentBarcodeFactory(document=self.d2, page=2, value="DE8937")
|
||||||
|
|
||||||
@@ -863,8 +863,7 @@ class TestExportImport(
|
|||||||
THEN:
|
THEN:
|
||||||
- Documents can be imported again
|
- Documents can be imported again
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
self._do_export(use_folder_prefix=True)
|
self._do_export(use_folder_prefix=True)
|
||||||
|
|
||||||
@@ -890,8 +889,7 @@ class TestExportImport(
|
|||||||
THEN:
|
THEN:
|
||||||
- Documents can be imported again
|
- Documents can be imported again
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
self._do_export(use_folder_prefix=True, split_manifest=True)
|
self._do_export(use_folder_prefix=True, split_manifest=True)
|
||||||
|
|
||||||
@@ -917,8 +915,7 @@ class TestExportImport(
|
|||||||
- ContentType & Permission objects are not deleted, db transaction rolled back
|
- ContentType & Permission objects are not deleted, db transaction rolled back
|
||||||
"""
|
"""
|
||||||
|
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
num_content_type_objects = ContentType.objects.count()
|
num_content_type_objects = ContentType.objects.count()
|
||||||
num_permission_objects = Permission.objects.count()
|
num_permission_objects = Permission.objects.count()
|
||||||
@@ -954,8 +951,7 @@ class TestExportImport(
|
|||||||
self.assertEqual(Permission.objects.count(), num_permission_objects + 1)
|
self.assertEqual(Permission.objects.count(), num_permission_objects + 1)
|
||||||
|
|
||||||
def test_exporter_with_auditlog_disabled(self) -> None:
|
def test_exporter_with_auditlog_disabled(self) -> None:
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
with override_settings(
|
with override_settings(
|
||||||
AUDIT_LOG_ENABLED=False,
|
AUDIT_LOG_ENABLED=False,
|
||||||
@@ -974,8 +970,7 @@ class TestExportImport(
|
|||||||
- The soft-deleted document, note, and custom field instance
|
- The soft-deleted document, note, and custom field instance
|
||||||
survive the round-trip with deleted_at preserved
|
survive the round-trip with deleted_at preserved
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
self._install_sample_files()
|
||||||
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
|
||||||
|
|
||||||
# d1 has self.note and self.cfi1 attached via setUp
|
# d1 has self.note and self.cfi1 attached via setUp
|
||||||
self.d1.delete()
|
self.d1.delete()
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
call_command(
|
call_command(
|
||||||
"document_fuzzy_match",
|
"document_fuzzy_match",
|
||||||
"--no-progress-bar",
|
"--no-progress-bar",
|
||||||
|
# The default scales with the CPU count and forks, which closes
|
||||||
|
# the test's database connection on PostgreSQL
|
||||||
|
"--processes",
|
||||||
|
"1",
|
||||||
*args,
|
*args,
|
||||||
stdout=stdout,
|
stdout=stdout,
|
||||||
stderr=stderr,
|
stderr=stderr,
|
||||||
|
|||||||
@@ -425,26 +425,29 @@ class TestRetaggerStoragePath(DirectoriesMixin):
|
|||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestRetaggerIdRange(DirectoriesMixin):
|
class TestRetaggerIdRange(DirectoriesMixin):
|
||||||
@pytest.mark.usefixtures("documents")
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("id_range_args", "expected_count"),
|
("range_end", "expected_count"),
|
||||||
[
|
[
|
||||||
pytest.param(["1", "2"], 1, id="narrow_range_limits_scope"),
|
pytest.param("second", 1, id="narrow_range_limits_scope"),
|
||||||
pytest.param(["1", "9999"], 2, id="wide_range_tags_all_matches"),
|
pytest.param("extra", 2, id="wide_range_tags_all_matches"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_id_range_limits_scope(
|
def test_id_range_limits_scope(
|
||||||
self,
|
self,
|
||||||
tags: TagTuple,
|
tags: TagTuple,
|
||||||
id_range_args: list[str],
|
documents: DocumentTuple,
|
||||||
|
range_end: str,
|
||||||
expected_count: int,
|
expected_count: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
DocumentFactory(content="NOT the first document")
|
first, second, *_ = documents
|
||||||
|
extra = DocumentFactory(content="NOT the first document")
|
||||||
|
end = {"second": second, "extra": extra}[range_end]
|
||||||
call_command(
|
call_command(
|
||||||
"document_retagger",
|
"document_retagger",
|
||||||
"--tags",
|
"--tags",
|
||||||
"--id-range",
|
"--id-range",
|
||||||
*id_range_args,
|
str(first.pk),
|
||||||
|
str(end.pk),
|
||||||
skip_checks=True,
|
skip_checks=True,
|
||||||
)
|
)
|
||||||
tag_first, *_ = tags
|
tag_first, *_ = tags
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ class TestWorkflows(
|
|||||||
w.save()
|
w.save()
|
||||||
|
|
||||||
self.assertEqual(w.__str__(), "Workflow: Workflow 1")
|
self.assertEqual(w.__str__(), "Workflow: Workflow 1")
|
||||||
self.assertEqual(trigger.__str__(), "WorkflowTrigger 1")
|
self.assertEqual(trigger.__str__(), f"WorkflowTrigger {trigger.pk}")
|
||||||
self.assertEqual(action.__str__(), "WorkflowAction 1")
|
self.assertEqual(action.__str__(), f"WorkflowAction {action.pk}")
|
||||||
|
|
||||||
test_file = shutil.copy(
|
test_file = shutil.copy(
|
||||||
self.SIMPLE_PDF,
|
self.SIMPLE_PDF,
|
||||||
@@ -433,7 +433,7 @@ class TestWorkflows(
|
|||||||
list(document.tags.all()),
|
list(document.tags.all()),
|
||||||
[self.t1, self.t2, self.t3],
|
[self.t1, self.t2, self.t3],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertCountEqual(
|
||||||
list(
|
list(
|
||||||
get_users_with_perms(
|
get_users_with_perms(
|
||||||
document,
|
document,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from contextlib import nullcontext
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -37,6 +38,13 @@ def patch_embed_model():
|
|||||||
llama_settings.Settings.embed_model = None
|
llama_settings.Settings.embed_model = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def keep_db_connection():
|
||||||
|
# Closing the connection would roll back the test's transaction on PostgreSQL
|
||||||
|
with patch("paperless_ai.chat.db_connection_released", nullcontext):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def patch_embed_nodes():
|
def patch_embed_nodes():
|
||||||
with patch(
|
with patch(
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
from collections.abc import Generator
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from llama_index.core.schema import TextNode
|
|
||||||
|
|
||||||
from paperless_ai.tables import DocumentChunksTable
|
|
||||||
from paperless_ai.vector_store import DB_FILENAME
|
|
||||||
from paperless_ai.vector_store import DEFAULT_TABLE_NAME
|
|
||||||
from paperless_ai.vector_store import VEC0_CHUNK_SIZE
|
|
||||||
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
|
|
||||||
|
|
||||||
DIM = 16
|
|
||||||
|
|
||||||
|
|
||||||
def make_node(node_id: str, document_id: int) -> TextNode:
|
|
||||||
node = TextNode(
|
|
||||||
id_=node_id,
|
|
||||||
text="some text",
|
|
||||||
metadata={"document_id": document_id, "modified": "2026-06-10T00:00:00"},
|
|
||||||
)
|
|
||||||
node.relationships = {}
|
|
||||||
node.embedding = [document_id + i / 100 for i in range(DIM)]
|
|
||||||
return node
|
|
||||||
|
|
||||||
|
|
||||||
def vec_table_sql(db_path: Path) -> str:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
return conn.execute(
|
|
||||||
"SELECT sql FROM sqlite_master WHERE name = ?",
|
|
||||||
(DEFAULT_TABLE_NAME,),
|
|
||||||
).fetchone()[0]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def create_legacy_vec_table(conn: sqlite3.Connection, dim: int) -> None:
|
|
||||||
"""The table definition from before chunk_size was set explicitly."""
|
|
||||||
conn.execute(
|
|
||||||
f"CREATE VIRTUAL TABLE {DEFAULT_TABLE_NAME} USING vec0("
|
|
||||||
"id TEXT PRIMARY KEY, document_id INTEGER, +node_content TEXT, "
|
|
||||||
f"embedding float[{dim}] distance_metric=cosine)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def store(tmp_path: Path) -> Generator[PaperlessSqliteVecVectorStore, None, None]:
|
|
||||||
with PaperlessSqliteVecVectorStore(uri=str(tmp_path)) as store:
|
|
||||||
yield store
|
|
||||||
|
|
||||||
|
|
||||||
class TestVecChunkSize:
|
|
||||||
def test_new_table_uses_configured_chunk_size(
|
|
||||||
self,
|
|
||||||
store: PaperlessSqliteVecVectorStore,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN an empty store
|
|
||||||
WHEN the first document is indexed and the vec0 table is created
|
|
||||||
THEN the table definition carries the configured chunk size
|
|
||||||
"""
|
|
||||||
store.upsert_document(1, [make_node("1-a", 1)])
|
|
||||||
|
|
||||||
assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
|
|
||||||
|
|
||||||
def test_compact_moves_legacy_table_to_configured_chunk_size(
|
|
||||||
self,
|
|
||||||
store: PaperlessSqliteVecVectorStore,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN an index whose vec0 table was created with the default chunk size
|
|
||||||
WHEN the index is compacted
|
|
||||||
THEN the rebuilt table uses the configured chunk size
|
|
||||||
AND no indexed chunks are lost
|
|
||||||
"""
|
|
||||||
with patch.object(
|
|
||||||
PaperlessSqliteVecVectorStore,
|
|
||||||
"_create_vec_table",
|
|
||||||
staticmethod(create_legacy_vec_table),
|
|
||||||
):
|
|
||||||
store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
|
|
||||||
store.upsert_document(2, [make_node("2-a", 2)])
|
|
||||||
assert "chunk_size" not in vec_table_sql(tmp_path / DB_FILENAME)
|
|
||||||
|
|
||||||
store.compact(force=True)
|
|
||||||
|
|
||||||
assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
|
|
||||||
assert DocumentChunksTable.count(store.client) == 3
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
from collections.abc import Generator
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from llama_index.core.schema import TextNode
|
|
||||||
|
|
||||||
from paperless_ai.tables import DocumentChunksTable
|
|
||||||
from paperless_ai.vector_store import DB_FILENAME
|
|
||||||
from paperless_ai.vector_store import DB_PAGE_SIZE
|
|
||||||
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
|
|
||||||
|
|
||||||
DIM = 16
|
|
||||||
LEGACY_PAGE_SIZE = 4096
|
|
||||||
|
|
||||||
|
|
||||||
def make_node(node_id: str, document_id: int) -> TextNode:
|
|
||||||
node = TextNode(
|
|
||||||
id_=node_id,
|
|
||||||
text="some text",
|
|
||||||
metadata={"document_id": document_id, "modified": "2026-06-10T00:00:00"},
|
|
||||||
)
|
|
||||||
node.relationships = {}
|
|
||||||
node.embedding = [document_id + i / 100 for i in range(DIM)]
|
|
||||||
return node
|
|
||||||
|
|
||||||
|
|
||||||
def page_size_of(db_path: Path) -> int:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
return conn.execute("PRAGMA page_size").fetchone()[0]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def legacy_page_size_db(tmp_path: Path) -> Path:
|
|
||||||
"""An existing index file created before the page size was set."""
|
|
||||||
db_path = tmp_path / DB_FILENAME
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.execute(f"PRAGMA page_size={LEGACY_PAGE_SIZE}")
|
|
||||||
conn.execute("CREATE TABLE _seed(x)")
|
|
||||||
conn.execute("DROP TABLE _seed")
|
|
||||||
conn.close()
|
|
||||||
assert page_size_of(db_path) == LEGACY_PAGE_SIZE
|
|
||||||
return db_path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def store(tmp_path: Path) -> Generator[PaperlessSqliteVecVectorStore, None, None]:
|
|
||||||
with PaperlessSqliteVecVectorStore(uri=str(tmp_path)) as store:
|
|
||||||
yield store
|
|
||||||
|
|
||||||
|
|
||||||
class TestIndexPageSize:
|
|
||||||
def test_new_index_file_uses_configured_page_size(
|
|
||||||
self,
|
|
||||||
store: PaperlessSqliteVecVectorStore,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN no existing index file
|
|
||||||
WHEN a store is opened and a document is indexed
|
|
||||||
THEN the database file uses the configured page size
|
|
||||||
"""
|
|
||||||
store.upsert_document(1, [make_node("1-a", 1)])
|
|
||||||
|
|
||||||
assert page_size_of(tmp_path / DB_FILENAME) == DB_PAGE_SIZE
|
|
||||||
|
|
||||||
def test_compact_moves_legacy_file_to_configured_page_size(
|
|
||||||
self,
|
|
||||||
legacy_page_size_db: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN an existing index file created with a different page size
|
|
||||||
WHEN the index is compacted
|
|
||||||
THEN the rebuilt file uses the configured page size
|
|
||||||
AND no indexed chunks are lost
|
|
||||||
"""
|
|
||||||
with PaperlessSqliteVecVectorStore(
|
|
||||||
uri=str(legacy_page_size_db.parent),
|
|
||||||
) as store:
|
|
||||||
store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
|
|
||||||
assert page_size_of(legacy_page_size_db) == LEGACY_PAGE_SIZE
|
|
||||||
|
|
||||||
store.compact(force=True)
|
|
||||||
|
|
||||||
assert page_size_of(legacy_page_size_db) == DB_PAGE_SIZE
|
|
||||||
assert DocumentChunksTable.count(store.client) == 2
|
|
||||||
@@ -47,20 +47,6 @@ SCHEMA_VERSION = 2
|
|||||||
# a rebuild copies the live rows into a fresh table.
|
# a rebuild copies the live rows into a fresh table.
|
||||||
COMPACT_BLOAT_RATIO = 2.0
|
COMPACT_BLOAT_RATIO = 2.0
|
||||||
|
|
||||||
# Rows per vec0 vector chunk (vec0's default is 1024, which makes each chunk a
|
|
||||||
# multi-megabyte blob). On spinning disks, inserts and re-indexing were dozens
|
|
||||||
# of times faster with small chunks, at no cost in query speed or file size.
|
|
||||||
# Only applies to tables created from here on; an existing index picks it up
|
|
||||||
# the next time it is rebuilt (compact or rebuild).
|
|
||||||
VEC0_CHUNK_SIZE = 32
|
|
||||||
|
|
||||||
# SQLite page size for the index file, in bytes. With the small vec0 chunks above,
|
|
||||||
# 64 KiB pages made cold scans about 35% faster and warm ones about 45% faster,
|
|
||||||
# at the cost of a larger write-ahead log (up to about 70 MB). Only takes effect
|
|
||||||
# on a newly created file, so like the chunk size it reaches an existing index
|
|
||||||
# when it is rebuilt.
|
|
||||||
DB_PAGE_SIZE = 65536
|
|
||||||
|
|
||||||
# Number of rows fetched/copied per batch whenever this module streams rows
|
# Number of rows fetched/copied per batch whenever this module streams rows
|
||||||
# instead of materializing them all at once, keeping memory bounded regardless
|
# instead of materializing them all at once, keeping memory bounded regardless
|
||||||
# of index size -- used by compact()'s rebuild, m0001_v1_to_v2's migration
|
# of index size -- used by compact()'s rebuild, m0001_v1_to_v2's migration
|
||||||
@@ -236,7 +222,6 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
|
|||||||
conn.enable_load_extension(True) # noqa: FBT003
|
conn.enable_load_extension(True) # noqa: FBT003
|
||||||
sqlite_vec.load(conn)
|
sqlite_vec.load(conn)
|
||||||
conn.enable_load_extension(False) # noqa: FBT003
|
conn.enable_load_extension(False) # noqa: FBT003
|
||||||
conn.execute(f"PRAGMA page_size={DB_PAGE_SIZE}")
|
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA synchronous=NORMAL")
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
IndexMetaTable.create(conn)
|
IndexMetaTable.create(conn)
|
||||||
@@ -344,8 +329,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
|
|||||||
+ " +node_content TEXT,"
|
+ " +node_content TEXT,"
|
||||||
+ " embedding float["
|
+ " embedding float["
|
||||||
+ str(int(dim))
|
+ str(int(dim))
|
||||||
+ "] distance_metric=cosine, chunk_size="
|
+ "] distance_metric=cosine"
|
||||||
+ str(VEC0_CHUNK_SIZE)
|
|
||||||
+ ")",
|
+ ")",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user