diff --git a/src/documents/search/__init__.py b/src/documents/search/__init__.py index fa5512cbd..22247ec55 100644 --- a/src/documents/search/__init__.py +++ b/src/documents/search/__init__.py @@ -6,11 +6,11 @@ from documents.search._backend import TantivyRelevanceList from documents.search._backend import WriteBatch from documents.search._backend import get_backend from documents.search._backend import reset_backend -from documents.search._query import InvalidDateQuery -from documents.search._query import InvalidNumberQuery -from documents.search._query import MultipleSearchQueryErrors -from documents.search._query import SearchQueryError -from documents.search._query import search_query_error_messages +from documents.search._errors import InvalidDateQuery +from documents.search._errors import InvalidNumberQuery +from documents.search._errors import MultipleSearchQueryErrors +from documents.search._errors import SearchQueryError +from documents.search._errors import search_query_error_messages from documents.search._schema import needs_rebuild from documents.search._schema import wipe_index diff --git a/src/documents/search/_backend.py b/src/documents/search/_backend.py index fe3fc646e..8c47ae817 100644 --- a/src/documents/search/_backend.py +++ b/src/documents/search/_backend.py @@ -25,7 +25,6 @@ from django.utils.timezone import get_current_timezone from guardian.shortcuts import get_groups_with_perms from guardian.shortcuts import get_users_with_perms -from documents.search._query import build_permission_filter from documents.search._query import extract_cjk_text from documents.search._query import parse_simple_text_highlight_query from documents.search._query import parse_simple_text_query @@ -43,6 +42,7 @@ from documents.utils import QuerySetStream from documents.utils import identity if TYPE_CHECKING: + from collections.abc import Iterable from collections.abc import Iterator from collections.abc import Sequence from pathlib import Path @@ -294,6 +294,47 @@ class WriteBatch: ) +def build_permission_filter( + schema: tantivy.Schema, + user: AbstractUser, + viewer_group_ids: Iterable[int] = (), +) -> tantivy.Query: + """ + Build a query filter for user document permissions. + + Creates a query that matches only documents visible to the specified user + according to paperless-ngx permission rules: + - Public documents (no owner) are visible to all users + - Private documents are visible to their owner + - Documents explicitly shared with the user are visible + - Documents shared with one of the user's current groups are visible + + Args: + schema: Tantivy schema for field validation + user: User to check permissions for + viewer_group_ids: Current group memberships for the user + + Returns: + Tantivy query that filters results to visible documents + """ + owner_any = tantivy.Query.exists_query("owner_id") + no_owner = tantivy.Query.boolean_query( + [ + (tantivy.Occur.Must, tantivy.Query.all_query()), + (tantivy.Occur.MustNot, owner_any), + ], + ) + owned = tantivy.Query.term_query(schema, "owner_id", user.pk) + shared = tantivy.Query.term_query(schema, "viewer_id", user.pk) + group_shared = [ + tantivy.Query.term_query(schema, "viewer_group_id", group_id) + for group_id in viewer_group_ids + ] + return tantivy.Query.disjunction_max_query( + [no_owner, owned, shared, *group_shared], + ) + + class TantivyBackend: """ Tantivy search backend with explicit lifecycle management. diff --git a/src/documents/search/_errors.py b/src/documents/search/_errors.py new file mode 100644 index 000000000..ba04bf287 --- /dev/null +++ b/src/documents/search/_errors.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + +class SearchQueryError(ValueError): + """ + Base for user-fixable search query errors. + + Carries a message safe to surface to the user (no internal details). The + view layer catches this and returns an HTTP 400, so any future subclass + gets the same treatment. + """ + + +class InvalidDateQuery(SearchQueryError): + """Raised when a date field value or range bound cannot be parsed.""" + + def __init__(self, field: str | None, value: str | None) -> None: + self.field = field + self.value = value + super().__init__(f"Invalid date value {value!r} for field {field!r}.") + + +class InvalidNumberQuery(SearchQueryError): + """Raised when a numeric field value or range bound cannot be parsed.""" + + def __init__(self, field: str | None, value: str | None) -> None: + self.field = field + self.value = value + super().__init__(f"Invalid numeric value {value!r} for field {field!r}.") + + +class MultipleSearchQueryErrors(SearchQueryError): + """Aggregates every user-fixable error from one parse, not just the first.""" + + def __init__(self, errors: Sequence[SearchQueryError]) -> None: + self.errors = tuple(errors) + super().__init__("; ".join(str(e) for e in self.errors)) + + +def search_query_error_messages(e: SearchQueryError) -> list[str]: + """The user-facing message list for a SearchQueryError. + + Every offending value's message, not just the first, so the user can + fix them all in one round-trip. Shared by every view that maps + SearchQueryError to an HTTP 400. + """ + if isinstance(e, MultipleSearchQueryErrors): + return [str(sub) for sub in e.errors] + return [str(e)] diff --git a/src/documents/search/_query.py b/src/documents/search/_query.py index 8b58ff8f6..1565e581d 100644 --- a/src/documents/search/_query.py +++ b/src/documents/search/_query.py @@ -14,66 +14,17 @@ from whoosh_compat.errors import DiagnosticKind from whoosh_compat.errors import QueryEmitError from whoosh_compat.errors import UnsupportedQueryError +from documents.search._errors import InvalidDateQuery +from documents.search._errors import InvalidNumberQuery +from documents.search._errors import MultipleSearchQueryErrors +from documents.search._errors import SearchQueryError from documents.search._fields import PUBLIC_FIELDS from documents.search._registry import get_field_registry from documents.search._tokenizer import simple_search_tokens if TYPE_CHECKING: - from collections.abc import Iterable - from collections.abc import Sequence from datetime import tzinfo - from django.contrib.auth.base_user import AbstractBaseUser - - -class SearchQueryError(ValueError): - """ - Base for user-fixable search query errors. - - Carries a message safe to surface to the user (no internal details). The - view layer catches this and returns an HTTP 400, so any future subclass - gets the same treatment. - """ - - -class InvalidDateQuery(SearchQueryError): - """Raised when a date field value or range bound cannot be parsed.""" - - def __init__(self, field: str | None, value: str | None) -> None: - self.field = field - self.value = value - super().__init__(f"Invalid date value {value!r} for field {field!r}.") - - -class InvalidNumberQuery(SearchQueryError): - """Raised when a numeric field value or range bound cannot be parsed.""" - - def __init__(self, field: str | None, value: str | None) -> None: - self.field = field - self.value = value - super().__init__(f"Invalid numeric value {value!r} for field {field!r}.") - - -class MultipleSearchQueryErrors(SearchQueryError): - """Aggregates every user-fixable error from one parse, not just the first.""" - - def __init__(self, errors: Sequence[SearchQueryError]) -> None: - self.errors = tuple(errors) - super().__init__("; ".join(str(e) for e in self.errors)) - - -def search_query_error_messages(e: SearchQueryError) -> list[str]: - """The user-facing message list for a SearchQueryError. - - Every offending value's message, not just the first, so the user can - fix them all in one round-trip. Shared by every view that maps - SearchQueryError to an HTTP 400. - """ - if isinstance(e, MultipleSearchQueryErrors): - return [str(sub) for sub in e.errors] - return [str(e)] - - logger = logging.getLogger("paperless.search") # Maximum seconds any single regex substitution may run. @@ -269,47 +220,6 @@ def _try_parse_fuzzy_query( return None -def build_permission_filter( - schema: tantivy.Schema, - user: AbstractBaseUser, - viewer_group_ids: Iterable[int] = (), -) -> tantivy.Query: - """ - Build a query filter for user document permissions. - - Creates a query that matches only documents visible to the specified user - according to paperless-ngx permission rules: - - Public documents (no owner) are visible to all users - - Private documents are visible to their owner - - Documents explicitly shared with the user are visible - - Documents shared with one of the user's current groups are visible - - Args: - schema: Tantivy schema for field validation - user: User to check permissions for - viewer_group_ids: Current group memberships for the user - - Returns: - Tantivy query that filters results to visible documents - """ - owner_any = tantivy.Query.exists_query("owner_id") - no_owner = tantivy.Query.boolean_query( - [ - (tantivy.Occur.Must, tantivy.Query.all_query()), - (tantivy.Occur.MustNot, owner_any), - ], - ) - owned = tantivy.Query.term_query(schema, "owner_id", user.pk) - shared = tantivy.Query.term_query(schema, "viewer_id", user.pk) - group_shared = [ - tantivy.Query.term_query(schema, "viewer_group_id", group_id) - for group_id in viewer_group_ids - ] - return tantivy.Query.disjunction_max_query( - [no_owner, owned, shared, *group_shared], - ) - - _DEFAULT_SEARCH_FIELDS: Final[list[str]] = [ "title", "content", diff --git a/src/documents/tests/search/test_query.py b/src/documents/tests/search/test_query.py index b3ceed225..39c661692 100644 --- a/src/documents/tests/search/test_query.py +++ b/src/documents/tests/search/test_query.py @@ -8,11 +8,11 @@ import pytest import tantivy import time_machine -from documents.search._query import InvalidDateQuery -from documents.search._query import InvalidNumberQuery -from documents.search._query import MultipleSearchQueryErrors -from documents.search._query import SearchQueryError -from documents.search._query import build_permission_filter +from documents.search._backend import build_permission_filter +from documents.search._errors import InvalidDateQuery +from documents.search._errors import InvalidNumberQuery +from documents.search._errors import MultipleSearchQueryErrors +from documents.search._errors import SearchQueryError from documents.search._query import parse_simple_text_highlight_query from documents.search._query import parse_user_query from documents.search._schema import build_schema