Merge branch 'dev' into feat/read-only-container
This commit is contained in:
commit
3b8205fe9d
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -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 }}
|
||||||
|
78
docs/api.md
78
docs/api.md
@ -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.
|
||||||
|
@ -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).
|
||||||
|
@ -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">
|
||||||
|
@ -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) {
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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">
|
||||||
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
@ -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">
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
@ -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/`
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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>()
|
||||||
|
|
||||||
|
@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -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 {
|
||||||
|
@ -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()
|
||||||
})
|
})
|
||||||
|
@ -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: () => {
|
||||||
|
@ -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/`
|
||||||
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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]
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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":
|
||||||
|
110
src/documents/test_bulk_edit.py
Normal file
110
src/documents/test_bulk_edit.py
Normal 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)
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
|
||||||
|
110
src/paperless/tests/test_remote_user.py
Normal file
110
src/paperless/tests/test_remote_user.py
Normal 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")
|
Loading…
x
Reference in New Issue
Block a user