mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-31 12:05:45 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7adc03dd66 | ||
|
|
01f5c81bc2 |
No files matched your search
+7
@@ -1,3 +1,10 @@
|
|||||||
.custom-fields-dropdown {
|
.custom-fields-dropdown {
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
|
|
||||||
|
// correct position on mobile
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
&.show {
|
||||||
|
margin-left: -245px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +308,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr class="align-middle">
|
<tr class="align-middle">
|
||||||
<td i18n>Original SHA256 checksum</td>
|
<td i18n>Original SHA256 checksum</td>
|
||||||
<td><span class="badge bg-light text-dark font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td i18n>Original file size</td>
|
<td i18n>Original file size</td>
|
||||||
|
|||||||
@@ -77,30 +77,6 @@ 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,
|
||||||
|
|||||||
@@ -576,12 +576,9 @@ def annotate_document_count_by_ids(
|
|||||||
or CustomFieldInstance)
|
or CustomFieldInstance)
|
||||||
related_object_field: field on the relation pointing back to queryset pk
|
related_object_field: field on the relation pointing back to queryset pk
|
||||||
document_ids: the document ids to count against -- a concrete list/set,
|
document_ids: the document ids to count against -- a concrete list/set,
|
||||||
or a simple (already resolved) queryset of ids. Callers
|
or a simple (already resolved) queryset of ids. Keep
|
||||||
that need this filtered by a complex condition (e.g. a
|
the list small: it is embedded in the statement as
|
||||||
permission check) should resolve it to a concrete list
|
literal parameters.
|
||||||
first if the same ids will be reused across multiple
|
|
||||||
calls, rather than passing the complex queryset itself
|
|
||||||
into each -- see ``_get_selection_data_for_queryset``.
|
|
||||||
target_field: field on the relation pointing to Document id
|
target_field: field on the relation pointing to Document id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1141,8 +1141,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertCountEqual(
|
self.assertCountEqual(
|
||||||
response.data["selected_document_types"],
|
response.data["selected_document_types"],
|
||||||
[
|
[
|
||||||
{"id": self.c1.id, "document_count": 1},
|
{"id": self.dt1.id, "document_count": 1},
|
||||||
{"id": self.c2.id, "document_count": 0},
|
{"id": self.dt2.id, "document_count": 0},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1062,7 +1062,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
"custom_fields": [
|
"custom_fields": [
|
||||||
{
|
{
|
||||||
"field": custom_field_doclink.id,
|
"field": custom_field_doclink.id,
|
||||||
"value": [doc2.id, doc3.id, doc4.id],
|
"value": [2, 3, 4],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -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, [doc1.id])
|
self.assertEqual(_cf_5.value, [1])
|
||||||
_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, [doc1.id])
|
self.assertEqual(_cf_6.value, [1])
|
||||||
_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, [doc1.id])
|
self.assertEqual(_cf_7.value, [1])
|
||||||
|
|
||||||
# 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": [doc1.id, doc4.id],
|
"value": [1, 4],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -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, [doc1.id, doc3.id])
|
self.assertEqual(_cf_8.value, [1, 3])
|
||||||
|
|
||||||
# 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": [doc2.id, doc3.id],
|
"value": [2, 3],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -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, [doc1.id])
|
self.assertEqual(_cf_9.value, [1])
|
||||||
_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, [doc1.id, doc4.id])
|
self.assertEqual(_cf_10.value, [1, 4])
|
||||||
_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, [doc3.id])
|
self.assertEqual(_cf_11.value, [3])
|
||||||
|
|
||||||
# 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, [doc4.id])
|
self.assertEqual(_cf_13.value, [4])
|
||||||
_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, [doc3.id])
|
self.assertEqual(_cf_14.value, [3])
|
||||||
|
|
||||||
# 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, [doc1.id])
|
self.assertEqual(_cf_15.value, [1])
|
||||||
|
|
||||||
def test_documentlink_patch_requires_change_permission_on_target_documents(
|
def test_documentlink_patch_requires_change_permission_on_target_documents(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from documents.models import WorkflowTrigger
|
|||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.tests.utils import ConsumeTaskMixin
|
from documents.tests.utils import ConsumeTaskMixin
|
||||||
|
from documents.views import DocumentViewSet
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import TagFactory
|
from paperless_testing.factories import TagFactory
|
||||||
@@ -1755,6 +1756,106 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
self.assertEqual(counts_by_field[custom_field.id], 1)
|
self.assertEqual(counts_by_field[custom_field.id], 1)
|
||||||
|
|
||||||
|
def test_selection_data_counts_with_shared_by_filter(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Several documents owned by one user with the same correspondent
|
||||||
|
and document type, only one of which is shared
|
||||||
|
WHEN:
|
||||||
|
- Requesting the document list filtered by shared_by__id with
|
||||||
|
include_selection_data=true
|
||||||
|
THEN:
|
||||||
|
- Counts reflect only the shared document, not every document the
|
||||||
|
owner has with that correspondent or document type, however the
|
||||||
|
link-table lookups are batched
|
||||||
|
"""
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
other = User.objects.create_user(username="other")
|
||||||
|
correspondent = Correspondent.objects.create(name="c1")
|
||||||
|
doc_type = DocumentType.objects.create(name="dt1")
|
||||||
|
tag = Tag.objects.create(name="tag")
|
||||||
|
docs = [
|
||||||
|
Document.objects.create(
|
||||||
|
checksum=str(i),
|
||||||
|
owner=owner,
|
||||||
|
correspondent=correspondent,
|
||||||
|
document_type=doc_type,
|
||||||
|
)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
for doc in docs:
|
||||||
|
doc.tags.add(tag)
|
||||||
|
grant_object(other, docs[0], "view_document")
|
||||||
|
|
||||||
|
for batch_size in (10_000, 1):
|
||||||
|
with (
|
||||||
|
self.subTest(link_batch_size=batch_size),
|
||||||
|
mock.patch.object(
|
||||||
|
DocumentViewSet,
|
||||||
|
"selection_data_link_batch_size",
|
||||||
|
batch_size,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/?shared_by__id={owner.id}"
|
||||||
|
"&include_selection_data=true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["count"], 1)
|
||||||
|
selection_data = response.data["selection_data"]
|
||||||
|
for key, obj in (
|
||||||
|
("selected_correspondents", correspondent),
|
||||||
|
("selected_document_types", doc_type),
|
||||||
|
("selected_tags", tag),
|
||||||
|
):
|
||||||
|
counts = {
|
||||||
|
item["id"]: item["document_count"]
|
||||||
|
for item in selection_data[key]
|
||||||
|
}
|
||||||
|
self.assertEqual(counts[obj.id], 1, key)
|
||||||
|
|
||||||
|
def test_selection_data_counts_documents_once_when_filter_joins(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document matching a multi-valued filter through two tags
|
||||||
|
WHEN:
|
||||||
|
- Requesting the document list with include_selection_data=true
|
||||||
|
THEN:
|
||||||
|
- The document is counted once per correspondent, not once per
|
||||||
|
matching tag, however the link-table lookups are batched
|
||||||
|
"""
|
||||||
|
correspondent = Correspondent.objects.create(name="c1")
|
||||||
|
tag_a = Tag.objects.create(name="a")
|
||||||
|
tag_b = Tag.objects.create(name="b")
|
||||||
|
both = Document.objects.create(checksum="1", correspondent=correspondent)
|
||||||
|
both.tags.add(tag_a, tag_b)
|
||||||
|
only_a = Document.objects.create(checksum="2", correspondent=correspondent)
|
||||||
|
only_a.tags.add(tag_a)
|
||||||
|
|
||||||
|
for batch_size in (10_000, 1):
|
||||||
|
with (
|
||||||
|
self.subTest(link_batch_size=batch_size),
|
||||||
|
mock.patch.object(
|
||||||
|
DocumentViewSet,
|
||||||
|
"selection_data_link_batch_size",
|
||||||
|
batch_size,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/?tags__id__in={tag_a.id},{tag_b.id}"
|
||||||
|
"&include_selection_data=true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
counts = {
|
||||||
|
item["id"]: item["document_count"]
|
||||||
|
for item in response.data["selection_data"][
|
||||||
|
"selected_correspondents"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.assertEqual(counts[correspondent.id], 2)
|
||||||
|
|
||||||
def test_statistics(self) -> None:
|
def test_statistics(self) -> None:
|
||||||
doc1 = Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
title="none1",
|
title="none1",
|
||||||
|
|||||||
@@ -628,23 +628,22 @@ 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", sorted(linked)],
|
["documentlink_field", "contains", [1, 2]],
|
||||||
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"]) >= linked
|
and set(document["documentlink_field"]) >= {1, 2}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# 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", sorted(linked, reverse=True)],
|
["documentlink_field", "contains", [2, 1]],
|
||||||
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"]) >= linked
|
and set(document["documentlink_field"]) >= {1, 2}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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}, {d1.id, d2.id})
|
self.assertEqual({r["id"] for r in results}, {1, 2})
|
||||||
|
|
||||||
@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,7 +1801,6 @@ 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")
|
||||||
@@ -1827,7 +1826,6 @@ 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.",
|
||||||
@@ -1880,14 +1878,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)
|
# owner sort: ORM orders by owner_id (integer); NULLs first in SQLite ASC
|
||||||
self.assertListEqual(
|
self.assertListEqual(
|
||||||
search_query("&ordering=owner"),
|
search_query("&ordering=owner"),
|
||||||
[d1.id, d2.id, d3.id],
|
[d3.id, d1.id, d2.id],
|
||||||
)
|
)
|
||||||
self.assertListEqual(
|
self.assertListEqual(
|
||||||
search_query("&ordering=-owner"),
|
search_query("&ordering=-owner"),
|
||||||
[d3.id, d2.id, d1.id],
|
[d2.id, d1.id, d3.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_search_ordering_by_score(self) -> None:
|
def test_search_ordering_by_score(self) -> None:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -69,7 +68,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"], connection.vendor)
|
self.assertEqual(response.data["database"]["type"], "sqlite")
|
||||||
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,7 +1031,9 @@ 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 = f"Error merging document {self.doc2.id}, it will not be included in the merge"
|
expected_str = (
|
||||||
|
"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()
|
||||||
@@ -1191,7 +1193,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 = f"Error splitting document {self.doc2.id}"
|
expected_str = "Error splitting document 2"
|
||||||
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, f"{document.pk:07}.pdf")
|
self.assertEqual(document.filename, "0000001.pdf")
|
||||||
self.assertEqual(document.archive_filename, f"{document.pk:07}.pdf")
|
self.assertEqual(document.archive_filename, "0000001.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.latest("pk")
|
doc1 = Document.objects.filter(pk=1).first()
|
||||||
|
|
||||||
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.latest("pk")
|
doc2 = Document.objects.filter(pk=2).first()
|
||||||
|
|
||||||
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.latest("pk")
|
doc3 = Document.objects.filter(pk=3).first()
|
||||||
|
|
||||||
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,9 +760,6 @@ 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)
|
||||||
@@ -981,9 +978,6 @@ 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,20 +151,6 @@ 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(
|
||||||
@@ -218,7 +204,8 @@ class TestExportImport(
|
|||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
def test_exporter(self, *, use_filename_format=False) -> None:
|
def test_exporter(self, *, use_filename_format=False) -> None:
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
||||||
|
|
||||||
num_permission_objects = Permission.objects.count()
|
num_permission_objects = Permission.objects.count()
|
||||||
|
|
||||||
@@ -321,7 +308,8 @@ 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:
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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}",
|
||||||
@@ -329,7 +317,8 @@ 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:
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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",
|
||||||
@@ -427,7 +416,8 @@ class TestExportImport(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_update_export_changed_time(self) -> None:
|
def test_update_export_changed_time(self) -> None:
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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")
|
||||||
@@ -462,7 +452,8 @@ 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:
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
||||||
|
|
||||||
self._do_export()
|
self._do_export()
|
||||||
|
|
||||||
@@ -488,7 +479,8 @@ 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:
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
||||||
|
|
||||||
manifest = self._do_export()
|
manifest = self._do_export()
|
||||||
|
|
||||||
@@ -519,7 +511,8 @@ 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:
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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")
|
||||||
@@ -560,7 +553,8 @@ class TestExportImport(
|
|||||||
- Zipfile is created
|
- Zipfile is created
|
||||||
- Zipfile contains exported files
|
- Zipfile contains exported files
|
||||||
"""
|
"""
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
||||||
|
|
||||||
args = ["document_exporter", self.target, "--zip"]
|
args = ["document_exporter", self.target, "--zip"]
|
||||||
|
|
||||||
@@ -588,7 +582,8 @@ class TestExportImport(
|
|||||||
- Zipfile is created
|
- Zipfile is created
|
||||||
- Zipfile contains exported files
|
- Zipfile contains exported files
|
||||||
"""
|
"""
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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"]
|
||||||
|
|
||||||
@@ -623,7 +618,8 @@ 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
|
||||||
"""
|
"""
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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"
|
||||||
@@ -718,7 +714,8 @@ 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
|
||||||
"""
|
"""
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
||||||
|
|
||||||
manifest = self._do_export()
|
manifest = self._do_export()
|
||||||
has_archive = False
|
has_archive = False
|
||||||
@@ -760,7 +757,8 @@ 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
|
||||||
"""
|
"""
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
||||||
|
|
||||||
manifest = self._do_export()
|
manifest = self._do_export()
|
||||||
has_thumbnail = False
|
has_thumbnail = False
|
||||||
@@ -804,7 +802,8 @@ 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
|
||||||
"""
|
"""
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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
|
||||||
@@ -828,7 +827,8 @@ 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:
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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,7 +863,8 @@ class TestExportImport(
|
|||||||
THEN:
|
THEN:
|
||||||
- Documents can be imported again
|
- Documents can be imported again
|
||||||
"""
|
"""
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
||||||
|
|
||||||
self._do_export(use_folder_prefix=True)
|
self._do_export(use_folder_prefix=True)
|
||||||
|
|
||||||
@@ -889,7 +890,8 @@ class TestExportImport(
|
|||||||
THEN:
|
THEN:
|
||||||
- Documents can be imported again
|
- Documents can be imported again
|
||||||
"""
|
"""
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -915,7 +917,8 @@ class TestExportImport(
|
|||||||
- ContentType & Permission objects are not deleted, db transaction rolled back
|
- ContentType & Permission objects are not deleted, db transaction rolled back
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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()
|
||||||
@@ -951,7 +954,8 @@ 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:
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
install_document_samples(Path(self.dirs.media_dir) / "documents")
|
||||||
|
|
||||||
with override_settings(
|
with override_settings(
|
||||||
AUDIT_LOG_ENABLED=False,
|
AUDIT_LOG_ENABLED=False,
|
||||||
@@ -970,7 +974,8 @@ 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
|
||||||
"""
|
"""
|
||||||
self._install_sample_files()
|
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||||
|
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,10 +19,6 @@ 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,29 +425,26 @@ 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(
|
||||||
("range_end", "expected_count"),
|
("id_range_args", "expected_count"),
|
||||||
[
|
[
|
||||||
pytest.param("second", 1, id="narrow_range_limits_scope"),
|
pytest.param(["1", "2"], 1, id="narrow_range_limits_scope"),
|
||||||
pytest.param("extra", 2, id="wide_range_tags_all_matches"),
|
pytest.param(["1", "9999"], 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,
|
||||||
documents: DocumentTuple,
|
id_range_args: list[str],
|
||||||
range_end: str,
|
|
||||||
expected_count: int,
|
expected_count: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
first, second, *_ = documents
|
DocumentFactory(content="NOT the first document")
|
||||||
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",
|
||||||
str(first.pk),
|
*id_range_args,
|
||||||
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__(), f"WorkflowTrigger {trigger.pk}")
|
self.assertEqual(trigger.__str__(), "WorkflowTrigger 1")
|
||||||
self.assertEqual(action.__str__(), f"WorkflowAction {action.pk}")
|
self.assertEqual(action.__str__(), "WorkflowAction 1")
|
||||||
|
|
||||||
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.assertCountEqual(
|
self.assertEqual(
|
||||||
list(
|
list(
|
||||||
get_users_with_perms(
|
get_users_with_perms(
|
||||||
document,
|
document,
|
||||||
|
|||||||
+74
-62
@@ -5,6 +5,7 @@ import platform
|
|||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from collections import Counter
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -168,7 +169,6 @@ from documents.permissions import PaperlessNotePermissions
|
|||||||
from documents.permissions import PaperlessObjectPermissions
|
from documents.permissions import PaperlessObjectPermissions
|
||||||
from documents.permissions import TrashPermissions
|
from documents.permissions import TrashPermissions
|
||||||
from documents.permissions import ViewDocumentsPermissions
|
from documents.permissions import ViewDocumentsPermissions
|
||||||
from documents.permissions import annotate_document_count_by_ids
|
|
||||||
from documents.permissions import annotate_document_count_for_related_queryset
|
from documents.permissions import annotate_document_count_for_related_queryset
|
||||||
from documents.permissions import get_document_count_filter_for_user
|
from documents.permissions import get_document_count_filter_for_user
|
||||||
from documents.permissions import get_permission_grantees
|
from documents.permissions import get_permission_grantees
|
||||||
@@ -1022,71 +1022,83 @@ class DocumentViewSet(
|
|||||||
"custom_field_",
|
"custom_field_",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_selection_data_for_queryset(self, queryset):
|
# Matching document ids are looked up in the link tables in batches of this
|
||||||
# Resolve once instead of once per model below. `queryset` can carry an
|
# size: one literal IN (...) over every match is a multi-MB statement per
|
||||||
# arbitrarily expensive WHERE clause (user filters plus the permission
|
# relation and, on SQLite, exceeds the bound-parameter limit.
|
||||||
# filter); re-embedding it as a subquery inside 5 separate Count(...)
|
selection_data_link_batch_size = 10_000
|
||||||
# calls forces the database to re-evaluate that whole thing 5 times, and
|
|
||||||
# -- for FK relations especially -- can defeat semi-join planning
|
|
||||||
# entirely at scale. A concrete id list is cheap to reuse.
|
|
||||||
# order_by() drops the default/user ordering -- irrelevant for a plain
|
|
||||||
# id list, but left in place it forces a sort over the full filtered
|
|
||||||
# set before the ids can even be collected.
|
|
||||||
document_ids = list(queryset.order_by().values_list("pk", flat=True))
|
|
||||||
|
|
||||||
correspondents = Correspondent.objects.annotate(
|
def _get_selection_data_for_queryset(self, queryset):
|
||||||
document_count=Count(
|
# The filtered queryset is evaluated once, as the page's own filter is,
|
||||||
"documents",
|
# and never wrapped in a subquery: re-deriving a join or a HAVING
|
||||||
filter=Q(documents__id__in=document_ids),
|
# (shared_by__id) per statement is very slow on some databases, and
|
||||||
distinct=True,
|
# regrouping the queryset by another column would miscount.
|
||||||
|
# order_by() drops the default/user ordering, which only costs a sort.
|
||||||
|
correspondents: Counter = Counter()
|
||||||
|
document_types: Counter = Counter()
|
||||||
|
storage_paths: Counter = Counter()
|
||||||
|
document_ids: list[int] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
rows = queryset.order_by().values_list(
|
||||||
|
"pk",
|
||||||
|
"correspondent_id",
|
||||||
|
"document_type_id",
|
||||||
|
"storage_path_id",
|
||||||
|
)
|
||||||
|
for pk, correspondent, document_type, storage_path in rows:
|
||||||
|
# A filter can join, repeating a document row.
|
||||||
|
if pk in seen:
|
||||||
|
continue
|
||||||
|
seen.add(pk)
|
||||||
|
document_ids.append(pk)
|
||||||
|
# Documents without a value count under None, which selection()
|
||||||
|
# never looks up.
|
||||||
|
correspondents[correspondent] += 1
|
||||||
|
document_types[document_type] += 1
|
||||||
|
storage_paths[storage_path] += 1
|
||||||
|
|
||||||
|
def link_table_counts(link_model, field_name: str) -> Counter:
|
||||||
|
result: Counter = Counter()
|
||||||
|
batch_size = self.selection_data_link_batch_size
|
||||||
|
for start in range(0, len(document_ids), batch_size):
|
||||||
|
result.update(
|
||||||
|
dict(
|
||||||
|
link_model.objects.filter(
|
||||||
|
document_id__in=document_ids[start : start + batch_size],
|
||||||
|
)
|
||||||
|
.values_list(field_name)
|
||||||
|
.annotate(document_count=Count("document_id")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
document_types = DocumentType.objects.annotate(
|
return result
|
||||||
document_count=Count(
|
|
||||||
"documents",
|
def selection(model, counts: Counter) -> list[dict[str, int]]:
|
||||||
filter=Q(documents__id__in=document_ids),
|
# Every object is listed, with 0 for those with no matching document.
|
||||||
distinct=True,
|
return [
|
||||||
),
|
{"id": pk, "document_count": counts.get(pk, 0)}
|
||||||
)
|
for pk in model.objects.values_list("pk", flat=True)
|
||||||
storage_paths = StoragePath.objects.annotate(
|
]
|
||||||
document_count=Count(
|
|
||||||
"documents",
|
|
||||||
filter=Q(documents__id__in=document_ids),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
# Tag and CustomField reach Document through an M2M/through-model table;
|
|
||||||
# a plain Count(filter=...) there is a much more expensive plan than the
|
|
||||||
# FK relations above once the bridge table is large -- see
|
|
||||||
# annotate_document_count_by_ids() for why.
|
|
||||||
tags = annotate_document_count_by_ids(
|
|
||||||
Tag.objects.all(),
|
|
||||||
through_model=Document.tags.through,
|
|
||||||
related_object_field="tag_id",
|
|
||||||
document_ids=document_ids,
|
|
||||||
)
|
|
||||||
custom_fields = annotate_document_count_by_ids(
|
|
||||||
CustomField.objects.all(),
|
|
||||||
through_model=CustomFieldInstance,
|
|
||||||
related_object_field="field_id",
|
|
||||||
document_ids=document_ids,
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"selected_correspondents": [
|
"selected_correspondents": selection(
|
||||||
{"id": t.id, "document_count": t.document_count} for t in correspondents
|
Correspondent,
|
||||||
],
|
correspondents,
|
||||||
"selected_tags": [
|
),
|
||||||
{"id": t.id, "document_count": t.document_count} for t in tags
|
"selected_tags": selection(
|
||||||
],
|
Tag,
|
||||||
"selected_document_types": [
|
link_table_counts(Document.tags.through, "tag_id"),
|
||||||
{"id": t.id, "document_count": t.document_count} for t in document_types
|
),
|
||||||
],
|
"selected_document_types": selection(
|
||||||
"selected_storage_paths": [
|
DocumentType,
|
||||||
{"id": t.id, "document_count": t.document_count} for t in storage_paths
|
document_types,
|
||||||
],
|
),
|
||||||
"selected_custom_fields": [
|
"selected_storage_paths": selection(
|
||||||
{"id": t.id, "document_count": t.document_count} for t in custom_fields
|
StoragePath,
|
||||||
],
|
storage_paths,
|
||||||
|
),
|
||||||
|
"selected_custom_fields": selection(
|
||||||
|
CustomField,
|
||||||
|
link_table_counts(CustomFieldInstance, "field_id"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -38,13 +37,6 @@ 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(
|
||||||
|
|||||||
Reference in new issue
Block a user