Compare commits

..
Author SHA1 Message Date
stumpylog 252652b161 Saves work on a probable fix 2026-10-15 15:32:31 -07:00
Trenton H d4a57204eb Fix: copy only direct user grants in DocumentMetadataOverrides.from_document (#14452)
get_users_with_perms includes group members by default, so PDF actions such
as merge and split gave them direct grants on the new documents, which
outlived their group membership.
2026-10-15 15:11:28 -07:00
dependabot[bot] c50c6f5517 Chore(deps): Bump filelock from 3.32.6 to 4.0.12 (#14336)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.32.6 to 4.0.12.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.32.6...4.0.12)

---
updated-dependencies:
- dependency-name: filelock
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 16:11:12 -07:00
GitHub Actions 34a6c718fa Auto translate strings 2026-10-14 15:18:54 +00:00
shamoon 14aaa42777 Fix: support combined text/query searches (#14443) 2026-10-14 08:17:22 -07:00
16 changed files with 393 additions and 91 deletions

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ dependencies = [
"drf-spectacular~=0.30",
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1",
"filelock~=3.32.0",
"filelock>=3.32,<4.1",
"flower>=2.0.1,<2.3",
"gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17",
@@ -624,7 +624,8 @@ main {
}
.dropdown-toggle::after {
display: none;
margin-left: 0.4em;
vertical-align: 0.155em;
}
.navbar .dropdown-menu {
@@ -484,6 +484,22 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toBeNull()
})
it('should ingest title filter rules alongside a fulltext query with date created', () => {
component.filterRules = [
{
rule_type: FILTER_SIMPLE_TITLE,
value: 'foo',
},
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'created:[-1 week to now]',
},
]
expect(component.dateCreatedRelativeDate).toEqual(1) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now']
expect(component.textFilter).toEqual('foo')
expect(component.textFilterTarget).toEqual('title') // TEXT_FILTER_TARGET_TITLE
})
it('should ingest text filter rules for fulltext query that include date added', () => {
expect(component.dateAddedRelativeDate).toBeNull()
component.filterRules = [
@@ -1845,7 +1861,7 @@ describe('FilterEditorComponent', () => {
])
})
it('should carry over text filtering on date created with relative date', () => {
it('should keep text filtering separate from date created relative date', () => {
component.textFilter = 'foo'
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DatesDropdownComponent)
@@ -1855,14 +1871,18 @@ describe('FilterEditorComponent', () => {
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TEXT,
value: 'foo',
},
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now]',
value: 'created:[-1 week to now]',
},
])
})
it('should carry over text filtering once with created and added relative dates', () => {
it('should keep text filtering separate from created and added relative dates', () => {
component.textFilter = 'foo'
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
@@ -1873,14 +1893,18 @@ describe('FilterEditorComponent', () => {
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TEXT,
value: 'foo',
},
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now],added:[-1 month to now]',
value: 'created:[-1 week to now],added:[-1 month to now]',
},
])
})
it('should convert legacy title filters into full text query when adding a created relative date', () => {
it('should keep legacy title filters as a title search when adding a created relative date', () => {
component.filterRules = [
{
rule_type: FILTER_TITLE,
@@ -1895,14 +1919,18 @@ describe('FilterEditorComponent', () => {
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TITLE,
value: 'foo',
},
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now]',
value: 'created:[-1 week to now]',
},
])
})
it('should convert simple title filters into full text query when adding a created relative date', () => {
it('should keep simple title filters as a title search when adding a created relative date', () => {
component.filterRules = [
{
rule_type: FILTER_SIMPLE_TITLE,
@@ -1917,9 +1945,13 @@ describe('FilterEditorComponent', () => {
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TITLE,
value: 'foo',
},
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now]',
value: 'created:[-1 week to now]',
},
])
})
@@ -2005,7 +2037,7 @@ describe('FilterEditorComponent', () => {
])
})
it('should carry over text filtering on date added with relative date', () => {
it('should keep text filtering separate from date added relative date', () => {
component.textFilter = 'foo'
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
@@ -2015,9 +2047,13 @@ describe('FilterEditorComponent', () => {
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TEXT,
value: 'foo',
},
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,added:[-1 week to now]',
value: 'added:[-1 week to now]',
},
])
})
@@ -1083,23 +1083,6 @@ export class FilterEditorComponent
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
)
// if had a title / content search and added a relative date we need to carry it over...
if (
!existingRule &&
this._textFilter?.length > 0 &&
(this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT ||
this.textFilterTarget == TEXT_FILTER_TARGET_TITLE)
) {
existingRule = filterRules.find(
(fr) =>
fr.rule_type == FILTER_TITLE_CONTENT ||
fr.rule_type == FILTER_SIMPLE_TEXT ||
fr.rule_type == FILTER_TITLE ||
fr.rule_type == FILTER_SIMPLE_TITLE
)
existingRule.rule_type = FILTER_FULLTEXT_QUERY
}
let queryArgs = existingRule?.value.split(',') ?? []
if (this.dateCreatedRelativeDate !== null) {
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
+2
View File
@@ -130,12 +130,14 @@ class DocumentMetadataOverrides:
get_users_with_perms(
doc,
only_with_perms_in=["view_document"],
with_group_users=False,
).values_list("id", flat=True),
)
overrides.change_users = list(
get_users_with_perms(
doc,
only_with_perms_in=["change_document"],
with_group_users=False,
).values_list("id", flat=True),
)
overrides.custom_fields = {
@@ -3,6 +3,7 @@ from itertools import islice
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import Final
from allauth.mfa.models import Authenticator
from allauth.socialaccount.models import SocialAccount
@@ -70,6 +71,10 @@ from paperless.models import ApplicationConfiguration
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
# Derived names replace the original's extension with these suffixes
THUMBNAIL_SUFFIX: Final[str] = "-thumbnail.webp"
ARCHIVE_SUFFIX: Final[str] = "-archive.pdf"
def serialize_queryset_batched(
queryset: "QuerySet[Any]",
@@ -512,7 +517,12 @@ class Command(CryptMixin, PaperlessCommand):
counter=filename_counter,
)
else:
base_name = document.get_public_filename(counter=filename_counter)
# Leave room for the longer of the derived name suffixes
base_name = document.get_public_filename(
counter=filename_counter,
max_bytes=Document.MAX_PUBLIC_FILENAME_BYTES
- max(len(THUMBNAIL_SUFFIX), len(ARCHIVE_SUFFIX)),
)
# The thumbnail and archive names are derived from the stem, so
# uniqueness must hold without the original's extension
@@ -541,7 +551,7 @@ class Command(CryptMixin, PaperlessCommand):
document_dict[EXPORTER_FILE_NAME] = original_arc
if not self.no_thumbnail:
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
thumbnail_name = base_name.parent / (base_name.stem + THUMBNAIL_SUFFIX)
if self.use_folder_prefix:
thumbnail_name = Path("thumbnails") / thumbnail_name
thumbnail_arc = thumbnail_name.as_posix()
@@ -550,7 +560,7 @@ class Command(CryptMixin, PaperlessCommand):
thumbnail_arc = None
if not self.no_archive and document.has_archive_version:
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
archive_name = base_name.parent / (base_name.stem + ARCHIVE_SUFFIX)
if self.use_folder_prefix:
archive_name = Path("archive") / archive_name
archive_arc = archive_name.as_posix()
+28 -7
View File
@@ -157,6 +157,8 @@ class StoragePath(MatchingModel):
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
correspondent = models.ForeignKey(
Correspondent,
@@ -484,28 +486,47 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
def archive_file(self):
return Path(self.archive_path).open("rb")
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str:
def get_public_filename(
self,
*,
archive=False,
counter=0,
suffix=None,
max_bytes: int = MAX_PUBLIC_FILENAME_BYTES,
) -> str:
"""
Returns a sanitized filename for the document, not including any paths.
The name fits in max_bytes (UTF-8). A title that is too long is
shortened, so the counter, suffix and extension are always kept.
"""
# Root owns metadata for all versions
context_document = (
self.root_document if self.root_document_id is not None else self
)
result = str(context_document)
tail = ""
if counter:
result += f"_{counter:02}"
tail += f"_{counter:02}"
if suffix:
result += suffix
tail += suffix
if archive:
result += ".pdf"
tail += ".pdf"
else:
result += self.file_type
tail += self.file_type
return pathvalidate.sanitize_filename(result, replacement_text="-")
# sanitize_filename truncates from the end, which would cut off the
# counter and extension, so shorten the title before appending them
head_bytes = max_bytes - len(tail.encode())
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
return pathvalidate.sanitize_filename(
head + tail,
replacement_text="-",
max_len=max_bytes,
)
@property
def file_type(self):
+9
View File
@@ -926,6 +926,7 @@ class TantivyBackend:
sort_field: str | None = None,
sort_reverse: bool = False,
search_mode: SearchMode = SearchMode.QUERY,
filter_query: str | None = None,
limit: int | None = None,
) -> list[int]:
"""
@@ -941,6 +942,7 @@ class TantivyBackend:
sort_field: Field to sort by (None for relevance ranking)
sort_reverse: Whether to reverse the sort order
search_mode: Query parsing mode (QUERY, TEXT, or TITLE)
filter_query: Optional advanced query results must also match
limit: Maximum number of IDs to return (None = all matching docs)
Returns:
@@ -948,6 +950,13 @@ class TantivyBackend:
"""
self._ensure_open()
user_query = self._parse_query(query, search_mode)
if filter_query is not None:
user_query = user_query.and_must_match(
tantivy.Query.const_score_query(
self._parse_query(filter_query, SearchMode.QUERY),
0.0, # zero so doesn't affect relevance
),
)
final_query = self._apply_permission_filter(user_query, user)
searcher = self._index.searcher()
+2 -1
View File
@@ -835,6 +835,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
for filters in (
{"text": "new doc 2017-03-16"},
{"title_search": "apple"},
{"title_search": "apple", "query": "created:[-1 year to now]"},
):
with self.subTest(filters=filters):
get_backend.return_value.search_ids.return_value = [self.doc2.id]
@@ -886,7 +887,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
"all": True,
"filters": {
"text": "B",
"query": "c1",
"title_search": "c1",
},
"method": "set_storage_path",
"parameters": {"storage_path": self.sp1.id},
+61 -1
View File
@@ -245,8 +245,68 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["id"], title_match.id)
def test_simple_search_combined_with_query_filter(self) -> None:
"""
GIVEN:
- A document with the term in its title, created this year
- A document with the term only in its content, created this year
- A document with the term in its title, created years ago
WHEN:
- A title or text search is combined with a relative created
date query, as the filter editor sends it
THEN:
- The simple search keeps its own field scope and the query only
narrows the results (issue #14436)
"""
this_year = timezone.localdate().replace(month=1, day=1)
title_match = Document.objects.create(
title="foo report",
content="nothing relevant",
checksum="F1",
created=this_year,
pk=21,
)
content_match = Document.objects.create(
title="something else",
content="foo appears here",
checksum="F2",
created=this_year,
pk=22,
)
old_title_match = Document.objects.create(
title="foo archive",
content="nothing relevant",
checksum="F3",
created=datetime.date(2001, 1, 1),
pk=23,
)
backend = get_backend()
for doc in (title_match, content_match, old_title_match):
backend.add_or_update(doc)
query = "created:[-1 year to now]"
response = self.client.get(
"/api/documents/",
{"title_search": "foo", "query": query},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
[r["id"] for r in response.data["results"]],
[title_match.id],
)
response = self.client.get(
"/api/documents/",
{"text": "foo", "query": query},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertCountEqual(
[r["id"] for r in response.data["results"]],
[title_match.id, content_match.id],
)
def test_search_rejects_multiple_search_modes(self) -> None:
response = self.client.get("/api/documents/?text=bank&query=bank")
response = self.client.get("/api/documents/?text=bank&title_search=bank")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.data["detail"],
+28
View File
@@ -34,8 +34,10 @@ from documents.tests.utils import GetConsumerMixin
from paperless_mail.models import MailRule
from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import UserFactory
from paperless_testing.fakes.progress import FakeProgressManager
from paperless_testing.permissions import grant_object
class _BaseNewStyleParser:
@@ -1683,6 +1685,32 @@ class TestMetadataOverrides(TestCase):
self.assertEqual(base.actor_id, 2)
self.assertEqual(base.version_label, "v2")
def test_from_document_copies_user_and_group_grants_separately(self) -> None:
"""
GIVEN:
- A document with view and change granted directly to one user and
to a group with another user as a member
WHEN:
- Overrides are built from the document
THEN:
- The user lists hold only the directly granted user
- The group lists hold the group
"""
direct_user = UserFactory()
group_member = UserFactory()
group = Group.objects.create(name="group")
group_member.groups.add(group)
document = DocumentFactory()
grant_object(direct_user, document, "view_document", "change_document")
grant_object(group, document, "view_document", "change_document")
overrides = DocumentMetadataOverrides.from_document(document)
self.assertEqual(overrides.view_users, [direct_user.pk])
self.assertEqual(overrides.change_users, [direct_user.pk])
self.assertEqual(overrides.view_groups, [group.pk])
self.assertEqual(overrides.change_groups, [group.pk])
class TestBarcodeApplyDetectedASN(TestCase):
"""
@@ -4,6 +4,7 @@ from datetime import date
from pathlib import Path
from unittest import mock
import pytest
from django.test import TestCase
from django.test import override_settings
from faker import Faker
@@ -257,3 +258,77 @@ def test_suggestion_content() -> None:
)
doc.content = content_over_limit
assert doc.suggestion_content == expected_cropped_content
@pytest.mark.django_db
class TestPublicFilenameLength:
@pytest.mark.parametrize(
("title", "archive", "extension"),
[
pytest.param("a" * 300, False, ".jpg", id="ascii-original"),
pytest.param("a" * 300, True, ".pdf", id="ascii-archive"),
pytest.param("ä" * 200, False, ".jpg", id="multibyte-original"),
],
)
def test_long_title_keeps_extension_within_limit(
self,
title: str,
*,
archive: bool,
extension: str,
) -> None:
"""
GIVEN:
- A document whose title alone is longer than 255 bytes
WHEN:
- The public file name is generated
THEN:
- The name fits in 255 bytes and still ends with the extension
"""
doc = Document(mime_type="image/jpeg", title=title, created=date(2020, 12, 25))
name = doc.get_public_filename(archive=archive)
assert len(name.encode()) <= 255
assert name.endswith(extension)
def test_long_title_counters_give_distinct_names(self) -> None:
"""
GIVEN:
- A document whose title alone is longer than 255 bytes
WHEN:
- Public file names are generated with increasing counters
THEN:
- Each counter produces a distinct name that keeps the counter
"""
doc = Document(
mime_type="application/pdf",
title="ä" * 200,
created=date(2020, 12, 25),
)
names = [doc.get_public_filename(counter=counter) for counter in range(3)]
assert len(set(names)) == 3
assert names[1].endswith("_01.pdf")
assert all(len(name.encode()) <= 255 for name in names)
def test_max_bytes_bounds_name(self) -> None:
"""
GIVEN:
- A document with a long title
WHEN:
- The public file name is generated with a smaller byte limit
THEN:
- The name fits in that limit and keeps the counter and extension
"""
doc = Document(
mime_type="application/pdf",
title="a" * 300,
created=date(2020, 12, 25),
)
name = doc.get_public_filename(counter=1, max_bytes=100)
assert len(name.encode()) <= 100
assert name.endswith("_01.pdf")
@@ -1537,3 +1537,67 @@ class TestExportDerivedFileNameCollisions:
assert (
target / record[EXPORTER_ARCHIVE_NAME]
).read_text() == f"archive {pk}"
def test_long_titles_export_within_file_name_limit(
self,
paperless_dirs: PaperlessDirs,
tmp_path: Path,
) -> None:
"""
GIVEN:
- Two documents with the same created date and the same title
- The title alone is longer than 255 bytes and contains umlauts
WHEN:
- The exporter runs
THEN:
- The export finishes with distinct original, thumbnail and archive names
- Every exported file name fits in 255 bytes
- Each exported file holds its own document's content
"""
for pk in (1, 2):
(paperless_dirs.originals_dir / f"{pk:07}.pdf").write_bytes(
f"original {pk}".encode(),
)
(paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes(
f"archive {pk}".encode(),
)
(paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes(
f"thumbnail {pk}".encode(),
)
DocumentFactory(
pk=pk,
title="Bestellbestätigung " * 15,
created=date(2026, 10, 15),
filename=f"{pk:07}.pdf",
archive_filename=f"{pk:07}.pdf",
mime_type="application/pdf",
)
target = tmp_path / "export"
target.mkdir()
call_command(
"document_exporter",
str(target),
"--no-progress-bar",
skip_checks=True,
)
manifest = json.loads((target / "manifest.json").read_text())
records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"}
for key in (
EXPORTER_FILE_NAME,
EXPORTER_THUMBNAIL_NAME,
EXPORTER_ARCHIVE_NAME,
):
assert records[1][key] != records[2][key]
for pk in (1, 2):
record = records[pk]
for key, content in (
(EXPORTER_FILE_NAME, f"original {pk}"),
(EXPORTER_THUMBNAIL_NAME, f"thumbnail {pk}"),
(EXPORTER_ARCHIVE_NAME, f"archive {pk}"),
):
assert len(Path(record[key]).name.encode()) <= 255
assert (target / record[key]).read_text() == content
+34 -22
View File
@@ -289,6 +289,7 @@ logger = logging.getLogger("paperless.api")
# clauses efficiently, so this threshold mainly protects SQLite users.
_TANTIVY_INTERSECT_THRESHOLD = 5_000
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
_TANTIVY_COMBINABLE_SEARCH_PARAMS = ({"text", "query"}, {"title_search", "query"})
# whoosh-compat's fieldname tagger (used only for SearchMode.QUERY, via the
# whoosh grammar in parse_user_query) is O(n^2) in plain word characters:
@@ -308,6 +309,18 @@ _TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
_MAX_QUERY_LENGTH: Final[int] = 4096
def _validate_tantivy_search_params(active: list[str]) -> None:
# may also have a filter query alongside a simple search e.g. relative dates
if len(active) > 1 and set(active) not in _TANTIVY_COMBINABLE_SEARCH_PARAMS:
raise ValidationError(
{
"detail": _(
"Specify only one of text, title_search, query, or more_like_id.",
),
},
)
def _get_tantivy_query_and_mode(params):
from documents.search import QueryTooLongError
from documents.search import SearchMode
@@ -321,9 +334,15 @@ def _get_tantivy_query_and_mode(params):
else:
return None # pragma: no cover
if len(raw) > _MAX_QUERY_LENGTH:
raise QueryTooLongError(len(raw), _MAX_QUERY_LENGTH)
return raw, mode
filter_query = (
str(params["query"])
if mode is not SearchMode.QUERY and "query" in params
else None
)
for value in (raw, filter_query):
if value is not None and len(value) > _MAX_QUERY_LENGTH:
raise QueryTooLongError(len(value), _MAX_QUERY_LENGTH)
return raw, mode, filter_query
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
@@ -2566,15 +2585,9 @@ class UnifiedSearchViewSet(DocumentViewSet):
def parse_search_params() -> SearchParams:
"""Extract query string, search mode, and ordering from request."""
active = self._get_active_search_params(request)
if len(active) > 1:
raise ValidationError(
{
"detail": _(
"Specify only one of text, title_search, query, or more_like_id.",
),
},
)
_validate_tantivy_search_params(
self._get_active_search_params(request),
)
ordering_param = request.query_params.get("ordering", "")
sort_reverse = ordering_param.startswith("-")
@@ -2636,7 +2649,9 @@ class UnifiedSearchViewSet(DocumentViewSet):
filtered_qs: QuerySet[Document],
) -> SearchResultPage:
"""Handle text/title/query search: IDs, ORM intersection, page highlights."""
query_str, search_mode = _get_tantivy_query_and_mode(request.query_params)
query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
request.query_params,
)
# "score" is not a real Tantivy sort field — it means relevance order,
# which is Tantivy's default when no sort field is specified.
@@ -2649,6 +2664,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
),
sort_reverse=sort_reverse,
search_mode=search_mode,
filter_query=filter_query,
)
ordered_ids = intersect_and_order(
all_ids,
@@ -2883,14 +2899,7 @@ class DocumentSelectionMixin:
]
if not search_filters:
return None
if len(search_filters) > 1:
raise ValidationError(
{
"detail": _(
"Specify only one of text, title_search, query, or more_like_id.",
),
},
)
_validate_tantivy_search_params(search_filters)
from documents.search import SearchQueryError
from documents.search import get_backend
@@ -2909,11 +2918,14 @@ class DocumentSelectionMixin:
user=search_user,
)
else:
query_str, search_mode = _get_tantivy_query_and_mode(filters)
query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
filters,
)
search_ids = backend.search_ids(
query_str,
user=search_user,
search_mode=search_mode,
filter_query=filter_query,
)
except SearchQueryError as e:
# Same user-fixable-query mapping as the search list endpoint:
+24 -24
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-12 19:12+0000\n"
"POT-Creation-Date: 2026-10-14 15:18+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1653,7 +1653,7 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:515 documents/serialisers.py:872
#: documents/serialisers.py:2902 documents/views.py:344 documents/views.py:2752
#: documents/serialisers.py:2902 documents/views.py:363 documents/views.py:2768
#: paperless_mail/serialisers.py:156
msgid "Insufficient permissions."
msgstr ""
@@ -1694,7 +1694,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2988 documents/views.py:4799
#: documents/serialisers.py:2988 documents/views.py:4811
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1945,40 +1945,40 @@ msgstr ""
msgid ", "
msgstr ""
#: documents/views.py:337 documents/views.py:2749
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1684
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1695
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:1707
msgid "AI backend rejected the request. Check logs for details."
msgstr ""
#: documents/views.py:2574 documents/views.py:2890
#: documents/views.py:318
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4815
#: documents/views.py:356 documents/views.py:2765
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1703
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1714
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:1726
msgid "AI backend rejected the request. Check logs for details."
msgstr ""
#: documents/views.py:4827
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4862
#: documents/views.py:4874
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4926
#: documents/views.py:4938
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4940
#: documents/views.py:4952
msgid "The share link bundle is unavailable."
msgstr ""
Generated
+4 -4
View File
@@ -1272,11 +1272,11 @@ wheels = [
[[package]]
name = "filelock"
version = "3.32.6"
version = "4.0.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/38/46/126b1831dca12060d4a8296bf9c4fe5c93c4f22197fa239cb0cc82042bba/filelock-3.32.6.tar.gz", hash = "sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c", size = 225172, upload-time = "2026-09-08T22:57:11.528Z" }
sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/06/4f138f618dbea66803291274f228f01daf29f306fe8b96bc30dab765df75/filelock-3.32.6-py3-none-any.whl", hash = "sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1", size = 100189, upload-time = "2026-09-08T22:57:10.182Z" },
{ url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" },
]
[[package]]
@@ -3184,7 +3184,7 @@ requires-dist = [
{ name = "drf-spectacular", specifier = "~=0.30" },
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
{ name = "filelock", specifier = "~=3.32.0" },
{ name = "filelock", specifier = ">=3.32,<4.1" },
{ name = "flower", specifier = ">=2.0.1,<2.3" },
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },