Support shared by me filter rule, rename is_shared property, add missing db migration

This commit is contained in:
shamoon 2023-12-09 10:52:06 -08:00
parent 491f0b9c87
commit 93806597b7
15 changed files with 193 additions and 10 deletions

View File

@ -80,7 +80,7 @@ django_checks() {
search_index() { search_index() {
local -r index_version=7 local -r index_version=8
local -r index_version_file=${DATA_DIR}/.index_version local -r index_version_file=${DATA_DIR}/.index_version
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then

View File

@ -38,6 +38,16 @@
<small i18n>Shared with me</small> <small i18n>Shared with me</small>
</div> </div>
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
<div class="selected-icon me-1">
<svg *ngIf="selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME" fill="currentColor" class="buttonicon-sm">
<use xlink:href="assets/bootstrap-icons.svg#check"/>
</svg>
</div>
<div class="me-1">
<small i18n>Shared by me</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
<svg *ngIf="selectionModel.ownerFilter === OwnerFilterType.UNOWNED" fill="currentColor" class="buttonicon-sm"> <svg *ngIf="selectionModel.ownerFilter === OwnerFilterType.UNOWNED" fill="currentColor" class="buttonicon-sm">

View File

@ -145,6 +145,15 @@ describe('PermissionsFilterDropdownComponent', () => {
userID: null, userID: null,
}) })
component.setFilter(OwnerFilterType.SHARED_BY_ME)
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
ownerFilter: OwnerFilterType.SHARED_BY_ME,
userID: currentUserID,
})
component.setFilter(OwnerFilterType.UNOWNED) component.setFilter(OwnerFilterType.UNOWNED)
expect(ownerFilterSetResult).toEqual({ expect(ownerFilterSetResult).toEqual({
excludeUsers: [], excludeUsers: [],

View File

@ -32,6 +32,7 @@ export enum OwnerFilterType {
NOT_SELF = 2, NOT_SELF = 2,
OTHERS = 3, OTHERS = 3,
UNOWNED = 4, UNOWNED = 4,
SHARED_BY_ME = 5,
} }
@Component({ @Component({
@ -108,6 +109,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = [] this.selectionModel.excludeUsers = []
this.selectionModel.hideUnowned = false this.selectionModel.hideUnowned = false
} else if (
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
) {
this.selectionModel.userID = this.settingsService.currentUser.id
this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = []
this.selectionModel.hideUnowned = false
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) { } else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
this.selectionModel.userID = null this.selectionModel.userID = null
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers = []

View File

@ -112,7 +112,7 @@
</svg> </svg>
<small>{{document.owner | username}}</small> <small>{{document.owner | username}}</small>
</div> </div>
<div *ngIf="document.is_shared" class="list-group-item bg-light text-dark p-1 border-0"> <div *ngIf="document.is_shared_by_requester" class="list-group-item bg-light text-dark p-1 border-0">
<svg class="metadata-icon me-2 text-muted" fill="currentColor"> <svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#people-fill"/> <use xlink:href="assets/bootstrap-icons.svg#people-fill"/>
</svg> </svg>

View File

@ -77,7 +77,7 @@
</svg> </svg>
<small>{{document.owner | username}}</small> <small>{{document.owner | username}}</small>
</div> </div>
<div *ngIf="document.is_shared" class="ps-0 p-1"> <div *ngIf="document.is_shared_by_requester" class="ps-0 p-1">
<svg class="metadata-icon me-2 text-muted" fill="currentColor"> <svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#people-fill"/> <use xlink:href="assets/bootstrap-icons.svg#people-fill"/>
</svg> </svg>

View File

@ -47,6 +47,7 @@ import {
FILTER_OWNER_DOES_NOT_INCLUDE, FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL, FILTER_OWNER_ISNULL,
FILTER_CUSTOM_FIELDS, FILTER_CUSTOM_FIELDS,
FILTER_SHARED_BY_USER,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
@ -826,6 +827,16 @@ describe('FilterEditorComponent', () => {
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy() 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 // GET filterRules
it('should convert user input to correct filter rules on text field search title + content', fakeAsync(() => { 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( const permissionsDropdown = fixture.debugElement.query(
By.directive(PermissionsFilterDropdownComponent) By.directive(PermissionsFilterDropdownComponent)
) )
const unownedButton = permissionsDropdown.queryAll(By.css('button'))[4] const unownedButton = permissionsDropdown.queryAll(By.css('button'))[4]
unownedButton.triggerEventHandler('click') unownedButton.triggerEventHandler('click')
fixture.detectChanges() 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([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_OWNER_ISNULL, rule_type: FILTER_OWNER_ISNULL,

View File

@ -49,6 +49,7 @@ import {
FILTER_OWNER_ISNULL, FILTER_OWNER_ISNULL,
FILTER_OWNER_ANY, FILTER_OWNER_ANY,
FILTER_CUSTOM_FIELDS, FILTER_CUSTOM_FIELDS,
FILTER_SHARED_BY_USER,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { import {
FilterableDropdownSelectionModel, FilterableDropdownSelectionModel,
@ -503,6 +504,12 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
parseInt(rule.value, 10) parseInt(rule.value, 10)
) )
break 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: case FILTER_OWNER_ISNULL:
if (rule.value === 'true' || rule.value === '1') { if (rule.value === 'true' || rule.value === '1') {
this.permissionsSelectionModel.hideUnowned = false this.permissionsSelectionModel.hideUnowned = false
@ -801,6 +808,13 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
rule_type: FILTER_OWNER_ANY, rule_type: FILTER_OWNER_ANY,
value: this.permissionsSelectionModel.includeUsers?.join(','), 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 ( } else if (
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED
) { ) {

View File

@ -45,6 +45,7 @@ export const FILTER_OWNER = 32
export const FILTER_OWNER_ANY = 33 export const FILTER_OWNER_ANY = 33
export const FILTER_OWNER_ISNULL = 34 export const FILTER_OWNER_ISNULL = 34
export const FILTER_OWNER_DOES_NOT_INCLUDE = 35 export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
export const FILTER_SHARED_BY_USER = 37
export const FILTER_CUSTOM_FIELDS = 36 export const FILTER_CUSTOM_FIELDS = 36
@ -273,6 +274,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'number', datatype: 'number',
multi: true, multi: true,
}, },
{
id: FILTER_SHARED_BY_USER,
filtervar: 'shared_by__id',
datatype: 'number',
multi: true,
},
{ {
id: FILTER_CUSTOM_FIELDS, id: FILTER_CUSTOM_FIELDS,
filtervar: 'custom_fields__icontains', filtervar: 'custom_fields__icontains',

View File

@ -18,5 +18,5 @@ export interface ObjectWithPermissions extends ObjectWithId {
user_can_change?: boolean user_can_change?: boolean
is_shared?: boolean is_shared_by_requester?: boolean
} }

View File

@ -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.db.models import Q
from django_filters.rest_framework import BooleanFilter from django_filters.rest_framework import BooleanFilter
from django_filters.rest_framework import Filter from django_filters.rest_framework import Filter
from django_filters.rest_framework import FilterSet 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 rest_framework_guardian.filters import ObjectPermissionsFilter
from documents.models import Correspondent from documents.models import Correspondent
@ -101,6 +106,39 @@ class TitleContentFilter(Filter):
return qs 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): class CustomFieldsFilter(Filter):
def filter(self, qs, value): def filter(self, qs, value):
if value: if value:
@ -144,6 +182,8 @@ class DocumentFilterSet(FilterSet):
custom_fields__icontains = CustomFieldsFilter() custom_fields__icontains = CustomFieldsFilter()
shared_by__id = SharedByUser()
class Meta: class Meta:
model = Document model = Document
fields = { fields = {

View File

@ -75,6 +75,7 @@ def get_schema():
viewer_id=KEYWORD(commas=True), viewer_id=KEYWORD(commas=True),
checksum=TEXT(), checksum=TEXT(),
original_filename=TEXT(sortable=True), 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, viewer_id=viewer_ids if viewer_ids else None,
checksum=doc.checksum, checksum=doc.checksum,
original_filename=doc.original_filename, 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"]), "document_type": ("type", ["id", "id__in", "id__none", "isnull"]),
"storage_path": ("path", ["id", "id__in", "id__none", "isnull"]), "storage_path": ("path", ["id", "id__in", "id__none", "isnull"]),
"owner": ("owner", ["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"]), "tags": ("tag", ["id__all", "id__in", "id__none"]),
"added": ("added", ["date__lt", "date__gt"]), "added": ("added", ["date__lt", "date__gt"]),
"created": ("created", ["date__lt", "date__gt"]), "created": ("created", ["date__lt", "date__gt"]),
@ -233,7 +236,11 @@ class DelayedQuery:
continue continue
if query_filter == "id": 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": elif query_filter == "id__in":
in_filter = [] in_filter = []
for object_id in value.split(","): for object_id in value.split(","):

View File

@ -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",
),
),
]

View File

@ -455,6 +455,8 @@ class SavedViewFilterRule(models.Model):
(33, _("has owner in")), (33, _("has owner in")),
(34, _("does not have owner")), (34, _("does not have owner")),
(35, _("does not have owner in")), (35, _("does not have owner in")),
(36, _("has custom field value")),
(37, _("is shared by me")),
] ]
saved_view = models.ForeignKey( saved_view = models.ForeignKey(

View File

@ -163,7 +163,7 @@ class OwnedObjectSerializer(serializers.ModelSerializer, SetPermissionsMixin):
try: try:
if full_perms: if full_perms:
self.fields.pop("user_can_change") self.fields.pop("user_can_change")
self.fields.pop("is_shared") self.fields.pop("is_shared_by_requester")
else: else:
self.fields.pop("permissions") self.fields.pop("permissions")
except KeyError: 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) ctype = ContentType.objects.get_for_model(obj)
UserObjectPermission = get_user_obj_perms_model() UserObjectPermission = get_user_obj_perms_model()
GroupObjectPermission = get_group_obj_perms_model() GroupObjectPermission = get_group_obj_perms_model()
@ -228,7 +228,7 @@ class OwnedObjectSerializer(serializers.ModelSerializer, SetPermissionsMixin):
permissions = SerializerMethodField(read_only=True) permissions = SerializerMethodField(read_only=True)
user_can_change = 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( set_permissions = serializers.DictField(
label="Set permissions", label="Set permissions",
@ -578,7 +578,7 @@ class DocumentSerializer(
"owner", "owner",
"permissions", "permissions",
"user_can_change", "user_can_change",
"is_shared", "is_shared_by_requester",
"set_permissions", "set_permissions",
"notes", "notes",
"custom_fields", "custom_fields",