Compare commits

..
Author SHA1 Message Date
Trenton HandClaude Sonnet 5.5 7adc03dd66 Chore: use document type ids in the bulk edit selection_data test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:08:37 -07:00
Trenton HandClaude Sonnet 5.5 01f5c81bc2 Fix: count selection_data from one pass over the filtered documents
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:08:37 -07:00
19 changed files with 267 additions and 192 deletions

No files matched your search

@@ -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>
-24
View File
@@ -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,
+3 -6
View File
@@ -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
""" """
+2 -2
View File
@@ -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},
], ],
) )
+13 -13
View File
@@ -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,
+101
View File
@@ -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}
), ),
) )
+4 -6
View File
@@ -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:
+1 -2
View File
@@ -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"])
+4 -2
View File
@@ -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()
+5 -5
View File
@@ -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)
+38 -33
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
-8
View File
@@ -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(