mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-14 20:17:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdadc35f55 |
No files matched your search
@@ -76,12 +76,11 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Reaching here with the honeypot means AI usage was not disclosed in the form, so that
|
||||
// message can name the cause.
|
||||
// A missing template label only tells us the issue did not come from the form, which
|
||||
// is reason enough to close it, but not proof of how it was written. It takes priority:
|
||||
// a hand-built body can carry the honeypot *and* a disclosure, and the form message is
|
||||
// the only one that is accurate for both. Reaching the honeypot message means the issue
|
||||
// came from the form without disclosing AI usage, so that message can name the cause.
|
||||
const body = honeypot && !bypassedTemplate
|
||||
// is reason enough to close it, but not proof of how it was written.
|
||||
const body = honeypot
|
||||
? 'This issue was automatically closed because it contains a marker that is only visible to ' +
|
||||
'automated tools, which indicates it was generated by an AI agent, but the "AI usage" field does not ' +
|
||||
'disclose this.\n\n' +
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+10
-46
@@ -484,22 +484,6 @@ 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 = [
|
||||
@@ -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'
|
||||
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(DatesDropdownComponent)
|
||||
@@ -1871,18 +1855,14 @@ describe('FilterEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TEXT,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
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'
|
||||
const datesDropdown = fixture.debugElement.query(
|
||||
By.directive(DatesDropdownComponent)
|
||||
@@ -1893,18 +1873,14 @@ describe('FilterEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TEXT,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
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 = [
|
||||
{
|
||||
rule_type: FILTER_TITLE,
|
||||
@@ -1919,18 +1895,14 @@ describe('FilterEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TITLE,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
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 = [
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TITLE,
|
||||
@@ -1945,13 +1917,9 @@ describe('FilterEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TITLE,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
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'
|
||||
const datesDropdown = fixture.debugElement.query(
|
||||
By.directive(DatesDropdownComponent)
|
||||
@@ -2047,13 +2015,9 @@ describe('FilterEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TEXT,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
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
|
||||
)
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -21,6 +21,7 @@ from guardian.core import ObjectPermissionChecker
|
||||
from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from guardian.shortcuts import remove_perm
|
||||
from rest_framework.permissions import BasePermission
|
||||
@@ -648,6 +649,41 @@ def annotate_document_count_for_related_queryset(
|
||||
)
|
||||
|
||||
|
||||
def get_objects_for_user_owner_aware(
|
||||
user: User | None,
|
||||
perms: str | list[str],
|
||||
Model: Any,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
) -> QuerySet[Any]:
|
||||
"""
|
||||
Returns objects the user owns, are unowned, or has explicit perms.
|
||||
When include_deleted is True, soft-deleted items are also included.
|
||||
|
||||
Legacy slow path (guardian-backed, O(n) style permission resolution).
|
||||
Most queryset-filtering call sites have migrated onto
|
||||
``PermittedObjectsFilter``/``permitted_object_ids()``, but this function
|
||||
is kept because production callers still remain. Several callers remain
|
||||
across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` --
|
||||
grep for this function name before removing it.
|
||||
"""
|
||||
manager = (
|
||||
Model.global_objects
|
||||
if include_deleted and hasattr(Model, "global_objects")
|
||||
else Model.objects
|
||||
)
|
||||
|
||||
objects_owned = manager.filter(owner=user)
|
||||
objects_unowned = manager.filter(owner__isnull=True)
|
||||
objects_with_perms = get_objects_for_user(
|
||||
user=user,
|
||||
perms=perms,
|
||||
klass=manager.all(),
|
||||
accept_global_perms=False,
|
||||
)
|
||||
return objects_owned | objects_unowned | objects_with_perms
|
||||
|
||||
|
||||
def has_perms_owner_aware(user, perms, obj):
|
||||
"""
|
||||
Legacy slow path (guardian-backed) single-object permission check.
|
||||
|
||||
@@ -926,7 +926,6 @@ 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]:
|
||||
"""
|
||||
@@ -942,7 +941,6 @@ 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:
|
||||
@@ -950,13 +948,6 @@ 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()
|
||||
|
||||
@@ -52,7 +52,7 @@ from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowRun
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.utils import compute_checksum
|
||||
@@ -81,11 +81,15 @@ DRF_DATETIME_FIELD = serializers.DateTimeField()
|
||||
|
||||
|
||||
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
|
||||
inbox_tags = restrict_queryset_to_visible(
|
||||
Tag.objects.filter(is_inbox_tag=True),
|
||||
document.owner,
|
||||
"view_tag",
|
||||
)
|
||||
if document.owner is not None:
|
||||
tags = get_objects_for_user_owner_aware(
|
||||
document.owner,
|
||||
"documents.view_tag",
|
||||
Tag,
|
||||
)
|
||||
else:
|
||||
tags = Tag.objects.all()
|
||||
inbox_tags = tags.filter(is_inbox_tag=True)
|
||||
document.add_nested_tags(inbox_tags)
|
||||
|
||||
|
||||
|
||||
@@ -835,7 +835,6 @@ 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]
|
||||
@@ -887,7 +886,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
"all": True,
|
||||
"filters": {
|
||||
"text": "B",
|
||||
"title_search": "c1",
|
||||
"query": "c1",
|
||||
},
|
||||
"method": "set_storage_path",
|
||||
"parameters": {"storage_path": self.sp1.id},
|
||||
|
||||
@@ -245,68 +245,8 @@ 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&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.data["detail"],
|
||||
|
||||
@@ -34,10 +34,8 @@ 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 TagFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
|
||||
class _BaseNewStyleParser:
|
||||
@@ -516,65 +514,6 @@ class TestConsumer(
|
||||
self.assertTrue(group_checker.has_perm("view_document", document))
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testInboxTagsScopedToOwner(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Inbox tags that are owned by the document owner, unowned, owned by
|
||||
another user but shared with the document owner, and owned by
|
||||
another user and not shared
|
||||
- A non-inbox tag owned by the document owner
|
||||
WHEN:
|
||||
- A document is consumed with that owner
|
||||
THEN:
|
||||
- The document gets the owned, unowned and shared inbox tags only
|
||||
"""
|
||||
owner = UserFactory()
|
||||
other = UserFactory()
|
||||
owned = TagFactory(is_inbox_tag=True, owner=owner)
|
||||
unowned = TagFactory(is_inbox_tag=True, owner=None)
|
||||
shared = TagFactory(is_inbox_tag=True, owner=other)
|
||||
grant_object(owner, shared, "view_tag")
|
||||
TagFactory(is_inbox_tag=True, owner=other)
|
||||
TagFactory(is_inbox_tag=False, owner=owner)
|
||||
|
||||
with self.get_consumer(
|
||||
self.get_test_file(),
|
||||
DocumentMetadataOverrides(owner_id=owner.pk),
|
||||
) as consumer:
|
||||
consumer.run()
|
||||
|
||||
document = Document.objects.first()
|
||||
assert document is not None
|
||||
|
||||
self.assertCountEqual(document.tags.all(), [owned, unowned, shared])
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testInboxTagsUnownedDocument(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Inbox tags owned by two different users, an unowned inbox tag and
|
||||
a non-inbox tag
|
||||
WHEN:
|
||||
- A document is consumed with no owner
|
||||
THEN:
|
||||
- The document gets every inbox tag
|
||||
"""
|
||||
inbox_tags = [
|
||||
TagFactory(is_inbox_tag=True, owner=UserFactory()),
|
||||
TagFactory(is_inbox_tag=True, owner=UserFactory()),
|
||||
TagFactory(is_inbox_tag=True, owner=None),
|
||||
]
|
||||
TagFactory(is_inbox_tag=False)
|
||||
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
|
||||
document = Document.objects.first()
|
||||
assert document is not None
|
||||
|
||||
self.assertCountEqual(document.tags.all(), inbox_tags)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testNotAFile(self) -> None:
|
||||
with self.assertRaisesMessage(ConsumerError, "File not found"):
|
||||
with self.get_consumer(Path("non-existing-file")) as consumer:
|
||||
|
||||
+53
-67
@@ -175,12 +175,12 @@ from documents.permissions import ViewDocumentsPermissions
|
||||
from documents.permissions import annotate_document_count_by_ids
|
||||
from documents.permissions import annotate_document_count_for_related_queryset
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import has_global_statistics_permission
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import has_system_status_permission
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import user_is_unrestricted
|
||||
from documents.plugins.date_parsing import get_date_parser
|
||||
@@ -289,7 +289,6 @@ 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:
|
||||
@@ -309,18 +308,6 @@ _TANTIVY_COMBINABLE_SEARCH_PARAMS = ({"text", "query"}, {"title_search", "query"
|
||||
_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
|
||||
@@ -334,15 +321,9 @@ def _get_tantivy_query_and_mode(params):
|
||||
else:
|
||||
return None # pragma: no cover
|
||||
|
||||
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
|
||||
if len(raw) > _MAX_QUERY_LENGTH:
|
||||
raise QueryTooLongError(len(raw), _MAX_QUERY_LENGTH)
|
||||
return raw, mode
|
||||
|
||||
|
||||
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:
|
||||
"""Extract query string, search mode, and ordering from request."""
|
||||
_validate_tantivy_search_params(
|
||||
self._get_active_search_params(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.",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
ordering_param = request.query_params.get("ordering", "")
|
||||
sort_reverse = ordering_param.startswith("-")
|
||||
@@ -2649,9 +2636,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
||||
filtered_qs: QuerySet[Document],
|
||||
) -> SearchResultPage:
|
||||
"""Handle text/title/query search: IDs, ORM intersection, page highlights."""
|
||||
query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
|
||||
request.query_params,
|
||||
)
|
||||
query_str, search_mode = _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.
|
||||
@@ -2664,7 +2649,6 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
||||
),
|
||||
sort_reverse=sort_reverse,
|
||||
search_mode=search_mode,
|
||||
filter_query=filter_query,
|
||||
)
|
||||
ordered_ids = intersect_and_order(
|
||||
all_ids,
|
||||
@@ -2899,7 +2883,14 @@ class DocumentSelectionMixin:
|
||||
]
|
||||
if not search_filters:
|
||||
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 get_backend
|
||||
@@ -2918,14 +2909,11 @@ class DocumentSelectionMixin:
|
||||
user=search_user,
|
||||
)
|
||||
else:
|
||||
query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
|
||||
filters,
|
||||
)
|
||||
query_str, search_mode = _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:
|
||||
@@ -3836,51 +3824,49 @@ class GlobalSearchView(PassUserMixin):
|
||||
if doc_id in docs_by_id
|
||||
][:OBJECT_LIMIT]
|
||||
saved_views = (
|
||||
restrict_queryset_to_visible(
|
||||
SavedView.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_savedview",
|
||||
)
|
||||
SavedView,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_savedview")
|
||||
else []
|
||||
)
|
||||
saved_views = saved_views[:OBJECT_LIMIT]
|
||||
tags = (
|
||||
restrict_queryset_to_visible(
|
||||
Tag.objects.filter(name__icontains=query),
|
||||
request.user,
|
||||
"view_tag",
|
||||
get_objects_for_user_owner_aware(request.user, "view_tag", Tag).filter(
|
||||
name__icontains=query,
|
||||
)
|
||||
if request.user.has_perm("documents.view_tag")
|
||||
else []
|
||||
)
|
||||
tags = tags[:OBJECT_LIMIT]
|
||||
correspondents = (
|
||||
restrict_queryset_to_visible(
|
||||
Correspondent.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_correspondent",
|
||||
)
|
||||
Correspondent,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_correspondent")
|
||||
else []
|
||||
)
|
||||
correspondents = correspondents[:OBJECT_LIMIT]
|
||||
document_types = (
|
||||
restrict_queryset_to_visible(
|
||||
DocumentType.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_documenttype",
|
||||
)
|
||||
DocumentType,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_documenttype")
|
||||
else []
|
||||
)
|
||||
document_types = document_types[:OBJECT_LIMIT]
|
||||
storage_paths = (
|
||||
restrict_queryset_to_visible(
|
||||
StoragePath.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_storagepath",
|
||||
)
|
||||
StoragePath,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_storagepath")
|
||||
else []
|
||||
)
|
||||
@@ -3898,21 +3884,21 @@ class GlobalSearchView(PassUserMixin):
|
||||
)
|
||||
groups = groups[:OBJECT_LIMIT]
|
||||
mail_rules = (
|
||||
restrict_queryset_to_visible(
|
||||
MailRule.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_mailrule",
|
||||
)
|
||||
MailRule,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("paperless_mail.view_mailrule")
|
||||
else []
|
||||
)
|
||||
mail_rules = mail_rules[:OBJECT_LIMIT]
|
||||
mail_accounts = (
|
||||
restrict_queryset_to_visible(
|
||||
MailAccount.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_mailaccount",
|
||||
)
|
||||
MailAccount,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("paperless_mail.view_mailaccount")
|
||||
else []
|
||||
)
|
||||
@@ -4028,33 +4014,33 @@ class StatisticsView(GenericAPIView[Any]):
|
||||
tags = (
|
||||
Tag.objects.all()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(Tag.objects.all(), user, "view_tag")
|
||||
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
|
||||
).only("id", "is_inbox_tag")
|
||||
correspondent_count = (
|
||||
Correspondent.objects.count()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(
|
||||
Correspondent.objects.all(),
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_correspondent",
|
||||
"documents.view_correspondent",
|
||||
Correspondent,
|
||||
).count()
|
||||
)
|
||||
document_type_count = (
|
||||
DocumentType.objects.count()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(
|
||||
DocumentType.objects.all(),
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_documenttype",
|
||||
"documents.view_documenttype",
|
||||
DocumentType,
|
||||
).count()
|
||||
)
|
||||
storage_path_count = (
|
||||
StoragePath.objects.count()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(
|
||||
StoragePath.objects.all(),
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_storagepath",
|
||||
"documents.view_storagepath",
|
||||
StoragePath,
|
||||
).count()
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\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"
|
||||
"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:363 documents/views.py:2768
|
||||
#: documents/serialisers.py:2902 documents/views.py:344 documents/views.py:2752
|
||||
#: 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:4811
|
||||
#: documents/serialisers.py:2988 documents/views.py:4799
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
@@ -1945,40 +1945,40 @@ msgstr ""
|
||||
msgid ", "
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:318
|
||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:356 documents/views.py:2765
|
||||
#: documents/views.py:337 documents/views.py:2749
|
||||
msgid "Invalid more_like_id"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1703
|
||||
#: documents/views.py:1684
|
||||
msgid "Invalid AI configuration."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1714
|
||||
#: documents/views.py:1695
|
||||
msgid "AI backend request timed out."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1726
|
||||
#: documents/views.py:1707
|
||||
msgid "AI backend rejected the request. Check logs for details."
|
||||
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
|
||||
msgid "Insufficient permissions to share document %(id)s."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4874
|
||||
#: documents/views.py:4862
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4938
|
||||
#: documents/views.py:4926
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4952
|
||||
#: documents/views.py:4940
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ from django.utils.translation import gettext as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.serialisers import CorrespondentField
|
||||
from documents.serialisers import DocumentTypeField
|
||||
from documents.serialisers import OwnedObjectSerializer
|
||||
@@ -68,10 +68,10 @@ class AccountField(serializers.PrimaryKeyRelatedField[MailAccount]):
|
||||
if user is None:
|
||||
return MailAccount.objects.none()
|
||||
|
||||
return restrict_queryset_to_visible(
|
||||
MailAccount.objects.all(),
|
||||
return get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"change_mailaccount",
|
||||
MailAccount,
|
||||
).order_by("-id")
|
||||
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user