+
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
index 30bee4d92..ac26bc393 100644
--- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
@@ -47,6 +47,7 @@ import {
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_CUSTOM_FIELDS,
+ FILTER_SHARED_BY_USER,
} from 'src/app/data/filter-rule-type'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
@@ -826,6 +827,16 @@ describe('FilterEditorComponent', () => {
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
}))
+ it('should ingest filter rules for shared by me', fakeAsync(() => {
+ component.filterRules = [
+ {
+ rule_type: FILTER_SHARED_BY_USER,
+ value: '2',
+ },
+ ]
+ expect(component.permissionsSelectionModel.userID).toEqual(2)
+ }))
+
// GET filterRules
it('should convert user input to correct filter rules on text field search title + content', fakeAsync(() => {
@@ -1453,13 +1464,28 @@ describe('FilterEditorComponent', () => {
])
}))
- it('should convert user input to correct filter on permissions select unowned', fakeAsync(() => {
+ it('should convert user input to correct filter on permissions select shared by me', fakeAsync(() => {
const permissionsDropdown = fixture.debugElement.query(
By.directive(PermissionsFilterDropdownComponent)
)
const unownedButton = permissionsDropdown.queryAll(By.css('button'))[4]
unownedButton.triggerEventHandler('click')
fixture.detectChanges()
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_SHARED_BY_USER,
+ value: '1',
+ },
+ ])
+ }))
+
+ it('should convert user input to correct filter on permissions select unowned', fakeAsync(() => {
+ const permissionsDropdown = fixture.debugElement.query(
+ By.directive(PermissionsFilterDropdownComponent)
+ )
+ const unownedButton = permissionsDropdown.queryAll(By.css('button'))[5]
+ unownedButton.triggerEventHandler('click')
+ fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_OWNER_ISNULL,
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
index 030f4ec07..f53921fff 100644
--- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
@@ -49,6 +49,7 @@ import {
FILTER_OWNER_ISNULL,
FILTER_OWNER_ANY,
FILTER_CUSTOM_FIELDS,
+ FILTER_SHARED_BY_USER,
} from 'src/app/data/filter-rule-type'
import {
FilterableDropdownSelectionModel,
@@ -503,6 +504,12 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
parseInt(rule.value, 10)
)
break
+ case FILTER_SHARED_BY_USER:
+ this.permissionsSelectionModel.ownerFilter =
+ OwnerFilterType.SHARED_BY_ME
+ if (rule.value)
+ this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
+ break
case FILTER_OWNER_ISNULL:
if (rule.value === 'true' || rule.value === '1') {
this.permissionsSelectionModel.hideUnowned = false
@@ -801,6 +808,13 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
rule_type: FILTER_OWNER_ANY,
value: this.permissionsSelectionModel.includeUsers?.join(','),
})
+ } else if (
+ this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SHARED_BY_ME
+ ) {
+ filterRules.push({
+ rule_type: FILTER_SHARED_BY_USER,
+ value: this.permissionsSelectionModel.userID.toString(),
+ })
} else if (
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED
) {
diff --git a/src-ui/src/app/data/filter-rule-type.ts b/src-ui/src/app/data/filter-rule-type.ts
index a6c73fe29..ee09f165d 100644
--- a/src-ui/src/app/data/filter-rule-type.ts
+++ b/src-ui/src/app/data/filter-rule-type.ts
@@ -45,6 +45,7 @@ export const FILTER_OWNER = 32
export const FILTER_OWNER_ANY = 33
export const FILTER_OWNER_ISNULL = 34
export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
+export const FILTER_SHARED_BY_USER = 37
export const FILTER_CUSTOM_FIELDS = 36
@@ -273,6 +274,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'number',
multi: true,
},
+ {
+ id: FILTER_SHARED_BY_USER,
+ filtervar: 'shared_by__id',
+ datatype: 'number',
+ multi: true,
+ },
{
id: FILTER_CUSTOM_FIELDS,
filtervar: 'custom_fields__icontains',
diff --git a/src-ui/src/app/data/object-with-permissions.ts b/src-ui/src/app/data/object-with-permissions.ts
index 55f2a2cef..dbaaa192e 100644
--- a/src-ui/src/app/data/object-with-permissions.ts
+++ b/src-ui/src/app/data/object-with-permissions.ts
@@ -18,5 +18,5 @@ export interface ObjectWithPermissions extends ObjectWithId {
user_can_change?: boolean
- is_shared?: boolean
+ is_shared_by_requester?: boolean
}
diff --git a/src/documents/filters.py b/src/documents/filters.py
index c6abff4de..0f49c7c27 100644
--- a/src/documents/filters.py
+++ b/src/documents/filters.py
@@ -1,7 +1,12 @@
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import Count
+from django.db.models import OuterRef
from django.db.models import Q
from django_filters.rest_framework import BooleanFilter
from django_filters.rest_framework import Filter
from django_filters.rest_framework import FilterSet
+from guardian.utils import get_group_obj_perms_model
+from guardian.utils import get_user_obj_perms_model
from rest_framework_guardian.filters import ObjectPermissionsFilter
from documents.models import Correspondent
@@ -101,6 +106,39 @@ class TitleContentFilter(Filter):
return qs
+class SharedByUser(Filter):
+ def filter(self, qs, value):
+ ctype = ContentType.objects.get_for_model(self.model)
+ UserObjectPermission = get_user_obj_perms_model()
+ GroupObjectPermission = get_group_obj_perms_model()
+ return (
+ qs.filter(
+ owner_id=value,
+ )
+ .annotate(
+ num_shared_users=Count(
+ UserObjectPermission.objects.filter(
+ content_type=ctype,
+ object_pk=OuterRef("pk"),
+ ).values("user_id"),
+ ),
+ )
+ .annotate(
+ num_shared_groups=Count(
+ GroupObjectPermission.objects.filter(
+ content_type=ctype,
+ object_pk=OuterRef("pk"),
+ ).values("group_id"),
+ ),
+ )
+ .filter(
+ Q(num_shared_users__gt=0) | Q(num_shared_groups__gt=0),
+ )
+ if value is not None
+ else qs
+ )
+
+
class CustomFieldsFilter(Filter):
def filter(self, qs, value):
if value:
@@ -144,6 +182,8 @@ class DocumentFilterSet(FilterSet):
custom_fields__icontains = CustomFieldsFilter()
+ shared_by__id = SharedByUser()
+
class Meta:
model = Document
fields = {
diff --git a/src/documents/index.py b/src/documents/index.py
index ebfe40e18..e4c9bcb34 100644
--- a/src/documents/index.py
+++ b/src/documents/index.py
@@ -75,6 +75,7 @@ def get_schema():
viewer_id=KEYWORD(commas=True),
checksum=TEXT(),
original_filename=TEXT(sortable=True),
+ is_shared=BOOLEAN(),
)
@@ -167,6 +168,7 @@ def update_document(writer: AsyncWriter, doc: Document):
viewer_id=viewer_ids if viewer_ids else None,
checksum=doc.checksum,
original_filename=doc.original_filename,
+ is_shared=len(viewer_ids) > 0,
)
@@ -194,6 +196,7 @@ class DelayedQuery:
"document_type": ("type", ["id", "id__in", "id__none", "isnull"]),
"storage_path": ("path", ["id", "id__in", "id__none", "isnull"]),
"owner": ("owner", ["id", "id__in", "id__none", "isnull"]),
+ "shared_by": ("shared_by", ["id"]),
"tags": ("tag", ["id__all", "id__in", "id__none"]),
"added": ("added", ["date__lt", "date__gt"]),
"created": ("created", ["date__lt", "date__gt"]),
@@ -233,7 +236,11 @@ class DelayedQuery:
continue
if query_filter == "id":
- criterias.append(query.Term(f"{field}_id", value))
+ if param == "shared_by":
+ criterias.append(query.Term("is_shared", True))
+ criterias.append(query.Term("owner_id", value))
+ else:
+ criterias.append(query.Term(f"{field}_id", value))
elif query_filter == "id__in":
in_filter = []
for object_id in value.split(","):
diff --git a/src/documents/migrations/1043_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1043_alter_savedviewfilterrule_rule_type.py
new file mode 100644
index 000000000..bd62673df
--- /dev/null
+++ b/src/documents/migrations/1043_alter_savedviewfilterrule_rule_type.py
@@ -0,0 +1,60 @@
+# Generated by Django 4.2.7 on 2023-12-09 18:13
+
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documents", "1042_consumptiontemplate_assign_custom_fields_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="savedviewfilterrule",
+ name="rule_type",
+ field=models.PositiveIntegerField(
+ choices=[
+ (0, "title contains"),
+ (1, "content contains"),
+ (2, "ASN is"),
+ (3, "correspondent is"),
+ (4, "document type is"),
+ (5, "is in inbox"),
+ (6, "has tag"),
+ (7, "has any tag"),
+ (8, "created before"),
+ (9, "created after"),
+ (10, "created year is"),
+ (11, "created month is"),
+ (12, "created day is"),
+ (13, "added before"),
+ (14, "added after"),
+ (15, "modified before"),
+ (16, "modified after"),
+ (17, "does not have tag"),
+ (18, "does not have ASN"),
+ (19, "title or content contains"),
+ (20, "fulltext query"),
+ (21, "more like this"),
+ (22, "has tags in"),
+ (23, "ASN greater than"),
+ (24, "ASN less than"),
+ (25, "storage path is"),
+ (26, "has correspondent in"),
+ (27, "does not have correspondent in"),
+ (28, "has document type in"),
+ (29, "does not have document type in"),
+ (30, "has storage path in"),
+ (31, "does not have storage path in"),
+ (32, "owner is"),
+ (33, "has owner in"),
+ (34, "does not have owner"),
+ (35, "does not have owner in"),
+ (36, "has custom field value"),
+ (37, "is shared by me"),
+ ],
+ verbose_name="rule type",
+ ),
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 250a9d35b..d95bf46e1 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -455,6 +455,8 @@ class SavedViewFilterRule(models.Model):
(33, _("has owner in")),
(34, _("does not have owner")),
(35, _("does not have owner in")),
+ (36, _("has custom field value")),
+ (37, _("is shared by me")),
]
saved_view = models.ForeignKey(
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 4b6abfeca..39b811e14 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -163,7 +163,7 @@ class OwnedObjectSerializer(serializers.ModelSerializer, SetPermissionsMixin):
try:
if full_perms:
self.fields.pop("user_can_change")
- self.fields.pop("is_shared")
+ self.fields.pop("is_shared_by_requester")
else:
self.fields.pop("permissions")
except KeyError:
@@ -209,7 +209,7 @@ class OwnedObjectSerializer(serializers.ModelSerializer, SetPermissionsMixin):
)
)
- def get_is_shared(self, obj: Document):
+ def get_is_shared_by_requester(self, obj: Document):
ctype = ContentType.objects.get_for_model(obj)
UserObjectPermission = get_user_obj_perms_model()
GroupObjectPermission = get_group_obj_perms_model()
@@ -228,7 +228,7 @@ class OwnedObjectSerializer(serializers.ModelSerializer, SetPermissionsMixin):
permissions = SerializerMethodField(read_only=True)
user_can_change = SerializerMethodField(read_only=True)
- is_shared = SerializerMethodField(read_only=True)
+ is_shared_by_requester = SerializerMethodField(read_only=True)
set_permissions = serializers.DictField(
label="Set permissions",
@@ -578,7 +578,7 @@ class DocumentSerializer(
"owner",
"permissions",
"user_can_change",
- "is_shared",
+ "is_shared_by_requester",
"set_permissions",
"notes",
"custom_fields",