Compare commits

..
Author SHA1 Message Date
Trenton HandClaude Sonnet 5.5 4a305aad7e Performance: read shared_by grants once instead of probing per document
The shared_by__id filter counted a correlated guardian subquery per owned document. No guardian index leads with object_pk, so MariaDB re-scanned the grant table for every row (a 360k document install did not finish in 442 s), and Postgres took 21 s for an owner sharing 196k documents.

Match against each grant table once, as an uncorrelated DISTINCT set of integer keys, with a plain OR of two IN conditions. This also drops the aggregate and HAVING.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 08:38:03 -07:00
17 changed files with 147 additions and 150 deletions

No files matched your search

@@ -1,3 +1,10 @@
.custom-fields-dropdown {
min-width: 300px;
// correct position on mobile
@media (max-width: 575.98px) {
&.show {
margin-left: -245px !important;
}
}
}
@@ -308,7 +308,7 @@
</tr>
<tr class="align-middle">
<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>
<td i18n>Original file size</td>
-24
View File
@@ -77,30 +77,6 @@ def _clear_django_caches() -> None:
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
def paperless_dirs(
tmp_path: Path,
+21 -28
View File
@@ -15,7 +15,6 @@ import dateparser
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Case
from django.db.models import CharField
from django.db.models import Count
from django.db.models import Exists
from django.db.models import IntegerField
@@ -208,36 +207,30 @@ class EffectiveContentFilter(Filter):
@extend_schema_field(serializers.BooleanField)
class SharedByUser(Filter):
def filter(self, qs, value):
if value is None:
return qs
ctype = ContentType.objects.get_for_model(self.model)
UserObjectPermission = get_user_obj_perms_model()
GroupObjectPermission = get_group_obj_perms_model()
# see https://github.com/paperless-ngx/paperless-ngx/issues/5392, we limit subqueries
# to 1 because Postgres doesn't like returning > 1 row, but all we care about is > 0
return (
qs.filter(
owner_id=value,
)
.annotate(
num_shared_users=Count(
UserObjectPermission.objects.filter(
content_type=ctype,
object_pk=Cast(OuterRef("pk"), CharField()),
).values("user_id")[:1],
),
)
.annotate(
num_shared_groups=Count(
GroupObjectPermission.objects.filter(
content_type=ctype,
object_pk=Cast(OuterRef("pk"), CharField()),
).values("group_id")[:1],
),
)
.filter(
Q(num_shared_users__gt=0) | Q(num_shared_groups__gt=0),
)
if value is not None
else qs
# Guardian stores object_pk as a string. Each grant table is read once,
# as an uncorrelated set of integer keys, because probing it per
# document by string key has no usable index (none leads with
# object_pk) and MariaDB re-scans the table for every owned document.
# DISTINCT keeps Postgres hashing each set once: without it a large
# group grant table exceeds work_mem and is re-scanned for every row.
shared_with_users = (
UserObjectPermission.objects.filter(content_type=ctype)
.values(shared_pk=Cast("object_pk", IntegerField()))
.distinct()
)
shared_with_groups = (
GroupObjectPermission.objects.filter(content_type=ctype)
.values(shared_pk=Cast("object_pk", IntegerField()))
.distinct()
)
return qs.filter(owner_id=value).filter(
Q(pk__in=shared_with_users) | Q(pk__in=shared_with_groups),
)
+13 -13
View File
@@ -1062,7 +1062,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
"custom_fields": [
{
"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)
_cf_5 = doc2.custom_fields.first()
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()
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()
assert _cf_7 is not None
self.assertEqual(_cf_7.value, [doc1.id])
self.assertEqual(_cf_7.value, [1])
# Add links appends if necessary
resp = self.client.patch(
@@ -1088,7 +1088,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
"custom_fields": [
{
"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)
_cf_8 = doc4.custom_fields.first()
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
resp = self.client.patch(
@@ -1107,7 +1107,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
"custom_fields": [
{
"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)
_cf_9 = doc2.custom_fields.first()
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()
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()
assert _cf_11 is not None
self.assertEqual(_cf_11.value, [doc3.id])
self.assertEqual(_cf_11.value, [3])
# Removes the field entirely
resp = self.client.patch(
@@ -1140,10 +1140,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
self.assertEqual(_cf_12.value, [])
_cf_13 = doc3.custom_fields.first()
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()
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
doc5 = Document.objects.create(
@@ -1170,7 +1170,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK)
_cf_15 = doc5.custom_fields.first()
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(
self,
+39
View File
@@ -1480,6 +1480,45 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
[u1_doc1.id],
)
def test_shared_by_filter_matches_user_and_group_grants_once(self) -> None:
"""
GIVEN:
- Documents owned by a user: one shared with a user, one shared with
a group, one shared with several users and a group, one unshared
- A shared document owned by someone else
WHEN:
- Documents are filtered by shared_by__id for the owner
THEN:
- Only the owner's shared documents are returned, each exactly once
"""
owner = UserFactory(username="owner")
other = UserFactory(username="other")
reader1 = UserFactory(username="reader1")
reader2 = UserFactory(username="reader2")
group = Group.objects.create(name="readers")
grant_all_global(owner)
shared_with_user = DocumentFactory.create(owner=owner)
shared_with_group = DocumentFactory.create(owner=owner)
shared_widely = DocumentFactory.create(owner=owner)
DocumentFactory.create(owner=owner)
others_shared = DocumentFactory.create(owner=other)
grant_object(reader1, shared_with_user, "view_document")
grant_object(group, shared_with_group, "view_document")
for target in (reader1, reader2, group):
grant_object(target, shared_widely, "view_document")
grant_object(reader1, others_shared, "view_document")
self.client.force_authenticate(owner)
response = self.client.get(f"/api/documents/?shared_by__id={owner.id}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 3)
self.assertCountEqual(
[result["id"] for result in response.data["results"]],
[shared_with_user.id, shared_with_group.id, shared_widely.id],
)
def test_document_owned_and_group_shared_not_duplicated_when_filtering_by_tags(
self,
) -> None:
@@ -628,23 +628,22 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
# Subset check (document link field only) #
# ==========================================================#
def test_document_link_contains(self) -> None:
linked = {self.documents[1].id, self.documents[2].id}
# Document link field "contains" performs a subset check.
self._assert_query_match_predicate(
["documentlink_field", "contains", sorted(linked)],
["documentlink_field", "contains", [1, 2]],
lambda document: (
"documentlink_field" in document
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.
self._assert_query_match_predicate(
["documentlink_field", "contains", sorted(linked, reverse=True)],
["documentlink_field", "contains", [2, 1]],
lambda document: (
"documentlink_field" in document
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)
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")
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:
u1 = UserFactory(username="user1")
u2 = UserFactory(username="user2")
u3 = UserFactory(username="user3")
c1 = Correspondent.objects.create(name="corres Ax")
c2 = Correspondent.objects.create(name="corres Cx")
c3 = Correspondent.objects.create(name="corres Bx")
@@ -1827,7 +1826,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
content="test",
archive_serial_number=1,
title="1",
owner=u3,
)
Note.objects.create(
note="This is a note.",
@@ -1880,14 +1878,14 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
search_query("&ordering=-num_notes"),
[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(
search_query("&ordering=owner"),
[d1.id, d2.id, d3.id],
[d3.id, d1.id, d2.id],
)
self.assertListEqual(
search_query("&ordering=-owner"),
[d3.id, d2.id, d1.id],
[d2.id, d1.id, d3.id],
)
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 unittest import mock
from django.db import connection
from django.test import override_settings
from django.utils import timezone
from rest_framework import status
@@ -69,7 +68,7 @@ class TestSystemStatus(APITestCase):
self.assertEqual(response.data["install_type"], "bare-metal")
self.assertIsNotNone(response.data["storage"]["total"])
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.assertEqual(response.data["database"]["status"], "OK")
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:
bulk_edit.merge(doc_ids)
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)
mock_consume_file.assert_not_called()
@@ -1191,7 +1193,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
bulk_edit.split(doc_ids, pages)
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)
mock_consume_file.assert_not_called()
+5 -5
View File
@@ -248,8 +248,8 @@ class TestConsumer(
)
self.assertIsNone(document.correspondent)
self.assertIsNone(document.document_type)
self.assertEqual(document.filename, f"{document.pk:07}.pdf")
self.assertEqual(document.archive_filename, f"{document.pk:07}.pdf")
self.assertEqual(document.filename, "0000001.pdf")
self.assertEqual(document.archive_filename, "0000001.pdf")
self.assertIsFile(document.source_path)
@@ -1127,17 +1127,17 @@ class TestConsumer(
with self.get_consumer(settings.CONSUMPTION_DIR / "simple.png") as consumer:
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:
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:
consumer.run()
doc3 = Document.objects.latest("pk")
doc3 = Document.objects.filter(pk=3).first()
self.assertEqual(doc1.filename, "simple.png")
self.assertEqual(doc1.archive_filename, "simple.pdf")
@@ -760,9 +760,6 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
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(archive)
self.assertIsFile(doc.source_path)
@@ -981,9 +978,6 @@ class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, Test
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(archive)
self.assertIsFile(doc.source_path)
+38 -33
View File
@@ -151,20 +151,6 @@ class TestExportImport(
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):
f = list(
filter(
@@ -218,7 +204,8 @@ class TestExportImport(
return manifest
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()
@@ -321,7 +308,8 @@ class TestExportImport(
self.assertEqual(messages.total_issue_count, 0)
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(
FILENAME_FORMAT="{created_year}/{correspondent}/{title}",
@@ -329,7 +317,8 @@ class TestExportImport(
self.test_exporter(use_filename_format=True)
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(
slug="share-link-slug",
@@ -427,7 +416,8 @@ class TestExportImport(
)
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.assertIsFile(self.target / "manifest.json")
@@ -462,7 +452,8 @@ class TestExportImport(
self.assertEqual(st_mtime_3, st_mtime_4)
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()
@@ -488,7 +479,8 @@ class TestExportImport(
self.assertIsFile(self.target / "manifest.json")
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()
@@ -519,7 +511,8 @@ class TestExportImport(
@override_settings(FILENAME_FORMAT="{title}/{correspondent}")
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.assertIsFile(self.target / "wow1" / "c.pdf")
@@ -560,7 +553,8 @@ class TestExportImport(
- Zipfile is created
- 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"]
@@ -588,7 +582,8 @@ class TestExportImport(
- Zipfile is created
- 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"]
@@ -623,7 +618,8 @@ class TestExportImport(
- Zipfile contains exported files
- 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
existing_file = self.target / "test.txt"
@@ -718,7 +714,8 @@ class TestExportImport(
- Manifest.json doesn't contain information about archive files
- 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()
has_archive = False
@@ -760,7 +757,8 @@ class TestExportImport(
- Manifest.json doesn't contain information about thumbnails
- 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()
has_thumbnail = False
@@ -804,7 +802,8 @@ class TestExportImport(
- Main manifest.json file doesn't contain information about documents
- 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)
has_document = False
@@ -828,7 +827,8 @@ class TestExportImport(
self.assertEqual(CustomFieldInstance.objects.count(), 1)
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.d2, page=2, value="DE8937")
@@ -863,7 +863,8 @@ class TestExportImport(
THEN:
- 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)
@@ -889,7 +890,8 @@ class TestExportImport(
THEN:
- 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)
@@ -915,7 +917,8 @@ class TestExportImport(
- 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_permission_objects = Permission.objects.count()
@@ -951,7 +954,8 @@ class TestExportImport(
self.assertEqual(Permission.objects.count(), num_permission_objects + 1)
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(
AUDIT_LOG_ENABLED=False,
@@ -970,7 +974,8 @@ class TestExportImport(
- The soft-deleted document, note, and custom field instance
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
self.d1.delete()
@@ -19,10 +19,6 @@ class TestFuzzyMatchCommand(TestCase):
call_command(
"document_fuzzy_match",
"--no-progress-bar",
# The default scales with the CPU count and forks, which closes
# the test's database connection on PostgreSQL
"--processes",
"1",
*args,
stdout=stdout,
stderr=stderr,
@@ -425,29 +425,26 @@ class TestRetaggerStoragePath(DirectoriesMixin):
@pytest.mark.management
@pytest.mark.django_db
class TestRetaggerIdRange(DirectoriesMixin):
@pytest.mark.usefixtures("documents")
@pytest.mark.parametrize(
("range_end", "expected_count"),
("id_range_args", "expected_count"),
[
pytest.param("second", 1, id="narrow_range_limits_scope"),
pytest.param("extra", 2, id="wide_range_tags_all_matches"),
pytest.param(["1", "2"], 1, id="narrow_range_limits_scope"),
pytest.param(["1", "9999"], 2, id="wide_range_tags_all_matches"),
],
)
def test_id_range_limits_scope(
self,
tags: TagTuple,
documents: DocumentTuple,
range_end: str,
id_range_args: list[str],
expected_count: int,
) -> None:
first, second, *_ = documents
extra = DocumentFactory(content="NOT the first document")
end = {"second": second, "extra": extra}[range_end]
DocumentFactory(content="NOT the first document")
call_command(
"document_retagger",
"--tags",
"--id-range",
str(first.pk),
str(end.pk),
*id_range_args,
skip_checks=True,
)
tag_first, *_ = tags
+3 -3
View File
@@ -174,8 +174,8 @@ class TestWorkflows(
w.save()
self.assertEqual(w.__str__(), "Workflow: Workflow 1")
self.assertEqual(trigger.__str__(), f"WorkflowTrigger {trigger.pk}")
self.assertEqual(action.__str__(), f"WorkflowAction {action.pk}")
self.assertEqual(trigger.__str__(), "WorkflowTrigger 1")
self.assertEqual(action.__str__(), "WorkflowAction 1")
test_file = shutil.copy(
self.SIMPLE_PDF,
@@ -433,7 +433,7 @@ class TestWorkflows(
list(document.tags.all()),
[self.t1, self.t2, self.t3],
)
self.assertCountEqual(
self.assertEqual(
list(
get_users_with_perms(
document,
-8
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import json
from contextlib import nullcontext
from typing import TYPE_CHECKING
from typing import Any
from unittest.mock import MagicMock
@@ -38,13 +37,6 @@ def patch_embed_model():
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)
def patch_embed_nodes():
with patch(