mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-16 13:07:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92d638cc74 |
No files matched your search
@@ -624,8 +624,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
margin-left: 0.4em;
|
display: none;
|
||||||
vertical-align: 0.155em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .dropdown-menu {
|
.navbar .dropdown-menu {
|
||||||
|
|||||||
+10
-46
@@ -484,22 +484,6 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.textFilter).toBeNull()
|
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', () => {
|
it('should ingest text filter rules for fulltext query that include date added', () => {
|
||||||
expect(component.dateAddedRelativeDate).toBeNull()
|
expect(component.dateAddedRelativeDate).toBeNull()
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1861,7 +1845,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep text filtering separate from date created relative date', () => {
|
it('should carry over text filtering on date created with relative date', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -1871,18 +1855,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TEXT,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now]',
|
value: 'foo,created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep text filtering separate from created and added relative dates', () => {
|
it('should carry over text filtering once with created and added relative dates', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const datesDropdown = fixture.debugElement.query(
|
const datesDropdown = fixture.debugElement.query(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -1893,18 +1873,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TEXT,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now],added:[-1 month to now]',
|
value: 'foo,created:[-1 week to now],added:[-1 month to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep legacy title filters as a title search when adding a created relative date', () => {
|
it('should convert legacy title filters into full text query when adding a created relative date', () => {
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_TITLE,
|
rule_type: FILTER_TITLE,
|
||||||
@@ -1919,18 +1895,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now]',
|
value: 'foo,created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep simple title filters as a title search when adding a created relative date', () => {
|
it('should convert simple title filters into full text query when adding a created relative date', () => {
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
rule_type: FILTER_SIMPLE_TITLE,
|
||||||
@@ -1945,13 +1917,9 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now]',
|
value: 'foo,created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -2037,7 +2005,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep text filtering separate from date added relative date', () => {
|
it('should carry over text filtering on date added with relative date', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const datesDropdown = fixture.debugElement.query(
|
const datesDropdown = fixture.debugElement.query(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -2047,13 +2015,9 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TEXT,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'added:[-1 week to now]',
|
value: 'foo,added:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1083,6 +1083,23 @@ export class FilterEditorComponent
|
|||||||
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
(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(',') ?? []
|
let queryArgs = existingRule?.value.split(',') ?? []
|
||||||
if (this.dateCreatedRelativeDate !== null) {
|
if (this.dateCreatedRelativeDate !== null) {
|
||||||
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
from collections.abc import Mapping
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import ClassVar
|
|
||||||
from typing import Literal
|
|
||||||
from typing import TypeGuard
|
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -68,23 +62,28 @@ class PaperlessAdminPermissions(BasePermission):
|
|||||||
return request.user.is_active and request.user.is_staff
|
return request.user.is_active and request.user.is_staff
|
||||||
|
|
||||||
|
|
||||||
def _is_active_and_authenticated(user: User | None) -> TypeGuard[User]:
|
|
||||||
return (
|
|
||||||
user is not None
|
|
||||||
and getattr(user, "is_active", False)
|
|
||||||
and getattr(user, "is_authenticated", False)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def has_global_statistics_permission(user: User | None) -> bool:
|
def has_global_statistics_permission(user: User | None) -> bool:
|
||||||
return _is_active_and_authenticated(user) and (
|
if (
|
||||||
getattr(user, "is_superuser", False)
|
user is None
|
||||||
or user.has_perm("paperless.view_global_statistics")
|
or not getattr(user, "is_active", False)
|
||||||
|
or not getattr(user, "is_authenticated", False)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return getattr(user, "is_superuser", False) or user.has_perm(
|
||||||
|
"paperless.view_global_statistics",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def has_system_status_permission(user: User | None) -> bool:
|
def has_system_status_permission(user: User | None) -> bool:
|
||||||
return _is_active_and_authenticated(user) and (
|
if (
|
||||||
|
user is None
|
||||||
|
or not getattr(user, "is_active", False)
|
||||||
|
or not getattr(user, "is_authenticated", False)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (
|
||||||
getattr(user, "is_superuser", False)
|
getattr(user, "is_superuser", False)
|
||||||
or getattr(user, "is_staff", False)
|
or getattr(user, "is_staff", False)
|
||||||
or user.has_perm("paperless.view_system_monitoring")
|
or user.has_perm("paperless.view_system_monitoring")
|
||||||
@@ -706,32 +705,7 @@ def has_perms_owner_aware(user, perms, obj):
|
|||||||
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
||||||
|
|
||||||
|
|
||||||
# HEAD is deliberately absent: GlobalPermissionsByMethod checks it against GET.
|
class ViewDocumentsPermissions(BasePermission):
|
||||||
MappedMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalPermissionsByMethod(BasePermission):
|
|
||||||
"""
|
|
||||||
Requires the global (model-level) permissions listed in ``perms_map`` for
|
|
||||||
the request's HTTP method. HEAD is checked against the ``"GET"`` entry,
|
|
||||||
since DRF runs the GET handler for it. A method missing from
|
|
||||||
``perms_map`` is denied.
|
|
||||||
"""
|
|
||||||
|
|
||||||
perms_map: ClassVar[Mapping[MappedMethod, Sequence[str]]]
|
|
||||||
|
|
||||||
def has_permission(self, request: Any, view: Any) -> bool:
|
|
||||||
if not request.user or not request.user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
method = "GET" if request.method == "HEAD" else request.method
|
|
||||||
# An unknown method is not a MappedMethod, but .get() returns None for
|
|
||||||
# it, so it is still denied.
|
|
||||||
perms = self.perms_map.get(cast("MappedMethod", method))
|
|
||||||
return perms is not None and request.user.has_perms(perms)
|
|
||||||
|
|
||||||
|
|
||||||
class ViewDocumentsPermissions(GlobalPermissionsByMethod):
|
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for only viewing Documents.
|
Permissions class that checks for model permissions for only viewing Documents.
|
||||||
"""
|
"""
|
||||||
@@ -742,18 +716,31 @@ class ViewDocumentsPermissions(GlobalPermissionsByMethod):
|
|||||||
"POST": ["documents.view_document"],
|
"POST": ["documents.view_document"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or (not request.user.is_authenticated): # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
class TrashPermissions(GlobalPermissionsByMethod):
|
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||||
|
|
||||||
|
|
||||||
|
class TrashPermissions(BasePermission):
|
||||||
"""Check the global document permission for each trash operation."""
|
"""Check the global document permission for each trash operation."""
|
||||||
|
|
||||||
perms_map = {
|
perms_map = {
|
||||||
"OPTIONS": ["documents.view_document"],
|
"OPTIONS": ["documents.view_document"],
|
||||||
|
"HEAD": ["documents.view_document"],
|
||||||
"GET": ["documents.view_document"],
|
"GET": ["documents.view_document"],
|
||||||
"POST": ["documents.delete_document"],
|
"POST": ["documents.delete_document"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
class PaperlessNotePermissions(GlobalPermissionsByMethod):
|
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||||
|
|
||||||
|
|
||||||
|
class PaperlessNotePermissions(BasePermission):
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for Notes.
|
Permissions class that checks for model permissions for Notes.
|
||||||
"""
|
"""
|
||||||
@@ -773,13 +760,28 @@ class PaperlessNotePermissions(GlobalPermissionsByMethod):
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or (not request.user.is_authenticated): # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
class AcknowledgeTasksPermissions(GlobalPermissionsByMethod):
|
perms = self.perms_map[request.method]
|
||||||
|
|
||||||
|
return request.user.has_perms(perms)
|
||||||
|
|
||||||
|
|
||||||
|
class AcknowledgeTasksPermissions(BasePermission):
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for acknowledging tasks.
|
Permissions class that checks for model permissions for acknowledging tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
perms_map = {
|
perms_map = {
|
||||||
"OPTIONS": ["documents.change_paperlesstask"],
|
|
||||||
"POST": ["documents.change_paperlesstask"],
|
"POST": ["documents.change_paperlesstask"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request: Any, view: Any) -> bool:
|
||||||
|
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
|
perms = self.perms_map.get(request.method, [])
|
||||||
|
|
||||||
|
return request.user.has_perms(perms)
|
||||||
@@ -926,7 +926,6 @@ class TantivyBackend:
|
|||||||
sort_field: str | None = None,
|
sort_field: str | None = None,
|
||||||
sort_reverse: bool = False,
|
sort_reverse: bool = False,
|
||||||
search_mode: SearchMode = SearchMode.QUERY,
|
search_mode: SearchMode = SearchMode.QUERY,
|
||||||
filter_query: str | None = None,
|
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
"""
|
"""
|
||||||
@@ -942,7 +941,6 @@ class TantivyBackend:
|
|||||||
sort_field: Field to sort by (None for relevance ranking)
|
sort_field: Field to sort by (None for relevance ranking)
|
||||||
sort_reverse: Whether to reverse the sort order
|
sort_reverse: Whether to reverse the sort order
|
||||||
search_mode: Query parsing mode (QUERY, TEXT, or TITLE)
|
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)
|
limit: Maximum number of IDs to return (None = all matching docs)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -950,13 +948,6 @@ class TantivyBackend:
|
|||||||
"""
|
"""
|
||||||
self._ensure_open()
|
self._ensure_open()
|
||||||
user_query = self._parse_query(query, search_mode)
|
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)
|
final_query = self._apply_permission_filter(user_query, user)
|
||||||
|
|
||||||
searcher = self._index.searcher()
|
searcher = self._index.searcher()
|
||||||
|
|||||||
@@ -835,7 +835,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
for filters in (
|
for filters in (
|
||||||
{"text": "new doc 2017-03-16"},
|
{"text": "new doc 2017-03-16"},
|
||||||
{"title_search": "apple"},
|
{"title_search": "apple"},
|
||||||
{"title_search": "apple", "query": "created:[-1 year to now]"},
|
|
||||||
):
|
):
|
||||||
with self.subTest(filters=filters):
|
with self.subTest(filters=filters):
|
||||||
get_backend.return_value.search_ids.return_value = [self.doc2.id]
|
get_backend.return_value.search_ids.return_value = [self.doc2.id]
|
||||||
@@ -887,7 +886,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
"all": True,
|
"all": True,
|
||||||
"filters": {
|
"filters": {
|
||||||
"text": "B",
|
"text": "B",
|
||||||
"title_search": "c1",
|
"query": "c1",
|
||||||
},
|
},
|
||||||
"method": "set_storage_path",
|
"method": "set_storage_path",
|
||||||
"parameters": {"storage_path": self.sp1.id},
|
"parameters": {"storage_path": self.sp1.id},
|
||||||
|
|||||||
@@ -3653,34 +3653,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_notes_head_and_unsupported_methods(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A user allowed to view notes on their own document
|
|
||||||
WHEN:
|
|
||||||
- The notes endpoint is requested with HEAD, PUT and PATCH
|
|
||||||
THEN:
|
|
||||||
- HEAD is allowed like GET
|
|
||||||
- PUT and PATCH are rejected with 403 rather than a server error
|
|
||||||
"""
|
|
||||||
user = UserFactory()
|
|
||||||
grant_global(user, "view_note", "view_document")
|
|
||||||
doc = DocumentFactory(owner=user)
|
|
||||||
self.client.force_authenticate(user)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
self.client.head(f"/api/documents/{doc.pk}/notes/").status_code,
|
|
||||||
status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
self.client.put(f"/api/documents/{doc.pk}/notes/").status_code,
|
|
||||||
status.HTTP_403_FORBIDDEN,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
self.client.patch(f"/api/documents/{doc.pk}/notes/").status_code,
|
|
||||||
status.HTTP_403_FORBIDDEN,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_delete_note(self) -> None:
|
def test_delete_note(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -245,68 +245,8 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.data["count"], 1)
|
self.assertEqual(response.data["count"], 1)
|
||||||
self.assertEqual(response.data["results"][0]["id"], title_match.id)
|
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:
|
def test_search_rejects_multiple_search_modes(self) -> None:
|
||||||
response = self.client.get("/api/documents/?text=bank&title_search=bank")
|
response = self.client.get("/api/documents/?text=bank&query=bank")
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.data["detail"],
|
response.data["detail"],
|
||||||
@@ -1001,21 +941,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), 10)
|
self.assertEqual(len(response.data), 10)
|
||||||
|
|
||||||
def test_search_autocomplete_head_requires_view_document(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A user without the global view document permission
|
|
||||||
WHEN:
|
|
||||||
- A HEAD request is made for autocomplete
|
|
||||||
THEN:
|
|
||||||
- It is rejected like the equivalent GET request
|
|
||||||
"""
|
|
||||||
self.client.force_authenticate(user=UserFactory())
|
|
||||||
|
|
||||||
response = self.client.head("/api/search/autocomplete/?term=test")
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
def test_search_autocomplete_respect_permissions(self) -> None:
|
def test_search_autocomplete_respect_permissions(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
+22
-34
@@ -289,7 +289,6 @@ logger = logging.getLogger("paperless.api")
|
|||||||
# clauses efficiently, so this threshold mainly protects SQLite users.
|
# clauses efficiently, so this threshold mainly protects SQLite users.
|
||||||
_TANTIVY_INTERSECT_THRESHOLD = 5_000
|
_TANTIVY_INTERSECT_THRESHOLD = 5_000
|
||||||
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
_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-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:
|
# whoosh grammar in parse_user_query) is O(n^2) in plain word characters:
|
||||||
@@ -309,18 +308,6 @@ _TANTIVY_COMBINABLE_SEARCH_PARAMS = ({"text", "query"}, {"title_search", "query"
|
|||||||
_MAX_QUERY_LENGTH: Final[int] = 4096
|
_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):
|
def _get_tantivy_query_and_mode(params):
|
||||||
from documents.search import QueryTooLongError
|
from documents.search import QueryTooLongError
|
||||||
from documents.search import SearchMode
|
from documents.search import SearchMode
|
||||||
@@ -334,15 +321,9 @@ def _get_tantivy_query_and_mode(params):
|
|||||||
else:
|
else:
|
||||||
return None # pragma: no cover
|
return None # pragma: no cover
|
||||||
|
|
||||||
filter_query = (
|
if len(raw) > _MAX_QUERY_LENGTH:
|
||||||
str(params["query"])
|
raise QueryTooLongError(len(raw), _MAX_QUERY_LENGTH)
|
||||||
if mode is not SearchMode.QUERY and "query" in params
|
return raw, mode
|
||||||
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:
|
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
||||||
@@ -2585,9 +2566,15 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
|
|
||||||
def parse_search_params() -> SearchParams:
|
def parse_search_params() -> SearchParams:
|
||||||
"""Extract query string, search mode, and ordering from request."""
|
"""Extract query string, search mode, and ordering from request."""
|
||||||
_validate_tantivy_search_params(
|
active = self._get_active_search_params(request)
|
||||||
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.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
ordering_param = request.query_params.get("ordering", "")
|
ordering_param = request.query_params.get("ordering", "")
|
||||||
sort_reverse = ordering_param.startswith("-")
|
sort_reverse = ordering_param.startswith("-")
|
||||||
@@ -2649,9 +2636,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
filtered_qs: QuerySet[Document],
|
filtered_qs: QuerySet[Document],
|
||||||
) -> SearchResultPage:
|
) -> SearchResultPage:
|
||||||
"""Handle text/title/query search: IDs, ORM intersection, page highlights."""
|
"""Handle text/title/query search: IDs, ORM intersection, page highlights."""
|
||||||
query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
|
query_str, search_mode = _get_tantivy_query_and_mode(request.query_params)
|
||||||
request.query_params,
|
|
||||||
)
|
|
||||||
|
|
||||||
# "score" is not a real Tantivy sort field — it means relevance order,
|
# "score" is not a real Tantivy sort field — it means relevance order,
|
||||||
# which is Tantivy's default when no sort field is specified.
|
# which is Tantivy's default when no sort field is specified.
|
||||||
@@ -2664,7 +2649,6 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
),
|
),
|
||||||
sort_reverse=sort_reverse,
|
sort_reverse=sort_reverse,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
filter_query=filter_query,
|
|
||||||
)
|
)
|
||||||
ordered_ids = intersect_and_order(
|
ordered_ids = intersect_and_order(
|
||||||
all_ids,
|
all_ids,
|
||||||
@@ -2899,7 +2883,14 @@ class DocumentSelectionMixin:
|
|||||||
]
|
]
|
||||||
if not search_filters:
|
if not search_filters:
|
||||||
return None
|
return None
|
||||||
_validate_tantivy_search_params(search_filters)
|
if len(search_filters) > 1:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"detail": _(
|
||||||
|
"Specify only one of text, title_search, query, or more_like_id.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
from documents.search import SearchQueryError
|
from documents.search import SearchQueryError
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
@@ -2918,14 +2909,11 @@ class DocumentSelectionMixin:
|
|||||||
user=search_user,
|
user=search_user,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
|
query_str, search_mode = _get_tantivy_query_and_mode(filters)
|
||||||
filters,
|
|
||||||
)
|
|
||||||
search_ids = backend.search_ids(
|
search_ids = backend.search_ids(
|
||||||
query_str,
|
query_str,
|
||||||
user=search_user,
|
user=search_user,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
filter_query=filter_query,
|
|
||||||
)
|
)
|
||||||
except SearchQueryError as e:
|
except SearchQueryError as e:
|
||||||
# Same user-fixable-query mapping as the search list endpoint:
|
# Same user-fixable-query mapping as the search list endpoint:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-10-14 15:18+0000\n"
|
"POT-Creation-Date: 2026-10-12 19:12+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1653,7 +1653,7 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:515 documents/serialisers.py:872
|
#: documents/serialisers.py:515 documents/serialisers.py:872
|
||||||
#: documents/serialisers.py:2902 documents/views.py:363 documents/views.py:2768
|
#: documents/serialisers.py:2902 documents/views.py:344 documents/views.py:2752
|
||||||
#: paperless_mail/serialisers.py:156
|
#: paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1694,7 +1694,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2988 documents/views.py:4811
|
#: documents/serialisers.py:2988 documents/views.py:4799
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1945,40 +1945,40 @@ msgstr ""
|
|||||||
msgid ", "
|
msgid ", "
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:318
|
#: documents/views.py:337 documents/views.py:2749
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: documents/views.py:356 documents/views.py:2765
|
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1703
|
#: documents/views.py:1684
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1714
|
#: documents/views.py:1695
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1726
|
#: documents/views.py:1707
|
||||||
msgid "AI backend rejected the request. Check logs for details."
|
msgid "AI backend rejected the request. Check logs for details."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4827
|
#: documents/views.py:2574 documents/views.py:2890
|
||||||
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: documents/views.py:4815
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4874
|
#: documents/views.py:4862
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4938
|
#: documents/views.py:4926
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4952
|
#: documents/views.py:4940
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user