Merge branch 'dev' into feat/read-only-container

This commit is contained in:
Trenton H 2024-02-01 12:33:48 -08:00 committed by GitHub
commit 3b8205fe9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1026 additions and 225 deletions

View File

@ -184,7 +184,7 @@ jobs:
cache-dependency-path: 'src-ui/package-lock.json'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.npm
@ -221,7 +221,7 @@ jobs:
cache-dependency-path: 'src-ui/package-lock.json'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.npm
@ -283,7 +283,7 @@ jobs:
merge-multiple: true
-
name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
# not required for public repos, but intermittently fails otherwise
token: ${{ secrets.CODECOV_TOKEN }}
@ -299,7 +299,7 @@ jobs:
path: src/
-
name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
# not required for public repos, but intermittently fails otherwise
token: ${{ secrets.CODECOV_TOKEN }}

View File

@ -139,7 +139,7 @@ document. Paperless only reports PDF metadata at this point.
## Authorization
The REST api provides three different forms of authentication.
The REST api provides four different forms of authentication.
1. Basic authentication
@ -177,6 +177,12 @@ The REST api provides three different forms of authentication.
Tokens can also be managed in the Django admin.
4. Remote User authentication
If enabled (see
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
you can authenticate against the API using Remote User auth.
## Searching for documents
Full text searching is available on the `/api/documents/` endpoint. Two
@ -185,7 +191,7 @@ results:
- `/api/documents/?query=your%20search%20query`: Search for a document
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
- `/api/documents/?more_like=1234`: Search for documents similar to
- `/api/documents/?more_like_id=1234`: Search for documents similar to
the document with id 1234.
Pagination works exactly the same as it does for normal requests on this
@ -324,6 +330,64 @@ granted). You can pass the parameter `full_perms=true` to API calls to view the
full permissions of objects in a format that mirrors the `set_permissions`
parameter above.
## Bulk Editing
The API supports various bulk-editing operations which are executed asynchronously.
### Documents
For bulk operations on documents, use the endpoint `/api/bulk_edit/` which accepts
a json payload of the format:
```json
{
"documents": [LIST_OF_DOCUMENT_IDS],
"method": METHOD, // see below
"parameters": args // see below
}
```
The following methods are supported:
- `set_correspondent`
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
- `set_document_type`
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
- `set_storage_path`
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
- `add_tag`
- Requires `parameters`: `{ "tag": TAG_ID }`
- `remove_tag`
- Requires `parameters`: `{ "tag": TAG_ID }`
- `modify_tags`
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
- `delete`
- No `parameters` required
- `redo_ocr`
- No `parameters` required
- `set_permissions`
- Requires `parameters`:
- `"permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
- `"owner": OWNER_ID or null`
- `"merge": true or false` (defaults to false)
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
removing them) or be merged with existing permissions.
### Objects
Bulk editing for objects (tags, document types etc.) currently supports only updating permissions, using
the endpoint: `/api/bulk_edit_object_perms/` which requires a json payload of the format:
```json
{
"objects": [LIST_OF_OBJECT_IDS],
"object_type": "tags", "correspondents", "document_types" or "storage_paths"
"owner": OWNER_ID // optional
"permissions": { "view": { "users": [] ... }, "change": { ... } }, // (see 'set_permissions' format above)
"merge": true / false // defaults to false, see above
}
```
## API Versioning
The REST API is versioned since Paperless-ngx 1.3.0.
@ -380,3 +444,13 @@ Initial API version.
color to use for a specific tag, which is either black or white
depending on the brightness of `Tag.color`.
- Removed field `Tag.colour`.
#### Version 3
- Permissions endpoints have been added.
- The format of the `/api/ui_settings/` has changed.
#### Version 4
- Consumption templates were refactored to workflows and API endpoints
changed as such.

View File

@ -462,9 +462,21 @@ applications.
Defaults to "false" which disables this feature.
#### [`PAPERLESS_ENABLE_HTTP_REMOTE_USER_API=<bool>`](#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API) {#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API}
: Allows authentication via HTTP_REMOTE_USER directly against the API
!!! warning
See the warning above about securing your installation when using remote user header authentication. This setting is separate from
`PAPERLESS_ENABLE_HTTP_REMOTE_USER` to avoid introducing a security vulnerability to existing reverse proxy setups. As above,
ensure that your reverse proxy does not simply pass the `Remote-User` header from the internet to paperless.
Defaults to "false" which disables this feature.
#### [`PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=<str>`](#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) {#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME}
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" is enabled, this
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" or `PAPERLESS_ENABLE_HTTP_REMOTE_USER_API` are enabled, this
property allows to customize the name of the HTTP header from which
the authenticated username is extracted. Values are in terms of
[HttpRequest.META](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.META).

View File

@ -620,7 +620,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
<context context-type="linenumber">20</context>
<context context-type="linenumber">23</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
@ -2438,7 +2438,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
<context context-type="linenumber">23</context>
<context context-type="linenumber">26</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@ -2505,7 +2505,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
<context context-type="linenumber">22</context>
<context context-type="linenumber">25</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
@ -3923,6 +3923,13 @@
<context context-type="linenumber">15</context>
</context-group>
</trans-unit>
<trans-unit id="7940755769131903278" datatype="html">
<source>Merge with existing permissions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
<context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="7062872617520618723" datatype="html">
<source>Set permissions</source>
<context-group purpose="location">
@ -3937,11 +3944,18 @@
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="8283439432608484491" datatype="html">
<source>Note that permissions set here will override any existing permissions</source>
<trans-unit id="347498040201588614" datatype="html">
<source>Existing owner, user and group permissions will be merged with these settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context>
<context context-type="linenumber">71</context>
<context context-type="linenumber">74</context>
</context-group>
</trans-unit>
<trans-unit id="3434726483516379481" datatype="html">
<source>Any and all existing owner, user and group permissions will be replaced.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context>
<context context-type="linenumber">75</context>
</context-group>
</trans-unit>
<trans-unit id="5947558132119506443" datatype="html">
@ -6100,18 +6114,18 @@
<source>Permissions updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">211</context>
<context context-type="linenumber">212</context>
</context-group>
</trans-unit>
<trans-unit id="4639647950943944112" datatype="html">
<source>Error updating permissions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">215</context>
<context context-type="linenumber">217</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">300</context>
<context context-type="linenumber">301</context>
</context-group>
</trans-unit>
<trans-unit id="4010735610815226758" datatype="html">
@ -6277,7 +6291,7 @@
<source>Permissions updated successfully</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">293</context>
<context context-type="linenumber">294</context>
</context-group>
</trans-unit>
<trans-unit id="5101757640976222639" datatype="html">

View File

@ -15,7 +15,7 @@
}
</div>
}
<div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
<div class="form-check form-switch">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
@if (horizontal) {

View File

@ -5,12 +5,15 @@
</div>
<div class="modal-body">
@if (!object && message) {
<p class="mb-3" [innerHTML]="message | safeHtml"></p>
}
<form [formGroup]="form">
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
<div class="form-group">
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
</div>
<div class="form-group mt-4">
<div class="offset-lg-3 row">
<pngx-input-switch i18n-title title="Merge with existing permissions" [horizontal]="true" [hint]="hint" formControlName="merge"></pngx-input-switch>
</div>
</div>
</form>
</div>
@ -20,5 +23,5 @@
<span class="visually-hidden" i18n>Loading...</span>
}
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="confirmClicked.emit(permissions)" [disabled]="!buttonsEnabled" i18n>Confirm</button>
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled" i18n>Confirm</button>
</div>

View File

@ -11,6 +11,7 @@ import { NgSelectModule } from '@ng-select/ng-select'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
import { SwitchComponent } from '../input/switch/switch.component'
const set_permissions = {
owner: 10,
@ -37,6 +38,7 @@ describe('PermissionsDialogComponent', () => {
PermissionsDialogComponent,
SafeHtmlPipe,
SelectComponent,
SwitchComponent,
PermissionsFormComponent,
PermissionsUserComponent,
PermissionsGroupComponent,
@ -112,4 +114,23 @@ describe('PermissionsDialogComponent', () => {
expect(component.title).toEqual(`Edit permissions for ${obj.name}`)
expect(component.permissions).toEqual(set_permissions)
})
it('should toggle hint based on object existence (if editing) or merge flag', () => {
component.form.get('merge').setValue(true)
expect(component.hint.includes('Existing')).toBeTruthy()
component.form.get('merge').setValue(false)
expect(component.hint.includes('will be replaced')).toBeTruthy()
component.object = {}
expect(component.hint).toBeNull()
})
it('should emit permissions and merge flag on confirm', () => {
const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
component.form.get('permissions_form').setValue(set_permissions)
component.confirm()
expect(confirmSpy).toHaveBeenCalledWith({
permissions: set_permissions,
merge: true,
})
})
})

View File

@ -32,6 +32,7 @@ export class PermissionsDialogComponent {
this.o = o
this.title = $localize`Edit permissions for ` + o['name']
this.form.patchValue({
merge: true,
permissions_form: {
owner: o.owner,
set_permissions: o.permissions,
@ -43,8 +44,9 @@ export class PermissionsDialogComponent {
return this.o
}
form = new FormGroup({
public form = new FormGroup({
permissions_form: new FormControl(),
merge: new FormControl(true),
})
buttonsEnabled: boolean = true
@ -66,11 +68,21 @@ export class PermissionsDialogComponent {
}
}
@Input()
message =
$localize`Note that permissions set here will override any existing permissions`
get hint(): string {
if (this.object) return null
return this.form.get('merge').value
? $localize`Existing owner, user and group permissions will be merged with these settings.`
: $localize`Any and all existing owner, user and group permissions will be replaced.`
}
cancelClicked() {
this.activeModal.close()
}
confirm() {
this.confirmClicked.emit({
permissions: this.permissions,
merge: this.form.get('merge').value,
})
}
}

View File

@ -62,22 +62,24 @@
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="me-1 w-100">
<ng-select
name="user"
class="user-select small"
[(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled"
[clearable]="false"
[items]="users"
bindLabel="username"
multiple="true"
bindValue="id"
placeholder="Users"
i18n-placeholder
(change)="onUserSelect()">
</ng-select>
</div>
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.User)) {
<div class="me-1 w-100">
<ng-select
name="user"
class="user-select small"
[(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled"
[clearable]="false"
[items]="users"
bindLabel="username"
multiple="true"
bindValue="id"
placeholder="Users"
i18n-placeholder
(change)="onUserSelect()">
</ng-select>
</div>
}
</button>
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">

View File

@ -67,7 +67,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
}
constructor(
permissionsService: PermissionsService,
public permissionsService: PermissionsService,
userService: UserService,
private settingsService: SettingsService
) {

View File

@ -15,8 +15,14 @@
<tr>
<th scope="col" i18n>Created</th>
<th scope="col" i18n>Title</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
} @else {
<th scope="col" class="d-none d-md-table-cell"></th>
}
</tr>
</thead>
<tbody>
@ -26,13 +32,15 @@
<td class="py-2 py-md-3">
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
</td>
<td class="py-2 py-md-3 d-none d-md-table-cell">
@for (t of doc.tags$ | async; track t) {
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
}
</td>
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<td class="py-2 py-md-3 d-none d-md-table-cell">
@for (t of doc.tags$ | async; track t) {
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
}
</td>
}
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
@if (doc.correspondent !== null) {
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && doc.correspondent !== null) {
<a class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
}
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">

View File

@ -22,6 +22,7 @@ import { DocumentListViewService } from 'src/app/services/document-list-view.ser
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { PermissionsService } from 'src/app/services/permissions.service'
@Component({
selector: 'pngx-saved-view-widget',
@ -40,7 +41,8 @@ export class SavedViewWidgetComponent
private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService
public documentListViewService: DocumentListViewService,
public permissionsService: PermissionsService
) {
super()
}

View File

@ -1,5 +1,8 @@
import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
@ -71,6 +74,7 @@ import { CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { environment } from 'src/environments/environment'
const doc: Document = {
id: 3,
@ -136,6 +140,7 @@ describe('DocumentDetailComponent', () => {
let documentListViewService: DocumentListViewService
let settingsService: SettingsService
let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController
let currentUserCan = true
let currentUserHasObjectPermissions = true
@ -266,6 +271,7 @@ describe('DocumentDetailComponent', () => {
settingsService.currentUser = { id: 1 }
customFieldsService = TestBed.inject(CustomFieldsService)
fixture = TestBed.createComponent(DocumentDetailComponent)
httpTestingController = TestBed.inject(HttpTestingController)
component = fixture.componentInstance
})
@ -350,6 +356,26 @@ describe('DocumentDetailComponent', () => {
expect(component.documentForm.disabled).toBeTruthy()
})
it('should not attempt to retrieve objects if user does not have permissions', () => {
currentUserCan = false
initNormally()
expect(component.correspondents).toBeUndefined()
expect(component.documentTypes).toBeUndefined()
expect(component.storagePaths).toBeUndefined()
expect(component.users).toBeUndefined()
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/correspondents/`
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/document_types/`
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/storage_paths/`
)
currentUserCan = true
})
it('should support creating document type', () => {
initNormally()
let openModal: NgbModalRef

View File

@ -250,25 +250,50 @@ export class DocumentDetailComponent
Object.assign(this.document, docValues)
})
this.correspondentService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.storagePaths = result.results))
this.userService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.users = result.results))
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Correspondent
)
) {
this.correspondentService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.correspondents = result.results))
}
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.DocumentType
)
) {
this.documentTypeService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.documentTypes = result.results))
}
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.StoragePath
)
) {
this.storagePathService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.storagePaths = result.results))
}
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.User
)
) {
this.userService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.users = result.results))
}
this.getCustomFields()

