Enhancement: shared icon & shared by me filter (#4859)

This commit is contained in:
shamoon 2023-12-19 12:45:04 -08:00 committed by GitHub
parent 088bad9030
commit 5e8de4c1da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 394 additions and 126 deletions

View File

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

View File

@ -1657,7 +1657,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">68</context>
<context context-type="linenumber">78</context>
</context-group>
</trans-unit>
<trans-unit id="2941198503117307737" datatype="html">
@ -1721,7 +1721,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">83</context>
<context context-type="linenumber">89</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.html</context>
@ -3670,18 +3670,25 @@
<context context-type="linenumber">38</context>
</context-group>
</trans-unit>
<trans-unit id="175385209536581523" datatype="html">
<source>Shared by me</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">48</context>
</context-group>
</trans-unit>
<trans-unit id="5151074932731293042" datatype="html">
<source>Unowned</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">48</context>
<context context-type="linenumber">58</context>
</context-group>
</trans-unit>
<trans-unit id="8999708063434507268" datatype="html">
<source>Hide unowned</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">77</context>
<context context-type="linenumber">87</context>
</context-group>
</trans-unit>
<trans-unit id="8650499415827640724" datatype="html">
@ -4012,7 +4019,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">203</context>
<context context-type="linenumber">204</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
@ -4073,7 +4080,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">99</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="872092479747931526" datatype="html">
@ -5007,11 +5014,26 @@
<context context-type="linenumber">58,59</context>
</context-group>
</trans-unit>
<trans-unit id="5739581984228459958" datatype="html">
<source>Shared</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">119</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">84</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pipes/username.pipe.ts</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="2332107018974972998" datatype="html">
<source>Score:</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">116</context>
<context context-type="linenumber">122</context>
</context-group>
</trans-unit>
<trans-unit id="3661756380991326939" datatype="html">
@ -5138,7 +5160,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">208</context>
<context context-type="linenumber">209</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
@ -5254,14 +5276,14 @@
)?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">120,122</context>
<context context-type="linenumber">121,123</context>
</context-group>
</trans-unit>
<trans-unit id="8170755470576301659" datatype="html">
<source>Without correspondent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">124</context>
<context context-type="linenumber">125</context>
</context-group>
</trans-unit>
<trans-unit id="317796810569008208" datatype="html">
@ -5270,14 +5292,14 @@
)?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">130,132</context>
<context context-type="linenumber">131,133</context>
</context-group>
</trans-unit>
<trans-unit id="4362173610367509215" datatype="html">
<source>Without document type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">134</context>
<context context-type="linenumber">135</context>
</context-group>
</trans-unit>
<trans-unit id="232202047340644471" datatype="html">
@ -5286,14 +5308,14 @@
)?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">140,142</context>
<context context-type="linenumber">141,143</context>
</context-group>
</trans-unit>
<trans-unit id="1562820715074533164" datatype="html">
<source>Without storage path</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">144</context>
<context context-type="linenumber">145</context>
</context-group>
</trans-unit>
<trans-unit id="8180755793012580465" datatype="html">
@ -5301,112 +5323,112 @@
?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">148,149</context>
<context context-type="linenumber">149,150</context>
</context-group>
</trans-unit>
<trans-unit id="6494566478302448576" datatype="html">
<source>Without any tag</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">153</context>
<context context-type="linenumber">154</context>
</context-group>
</trans-unit>
<trans-unit id="6523384805359286307" datatype="html">
<source>Title: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">157</context>
<context context-type="linenumber">158</context>
</context-group>
</trans-unit>
<trans-unit id="1872523635812236432" datatype="html">
<source>ASN: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">160</context>
<context context-type="linenumber">161</context>
</context-group>
</trans-unit>
<trans-unit id="102674688969746976" datatype="html">
<source>Owner: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">163</context>
<context context-type="linenumber">164</context>
</context-group>
</trans-unit>
<trans-unit id="3550877650686009106" datatype="html">
<source>Owner not in: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">166</context>
<context context-type="linenumber">167</context>
</context-group>
</trans-unit>
<trans-unit id="1082034558646673343" datatype="html">
<source>Without an owner</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">169</context>
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="3100631071441658964" datatype="html">
<source>Title &amp; content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">206</context>
<context context-type="linenumber">207</context>
</context-group>
</trans-unit>
<trans-unit id="9149498548977462220" datatype="html">
<source>Custom fields</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">211</context>
<context context-type="linenumber">212</context>
</context-group>
</trans-unit>
<trans-unit id="1010505078885609376" datatype="html">
<source>Advanced search</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">215</context>
<context context-type="linenumber">216</context>
</context-group>
</trans-unit>
<trans-unit id="2649431021108393503" datatype="html">
<source>More like</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">221</context>
<context context-type="linenumber">222</context>
</context-group>
</trans-unit>
<trans-unit id="3697582909018473071" datatype="html">
<source>equals</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">240</context>
<context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="5325481293405718739" datatype="html">
<source>is empty</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">244</context>
<context context-type="linenumber">245</context>
</context-group>
</trans-unit>
<trans-unit id="6166785695326182482" datatype="html">
<source>is not empty</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">248</context>
<context context-type="linenumber">249</context>
</context-group>
</trans-unit>
<trans-unit id="4686622206659266699" datatype="html">
<source>greater than</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">252</context>
<context context-type="linenumber">253</context>
</context-group>
</trans-unit>
<trans-unit id="8014012170270529279" datatype="html">
<source>less than</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">256</context>
<context context-type="linenumber">257</context>
</context-group>
</trans-unit>
<trans-unit id="7210076240260527720" datatype="html">
@ -6297,13 +6319,6 @@
<context context-type="linenumber">11</context>
</context-group>
</trans-unit>
<trans-unit id="5739581984228459958" datatype="html">
<source>Shared</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pipes/username.pipe.ts</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="2807800733729323332" datatype="html">
<source>Yes</source>
<context-group purpose="location">

