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

View File

@ -139,7 +139,7 @@ document. Paperless only reports PDF metadata at this point.
## Authorization ## Authorization
The REST api provides three different forms of authentication. The REST api provides four different forms of authentication.
1. Basic 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. 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 ## Searching for documents
Full text searching is available on the `/api/documents/` endpoint. Two 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 - `/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). 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. the document with id 1234.
Pagination works exactly the same as it does for normal requests on this 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` full permissions of objects in a format that mirrors the `set_permissions`
parameter above. 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 ## API Versioning
The REST API is versioned since Paperless-ngx 1.3.0. 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 color to use for a specific tag, which is either black or white
depending on the brightness of `Tag.color`. depending on the brightness of `Tag.color`.
- Removed field `Tag.colour`. - 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. 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} #### [`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 property allows to customize the name of the HTTP header from which
the authenticated username is extracted. Values are in terms of 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). [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>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context> <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>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
@ -2438,7 +2438,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context> <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>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@ -2505,7 +2505,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context> <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>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> <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 context-type="linenumber">15</context>
</context-group> </context-group>
</trans-unit> </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"> <trans-unit id="7062872617520618723" datatype="html">
<source>Set permissions</source> <source>Set permissions</source>
<context-group purpose="location"> <context-group purpose="location">
@ -3937,11 +3944,18 @@
<context context-type="linenumber">33</context> <context context-type="linenumber">33</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8283439432608484491" datatype="html"> <trans-unit id="347498040201588614" datatype="html">
<source>Note that permissions set here will override any existing permissions</source> <source>Existing owner, user and group permissions will be merged with these settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5947558132119506443" datatype="html"> <trans-unit id="5947558132119506443" datatype="html">
@ -6100,18 +6114,18 @@
<source>Permissions updated</source> <source>Permissions updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4639647950943944112" datatype="html"> <trans-unit id="4639647950943944112" datatype="html">
<source>Error updating permissions</source> <source>Error updating permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <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>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4010735610815226758" datatype="html"> <trans-unit id="4010735610815226758" datatype="html">
@ -6277,7 +6291,7 @@
<source>Permissions updated successfully</source> <source>Permissions updated successfully</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5101757640976222639" datatype="html"> <trans-unit id="5101757640976222639" datatype="html">

View File

@ -15,7 +15,7 @@
} }
</div> </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"> <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"> <input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
@if (horizontal) { @if (horizontal) {

View File

@ -5,12 +5,15 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
@if (!object && message) {
<p class="mb-3" [innerHTML]="message | safeHtml"></p>
}
<form [formGroup]="form"> <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> </form>
</div> </div>
@ -20,5 +23,5 @@
<span class="visually-hidden" i18n>Loading...</span> <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-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> </div>

View File

@ -11,6 +11,7 @@ import { NgSelectModule } from '@ng-select/ng-select'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component' import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component' import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
import { SwitchComponent } from '../input/switch/switch.component'
const set_permissions = { const set_permissions = {
owner: 10, owner: 10,
@ -37,6 +38,7 @@ describe('PermissionsDialogComponent', () => {
PermissionsDialogComponent, PermissionsDialogComponent,
SafeHtmlPipe, SafeHtmlPipe,
SelectComponent, SelectComponent,
SwitchComponent,
PermissionsFormComponent, PermissionsFormComponent,
PermissionsUserComponent, PermissionsUserComponent,
PermissionsGroupComponent, PermissionsGroupComponent,
@ -112,4 +114,23 @@ describe('PermissionsDialogComponent', () => {
expect(component.title).toEqual(`Edit permissions for ${obj.name}`) expect(component.title).toEqual(`Edit permissions for ${obj.name}`)
expect(component.permissions).toEqual(set_permissions) 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.o = o
this.title = $localize`Edit permissions for ` + o['name'] this.title = $localize`Edit permissions for ` + o['name']
this.form.patchValue({ this.form.patchValue({
merge: true,
permissions_form: { permissions_form: {
owner: o.owner, owner: o.owner,
set_permissions: o.permissions, set_permissions: o.permissions,
@ -43,8 +44,9 @@ export class PermissionsDialogComponent {
return this.o return this.o
} }
form = new FormGroup({ public form = new FormGroup({
permissions_form: new FormControl(), permissions_form: new FormControl(),
merge: new FormControl(true),
}) })
buttonsEnabled: boolean = true buttonsEnabled: boolean = true
@ -66,11 +68,21 @@ export class PermissionsDialogComponent {
} }
} }
@Input() get hint(): string {
message = if (this.object) return null
$localize`Note that permissions set here will override any existing permissions` 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() { cancelClicked() {
this.activeModal.close() 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> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
<div class="me-1 w-100"> @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.User)) {
<ng-select <div class="me-1 w-100">
name="user" <ng-select
class="user-select small" name="user"
[(ngModel)]="selectionModel.includeUsers" class="user-select small"
[disabled]="disabled" [(ngModel)]="selectionModel.includeUsers"
[clearable]="false" [disabled]="disabled"
[items]="users" [clearable]="false"
bindLabel="username" [items]="users"
multiple="true" bindLabel="username"
bindValue="id" multiple="true"
placeholder="Users" bindValue="id"
i18n-placeholder placeholder="Users"
(change)="onUserSelect()"> i18n-placeholder
</ng-select> (change)="onUserSelect()">
</div> </ng-select>
</div>
}
</button> </button>
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) { @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"> <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( constructor(
permissionsService: PermissionsService, public permissionsService: PermissionsService,
userService: UserService, userService: UserService,
private settingsService: SettingsService private settingsService: SettingsService
) { ) {

View File

@ -15,8 +15,14 @@
<tr> <tr>
<th scope="col" i18n>Created</th> <th scope="col" i18n>Created</th>
<th scope="col" i18n>Title</th> <th scope="col" i18n>Title</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th> @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th> <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> </tr>
</thead> </thead>
<tbody> <tbody>
@ -26,13 +32,15 @@
<td class="py-2 py-md-3"> <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> <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>
<td class="py-2 py-md-3 d-none d-md-table-cell"> @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
@for (t of doc.tags$ | async; track t) { <td class="py-2 py-md-3 d-none d-md-table-cell">
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag> @for (t of doc.tags$ | async; track t) {
} <pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
</td> }
</td>
}
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell"> <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> <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"> <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 { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { PermissionsService } from 'src/app/services/permissions.service'
@Component({ @Component({
selector: 'pngx-saved-view-widget', selector: 'pngx-saved-view-widget',
@ -40,7 +41,8 @@ export class SavedViewWidgetComponent
private list: DocumentListViewService, private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService, private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService, public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService public documentListViewService: DocumentListViewService,
public permissionsService: PermissionsService
) { ) {
super() super()
} }

View File

@ -1,5 +1,8 @@
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing' import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { import {
ComponentFixture, ComponentFixture,
TestBed, TestBed,
@ -71,6 +74,7 @@ import { CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component' import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { environment } from 'src/environments/environment'
const doc: Document = { const doc: Document = {
id: 3, id: 3,
@ -136,6 +140,7 @@ describe('DocumentDetailComponent', () => {
let documentListViewService: DocumentListViewService let documentListViewService: DocumentListViewService
let settingsService: SettingsService let settingsService: SettingsService
let customFieldsService: CustomFieldsService let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController
let currentUserCan = true let currentUserCan = true
let currentUserHasObjectPermissions = true let currentUserHasObjectPermissions = true
@ -266,6 +271,7 @@ describe('DocumentDetailComponent', () => {
settingsService.currentUser = { id: 1 } settingsService.currentUser = { id: 1 }
customFieldsService = TestBed.inject(CustomFieldsService) customFieldsService = TestBed.inject(CustomFieldsService)
fixture = TestBed.createComponent(DocumentDetailComponent) fixture = TestBed.createComponent(DocumentDetailComponent)
httpTestingController = TestBed.inject(HttpTestingController)
component = fixture.componentInstance component = fixture.componentInstance
}) })
@ -350,6 +356,26 @@ describe('DocumentDetailComponent', () => {
expect(component.documentForm.disabled).toBeTruthy() 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', () => { it('should support creating document type', () => {
initNormally() initNormally()
let openModal: NgbModalRef let openModal: NgbModalRef

View File

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

View File

@ -17,51 +17,59 @@
</div> </div>
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> <div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<label class="me-2" i18n>Edit:</label> <label class="me-2" i18n>Edit:</label>
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
filterPlaceholder="Filter tags" i18n-filterPlaceholder <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
[items]="tags" filterPlaceholder="Filter tags" i18n-filterPlaceholder
[disabled]="!userCanEditAll" [items]="tags"
[editing]="true" [disabled]="!userCanEditAll"
[manyToOne]="true" [editing]="true"
[applyOnClose]="applyOnClose" [manyToOne]="true"
(opened)="openTagsDropdown()" [applyOnClose]="applyOnClose"
[(selectionModel)]="tagSelectionModel" (opened)="openTagsDropdown()"
[documentCounts]="tagDocumentCounts" [(selectionModel)]="tagSelectionModel"
(apply)="setTags($event)"> [documentCounts]="tagDocumentCounts"
</pngx-filterable-dropdown> (apply)="setTags($event)">
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title </pngx-filterable-dropdown>
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder }
[items]="correspondents" @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
[disabled]="!userCanEditAll" <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
[editing]="true" filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[applyOnClose]="applyOnClose" [items]="correspondents"
(opened)="openCorrespondentDropdown()" [disabled]="!userCanEditAll"
[(selectionModel)]="correspondentSelectionModel" [editing]="true"
[documentCounts]="correspondentDocumentCounts" [applyOnClose]="applyOnClose"
(apply)="setCorrespondents($event)"> (opened)="openCorrespondentDropdown()"
</pngx-filterable-dropdown> [(selectionModel)]="correspondentSelectionModel"
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title [documentCounts]="correspondentDocumentCounts"
filterPlaceholder="Filter document types" i18n-filterPlaceholder (apply)="setCorrespondents($event)">
[items]="documentTypes" </pngx-filterable-dropdown>
[disabled]="!userCanEditAll" }
[editing]="true" @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
[applyOnClose]="applyOnClose" <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
(opened)="openDocumentTypeDropdown()" filterPlaceholder="Filter document types" i18n-filterPlaceholder
[(selectionModel)]="documentTypeSelectionModel" [items]="documentTypes"
[documentCounts]="documentTypeDocumentCounts" [disabled]="!userCanEditAll"
(apply)="setDocumentTypes($event)"> [editing]="true"
</pngx-filterable-dropdown> [applyOnClose]="applyOnClose"
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title (opened)="openDocumentTypeDropdown()"
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder [(selectionModel)]="documentTypeSelectionModel"
[items]="storagePaths" [documentCounts]="documentTypeDocumentCounts"
[disabled]="!userCanEditAll" (apply)="setDocumentTypes($event)">
[editing]="true" </pngx-filterable-dropdown>
[applyOnClose]="applyOnClose" }
(opened)="openStoragePathDropdown()" @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
[(selectionModel)]="storagePathsSelectionModel" <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
[documentCounts]="storagePathDocumentCounts" filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
(apply)="setStoragePaths($event)"> [items]="storagePaths"
</pngx-filterable-dropdown> [disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
(opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts"
(apply)="setStoragePaths($event)">
</pngx-filterable-dropdown>
}
</div> </div>
<div class="d-flex align-items-center gap-2 ms-auto"> <div class="d-flex align-items-center gap-2 ms-auto">
<div class="btn-toolbar"> <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 { NgSelectModule } from '@ng-select/ng-select'
import { GroupService } from 'src/app/services/rest/group.service' import { GroupService } from 'src/app/services/rest/group.service'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SwitchComponent } from '../../common/input/switch/switch.component'
const selectionData: SelectionData = { const selectionData: SelectionData = {
selected_tags: [ selected_tags: [
@ -81,6 +82,7 @@ describe('BulkEditorComponent', () => {
SelectComponent, SelectComponent,
PermissionsGroupComponent, PermissionsGroupComponent,
PermissionsUserComponent, PermissionsUserComponent,
SwitchComponent,
], ],
providers: [ providers: [
PermissionsService, PermissionsService,
@ -851,7 +853,18 @@ describe('BulkEditorComponent', () => {
fixture.detectChanges() fixture.detectChanges()
component.setPermissions() component.setPermissions()
expect(modal).not.toBeUndefined() 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( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
@ -859,7 +872,10 @@ describe('BulkEditorComponent', () => {
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
method: 'set_permissions', method: 'set_permissions',
parameters: undefined, parameters: {
permissions: perms.permissions,
merge: true,
},
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${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` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds ) // 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() { ngOnInit() {
this.tagService if (
.listAll() this.permissionService.currentUserCan(
.pipe(first()) PermissionAction.View,
.subscribe((result) => (this.tags = result.results)) PermissionType.Tag
this.correspondentService )
.listAll() ) {
.pipe(first()) this.tagService
.subscribe((result) => (this.correspondents = result.results)) .listAll()
this.documentTypeService .pipe(first())
.listAll() .subscribe((result) => (this.tags = result.results))
.pipe(first()) }
.subscribe((result) => (this.documentTypes = result.results)) if (
this.storagePathService this.permissionService.currentUserCan(
.listAll() PermissionAction.View,
.pipe(first()) PermissionType.Correspondent
.subscribe((result) => (this.storagePaths = result.results)) )
) {
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 this.downloadForm
.get('downloadFileTypeArchive') .get('downloadFileTypeArchive')
@ -512,9 +540,14 @@ export class BulkEditorComponent
let modal = this.modalService.open(PermissionsDialogComponent, { let modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.confirmClicked.subscribe((permissions) => { modal.componentInstance.confirmClicked.subscribe(
modal.componentInstance.buttonsEnabled = false ({ permissions, merge }) => {
this.executeBulkOperation(modal, 'set_permissions', permissions) modal.componentInstance.buttonsEnabled = false
}) this.executeBulkOperation(modal, 'set_permissions', {
...permissions,
merge,
})
}
)
} }
} }

View File

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

View File

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

View File

@ -1,5 +1,8 @@
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing' import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { import {
ComponentFixture, ComponentFixture,
fakeAsync, fakeAsync,
@ -78,6 +81,11 @@ import {
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component' } from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
import { FilterEditorComponent } from './filter-editor.component' import { FilterEditorComponent } from './filter-editor.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' 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[] = [ const tags: Tag[] = [
{ {
@ -135,6 +143,8 @@ describe('FilterEditorComponent', () => {
let fixture: ComponentFixture<FilterEditorComponent> let fixture: ComponentFixture<FilterEditorComponent>
let documentService: DocumentService let documentService: DocumentService
let settingsService: SettingsService let settingsService: SettingsService
let permissionsService: PermissionsService
let httpTestingController: HttpTestingController
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@ -199,6 +209,15 @@ describe('FilterEditorComponent', () => {
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = users[0] 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) fixture = TestBed.createComponent(FilterEditorComponent)
component = fixture.componentInstance component = fixture.componentInstance
component.filterRules = [] component.filterRules = []
@ -206,6 +225,24 @@ describe('FilterEditorComponent', () => {
tick() 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 // SET filterRules
it('should ingest text filter rules for doc title', fakeAsync(() => { it('should ingest text filter rules for doc title', fakeAsync(() => {

View File

@ -70,6 +70,12 @@ import {
OwnerFilterType, OwnerFilterType,
PermissionsSelectionModel, PermissionsSelectionModel,
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component' } 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 = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content' const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@ -155,7 +161,10 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
templateUrl: './filter-editor.component.html', templateUrl: './filter-editor.component.html',
styleUrls: ['./filter-editor.component.scss'], styleUrls: ['./filter-editor.component.scss'],
}) })
export class FilterEditorComponent implements OnInit, OnDestroy { export class FilterEditorComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
generateFilterName() { generateFilterName() {
if (this.filterRules.length == 1) { if (this.filterRules.length == 1) {
let rule = this.filterRules[0] let rule = this.filterRules[0]
@ -224,8 +233,11 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
private tagService: TagService, private tagService: TagService,
private correspondentService: CorrespondentService, private correspondentService: CorrespondentService,
private documentService: DocumentService, private documentService: DocumentService,
private storagePathService: StoragePathService private storagePathService: StoragePathService,
) {} public permissionsService: PermissionsService
) {
super()
}
@ViewChild('textFilterInput') @ViewChild('textFilterInput')
textFilterInput: ElementRef textFilterInput: ElementRef
@ -872,18 +884,46 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
subscription: Subscription subscription: Subscription
ngOnInit() { ngOnInit() {
this.tagService if (
.listAll() this.permissionsService.currentUserCan(
.subscribe((result) => (this.tags = result.results)) PermissionAction.View,
this.correspondentService PermissionType.Tag
.listAll() )
.subscribe((result) => (this.correspondents = result.results)) ) {
this.documentTypeService this.tagService
.listAll() .listAll()
.subscribe((result) => (this.documentTypes = result.results)) .subscribe((result) => (this.tags = result.results))
this.storagePathService }
.listAll() if (
.subscribe((result) => (this.storagePaths = result.results)) 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>() 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SwitchComponent } from '../../common/input/switch/switch.component'
const mailAccounts = [ const mailAccounts = [
{ id: 1, name: 'account1' }, { id: 1, name: 'account1' },
@ -82,6 +83,7 @@ describe('MailComponent', () => {
PermissionsGroupComponent, PermissionsGroupComponent,
PermissionsDialogComponent, PermissionsDialogComponent,
PermissionsFormComponent, PermissionsFormComponent,
SwitchComponent,
], ],
providers: [CustomDatePipe, DatePipe, PermissionsGuard], providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [ imports: [
@ -267,11 +269,11 @@ describe('MailComponent', () => {
rulePatchSpy.mockReturnValueOnce( rulePatchSpy.mockReturnValueOnce(
throwError(() => new Error('error saving perms')) throwError(() => new Error('error saving perms'))
) )
dialog.confirmClicked.emit(perms) dialog.confirmClicked.emit({ permissions: perms, merge: true })
expect(rulePatchSpy).toHaveBeenCalled() expect(rulePatchSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as MailRule)) rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as MailRule))
dialog.confirmClicked.emit(perms) dialog.confirmClicked.emit({ permissions: perms, merge: true })
expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated') expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated')
modalService.dismissAll() modalService.dismissAll()
@ -299,8 +301,7 @@ describe('MailComponent', () => {
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
let dialog = modal.componentInstance as PermissionsDialogComponent let dialog = modal.componentInstance as PermissionsDialogComponent
expect(dialog.object).toEqual(mailAccounts[0]) expect(dialog.object).toEqual(mailAccounts[0])
dialog = modal.componentInstance as PermissionsDialogComponent dialog.confirmClicked.emit({ permissions: perms, merge: true })
dialog.confirmClicked.emit(perms)
expect(accountPatchSpy).toHaveBeenCalled() expect(accountPatchSpy).toHaveBeenCalled()
}) })
}) })

View File

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

View File

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

View File

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

View File

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

View File

@ -26,13 +26,15 @@ export abstract class AbstractNameFilterService<
bulk_update_permissions( bulk_update_permissions(
objects: Array<number>, objects: Array<number>,
permissions: { owner: number; set_permissions: PermissionsObject } permissions: { owner: number; set_permissions: PermissionsObject },
merge: boolean
): Observable<string> { ): Observable<string> {
return this.http.post<string>(`${this.baseUrl}bulk_edit_object_perms/`, { return this.http.post<string>(`${this.baseUrl}bulk_edit_object_perms/`, {
objects, objects,
object_type: this.resourceName, object_type: this.resourceName,
owner: permissions.owner, owner: permissions.owner,
permissions: permissions.set_permissions, 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 { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { queryParamsFromFilterRules } from '../../utils/query-params' import { queryParamsFromFilterRules } from '../../utils/query-params'
import { StoragePathService } from './storage-path.service' import { StoragePathService } from './storage-path.service'
import {
PermissionAction,
PermissionType,
PermissionsService,
} from '../permissions.service'
export const DOCUMENT_SORT_FIELDS = [ export const DOCUMENT_SORT_FIELDS = [
{ field: 'archive_serial_number', name: $localize`ASN` }, { field: 'archive_serial_number', name: $localize`ASN` },
@ -57,21 +62,40 @@ export class DocumentService extends AbstractPaperlessService<Document> {
private correspondentService: CorrespondentService, private correspondentService: CorrespondentService,
private documentTypeService: DocumentTypeService, private documentTypeService: DocumentTypeService,
private tagService: TagService, private tagService: TagService,
private storagePathService: StoragePathService private storagePathService: StoragePathService,
private permissionsService: PermissionsService
) { ) {
super(http, 'documents') super(http, 'documents')
} }
addObservablesToDocument(doc: Document) { addObservablesToDocument(doc: Document) {
if (doc.correspondent) { if (
doc.correspondent &&
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Correspondent
)
) {
doc.correspondent$ = this.correspondentService.getCached( doc.correspondent$ = this.correspondentService.getCached(
doc.correspondent 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) 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 doc.tags$ = this.tagService
.getCachedMany(doc.tags) .getCachedMany(doc.tags)
.pipe( .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) doc.storage_path$ = this.storagePathService.getCached(doc.storage_path)
} }
return doc return doc

View File

@ -129,13 +129,17 @@ def redo_ocr(doc_ids):
return "OK" 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 = 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: 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] affected_docs = [doc.id for doc in qs]

View File

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

View File

@ -916,6 +916,8 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin):
) )
if "owner" in parameters and parameters["owner"] is not None: if "owner" in parameters and parameters["owner"] is not None:
self._validate_owner(parameters["owner"]) self._validate_owner(parameters["owner"])
if "merge" not in parameters:
parameters["merge"] = False
def validate(self, attrs): def validate(self, attrs):
method = attrs["method"] method = attrs["method"]
@ -1258,6 +1260,12 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
write_only=True, write_only=True,
) )
merge = serializers.BooleanField(
default=False,
write_only=True,
required=False,
)
def get_object_class(self, object_type): def get_object_class(self, object_type):
object_class = None object_class = None
if object_type == "tags": 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.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2) 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") @mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_insufficient_permissions_ownership(self, m): def test_insufficient_permissions_ownership(self, m):
""" """

View File

@ -700,8 +700,8 @@ class TestBulkEditObjectPermissions(APITestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
user = User.objects.create_superuser(username="temp_admin") self.temp_admin = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=user) self.client.force_authenticate(user=self.temp_admin)
self.t1 = Tag.objects.create(name="t1") self.t1 = Tag.objects.create(name="t1")
self.t2 = Tag.objects.create(name="t2") 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(response.status_code, status.HTTP_200_OK)
self.assertEqual(StoragePath.objects.get(pk=self.sp1.id).owner, self.user3) 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): def test_bulk_edit_object_permissions_insufficient_perms(self):
""" """
GIVEN: GIVEN:

View File

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

View File

@ -47,3 +47,11 @@ class HttpRemoteUserMiddleware(PersistentRemoteUserMiddleware):
""" """
header = settings.HTTP_REMOTE_USER_HEADER_NAME 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. # regular login in case the provided user does not exist.
MIDDLEWARE.insert(_index + 1, "paperless.auth.AutoLoginMiddleware") 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: def _parse_remote_user_settings() -> str:
MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware") global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK
AUTHENTICATION_BACKENDS.insert(0, "django.contrib.auth.backends.RemoteUserBackend") enable = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].append( enable_api = __get_boolean("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API")
"rest_framework.authentication.RemoteUserAuthentication", 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 for embedded PDF display:
X_FRAME_OPTIONS = "ANY" if DEBUG else "SAMEORIGIN" 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")