mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-15 04:27:14 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c45c7c7a99 | ||
|
|
e3b4cf55f0 | ||
|
|
34a6c718fa | ||
|
|
14aaa42777 |
No files matched your search
+46
-10
@@ -484,6 +484,22 @@ 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 = [
|
||||||
@@ -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'
|
component.textFilter = 'foo'
|
||||||
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -1855,14 +1871,18 @@ 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: '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'
|
component.textFilter = 'foo'
|
||||||
const datesDropdown = fixture.debugElement.query(
|
const datesDropdown = fixture.debugElement.query(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -1873,14 +1893,18 @@ 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: '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 = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_TITLE,
|
rule_type: FILTER_TITLE,
|
||||||
@@ -1895,14 +1919,18 @@ 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: '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 = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
rule_type: FILTER_SIMPLE_TITLE,
|
||||||
@@ -1917,9 +1945,13 @@ 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: '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'
|
component.textFilter = 'foo'
|
||||||
const datesDropdown = fixture.debugElement.query(
|
const datesDropdown = fixture.debugElement.query(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -2015,9 +2047,13 @@ 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: '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
|
(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(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from documents.models import DocumentType
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.permissions import PermissionsSpec
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
@@ -445,7 +446,7 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
|||||||
|
|
||||||
def set_permissions(
|
def set_permissions(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
set_permissions: dict,
|
set_permissions: PermissionsSpec,
|
||||||
*,
|
*,
|
||||||
owner: User | None = None,
|
owner: User | None = None,
|
||||||
merge: bool = False,
|
merge: bool = False,
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ from documents.models import StoragePath
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.parsers import ParseError
|
from documents.parsers import ParseError
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import PermissionsSpec
|
||||||
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.plugins.base import AlwaysRunPluginMixin
|
from documents.plugins.base import AlwaysRunPluginMixin
|
||||||
from documents.plugins.base import ConsumeTaskPlugin
|
from documents.plugins.base import ConsumeTaskPlugin
|
||||||
from documents.plugins.base import NoCleanupPluginMixin
|
from documents.plugins.base import NoCleanupPluginMixin
|
||||||
@@ -948,7 +949,7 @@ class ConsumerPlugin(
|
|||||||
or self.metadata.change_users is not None
|
or self.metadata.change_users is not None
|
||||||
or self.metadata.change_groups is not None
|
or self.metadata.change_groups is not None
|
||||||
):
|
):
|
||||||
permissions = {
|
permissions: PermissionsSpec = {
|
||||||
"view": {
|
"view": {
|
||||||
"users": self.metadata.view_users or [],
|
"users": self.metadata.view_users or [],
|
||||||
"groups": self.metadata.view_groups or [],
|
"groups": self.metadata.view_groups or [],
|
||||||
@@ -958,7 +959,7 @@ class ConsumerPlugin(
|
|||||||
"groups": self.metadata.change_groups or [],
|
"groups": self.metadata.change_groups or [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
set_permissions_for_object(permissions=permissions, object=document)
|
set_permissions_for_objects(permissions, Document, [document.pk])
|
||||||
|
|
||||||
if self.metadata.custom_fields:
|
if self.metadata.custom_fields:
|
||||||
for field in CustomField.objects.filter(
|
for field in CustomField.objects.filter(
|
||||||
|
|||||||
+37
-120
@@ -1,4 +1,10 @@
|
|||||||
|
from collections.abc import Iterable
|
||||||
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import Final
|
||||||
|
from typing import Literal
|
||||||
|
from typing import TypeAlias
|
||||||
|
from typing import TypedDict
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
@@ -20,16 +26,29 @@ from django.db.models.functions import Coalesce
|
|||||||
from guardian.core import ObjectPermissionChecker
|
from guardian.core import ObjectPermissionChecker
|
||||||
from guardian.models import GroupObjectPermission
|
from guardian.models import GroupObjectPermission
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
from guardian.shortcuts import assign_perm
|
|
||||||
from guardian.shortcuts import get_objects_for_user
|
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
|
from rest_framework.permissions import BasePermission
|
||||||
from rest_framework.permissions import DjangoObjectPermissions
|
from rest_framework.permissions import DjangoObjectPermissions
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.versioning import get_root_document
|
from documents.versioning import get_root_document
|
||||||
|
|
||||||
|
PermissionAction = Literal["view", "change"]
|
||||||
|
PERMISSION_ACTIONS: Final[tuple[PermissionAction, ...]] = ("view", "change")
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionGrantees(TypedDict, total=False):
|
||||||
|
"""
|
||||||
|
The user and group ids to grant one permission action to. A missing key
|
||||||
|
leaves that kind of grant untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
users: Iterable[int]
|
||||||
|
groups: Iterable[int]
|
||||||
|
|
||||||
|
|
||||||
|
PermissionsSpec: TypeAlias = Mapping[PermissionAction, PermissionGrantees]
|
||||||
|
|
||||||
|
|
||||||
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
||||||
"""
|
"""
|
||||||
@@ -104,87 +123,10 @@ def get_groups_with_only_permission(obj, codename):
|
|||||||
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
|
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
|
||||||
|
|
||||||
|
|
||||||
def set_permissions_for_object(
|
|
||||||
permissions: dict,
|
|
||||||
object,
|
|
||||||
*,
|
|
||||||
merge: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Set permissions for an object. The permissions are given as a mapping of actions
|
|
||||||
to a dict of user / group id lists, e.g.
|
|
||||||
{"view": {"users": [1], "groups": [2]}, "change": {"users": [], "groups": []}}.
|
|
||||||
|
|
||||||
If merge is True, the permissions are merged with the existing permissions and
|
|
||||||
no users or groups are removed. If False, the permissions are set to exactly
|
|
||||||
the given list of users and groups.
|
|
||||||
"""
|
|
||||||
|
|
||||||
for action, entry in permissions.items():
|
|
||||||
permission = f"{action}_{object.__class__.__name__.lower()}"
|
|
||||||
if "users" in entry:
|
|
||||||
# users
|
|
||||||
users_to_add = User.objects.filter(id__in=entry["users"])
|
|
||||||
users_to_remove = (
|
|
||||||
get_users_with_perms(
|
|
||||||
object,
|
|
||||||
only_with_perms_in=[permission],
|
|
||||||
with_group_users=False,
|
|
||||||
)
|
|
||||||
if not merge
|
|
||||||
else User.objects.none()
|
|
||||||
)
|
|
||||||
if users_to_add.exists() and users_to_remove.exists():
|
|
||||||
users_to_remove = users_to_remove.exclude(id__in=users_to_add)
|
|
||||||
if users_to_remove.exists():
|
|
||||||
for user in users_to_remove:
|
|
||||||
remove_perm(permission, user, object)
|
|
||||||
if users_to_add.exists():
|
|
||||||
for user in users_to_add:
|
|
||||||
assign_perm(permission, user, object)
|
|
||||||
if action == "change":
|
|
||||||
# change gives view too
|
|
||||||
assign_perm(
|
|
||||||
f"view_{object.__class__.__name__.lower()}",
|
|
||||||
user,
|
|
||||||
object,
|
|
||||||
)
|
|
||||||
if "groups" in entry:
|
|
||||||
# groups
|
|
||||||
groups_to_add = Group.objects.filter(id__in=entry["groups"])
|
|
||||||
groups_to_remove = (
|
|
||||||
get_groups_with_only_permission(
|
|
||||||
object,
|
|
||||||
permission,
|
|
||||||
)
|
|
||||||
if not merge
|
|
||||||
else Group.objects.none()
|
|
||||||
)
|
|
||||||
if groups_to_add.exists() and groups_to_remove.exists():
|
|
||||||
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
|
|
||||||
if groups_to_remove.exists():
|
|
||||||
for group in groups_to_remove:
|
|
||||||
remove_perm(permission, group, object)
|
|
||||||
if groups_to_add.exists():
|
|
||||||
for group in groups_to_add:
|
|
||||||
assign_perm(permission, group, object)
|
|
||||||
if action == "change":
|
|
||||||
# change gives view too
|
|
||||||
assign_perm(
|
|
||||||
f"view_{object.__class__.__name__.lower()}",
|
|
||||||
group,
|
|
||||||
object,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
||||||
"""
|
"""
|
||||||
Resolves `codenames` to Permission rows, raising like the single-object
|
Resolve ``codenames`` to Permission rows, raising for any that don't exist
|
||||||
assign_perm() this bulk path replaces does (via a `.get()` internally)
|
so a bad action name fails loudly instead of granting nothing.
|
||||||
if any codename doesn't exist. SetPermissionsSerializer rejects unknown
|
|
||||||
action names at the API, but a caller passing one directly would
|
|
||||||
otherwise get a plain `.filter()` that silently builds zero rows and
|
|
||||||
no-ops instead of reporting the bad input.
|
|
||||||
"""
|
"""
|
||||||
permission_objs = list(
|
permission_objs = list(
|
||||||
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
||||||
@@ -203,16 +145,13 @@ def _apply_bulk_permission_entry(
|
|||||||
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
||||||
identity_model: type[User] | type[Group],
|
identity_model: type[User] | type[Group],
|
||||||
identity_field: str,
|
identity_field: str,
|
||||||
ids: list[int],
|
ids: Iterable[int],
|
||||||
codename: str,
|
codename: str,
|
||||||
permission_objs: list[Permission],
|
permission_objs: list[Permission],
|
||||||
ctype: ContentType,
|
ctype: ContentType,
|
||||||
object_pks: list[str],
|
object_pks: list[str],
|
||||||
merge: bool,
|
merge: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Only the ids are needed to build permission rows (via `<field>_id=`),
|
|
||||||
# so avoid fetching full User/Group rows for identities that may not
|
|
||||||
# even end up being granted anything new.
|
|
||||||
add_ids = set(
|
add_ids = set(
|
||||||
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
||||||
)
|
)
|
||||||
@@ -250,45 +189,26 @@ def _apply_bulk_permission_entry(
|
|||||||
for pk in object_pks
|
for pk in object_pks
|
||||||
for identity_id in add_ids
|
for identity_id in add_ids
|
||||||
]
|
]
|
||||||
# ignore_conflicts skips only rows that already exist as an exact
|
# ignore_conflicts skips grants that already exist (unique constraint).
|
||||||
# (identity, permission, object) match -- the same de-dup the
|
|
||||||
# underlying (user|group, permission, object_pk) unique constraint
|
|
||||||
# already enforces for the single-object assign_perm() this replaces,
|
|
||||||
# so it doesn't change what counts as "already granted". batch_size
|
|
||||||
# caps how many rows go into a single INSERT statement.
|
|
||||||
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
||||||
|
|
||||||
|
|
||||||
def set_permissions_for_objects(
|
def set_permissions_for_objects(
|
||||||
permissions: dict,
|
permissions: PermissionsSpec,
|
||||||
model: type[Model],
|
model: type[Model],
|
||||||
pks: QuerySet | list,
|
pks: QuerySet | list,
|
||||||
*,
|
*,
|
||||||
merge: bool = False,
|
merge: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Bulk equivalent of set_permissions_for_object: applies the same
|
Set the view/change grants for every object in ``pks``. With ``merge``,
|
||||||
permission changes to every object identified by `pks` at once.
|
the given users and groups are added; without it, each given action's
|
||||||
|
direct grants are replaced. Granting change also grants view, and an
|
||||||
|
action entry with neither key is left untouched.
|
||||||
|
|
||||||
Takes a model + pks (rather than model instances) deliberately -- the
|
guardian's bulk assign_perm is avoided because it skips the direct row
|
||||||
permission rows built below only ever need `pk`, `content_type`, and
|
for anyone who already has access through a group, so revoking the group
|
||||||
identity ids, so callers shouldn't have to fetch full rows (with every
|
later would silently remove access that was granted explicitly.
|
||||||
other field) just to hand them to this function.
|
|
||||||
|
|
||||||
Deliberately does not use guardian's queryset/list-aware assign_perm:
|
|
||||||
passing a list as the object routes to bulk_assign_perm, which skips
|
|
||||||
creating a direct permission row for anyone who already has the
|
|
||||||
permission via ANY group membership (it checks
|
|
||||||
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
|
|
||||||
unlike the single-object assign_perm this replaces, which always
|
|
||||||
ensures a direct row via get_or_create regardless of group-derived
|
|
||||||
access. Losing that guarantee would mean a later revocation of the
|
|
||||||
group's grant silently strips access an admin explicitly asked to be
|
|
||||||
direct. Bulk-creating rows straight against the permission models
|
|
||||||
instead (see _apply_bulk_permission_entry) preserves the original
|
|
||||||
always-create-a-direct-row semantics while still batching every object
|
|
||||||
and every identity into one query per action, rather than one query per
|
|
||||||
(object, user) pair.
|
|
||||||
"""
|
"""
|
||||||
object_pks = [str(pk) for pk in pks]
|
object_pks = [str(pk) for pk in pks]
|
||||||
if not object_pks: # pragma: no cover
|
if not object_pks: # pragma: no cover
|
||||||
@@ -297,12 +217,9 @@ def set_permissions_for_objects(
|
|||||||
model_name = model.__name__.lower()
|
model_name = model.__name__.lower()
|
||||||
ctype = ContentType.objects.get_for_model(model)
|
ctype = ContentType.objects.get_for_model(model)
|
||||||
|
|
||||||
# Every action is resolved up front, before anything is written, so an
|
# Resolve every action before writing, so a bad one can't leave the
|
||||||
# unrecognized action name (see _resolve_permissions) aborts the whole
|
# actions ahead of it applied.
|
||||||
# call instead of leaving the actions ahead of it already applied.
|
permissions_by_action: dict[PermissionAction, list[Permission]] = {}
|
||||||
# SetPermissionsSerializer rejects unknown actions at the API, so this
|
|
||||||
# guards any other caller.
|
|
||||||
permissions_by_action: dict[str, list[Permission]] = {}
|
|
||||||
for action, entry in permissions.items():
|
for action, entry in permissions.items():
|
||||||
if "users" not in entry and "groups" not in entry:
|
if "users" not in entry and "groups" not in entry:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -926,6 +926,7 @@ 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]:
|
||||||
"""
|
"""
|
||||||
@@ -941,6 +942,7 @@ 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:
|
||||||
@@ -948,6 +950,13 @@ 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()
|
||||||
|
|||||||
@@ -78,12 +78,16 @@ from documents.models import WorkflowActionEmail
|
|||||||
from documents.models import WorkflowActionWebhook
|
from documents.models import WorkflowActionWebhook
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.parsers import is_mime_type_supported
|
from documents.parsers import is_mime_type_supported
|
||||||
|
from documents.permissions import PERMISSION_ACTIONS
|
||||||
|
from documents.permissions import PermissionAction
|
||||||
|
from documents.permissions import PermissionGrantees
|
||||||
|
from documents.permissions import PermissionsSpec
|
||||||
from documents.permissions import get_document_count_filter_for_user
|
from documents.permissions import get_document_count_filter_for_user
|
||||||
from documents.permissions import get_groups_with_only_permission
|
from documents.permissions import get_groups_with_only_permission
|
||||||
from documents.permissions import has_perms_owner_aware
|
from documents.permissions import has_perms_owner_aware
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.regex import validate_regex_pattern
|
from documents.regex import validate_regex_pattern
|
||||||
from documents.templating.filepath import validate_filepath_template_and_render
|
from documents.templating.filepath import validate_filepath_template_and_render
|
||||||
from documents.templating.utils import convert_format_str_to_template_format
|
from documents.templating.utils import convert_format_str_to_template_format
|
||||||
@@ -211,9 +215,6 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
|
|||||||
return match
|
return match
|
||||||
|
|
||||||
|
|
||||||
PERMISSION_ACTIONS = ("view", "change")
|
|
||||||
|
|
||||||
|
|
||||||
class SetPermissionsMixin:
|
class SetPermissionsMixin:
|
||||||
def _validate_user_ids(self, user_ids):
|
def _validate_user_ids(self, user_ids):
|
||||||
users = User.objects.none()
|
users = User.objects.none()
|
||||||
@@ -235,8 +236,10 @@ class SetPermissionsMixin:
|
|||||||
)
|
)
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
def validate_set_permissions(self, set_permissions=None):
|
def validate_set_permissions(self, set_permissions=None) -> PermissionsSpec:
|
||||||
permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
|
permissions_dict: dict[PermissionAction, PermissionGrantees] = {
|
||||||
|
action: {} for action in PERMISSION_ACTIONS
|
||||||
|
}
|
||||||
if set_permissions is not None:
|
if set_permissions is not None:
|
||||||
for action in PERMISSION_ACTIONS:
|
for action in PERMISSION_ACTIONS:
|
||||||
if action in set_permissions:
|
if action in set_permissions:
|
||||||
@@ -244,18 +247,18 @@ class SetPermissionsMixin:
|
|||||||
users = set_permissions[action]["users"]
|
users = set_permissions[action]["users"]
|
||||||
permissions_dict[action]["users"] = self._validate_user_ids(
|
permissions_dict[action]["users"] = self._validate_user_ids(
|
||||||
users,
|
users,
|
||||||
)
|
).values_list("id", flat=True)
|
||||||
if "groups" in set_permissions[action]:
|
if "groups" in set_permissions[action]:
|
||||||
groups = set_permissions[action]["groups"]
|
groups = set_permissions[action]["groups"]
|
||||||
permissions_dict[action]["groups"] = self._validate_group_ids(
|
permissions_dict[action]["groups"] = self._validate_group_ids(
|
||||||
groups,
|
groups,
|
||||||
)
|
).values_list("id", flat=True)
|
||||||
else:
|
else:
|
||||||
del permissions_dict[action]
|
del permissions_dict[action]
|
||||||
return permissions_dict
|
return permissions_dict
|
||||||
|
|
||||||
def _set_permissions(self, permissions, object) -> None:
|
def _set_permissions(self, permissions: PermissionsSpec, object) -> None:
|
||||||
set_permissions_for_object(permissions, object)
|
set_permissions_for_objects(permissions, type(object), [object.pk])
|
||||||
|
|
||||||
|
|
||||||
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
|
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
|
||||||
@@ -3086,7 +3089,7 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
|||||||
)
|
)
|
||||||
return objects
|
return objects
|
||||||
|
|
||||||
def _validate_permissions(self, permissions) -> dict:
|
def _validate_permissions(self, permissions) -> PermissionsSpec:
|
||||||
return self.validate_set_permissions(
|
return self.validate_set_permissions(
|
||||||
permissions,
|
permissions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -835,6 +835,7 @@ 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]
|
||||||
@@ -886,7 +887,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
"all": True,
|
"all": True,
|
||||||
"filters": {
|
"filters": {
|
||||||
"text": "B",
|
"text": "B",
|
||||||
"query": "c1",
|
"title_search": "c1",
|
||||||
},
|
},
|
||||||
"method": "set_storage_path",
|
"method": "set_storage_path",
|
||||||
"parameters": {"storage_path": self.sp1.id},
|
"parameters": {"storage_path": self.sp1.id},
|
||||||
|
|||||||
@@ -245,8 +245,68 @@ 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&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.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.data["detail"],
|
response.data["detail"],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.permissions import PermissionsSpec
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_objects
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
@@ -445,7 +446,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
grant_object(self.group1, self.doc1, "view_document")
|
grant_object(self.group1, self.doc1, "view_document")
|
||||||
|
|
||||||
permissions = {
|
permissions: PermissionsSpec = {
|
||||||
"view": {
|
"view": {
|
||||||
"users": [self.user1.id, self.user2.id],
|
"users": [self.user1.id, self.user2.id],
|
||||||
"groups": [self.group2.id],
|
"groups": [self.group2.id],
|
||||||
@@ -488,7 +489,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
grant_object(self.user1, self.doc1, "view_document")
|
grant_object(self.user1, self.doc1, "view_document")
|
||||||
grant_object(self.group1, self.doc1, "view_document")
|
grant_object(self.group1, self.doc1, "view_document")
|
||||||
|
|
||||||
permissions = {
|
permissions: PermissionsSpec = {
|
||||||
"view": {
|
"view": {
|
||||||
"users": [self.user2.id],
|
"users": [self.user2.id],
|
||||||
"groups": [self.group2.id],
|
"groups": [self.group2.id],
|
||||||
@@ -539,7 +540,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
batched call rather than one call per (document, identity)
|
batched call rather than one call per (document, identity)
|
||||||
pair
|
pair
|
||||||
"""
|
"""
|
||||||
permissions = {
|
permissions: PermissionsSpec = {
|
||||||
"view": {
|
"view": {
|
||||||
"users": [self.user1.id, self.user2.id],
|
"users": [self.user1.id, self.user2.id],
|
||||||
"groups": [self.group2.id],
|
"groups": [self.group2.id],
|
||||||
@@ -646,7 +647,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
"""
|
"""
|
||||||
with self.assertRaises(Permission.DoesNotExist):
|
with self.assertRaises(Permission.DoesNotExist):
|
||||||
set_permissions_for_objects(
|
set_permissions_for_objects(
|
||||||
{"not_a_real_action": {"users": [self.user1.id], "groups": []}},
|
{"not_a_real_action": {"users": [self.user1.id], "groups": []}}, # type: ignore[bad-assignment]
|
||||||
Document,
|
Document,
|
||||||
[self.doc1.pk],
|
[self.doc1.pk],
|
||||||
)
|
)
|
||||||
@@ -674,7 +675,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
set_permissions_for_objects(
|
set_permissions_for_objects(
|
||||||
{
|
{
|
||||||
"view": {"users": [self.user1.id], "groups": []},
|
"view": {"users": [self.user1.id], "groups": []},
|
||||||
"not_a_real_action": {"users": [self.user1.id], "groups": []},
|
"not_a_real_action": {"users": [self.user1.id], "groups": []}, # type: ignore[bad-assignment]
|
||||||
},
|
},
|
||||||
Document,
|
Document,
|
||||||
[self.doc1.pk],
|
[self.doc1.pk],
|
||||||
|
|||||||
+34
-22
@@ -289,6 +289,7 @@ 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:
|
||||||
@@ -308,6 +309,18 @@ _TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
|||||||
_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
|
||||||
@@ -321,9 +334,15 @@ def _get_tantivy_query_and_mode(params):
|
|||||||
else:
|
else:
|
||||||
return None # pragma: no cover
|
return None # pragma: no cover
|
||||||
|
|
||||||
if len(raw) > _MAX_QUERY_LENGTH:
|
filter_query = (
|
||||||
raise QueryTooLongError(len(raw), _MAX_QUERY_LENGTH)
|
str(params["query"])
|
||||||
return raw, mode
|
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:
|
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:
|
def parse_search_params() -> SearchParams:
|
||||||
"""Extract query string, search mode, and ordering from request."""
|
"""Extract query string, search mode, and ordering from request."""
|
||||||
active = self._get_active_search_params(request)
|
_validate_tantivy_search_params(
|
||||||
if len(active) > 1:
|
self._get_active_search_params(request),
|
||||||
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("-")
|
||||||
@@ -2636,7 +2649,9 @@ 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 = _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,
|
# "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.
|
||||||
@@ -2649,6 +2664,7 @@ 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,
|
||||||
@@ -2883,14 +2899,7 @@ class DocumentSelectionMixin:
|
|||||||
]
|
]
|
||||||
if not search_filters:
|
if not search_filters:
|
||||||
return None
|
return None
|
||||||
if len(search_filters) > 1:
|
_validate_tantivy_search_params(search_filters)
|
||||||
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
|
||||||
@@ -2909,11 +2918,14 @@ class DocumentSelectionMixin:
|
|||||||
user=search_user,
|
user=search_user,
|
||||||
)
|
)
|
||||||
else:
|
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(
|
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:
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from documents.data_models import DocumentMetadataOverrides
|
|||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import PermissionsSpec
|
||||||
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
from documents.templating.workflows import parse_w_workflow_placeholders
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.workflows.mutations")
|
logger = logging.getLogger("paperless.workflows.mutations")
|
||||||
@@ -73,7 +74,7 @@ def apply_assignment_to_document(
|
|||||||
action.has_assign_change_groups,
|
action.has_assign_change_groups,
|
||||||
],
|
],
|
||||||
):
|
):
|
||||||
permissions = {
|
permissions: PermissionsSpec = {
|
||||||
"view": {
|
"view": {
|
||||||
"users": action.assign_view_users.values_list("id", flat=True),
|
"users": action.assign_view_users.values_list("id", flat=True),
|
||||||
"groups": action.assign_view_groups.values_list("id", flat=True),
|
"groups": action.assign_view_groups.values_list("id", flat=True),
|
||||||
@@ -83,9 +84,10 @@ def apply_assignment_to_document(
|
|||||||
"groups": action.assign_change_groups.values_list("id", flat=True),
|
"groups": action.assign_change_groups.values_list("id", flat=True),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
set_permissions_for_object(
|
set_permissions_for_objects(
|
||||||
permissions=permissions,
|
permissions,
|
||||||
object=document,
|
Document,
|
||||||
|
[document.pk],
|
||||||
merge=True,
|
merge=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -243,13 +245,14 @@ def apply_removal_to_document(
|
|||||||
document.owner = None
|
document.owner = None
|
||||||
|
|
||||||
if action.remove_all_permissions:
|
if action.remove_all_permissions:
|
||||||
permissions = {
|
permissions: PermissionsSpec = {
|
||||||
"view": {"users": [], "groups": []},
|
"view": {"users": [], "groups": []},
|
||||||
"change": {"users": [], "groups": []},
|
"change": {"users": [], "groups": []},
|
||||||
}
|
}
|
||||||
set_permissions_for_object(
|
set_permissions_for_objects(
|
||||||
permissions=permissions,
|
permissions,
|
||||||
object=document,
|
Document,
|
||||||
|
[document.pk],
|
||||||
merge=False,
|
merge=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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-12 19:12+0000\n"
|
"POT-Creation-Date: 2026-10-14 15:18+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:344 documents/views.py:2752
|
#: documents/serialisers.py:2902 documents/views.py:363 documents/views.py:2768
|
||||||
#: 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:4799
|
#: documents/serialisers.py:2988 documents/views.py:4811
|
||||||
#, 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:337 documents/views.py:2749
|
#: documents/views.py:318
|
||||||
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
|
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
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
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4862
|
#: documents/views.py:4874
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4926
|
#: documents/views.py:4938
|
||||||
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:4940
|
#: documents/views.py:4952
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user