View File

@ -38,6 +38,16 @@
<small i18n>Shared with 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.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">
<div class="selected-icon me-1">
<svg *ngIf="selectionModel.ownerFilter === OwnerFilterType.UNOWNED" fill="currentColor" class="buttonicon-sm">

View File

@ -145,6 +145,15 @@ describe('PermissionsFilterDropdownComponent', () => {
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)
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],

View File

@ -32,6 +32,7 @@ export enum OwnerFilterType {
NOT_SELF = 2,
OTHERS = 3,
UNOWNED = 4,
SHARED_BY_ME = 5,
}
@Component({
@ -108,6 +109,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = []
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) {
this.selectionModel.userID = null
this.selectionModel.includeUsers = []

View File

@ -112,6 +112,12 @@
</svg>
<small>{{document.owner | username}}</small>
</div>
<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">
<use xlink:href="assets/bootstrap-icons.svg#people-fill"/>
</svg>
<small i18n>Shared</small>
</div>
<div *ngIf="document.__search_hit__?.score" class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
<small class="text-muted" i18n>Score:</small>
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>

View File

@ -77,6 +77,12 @@
</svg>
<small>{{document.owner | username}}</small>
</div>
<div *ngIf="document.is_shared_by_requester" class="ps-0 p-1">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#people-fill"/>
</svg>
<small i18n>Shared</small>
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="btn-group w-100">

View File

@ -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,

View File

@ -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
) {

View File

@ -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',

View File

@ -17,4 +17,6 @@ export interface ObjectWithPermissions extends ObjectWithId {
permissions?: PermissionsObject
user_can_change?: 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_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 = {

View File

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

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")),
(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(

View File

@ -8,6 +8,7 @@ from celery import states
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.validators import URLValidator
from django.utils.crypto import get_random_string
from django.utils.text import slugify
@ -15,6 +16,8 @@ from django.utils.translation import gettext as _
from drf_writable_nested.serializers import NestedUpdateMixin
from guardian.core import ObjectPermissionChecker
from guardian.shortcuts import get_users_with_perms
from guardian.utils import get_group_obj_perms_model
from guardian.utils import get_user_obj_perms_model
from rest_framework import fields
from rest_framework import serializers
from rest_framework.fields import SerializerMethodField
@ -160,6 +163,7 @@ class OwnedObjectSerializer(serializers.ModelSerializer, SetPermissionsMixin):
try:
if full_perms:
self.fields.pop("user_can_change")
self.fields.pop("is_shared_by_requester")
else:
self.fields.pop("permissions")
except KeyError:
@ -205,8 +209,26 @@ class OwnedObjectSerializer(serializers.ModelSerializer, SetPermissionsMixin):
)
)
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()
return obj.owner == self.user and (
UserObjectPermission.objects.filter(
content_type=ctype,
object_pk=obj.pk,
).count()
> 0
or GroupObjectPermission.objects.filter(
content_type=ctype,
object_pk=obj.pk,
).count()
> 0
)
permissions = SerializerMethodField(read_only=True)
user_can_change = SerializerMethodField(read_only=True)
is_shared_by_requester = SerializerMethodField(read_only=True)
set_permissions = serializers.DictField(
label="Set permissions",
@ -556,6 +578,7 @@ class DocumentSerializer(
"owner",
"permissions",
"user_can_change",
"is_shared_by_requester",
"set_permissions",
"notes",
"custom_fields",

View File

@ -594,7 +594,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
results = response.data["results"]
self.assertEqual(len(results), 0)
def test_document_owner_filters(self):
def test_document_permissions_filters(self):
"""
GIVEN:
- Documents with owners, with and without granted permissions
@ -686,6 +686,18 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
[u1_doc1.id, u1_doc2.id, u2_doc2.id],
)
assign_perm("view_document", u2, u1_doc1)
# Will show only documents shared by user
response = self.client.get(f"/api/documents/?shared_by__id={u1.id}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.data["results"]
self.assertEqual(len(results), 1)
self.assertCountEqual(
[results[0]["id"]],
[u1_doc1.id],
)
def test_pagination_all(self):
"""
GIVEN:

View File

@ -408,10 +408,17 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
checksum="3",
owner=user2,
)
doc4 = Document.objects.create(
title="Test4",
content="content 4",
checksum="4",
owner=user1,
)
assign_perm("view_document", user1, doc2)
assign_perm("view_document", user1, doc3)
assign_perm("change_document", user1, doc3)
assign_perm("view_document", user2, doc4)
self.client.force_authenticate(user1)
@ -426,9 +433,11 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
self.assertNotIn("permissions", resp_data["results"][0])
self.assertIn("user_can_change", resp_data["results"][0])
self.assertEqual(resp_data["results"][0]["user_can_change"], True) # doc1
self.assertEqual(resp_data["results"][1]["user_can_change"], False) # doc2
self.assertEqual(resp_data["results"][2]["user_can_change"], True) # doc3
self.assertTrue(resp_data["results"][0]["user_can_change"]) # doc1
self.assertFalse(resp_data["results"][0]["is_shared_by_requester"]) # doc1
self.assertFalse(resp_data["results"][1]["user_can_change"]) # doc2
self.assertTrue(resp_data["results"][2]["user_can_change"]) # doc3
self.assertTrue(resp_data["results"][3]["is_shared_by_requester"]) # doc4
response = self.client.get(
"/api/documents/?full_perms=true",
@ -441,6 +450,7 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
self.assertIn("permissions", resp_data["results"][0])
self.assertNotIn("user_can_change", resp_data["results"][0])
self.assertNotIn("is_shared_by_requester", resp_data["results"][0])
class TestApiUser(DirectoriesMixin, APITestCase):

View File

@ -968,7 +968,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
u1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
u2.user_permissions.add(*Permission.objects.filter(codename="view_document"))
Document.objects.create(checksum="1", content="test 1", owner=u1)
d1 = Document.objects.create(checksum="1", content="test 1", owner=u1)
d2 = Document.objects.create(checksum="2", content="test 2", owner=u2)
d3 = Document.objects.create(checksum="3", content="test 3", owner=u2)
Document.objects.create(checksum="4", content="test 4")
@ -993,9 +993,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
assign_perm("view_document", u1, d2)
assign_perm("view_document", u1, d3)
assign_perm("view_document", u2, d1)
with AsyncWriter(index.open_index()) as writer:
for doc in [d2, d3]:
for doc in [d1, d2, d3]:
index.update_document(writer, doc)
self.client.force_authenticate(user=u1)
@ -1011,6 +1012,8 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(r.data["count"], 1)
r = self.client.get("/api/documents/?query=test&owner__isnull=true")
self.assertEqual(r.data["count"], 1)
r = self.client.get(f"/api/documents/?query=test&shared_by__id={u1.id}")
self.assertEqual(r.data["count"], 1)
def test_search_sorting(self):
u1 = User.objects.create_user("user1")

View File

@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-05 08:26-0800\n"
"POT-Creation-Date: 2023-12-09 10:53-0800\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@ -21,7 +21,7 @@ msgstr ""
msgid "Documents"
msgstr ""
#: documents/models.py:36 documents/models.py:734
#: documents/models.py:36 documents/models.py:736
msgid "owner"
msgstr ""
@ -53,7 +53,7 @@ msgstr ""
msgid "Automatic"
msgstr ""
#: documents/models.py:62 documents/models.py:402 documents/models.py:895
#: documents/models.py:62 documents/models.py:402 documents/models.py:897
#: paperless_mail/models.py:18 paperless_mail/models.py:93
msgid "name"
msgstr ""
@ -132,7 +132,7 @@ msgstr ""
msgid "title"
msgstr ""
#: documents/models.py:171 documents/models.py:648
#: documents/models.py:171 documents/models.py:650
msgid "content"
msgstr ""
@ -162,8 +162,8 @@ msgstr ""
msgid "The checksum of the archived document."
msgstr ""
#: documents/models.py:205 documents/models.py:385 documents/models.py:654
#: documents/models.py:692 documents/models.py:762 documents/models.py:799
#: documents/models.py:205 documents/models.py:385 documents/models.py:656
#: documents/models.py:694 documents/models.py:764 documents/models.py:801
msgid "created"
msgstr ""
@ -211,7 +211,7 @@ msgstr ""
msgid "The position of this document in your physical document archive."
msgstr ""
#: documents/models.py:279 documents/models.py:665 documents/models.py:719
#: documents/models.py:279 documents/models.py:667 documents/models.py:721
msgid "document"
msgstr ""
@ -259,7 +259,7 @@ msgstr ""
msgid "logs"
msgstr ""
#: documents/models.py:399 documents/models.py:464
#: documents/models.py:399 documents/models.py:466
msgid "saved view"
msgstr ""
@ -427,298 +427,306 @@ msgstr ""
msgid "does not have owner in"
msgstr ""
#: documents/models.py:467
msgid "rule type"
#: documents/models.py:458
msgid "has custom field value"
msgstr ""
#: documents/models.py:459
msgid "is shared by me"
msgstr ""
#: documents/models.py:469
msgid "rule type"
msgstr ""
#: documents/models.py:471
msgid "value"
msgstr ""
#: documents/models.py:472
#: documents/models.py:474
msgid "filter rule"
msgstr ""
#: documents/models.py:473
#: documents/models.py:475
msgid "filter rules"
msgstr ""
#: documents/models.py:584
#: documents/models.py:586
msgid "Task ID"
msgstr ""
#: documents/models.py:585
#: documents/models.py:587
msgid "Celery ID for the Task that was run"
msgstr ""
#: documents/models.py:590
#: documents/models.py:592
msgid "Acknowledged"
msgstr ""
#: documents/models.py:591
#: documents/models.py:593
msgid "If the task is acknowledged via the frontend or API"
msgstr ""
#: documents/models.py:597
#: documents/models.py:599
msgid "Task Filename"
msgstr ""
#: documents/models.py:598
#: documents/models.py:600
msgid "Name of the file which the Task was run for"
msgstr ""
#: documents/models.py:604
#: documents/models.py:606
msgid "Task Name"
msgstr ""
#: documents/models.py:605
#: documents/models.py:607
msgid "Name of the Task which was run"
msgstr ""
#: documents/models.py:612
#: documents/models.py:614
msgid "Task State"
msgstr ""
#: documents/models.py:613
#: documents/models.py:615
msgid "Current state of the task being run"
msgstr ""
#: documents/models.py:618
#: documents/models.py:620
msgid "Created DateTime"
msgstr ""
#: documents/models.py:619
#: documents/models.py:621
msgid "Datetime field when the task result was created in UTC"
msgstr ""
#: documents/models.py:624
#: documents/models.py:626
msgid "Started DateTime"
msgstr ""
#: documents/models.py:625
#: documents/models.py:627
msgid "Datetime field when the task was started in UTC"
msgstr ""
#: documents/models.py:630
#: documents/models.py:632
msgid "Completed DateTime"
msgstr ""
#: documents/models.py:631
#: documents/models.py:633
msgid "Datetime field when the task was completed in UTC"
msgstr ""
#: documents/models.py:636
#: documents/models.py:638
msgid "Result Data"
msgstr ""
#: documents/models.py:638
#: documents/models.py:640
msgid "The data returned by the task"
msgstr ""
#: documents/models.py:650
#: documents/models.py:652
msgid "Note for the document"
msgstr ""
#: documents/models.py:674
#: documents/models.py:676
msgid "user"
msgstr ""
#: documents/models.py:679
#: documents/models.py:681
msgid "note"
msgstr ""
#: documents/models.py:680
#: documents/models.py:682
msgid "notes"
msgstr ""
#: documents/models.py:688
#: documents/models.py:690
msgid "Archive"
msgstr ""
#: documents/models.py:689
#: documents/models.py:691
msgid "Original"
msgstr ""
#: documents/models.py:700
#: documents/models.py:702
msgid "expiration"
msgstr ""
#: documents/models.py:707
#: documents/models.py:709
msgid "slug"
msgstr ""
#: documents/models.py:739
#: documents/models.py:741
msgid "share link"
msgstr ""
#: documents/models.py:740
#: documents/models.py:742
msgid "share links"
msgstr ""
#: documents/models.py:752
#: documents/models.py:754
msgid "String"
msgstr ""
#: documents/models.py:753
#: documents/models.py:755
msgid "URL"
msgstr ""
#: documents/models.py:754
#: documents/models.py:756
msgid "Date"
msgstr ""
#: documents/models.py:755
#: documents/models.py:757
msgid "Boolean"
msgstr ""
#: documents/models.py:756
#: documents/models.py:758
msgid "Integer"
msgstr ""
#: documents/models.py:757
#: documents/models.py:759
msgid "Float"
msgstr ""
#: documents/models.py:758
#: documents/models.py:760
msgid "Monetary"
msgstr ""
#: documents/models.py:759
#: documents/models.py:761
msgid "Document Link"
msgstr ""
#: documents/models.py:771
#: documents/models.py:773
msgid "data type"
msgstr ""
#: documents/models.py:779
#: documents/models.py:781
msgid "custom field"
msgstr ""
#: documents/models.py:780
#: documents/models.py:782
msgid "custom fields"
msgstr ""
#: documents/models.py:842
#: documents/models.py:844
msgid "custom field instance"
msgstr ""
#: documents/models.py:843
#: documents/models.py:845
msgid "custom field instances"
msgstr ""
#: documents/models.py:891
#: documents/models.py:893
msgid "Consume Folder"
msgstr ""
#: documents/models.py:892
#: documents/models.py:894
msgid "Api Upload"
msgstr ""
#: documents/models.py:893
#: documents/models.py:895
msgid "Mail Fetch"
msgstr ""
#: documents/models.py:897 paperless_mail/models.py:95
#: documents/models.py:899 paperless_mail/models.py:95
msgid "order"
msgstr ""
#: documents/models.py:906
#: documents/models.py:908
msgid "filter path"
msgstr ""
#: documents/models.py:911
#: documents/models.py:913
msgid ""
"Only consume documents with a path that matches this if specified. Wildcards "
"specified as * are allowed. Case insensitive."
msgstr ""
#: documents/models.py:918
#: documents/models.py:920
msgid "filter filename"
msgstr ""
#: documents/models.py:923 paperless_mail/models.py:148
#: documents/models.py:925 paperless_mail/models.py:148
msgid ""
"Only consume documents which entirely match this filename if specified. "
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
msgstr ""
#: documents/models.py:934
#: documents/models.py:936
msgid "filter documents from this mail rule"
msgstr ""
#: documents/models.py:938
#: documents/models.py:940
msgid "assign title"
msgstr ""
#: documents/models.py:943
#: documents/models.py:945
msgid ""
"Assign a document title, can include some placeholders, see documentation."
msgstr ""
#: documents/models.py:951 paperless_mail/models.py:216
#: documents/models.py:953 paperless_mail/models.py:216
msgid "assign this tag"
msgstr ""
#: documents/models.py:959 paperless_mail/models.py:224
#: documents/models.py:961 paperless_mail/models.py:224
msgid "assign this document type"
msgstr ""
#: documents/models.py:967 paperless_mail/models.py:238
#: documents/models.py:969 paperless_mail/models.py:238
msgid "assign this correspondent"
msgstr ""
#: documents/models.py:975
#: documents/models.py:977
msgid "assign this storage path"
msgstr ""
#: documents/models.py:984
#: documents/models.py:986
msgid "assign this owner"
msgstr ""
#: documents/models.py:991
#: documents/models.py:993
msgid "grant view permissions to these users"
msgstr ""
#: documents/models.py:998
#: documents/models.py:1000
msgid "grant view permissions to these groups"
msgstr ""
#: documents/models.py:1005
#: documents/models.py:1007
msgid "grant change permissions to these users"
msgstr ""
#: documents/models.py:1012
#: documents/models.py:1014
msgid "grant change permissions to these groups"
msgstr ""
#: documents/models.py:1019
#: documents/models.py:1021
msgid "assign these custom fields"
msgstr ""
#: documents/models.py:1023
#: documents/models.py:1025
msgid "consumption template"
msgstr ""
#: documents/models.py:1024
#: documents/models.py:1026
msgid "consumption templates"
msgstr ""
#: documents/serialisers.py:102
#: documents/serialisers.py:105
#, python-format
msgid "Invalid regular expression: %(error)s"
msgstr ""
#: documents/serialisers.py:377
#: documents/serialisers.py:399
msgid "Invalid color."
msgstr ""
#: documents/serialisers.py:842
#: documents/serialisers.py:865
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:939
#: documents/serialisers.py:962
msgid "Invalid variable detected."
msgstr ""