View File

@ -17,51 +17,59 @@
</div>
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<label class="me-2" i18n>Edit:</label>
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[disabled]="!userCanEditAll"
[editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose"
(opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts"
(apply)="setTags($event)">
</pngx-filterable-dropdown>
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts"
(apply)="setCorrespondents($event)">
</pngx-filterable-dropdown>
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts"
(apply)="setDocumentTypes($event)">
</pngx-filterable-dropdown>
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts"
(apply)="setStoragePaths($event)">
</pngx-filterable-dropdown>
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[disabled]="!userCanEditAll"
[editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose"
(opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts"
(apply)="setTags($event)">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts"
(apply)="setCorrespondents($event)">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts"
(apply)="setDocumentTypes($event)">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts"
(apply)="setStoragePaths($event)">
</pngx-filterable-dropdown>
}
</div>
<div class="d-flex align-items-center gap-2 ms-auto">
<div class="btn-toolbar">

View File

@ -41,6 +41,7 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
import { NgSelectModule } from '@ng-select/ng-select'
import { GroupService } from 'src/app/services/rest/group.service'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SwitchComponent } from '../../common/input/switch/switch.component'
const selectionData: SelectionData = {
selected_tags: [
@ -81,6 +82,7 @@ describe('BulkEditorComponent', () => {
SelectComponent,
PermissionsGroupComponent,
PermissionsUserComponent,
SwitchComponent,
],
providers: [
PermissionsService,
@ -851,7 +853,18 @@ describe('BulkEditorComponent', () => {
fixture.detectChanges()
component.setPermissions()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.next()
const perms = {
permissions: {
view_users: [],
change_users: [],
view_groups: [],
change_groups: [],
},
}
modal.componentInstance.confirmClicked.emit({
permissions: perms,
merge: true,
})
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
@ -859,7 +872,10 @@ describe('BulkEditorComponent', () => {
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'set_permissions',
parameters: undefined,
parameters: {
permissions: perms.permissions,
merge: true,
},
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
@ -868,4 +884,22 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should not attempt to retrieve objects if user does not have permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.tags).toBeUndefined()
expect(component.correspondents).toBeUndefined()
expect(component.documentTypes).toBeUndefined()
expect(component.storagePaths).toBeUndefined()
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/correspondents/`
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/document_types/`
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/storage_paths/`
)
})
})

View File

@ -115,22 +115,50 @@ export class BulkEditorComponent
}
ngOnInit() {
this.tagService
.listAll()
.pipe(first())
.subscribe((result) => (this.tags = result.results))
this.correspondentService
.listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
)
) {
this.tagService
.listAll()
.pipe(first())
.subscribe((result) => (this.tags = result.results))
}
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.Correspondent
)
) {
this.correspondentService
.listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results))
}
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.DocumentType
)
) {
this.documentTypeService
.listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results))
}
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.StoragePath
)
) {
this.storagePathService
.listAll()
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
}
this.downloadForm
.get('downloadFileTypeArchive')
@ -512,9 +540,14 @@ export class BulkEditorComponent
let modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.confirmClicked.subscribe((permissions) => {
modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'set_permissions', permissions)
})
modal.componentInstance.confirmClicked.subscribe(
({ permissions, merge }) => {
modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'set_permissions', {
...permissions,
merge,
})
}
)
}
}

View File

@ -79,7 +79,7 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
getTagsLimited$() {
const limit = this.document.notes.length > 0 ? 6 : 7
return this.document.tags$.pipe(
return this.document.tags$?.pipe(
map((tags) => {
if (tags.length > limit) {
this.moreTags = tags.length - (limit - 1)

View File

@ -18,7 +18,7 @@
</select>
}
@if (_textFilter) {
<button class="btn btn-link btn-sm px-0 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
<i-bs width="1em" height="1em" name="x"></i-bs>
</button>
}
@ -29,7 +29,8 @@
<div class="col-auto">
<div class="d-flex flex-wrap gap-3">
<div class="d-flex flex-wrap gap-2">
<pngx-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<pngx-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[manyToOne]="true"
@ -37,31 +38,38 @@
(selectionModelChange)="updateRules()"
(opened)="onTagsDropdownOpen()"
[documentCounts]="tagDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
<pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
[allowSelectNone]="true"></pngx-filterable-dropdown>
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[(selectionModel)]="correspondentSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onCorrespondentDropdownOpen()"
[documentCounts]="correspondentDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
<pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[(selectionModel)]="documentTypeSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onDocumentTypeDropdownOpen()"
[documentCounts]="documentTypeDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
<pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
[allowSelectNone]="true"></pngx-filterable-dropdown>
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[(selectionModel)]="documentTypeSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onDocumentTypeDropdownOpen()"
[documentCounts]="documentTypeDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[(selectionModel)]="storagePathSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onStoragePathDropdownOpen()"
[documentCounts]="storagePathDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
[allowSelectNone]="true"></pngx-filterable-dropdown>
}
</div>
<div class="d-flex flex-wrap gap-2">
<pngx-date-dropdown

View File

@ -1,5 +1,8 @@
import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import {
ComponentFixture,
fakeAsync,
@ -78,6 +81,11 @@ import {
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
import { FilterEditorComponent } from './filter-editor.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import {
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
import { environment } from 'src/environments/environment'
const tags: Tag[] = [
{
@ -135,6 +143,8 @@ describe('FilterEditorComponent', () => {
let fixture: ComponentFixture<FilterEditorComponent>
let documentService: DocumentService
let settingsService: SettingsService
let permissionsService: PermissionsService
let httpTestingController: HttpTestingController
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
@ -199,6 +209,15 @@ describe('FilterEditorComponent', () => {
documentService = TestBed.inject(DocumentService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = users[0]
permissionsService = TestBed.inject(PermissionsService)
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => {
// a little hack-ish, permissions filter dropdown causes reactive forms issue due to ng-select
// trying to apply formControlName
return type !== PermissionType.User
})
httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(FilterEditorComponent)
component = fixture.componentInstance
component.filterRules = []
@ -206,6 +225,24 @@ describe('FilterEditorComponent', () => {
tick()
}))
it('should not attempt to retrieve objects if user does not have permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReset()
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => false)
component.ngOnInit()
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/correspondents/`
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/document_types/`
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/storage_paths/`
)
})
// SET filterRules
it('should ingest text filter rules for doc title', fakeAsync(() => {

View File

@ -70,6 +70,12 @@ import {
OwnerFilterType,
PermissionsSelectionModel,
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
import {
PermissionAction,
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
const TEXT_FILTER_TARGET_TITLE = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@ -155,7 +161,10 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
templateUrl: './filter-editor.component.html',
styleUrls: ['./filter-editor.component.scss'],
})
export class FilterEditorComponent implements OnInit, OnDestroy {
export class FilterEditorComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
generateFilterName() {
if (this.filterRules.length == 1) {
let rule = this.filterRules[0]
@ -224,8 +233,11 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
private tagService: TagService,
private correspondentService: CorrespondentService,
private documentService: DocumentService,
private storagePathService: StoragePathService
) {}
private storagePathService: StoragePathService,
public permissionsService: PermissionsService
) {
super()
}
@ViewChild('textFilterInput')
textFilterInput: ElementRef
@ -872,18 +884,46 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
subscription: Subscription
ngOnInit() {
this.tagService
.listAll()
.subscribe((result) => (this.tags = result.results))
this.correspondentService
.listAll()
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.subscribe((result) => (this.storagePaths = result.results))
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
)
) {
this.tagService
.listAll()
.subscribe((result) => (this.tags = result.results))
}
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Correspondent
)
) {
this.correspondentService
.listAll()
.subscribe((result) => (this.correspondents = result.results))
}
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.DocumentType
)
) {
this.documentTypeService
.listAll()
.subscribe((result) => (this.documentTypes = result.results))
}
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.StoragePath
)
) {
this.storagePathService
.listAll()
.subscribe((result) => (this.storagePaths = result.results))
}
this.textFilterDebounce = new Subject<string>()

View File

@ -41,6 +41,7 @@ import { TagsComponent } from '../../common/input/tags/tags.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SwitchComponent } from '../../common/input/switch/switch.component'
const mailAccounts = [
{ id: 1, name: 'account1' },
@ -82,6 +83,7 @@ describe('MailComponent', () => {
PermissionsGroupComponent,
PermissionsDialogComponent,
PermissionsFormComponent,
SwitchComponent,
],
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [
@ -267,11 +269,11 @@ describe('MailComponent', () => {
rulePatchSpy.mockReturnValueOnce(
throwError(() => new Error('error saving perms'))
)
dialog.confirmClicked.emit(perms)
dialog.confirmClicked.emit({ permissions: perms, merge: true })
expect(rulePatchSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as MailRule))
dialog.confirmClicked.emit(perms)
dialog.confirmClicked.emit({ permissions: perms, merge: true })
expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated')
modalService.dismissAll()
@ -299,8 +301,7 @@ describe('MailComponent', () => {
expect(modal).not.toBeUndefined()
let dialog = modal.componentInstance as PermissionsDialogComponent
expect(dialog.object).toEqual(mailAccounts[0])
dialog = modal.componentInstance as PermissionsDialogComponent
dialog.confirmClicked.emit(perms)
dialog.confirmClicked.emit({ permissions: perms, merge: true })
expect(accountPatchSpy).toHaveBeenCalled()
})
})

View File

@ -200,22 +200,27 @@ export class MailComponent
const dialog: PermissionsDialogComponent =
modal.componentInstance as PermissionsDialogComponent
dialog.object = object
modal.componentInstance.confirmClicked.subscribe((permissions) => {
modal.componentInstance.buttonsEnabled = false
const service: AbstractPaperlessService<MailRule | MailAccount> =
'account' in object ? this.mailRuleService : this.mailAccountService
object.owner = permissions['owner']
object['set_permissions'] = permissions['set_permissions']
service.patch(object).subscribe({
next: () => {
this.toastService.showInfo($localize`Permissions updated`)
modal.close()
},
error: (e) => {
this.toastService.showError($localize`Error updating permissions`, e)
},
})
})
modal.componentInstance.confirmClicked.subscribe(
({ permissions, merge }) => {
modal.componentInstance.buttonsEnabled = false
const service: AbstractPaperlessService<MailRule | MailAccount> =
'account' in object ? this.mailRuleService : this.mailAccountService
object.owner = permissions['owner']
object['set_permissions'] = permissions['set_permissions']
service.patch(object).subscribe({
next: () => {
this.toastService.showInfo($localize`Permissions updated`)
modal.close()
},
error: (e) => {
this.toastService.showError(
$localize`Error updating permissions`,
e
)
},
})
}
)
}
userCanEdit(obj: ObjectWithPermissions): boolean {

View File

@ -264,13 +264,19 @@ describe('ManagementListComponent', () => {
throwError(() => new Error('error setting permissions'))
)
const errorToastSpy = jest.spyOn(toastService, 'showError')
modal.componentInstance.confirmClicked.emit()
modal.componentInstance.confirmClicked.emit({
permissions: {},
merge: true,
})
expect(bulkEditPermsSpy).toHaveBeenCalled()
expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showInfo')
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit()
modal.componentInstance.confirmClicked.emit({
permissions: {},
merge: true,
})
expect(bulkEditPermsSpy).toHaveBeenCalled()
expect(successToastSpy).toHaveBeenCalled()
})

View File

@ -279,12 +279,13 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
backdrop: 'static',
})
modal.componentInstance.confirmClicked.subscribe(
(permissions: { owner: number; set_permissions: PermissionsObject }) => {
({ permissions, merge }) => {
modal.componentInstance.buttonsEnabled = false
this.service
.bulk_update_permissions(
Array.from(this.selectedObjects),
permissions
permissions,
merge
)
.subscribe({
next: () => {

View File

@ -53,10 +53,14 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
},
}
subscription = service
.bulk_update_permissions([1, 2], {
owner,
set_permissions: permissions,
})
.bulk_update_permissions(
[1, 2],
{
owner,
set_permissions: permissions,
},
true
)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}bulk_edit_object_perms/`

View File

@ -26,13 +26,15 @@ export abstract class AbstractNameFilterService<
bulk_update_permissions(
objects: Array<number>,
permissions: { owner: number; set_permissions: PermissionsObject }
permissions: { owner: number; set_permissions: PermissionsObject },
merge: boolean
): Observable<string> {
return this.http.post<string>(`${this.baseUrl}bulk_edit_object_perms/`, {
objects,
object_type: this.resourceName,
owner: permissions.owner,
permissions: permissions.set_permissions,
merge,
})
}
}

View File

@ -13,6 +13,11 @@ import { TagService } from './tag.service'
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { queryParamsFromFilterRules } from '../../utils/query-params'
import { StoragePathService } from './storage-path.service'
import {
PermissionAction,
PermissionType,
PermissionsService,
} from '../permissions.service'
export const DOCUMENT_SORT_FIELDS = [
{ field: 'archive_serial_number', name: $localize`ASN` },
@ -57,21 +62,40 @@ export class DocumentService extends AbstractPaperlessService<Document> {
private correspondentService: CorrespondentService,
private documentTypeService: DocumentTypeService,
private tagService: TagService,
private storagePathService: StoragePathService
private storagePathService: StoragePathService,
private permissionsService: PermissionsService
) {
super(http, 'documents')
}
addObservablesToDocument(doc: Document) {
if (doc.correspondent) {
if (
doc.correspondent &&
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Correspondent
)
) {
doc.correspondent$ = this.correspondentService.getCached(
doc.correspondent
)
}
if (doc.document_type) {
if (
doc.document_type &&
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.DocumentType
)
) {
doc.document_type$ = this.documentTypeService.getCached(doc.document_type)
}
if (doc.tags) {
if (
doc.tags &&
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
)
) {
doc.tags$ = this.tagService
.getCachedMany(doc.tags)
.pipe(
@ -80,7 +104,13 @@ export class DocumentService extends AbstractPaperlessService<Document> {
)
)
}
if (doc.storage_path) {
if (
doc.storage_path &&
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.StoragePath
)
) {
doc.storage_path$ = this.storagePathService.getCached(doc.storage_path)
}
return doc

View File

@ -129,13 +129,17 @@ def redo_ocr(doc_ids):
return "OK"
def set_permissions(doc_ids, set_permissions, owner=None):
def set_permissions(doc_ids, set_permissions, owner=None, merge=False):
qs = Document.objects.filter(id__in=doc_ids)
qs.update(owner=owner)
if merge:
# If merging, only set owner for documents that don't have an owner
qs.filter(owner__isnull=True).update(owner=owner)
else:
qs.update(owner=owner)
for doc in qs:
set_permissions_for_object(set_permissions, doc)
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
affected_docs = [doc.id for doc in qs]

View File

@ -140,6 +140,7 @@ def run_convert(
type=None,
depth=None,
auto_orient=False,
use_cropbox=False,
extra=None,
logging_group=None,
) -> None:
@ -158,6 +159,7 @@ def run_convert(
args += ["-type", str(type)] if type else []
args += ["-depth", str(depth)] if depth else []
args += ["-auto-orient"] if auto_orient else []
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
args += [input_file, output_file]
logger.debug("Execute: " + " ".join(args), extra={"group": logging_group})
@ -229,6 +231,7 @@ def make_thumbnail_from_pdf(in_path, temp_dir, logging_group=None) -> str:
strip=True,
trim=False,
auto_orient=True,
use_cropbox=True,
input_file=f"{in_path}[0]",
output_file=out_path,
logging_group=logging_group,

View File

@ -916,6 +916,8 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin):
)
if "owner" in parameters and parameters["owner"] is not None:
self._validate_owner(parameters["owner"])
if "merge" not in parameters:
parameters["merge"] = False
def validate(self, attrs):
method = attrs["method"]
@ -1258,6 +1260,12 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
write_only=True,
)
merge = serializers.BooleanField(
default=False,
write_only=True,
required=False,
)
def get_object_class(self, object_type):
object_class = None
if object_type == "tags":

View File

@ -0,0 +1,110 @@
from unittest import mock
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.test import TestCase
from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms
from documents.bulk_edit import set_permissions
from documents.models import Document
from documents.tests.utils import DirectoriesMixin
class TestBulkEditPermissions(DirectoriesMixin, TestCase):
def setUp(self):
super().setUp()
self.doc1 = Document.objects.create(checksum="A", title="A")
self.doc2 = Document.objects.create(checksum="B", title="B")
self.doc3 = Document.objects.create(checksum="C", title="C")
self.owner = User.objects.create(username="test_owner")
self.user1 = User.objects.create(username="user1")
self.user2 = User.objects.create(username="user2")
self.group1 = Group.objects.create(name="group1")
self.group2 = Group.objects.create(name="group2")
@mock.patch("documents.tasks.bulk_update_documents.delay")
def test_set_permissions(self, m):
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
assign_perm("view_document", self.group1, self.doc1)
permissions = {
"view": {
"users": [self.user1.id, self.user2.id],
"groups": [self.group2.id],
},
"change": {
"users": [self.user1.id],
"groups": [self.group2.id],
},
}
set_permissions(
doc_ids,
set_permissions=permissions,
owner=self.owner,
merge=False,
)
m.assert_called_once()
self.assertEqual(Document.objects.filter(owner=self.owner).count(), 3)
self.assertEqual(Document.objects.filter(id__in=doc_ids).count(), 3)
users_with_perms = get_users_with_perms(
self.doc1,
)
self.assertEqual(users_with_perms.count(), 2)
# group1 should be replaced by group2
groups_with_perms = get_groups_with_perms(
self.doc1,
)
self.assertEqual(groups_with_perms.count(), 1)
@mock.patch("documents.tasks.bulk_update_documents.delay")
def test_set_permissions_merge(self, m):
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
self.doc1.owner = self.user1
self.doc1.save()
assign_perm("view_document", self.user1, self.doc1)
assign_perm("view_document", self.group1, self.doc1)
permissions = {
"view": {
"users": [self.user2.id],
"groups": [self.group2.id],
},
"change": {
"users": [self.user2.id],
"groups": [self.group2.id],
},
}
set_permissions(
doc_ids,
set_permissions=permissions,
owner=self.owner,
merge=True,
)
m.assert_called_once()
# when merge is true owner doesn't get replaced if its not empty
self.assertEqual(Document.objects.filter(owner=self.owner).count(), 2)
self.assertEqual(Document.objects.filter(id__in=doc_ids).count(), 3)
# merge of user1 which was pre-existing and user2
users_with_perms = get_users_with_perms(
self.doc1,
)
self.assertEqual(users_with_perms.count(), 2)
# group1 should be merged by group2
groups_with_perms = get_groups_with_perms(
self.doc1,
)
self.assertEqual(groups_with_perms.count(), 2)

View File

@ -765,6 +765,58 @@ class TestBulkEdit(DirectoriesMixin, APITestCase):
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_merge(self, m):
m.return_value = "OK"
user1 = User.objects.create(username="user1")
user2 = User.objects.create(username="user2")
permissions = {
"view": {
"users": [user1.id, user2.id],
"groups": None,
},
"change": {
"users": [user1.id],
"groups": None,
},
}
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id, self.doc3.id],
"method": "set_permissions",
"parameters": {"set_permissions": permissions},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called()
args, kwargs = m.call_args
self.assertEqual(kwargs["merge"], False)
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id, self.doc3.id],
"method": "set_permissions",
"parameters": {"set_permissions": permissions, "merge": True},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called()
args, kwargs = m.call_args
self.assertEqual(kwargs["merge"], True)
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_insufficient_permissions_ownership(self, m):
"""

View File

@ -700,8 +700,8 @@ class TestBulkEditObjectPermissions(APITestCase):
def setUp(self):
super().setUp()
user = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=user)
self.temp_admin = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=self.temp_admin)
self.t1 = Tag.objects.create(name="t1")
self.t2 = Tag.objects.create(name="t2")
@ -822,6 +822,79 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(StoragePath.objects.get(pk=self.sp1.id).owner, self.user3)
def test_bulk_object_set_permissions_merge(self):
"""
GIVEN:
- Existing objects
WHEN:
- bulk_edit_object_perms API endpoint is called with merge=True or merge=False (default)
THEN:
- Permissions and / or owner are replaced or merged, depending on the merge flag
"""
permissions = {
"view": {
"users": [self.user1.id, self.user2.id],
"groups": [],
},
"change": {
"users": [self.user1.id],
"groups": [],
},
}
assign_perm("view_tag", self.user3, self.t1)
self.t1.owner = self.user3
self.t1.save()
# merge=True
response = self.client.post(
"/api/bulk_edit_object_perms/",
json.dumps(
{
"objects": [self.t1.id, self.t2.id],
"object_type": "tags",
"owner": self.user1.id,
"permissions": permissions,
"merge": True,
},
),
content_type="application/json",
)
self.t1.refresh_from_db()
self.t2.refresh_from_db()
self.assertEqual(response.status_code, status.HTTP_200_OK)
# user3 should still be owner of t1 since was set prior
self.assertEqual(self.t1.owner, self.user3)
# user1 should now be owner of t2 since it didn't have an owner
self.assertEqual(self.t2.owner, self.user1)
# user1 should be added
self.assertIn(self.user1, get_users_with_perms(self.t1))
# user3 should be preserved
self.assertIn(self.user3, get_users_with_perms(self.t1))
# merge=False (default)
response = self.client.post(
"/api/bulk_edit_object_perms/",
json.dumps(
{
"objects": [self.t1.id, self.t2.id],
"object_type": "tags",
"permissions": permissions,
"merge": False,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# user1 should be added
self.assertIn(self.user1, get_users_with_perms(self.t1))
# user3 should be removed
self.assertNotIn(self.user3, get_users_with_perms(self.t1))
def test_bulk_edit_object_permissions_insufficient_perms(self):
"""
GIVEN:

View File

@ -1385,6 +1385,7 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
object_class = serializer.get_object_class(object_type)
permissions = serializer.validated_data.get("permissions")
owner = serializer.validated_data.get("owner")
merge = serializer.validated_data.get("merge")
if not user.is_superuser:
objs = object_class.objects.filter(pk__in=object_ids)
@ -1396,12 +1397,21 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
try:
qs = object_class.objects.filter(id__in=object_ids)
if "owner" in serializer.validated_data:
qs.update(owner=owner)
# if merge is true, we dont want to remove the owner
if "owner" in serializer.validated_data and (
not merge or (merge and owner is not None)
):
# if merge is true, we dont want to overwrite the owner
qs_owner_update = qs.filter(owner__isnull=True) if merge else qs
qs_owner_update.update(owner=owner)
if "permissions" in serializer.validated_data:
for obj in qs:
set_permissions_for_object(permissions, obj)
set_permissions_for_object(
permissions=permissions,
object=obj,
merge=merge,
)
return Response({"result": "OK"})
except Exception as e:

View File

@ -47,3 +47,11 @@ class HttpRemoteUserMiddleware(PersistentRemoteUserMiddleware):
"""
header = settings.HTTP_REMOTE_USER_HEADER_NAME
class PaperlessRemoteUserAuthentication(authentication.RemoteUserAuthentication):
"""
REMOTE_USER authentication for DRF which overrides the default header.
"""
header = settings.HTTP_REMOTE_USER_HEADER_NAME

View File

@ -420,19 +420,34 @@ if AUTO_LOGIN_USERNAME:
# regular login in case the provided user does not exist.
MIDDLEWARE.insert(_index + 1, "paperless.auth.AutoLoginMiddleware")
ENABLE_HTTP_REMOTE_USER = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
HTTP_REMOTE_USER_HEADER_NAME = os.getenv(
"PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME",
"HTTP_REMOTE_USER",
)
if ENABLE_HTTP_REMOTE_USER:
MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware")
AUTHENTICATION_BACKENDS.insert(0, "django.contrib.auth.backends.RemoteUserBackend")
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].append(
"rest_framework.authentication.RemoteUserAuthentication",
def _parse_remote_user_settings() -> str:
global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK
enable = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
enable_api = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API")
if enable or enable_api:
MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware")
AUTHENTICATION_BACKENDS.insert(
0,
"django.contrib.auth.backends.RemoteUserBackend",
)
if enable_api:
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].insert(
0,
"paperless.auth.PaperlessRemoteUserAuthentication",
)
header_name = os.getenv(
"PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME",
"HTTP_REMOTE_USER",
)
return header_name
HTTP_REMOTE_USER_HEADER_NAME = _parse_remote_user_settings()
# X-Frame options for embedded PDF display:
X_FRAME_OPTIONS = "ANY" if DEBUG else "SAMEORIGIN"

View File

@ -0,0 +1,110 @@
import os
from unittest import mock
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.test import APITestCase
from documents.tests.utils import DirectoriesMixin
from paperless.settings import _parse_remote_user_settings
class TestRemoteUser(DirectoriesMixin, APITestCase):
def setUp(self):
super().setUp()
self.user = User.objects.create_superuser(
username="temp_admin",
)
def test_remote_user(self):
"""
GIVEN:
- Configured user
- Remote user auth is enabled
WHEN:
- Call is made to root
THEN:
- Call succeeds
"""
with mock.patch.dict(
os.environ,
{
"PAPERLESS_ENABLE_HTTP_REMOTE_USER": "True",
},
):
_parse_remote_user_settings()
response = self.client.get("/documents/")
self.assertEqual(
response.status_code,
status.HTTP_302_FOUND,
)
response = self.client.get(
"/documents/",
headers={
"Remote-User": self.user.username,
},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_remote_user_api(self):
"""
GIVEN:
- Configured user
- Remote user auth is enabled for the API
WHEN:
- API call is made to get documents
THEN:
- Call succeeds
"""
with mock.patch.dict(
os.environ,
{
"PAPERLESS_ENABLE_HTTP_REMOTE_USER_API": "True",
},
):
_parse_remote_user_settings()
response = self.client.get("/api/documents/")
# 403 testing locally, 401 on ci...
self.assertIn(
response.status_code,
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
)
response = self.client.get(
"/api/documents/",
headers={
"Remote-User": self.user.username,
},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_remote_user_header_setting(self):
"""
GIVEN:
- Remote user header name is set
WHEN:
- Settings are parsed
THEN:
- Correct header name is returned
"""
with mock.patch.dict(
os.environ,
{
"PAPERLESS_ENABLE_HTTP_REMOTE_USER": "True",
"PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME": "HTTP_FOO",
},
):
header_name = _parse_remote_user_settings()
self.assertEqual(header_name, "HTTP_FOO")