mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Feature: Implement custom fields for documents (#4502)
Adds custom fields of certain data types, attachable to documents and searchable Co-Authored-By: Trenton H <797416+stumpylog@users.noreply.github.com>
This commit is contained in:
parent
800f54f263
commit
10729f0362
1
Pipfile
1
Pipfile
@ -16,6 +16,7 @@ django-extensions = "*"
|
||||
django-filter = "~=23.3"
|
||||
djangorestframework = "~=3.14"
|
||||
djangorestframework-guardian = "*"
|
||||
drf-writable-nested = "*"
|
||||
filelock = "*"
|
||||
gunicorn = "*"
|
||||
imap-tools = "*"
|
||||
|
10
Pipfile.lock
generated
10
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "7b4272de2042a346f3252ae20e7bbeee60c375381f59526caa35511a706d4977"
|
||||
"sha256": "3c380d590439f008ec85f1d5821ed96b4ebd56fcee3f287e6e0a6f5923262229"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {},
|
||||
@ -522,6 +522,14 @@
|
||||
"index": "pypi",
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"drf-writable-nested": {
|
||||
"hashes": [
|
||||
"sha256:154c0381e8a3a477e0fd539d5e1caf8ff4c1097a9c0c0fe741d4858b11b0455b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==0.7.0"
|
||||
},
|
||||
"exceptiongroup": {
|
||||
"hashes": [
|
||||
"sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9",
|
||||
|
@ -80,7 +80,7 @@ django_checks() {
|
||||
|
||||
search_index() {
|
||||
|
||||
local -r index_version=6
|
||||
local -r index_version=7
|
||||
local -r index_version_file=${DATA_DIR}/.index_version
|
||||
|
||||
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
|
||||
|
@ -20,6 +20,7 @@ The API provides the following main endpoints:
|
||||
- `/api/users/`: Full CRUD support.
|
||||
- `/api/groups/`: Full CRUD support.
|
||||
- `/api/share_links/`: Full CRUD support.
|
||||
- `/api/custom_fields/`: Full CRUD support.
|
||||
|
||||
All of these endpoints except for the logging endpoint allow you to
|
||||
fetch (and edit and delete where appropriate) individual objects by
|
||||
@ -51,6 +52,8 @@ fields:
|
||||
- `notes`: Array of notes associated with the document.
|
||||
- `set_permissions`: Allows setting document permissions. Optional,
|
||||
write-only. See [below](#permissions).
|
||||
- `custom_fields`: Array of custom fields & values, specified as
|
||||
{ field: CUSTOM_FIELD_ID, value: VALUE }
|
||||
|
||||
## Downloading documents
|
||||
|
||||
|
@ -322,6 +322,38 @@ applied. You can use the following placeholders:
|
||||
- `{added_month_name_short}`: added month short name
|
||||
- `{added_day}`: added day
|
||||
|
||||
## Custom Fields {#custom-fields}
|
||||
|
||||
Paperless-ngx supports the use of custom fields for documents as of v2.0, allowing a user
|
||||
to optionally attach data to documents which does not fit in the existing set of fields
|
||||
Paperless-ngx provides.
|
||||
|
||||
1. First, create a custom field (under "Manage"), with a given name and data type. This could be something like "Invoice Number" or "Date Paid", with a data type of "Number", "Date", "String", etc.
|
||||
2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field and click "Add". Once the field is visible in the form you can enter the appropriate
|
||||
data which will be validated according to the custom field "data type".
|
||||
3. Fields can be removed by hovering over the field name revealing a "Remove" button.
|
||||
|
||||
!!! important
|
||||
|
||||
Added / removed fields, as well as any data is not saved to the document until you
|
||||
actually hit the "Save" button, similar to other changes on the document details page.
|
||||
|
||||
!!! note
|
||||
|
||||
Once the data type for a field is set, it cannot be changed.
|
||||
|
||||
Multiple fields may be attached to a document but the same field name cannot be assigned multiple times to the a single document.
|
||||
|
||||
The following custom field types are supported:
|
||||
|
||||
- `Text`: any text
|
||||
- `Boolean`: true / false (check / unchecked) field
|
||||
- `Date`: date
|
||||
- `URL`: a valid url
|
||||
- `Integer`: integer number e.g. 12
|
||||
- `Number`: float number e.g. 12.3456
|
||||
- `Monetary`: float number with exactly two decimals, e.g. 12.30
|
||||
|
||||
## Best practices {#basic-searching}
|
||||
|
||||
Paperless offers a couple tools that help you organize your document
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,7 @@ import {
|
||||
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
@ -189,6 +190,17 @@ export const routes: Routes = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'customfields',
|
||||
component: CustomFieldsComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.CustomField,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'templates',
|
||||
component: ConsumptionTemplatesComponent,
|
||||
|
@ -39,6 +39,7 @@ import { NgxFileDropModule } from 'ngx-file-drop'
|
||||
import { TextComponent } from './components/common/input/text/text.component'
|
||||
import { SelectComponent } from './components/common/input/select/select.component'
|
||||
import { CheckComponent } from './components/common/input/check/check.component'
|
||||
import { UrlComponent } from './components/common/input/url/url.component'
|
||||
import { PasswordComponent } from './components/common/input/password/password.component'
|
||||
import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component'
|
||||
import { TagsComponent } from './components/common/input/tags/tags.component'
|
||||
@ -101,6 +102,9 @@ import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
|
||||
import localeAf from '@angular/common/locales/af'
|
||||
import localeAr from '@angular/common/locales/ar'
|
||||
@ -200,6 +204,7 @@ function initializeApp(settings: SettingsService) {
|
||||
TextComponent,
|
||||
SelectComponent,
|
||||
CheckComponent,
|
||||
UrlComponent,
|
||||
PasswordComponent,
|
||||
SaveViewConfigDialogComponent,
|
||||
TagsComponent,
|
||||
@ -246,6 +251,9 @@ function initializeApp(settings: SettingsService) {
|
||||
MailComponent,
|
||||
UsersAndGroupsComponent,
|
||||
FileDropComponent,
|
||||
CustomFieldsComponent,
|
||||
CustomFieldEditDialogComponent,
|
||||
CustomFieldsDropdownComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
|
@ -159,17 +159,24 @@
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#hash"/>
|
||||
</svg><span> <ng-container i18n>Document types</ng-container></span>
|
||||
</svg><span> <ng-container i18n>Document Types</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#folder"/>
|
||||
</svg><span> <ng-container i18n>Storage paths</ng-container></span>
|
||||
</svg><span> <ng-container i18n>Storage Paths</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#ui-radios"/>
|
||||
</svg><span> <ng-container i18n>Custom Fields</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }" tourAnchor="tour.consumption-templates">
|
||||
|
@ -30,7 +30,7 @@ import { DocumentListViewService } from 'src/app/services/document-list-view.ser
|
||||
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { CdkDragDrop } from '@angular/cdk/drag-drop'
|
||||
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
|
||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
||||
|
||||
const saved_views = [
|
||||
@ -97,6 +97,7 @@ describe('AppFrameComponent', () => {
|
||||
NgbModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
DragDropModule,
|
||||
],
|
||||
providers: [
|
||||
SettingsService,
|
||||
|
@ -0,0 +1,36 @@
|
||||
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose()">
|
||||
<button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#ui-radios" />
|
||||
</svg>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Custom Fields</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<pngx-input-select class="mb-3"
|
||||
[items]="unusedFields"
|
||||
bindLabel="name"
|
||||
[(ngModel)]="field"
|
||||
[placeholder]="placeholderText"
|
||||
[notFoundText]="notFoundText"
|
||||
[disableCreateNew]="!canCreateFields"
|
||||
(createNew)="createField($event)"
|
||||
bindValue="id">
|
||||
</pngx-input-select>
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<button class="btn btn-sm btn-outline-secondary me-auto" type="button" (click)="createField()" [disabled]="!canCreateFields">
|
||||
<svg fill="currentColor" class="buttonicon-sm me-1 mb-1">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#asterisk"/>
|
||||
</svg><ng-container i18n>Create New Field</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary me-1" type="button" (click)="addField(); fieldDropdown.close()" [disabled]="field === undefined">
|
||||
<svg fill="currentColor" class="buttonicon me-1">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle"/>
|
||||
</svg><ng-container i18n>Add</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,24 @@
|
||||
.custom-fields-dropdown {
|
||||
min-width: 350px;
|
||||
|
||||
// correct position on mobile
|
||||
@media (max-width: 575.98px) {
|
||||
&.show {
|
||||
margin-left: -175px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .ng-select .ng-select-container .ng-value-container .ng-placeholder,
|
||||
::ng-deep .ng-select .ng-option,
|
||||
::ng-deep .ng-select .ng-select-container .ng-value-container .ng-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
::ng-deep .paperless-input-select .ng-select {
|
||||
min-height: calc(1em + 0.75rem + 5px);
|
||||
}
|
||||
|
||||
::ng-deep .paperless-input-select .ng-select .ng-select-container .ng-value-container .ng-input {
|
||||
top: 4px;
|
||||
}
|
@ -0,0 +1,137 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { of } from 'rxjs'
|
||||
import {
|
||||
PaperlessCustomField,
|
||||
PaperlessCustomFieldDataType,
|
||||
} from 'src/app/data/paperless-custom-field'
|
||||
import { SelectComponent } from '../input/select/select.component'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbModalModule,
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { By } from '@angular/platform-browser'
|
||||
|
||||
const fields: PaperlessCustomField[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Field 1',
|
||||
data_type: PaperlessCustomFieldDataType.Integer,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Field 2',
|
||||
data_type: PaperlessCustomFieldDataType.String,
|
||||
},
|
||||
]
|
||||
|
||||
describe('CustomFieldsDropdownComponent', () => {
|
||||
let component: CustomFieldsDropdownComponent
|
||||
let fixture: ComponentFixture<CustomFieldsDropdownComponent>
|
||||
let customFieldService: CustomFieldsService
|
||||
let toastService: ToastService
|
||||
let modalService: NgbModal
|
||||
let httpController: HttpTestingController
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [CustomFieldsDropdownComponent, SelectComponent],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgSelectModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbModalModule,
|
||||
NgbDropdownModule,
|
||||
],
|
||||
})
|
||||
customFieldService = TestBed.inject(CustomFieldsService)
|
||||
httpController = TestBed.inject(HttpTestingController)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
jest.spyOn(customFieldService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: fields.map((f) => f.id),
|
||||
count: fields.length,
|
||||
results: fields.concat([]),
|
||||
})
|
||||
)
|
||||
fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support add field', () => {
|
||||
let addedField
|
||||
component.added.subscribe((f) => (addedField = f))
|
||||
component.documentId = 11
|
||||
component.field = fields[0].id
|
||||
component.addField()
|
||||
expect(addedField).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear field on open / close, updated unused fields', () => {
|
||||
component.field = fields[1].id
|
||||
component.onOpenClose()
|
||||
expect(component.field).toBeUndefined()
|
||||
|
||||
expect(component.unusedFields).toEqual(fields)
|
||||
const updateSpy = jest.spyOn(
|
||||
CustomFieldsDropdownComponent.prototype as any,
|
||||
'updateUnusedFields'
|
||||
)
|
||||
component.existingFields = [{ field: fields[1].id } as any]
|
||||
component.onOpenClose()
|
||||
expect(updateSpy).toHaveBeenCalled()
|
||||
expect(component.unusedFields).toEqual([fields[0]])
|
||||
})
|
||||
|
||||
it('should support creating field, show error if necessary', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const getFieldsSpy = jest.spyOn(
|
||||
CustomFieldsDropdownComponent.prototype as any,
|
||||
'getFields'
|
||||
)
|
||||
|
||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
|
||||
createButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating field' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(getFieldsSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(fields[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(getFieldsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support creating field with name', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
component.createField('Foo bar')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
expect(editDialog.object.name).toEqual('Foo bar')
|
||||
})
|
||||
})
|
@ -0,0 +1,131 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, first, takeUntil } from 'rxjs'
|
||||
import { PaperlessCustomField } from 'src/app/data/paperless-custom-field'
|
||||
import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-fields-dropdown',
|
||||
templateUrl: './custom-fields-dropdown.component.html',
|
||||
styleUrls: ['./custom-fields-dropdown.component.scss'],
|
||||
})
|
||||
export class CustomFieldsDropdownComponent implements OnDestroy {
|
||||
@Input()
|
||||
documentId: number
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
@Input()
|
||||
existingFields: PaperlessCustomFieldInstance[] = []
|
||||
|
||||
@Output()
|
||||
added: EventEmitter<PaperlessCustomField> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
created: EventEmitter<PaperlessCustomField> = new EventEmitter()
|
||||
|
||||
private customFields: PaperlessCustomField[] = []
|
||||
public unusedFields: PaperlessCustomField[]
|
||||
|
||||
public name: string
|
||||
|
||||
public field: number
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
get placeholderText(): string {
|
||||
return $localize`Choose field`
|
||||
}
|
||||
|
||||
get notFoundText(): string {
|
||||
return $localize`No unused fields found`
|
||||
}
|
||||
|
||||
get canCreateFields(): boolean {
|
||||
return this.permissionsService.currentUserCan(
|
||||
PermissionAction.Add,
|
||||
PermissionType.CustomField
|
||||
)
|
||||
}
|
||||
|
||||
constructor(
|
||||
private customFieldsService: CustomFieldsService,
|
||||
private modalService: NgbModal,
|
||||
private toastService: ToastService,
|
||||
private permissionsService: PermissionsService
|
||||
) {
|
||||
this.getFields()
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(this)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
private getFields() {
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => {
|
||||
this.customFields = result.results
|
||||
this.updateUnusedFields()
|
||||
})
|
||||
}
|
||||
|
||||
public getCustomFieldFromInstance(
|
||||
instance: PaperlessCustomFieldInstance
|
||||
): PaperlessCustomField {
|
||||
return this.customFields.find((f) => f.id === instance.field)
|
||||
}
|
||||
|
||||
private updateUnusedFields() {
|
||||
this.unusedFields = this.customFields.filter(
|
||||
(f) =>
|
||||
!this.existingFields?.find(
|
||||
(e) => this.getCustomFieldFromInstance(e)?.id === f.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
onOpenClose() {
|
||||
this.field = undefined
|
||||
this.updateUnusedFields()
|
||||
}
|
||||
|
||||
addField() {
|
||||
this.added.emit(this.customFields.find((f) => f.id === this.field))
|
||||
}
|
||||
|
||||
createField(newName: string = null) {
|
||||
const modal = this.modalService.open(CustomFieldEditDialogComponent)
|
||||
if (newName) modal.componentInstance.object = { name: newName }
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newField) => {
|
||||
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
|
||||
this.customFieldsService.clearCache()
|
||||
this.getFields()
|
||||
this.created.emit(newField)
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving field.`, e)
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
|
||||
<small class="d-block mt-n2" *ngIf="typeFieldDisabled" i18n>Data type cannot be changed after a field is created</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
|
||||
</div>
|
||||
</form>
|
@ -0,0 +1,67 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { CustomFieldEditDialogComponent } from './custom-field-edit-dialog.component'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { SelectComponent } from '../../input/select/select.component'
|
||||
import { TextComponent } from '../../input/text/text.component'
|
||||
import { EditDialogMode } from '../edit-dialog.component'
|
||||
|
||||
describe('CustomFieldEditDialogComponent', () => {
|
||||
let component: CustomFieldEditDialogComponent
|
||||
let settingsService: SettingsService
|
||||
let fixture: ComponentFixture<CustomFieldEditDialogComponent>
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
CustomFieldEditDialogComponent,
|
||||
IfPermissionsDirective,
|
||||
IfOwnerDirective,
|
||||
SelectComponent,
|
||||
TextComponent,
|
||||
SafeHtmlPipe,
|
||||
],
|
||||
providers: [NgbActiveModal],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgSelectModule,
|
||||
NgbModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||
component = fixture.componentInstance
|
||||
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support create and edit modes', () => {
|
||||
component.dialogMode = EditDialogMode.CREATE
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||
fixture.detectChanges()
|
||||
expect(createTitleSpy).toHaveBeenCalled()
|
||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||
component.dialogMode = EditDialogMode.EDIT
|
||||
fixture.detectChanges()
|
||||
expect(editTitleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable data type select on edit', () => {
|
||||
component.dialogMode = EditDialogMode.EDIT
|
||||
fixture.detectChanges()
|
||||
component.ngOnInit()
|
||||
expect(component.objectForm.get('data_type').disabled).toBeTruthy()
|
||||
})
|
||||
})
|
@ -0,0 +1,60 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { FormGroup, FormControl } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
DATA_TYPE_LABELS,
|
||||
PaperlessCustomField,
|
||||
} from 'src/app/data/paperless-custom-field'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-field-edit-dialog',
|
||||
templateUrl: './custom-field-edit-dialog.component.html',
|
||||
styleUrls: ['./custom-field-edit-dialog.component.scss'],
|
||||
})
|
||||
export class CustomFieldEditDialogComponent
|
||||
extends EditDialogComponent<PaperlessCustomField>
|
||||
implements OnInit
|
||||
{
|
||||
constructor(
|
||||
service: CustomFieldsService,
|
||||
activeModal: NgbActiveModal,
|
||||
userService: UserService,
|
||||
settingsService: SettingsService
|
||||
) {
|
||||
super(service, activeModal, userService, settingsService)
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit()
|
||||
if (this.typeFieldDisabled) {
|
||||
this.objectForm.get('data_type').disable()
|
||||
}
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
return $localize`Create new custom field`
|
||||
}
|
||||
|
||||
getEditTitle() {
|
||||
return $localize`Edit custom field`
|
||||
}
|
||||
|
||||
getForm(): FormGroup {
|
||||
return new FormGroup({
|
||||
name: new FormControl(null),
|
||||
data_type: new FormControl(null),
|
||||
})
|
||||
}
|
||||
|
||||
getDataTypes() {
|
||||
return DATA_TYPE_LABELS
|
||||
}
|
||||
|
||||
get typeFieldDisabled(): boolean {
|
||||
return this.dialogMode === EditDialogMode.EDIT
|
||||
}
|
||||
}
|
@ -1,4 +1,12 @@
|
||||
import { Directive, ElementRef, Input, OnInit, ViewChild } from '@angular/core'
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { ControlValueAccessor } from '@angular/forms'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@ -41,6 +49,18 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
|
||||
@Input()
|
||||
error: string
|
||||
|
||||
@Input()
|
||||
hint: string
|
||||
|
||||
@Input()
|
||||
horizontal: boolean = false
|
||||
|
||||
@Input()
|
||||
removable: boolean = false
|
||||
|
||||
@Output()
|
||||
removed: EventEmitter<AbstractInputComponent<any>> = new EventEmitter()
|
||||
|
||||
value: T
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -48,7 +68,4 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
|
||||
}
|
||||
|
||||
inputId: string
|
||||
|
||||
@Input()
|
||||
hint: string
|
||||
}
|
||||
|
@ -1,5 +1,19 @@
|
||||
<div class="mb-3 form-check">
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||
<label class="form-check-label" [for]="inputId">{{title}}</label>
|
||||
<div *ngIf="hint" class="form-text text-muted">{{hint}}</div>
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
<div *ngIf="horizontal" class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
||||
<label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
|
||||
<button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||
<div class="form-check">
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||
<label *ngIf="!horizontal" class="form-check-label" [for]="inputId">{{title}}</label>
|
||||
<div *ngIf="hint" class="form-text text-muted">{{hint}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,26 +1,37 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label" [for]="inputId">{{title}}</label>
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
|
||||
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
|
||||
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
|
||||
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
|
||||
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ fitlerButtonTitle }}">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#filter" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="mb-3" [class.pb-3]="error">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
<label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
|
||||
<button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
|
||||
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
|
||||
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
|
||||
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="buttonicon">
|
||||
<use _ngcontent-ng-c3750736003="" xlink:href="assets/bootstrap-icons.svg#calendar"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ fitlerButtonTitle }}">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#filter" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="invalid-feedback position-absolute top-100" i18n>Invalid date.</div>
|
||||
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
|
||||
<small *ngIf="getSuggestions().length > 0">
|
||||
<span i18n>Suggestions:</span>
|
||||
<ng-container *ngFor="let s of getSuggestions()">
|
||||
<a (click)="onSuggestionClick(s)" [routerLink]="[]">{{s}}</a>
|
||||
</ng-container>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalid-feedback" i18n>Invalid date.</div>
|
||||
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
|
||||
<small *ngIf="getSuggestions().length > 0">
|
||||
<span i18n>Suggestions:</span>
|
||||
<ng-container *ngFor="let s of getSuggestions()">
|
||||
<a (click)="onSuggestionClick(s)" [routerLink]="[]">{{s}}</a>
|
||||
</ng-container>
|
||||
</small>
|
||||
</div>
|
||||
|
@ -1,12 +1,22 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label" [for]="inputId">{{title}}</label>
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<input #inputField type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
|
||||
<button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
|
||||
<div class="mb-3" [class.pb-3]="error">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
<label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
|
||||
<button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<input #inputField type="number" class="form-control" [step]="step" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
|
||||
<button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
|
||||
</div>
|
||||
<div class="invalid-feedback position-absolute top-100">
|
||||
{{error}}
|
||||
</div>
|
||||
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalid-feedback">
|
||||
{{error}}
|
||||
</div>
|
||||
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
|
||||
|
||||
</div>
|
||||
|
@ -46,4 +46,18 @@ describe('NumberComponent', () => {
|
||||
component.nextAsn()
|
||||
expect(component.value).toEqual(1002)
|
||||
})
|
||||
|
||||
it('should support float & monetary values', () => {
|
||||
component.writeValue(11.13)
|
||||
expect(component.value).toEqual(11)
|
||||
component.step = 0.01
|
||||
component.writeValue(11.1)
|
||||
expect(component.value).toEqual('11.10')
|
||||
component.step = 0.1
|
||||
component.writeValue(12.3456)
|
||||
expect(component.value).toEqual(12.3456)
|
||||
// float (step = .1) doesnt force 2 decimals
|
||||
component.writeValue(11.1)
|
||||
expect(component.value).toEqual(11.1)
|
||||
})
|
||||
})
|
||||
|
@ -19,6 +19,9 @@ export class NumberComponent extends AbstractInputComponent<number> {
|
||||
@Input()
|
||||
showAdd: boolean = true
|
||||
|
||||
@Input()
|
||||
step: number = 1
|
||||
|
||||
constructor(private documentService: DocumentService) {
|
||||
super()
|
||||
}
|
||||
@ -32,4 +35,10 @@ export class NumberComponent extends AbstractInputComponent<number> {
|
||||
this.onChange(this.value)
|
||||
})
|
||||
}
|
||||
|
||||
writeValue(newValue: any): void {
|
||||
if (this.step === 1) newValue = parseInt(newValue, 10)
|
||||
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
|
||||
super.writeValue(newValue)
|
||||
}
|
||||
}
|
||||
|
@ -1,44 +1,54 @@
|
||||
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
||||
<label *ngIf="title" class="form-label" [for]="inputId">{{title}}</label>
|
||||
<div [class.input-group]="allowCreateNew || showFilter">
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[style.color]="textColor"
|
||||
[style.background]="backgroundColor"
|
||||
[class.private]="isPrivate"
|
||||
[clearable]="allowNull"
|
||||
[items]="items"
|
||||
[addTag]="allowCreateNew && addItemRef"
|
||||
addTagText="Add item"
|
||||
i18n-addTagText="Used for both types, correspondents, storage paths"
|
||||
[placeholder]="placeholder"
|
||||
[multiple]="multiple"
|
||||
[bindLabel]="bindLabel"
|
||||
bindValue="id"
|
||||
(change)="onChange(value)"
|
||||
(search)="onSearch($event)"
|
||||
(focus)="clearLastSearchTerm()"
|
||||
(clear)="clearLastSearchTerm()"
|
||||
(blur)="onBlur()">
|
||||
</ng-select>
|
||||
<button *ngIf="allowCreateNew" class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus" />
|
||||
</svg>
|
||||
</button>
|
||||
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#filter" />
|
||||
</svg>
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
<label *ngIf="title" class="form-label mb-md-0" [for]="inputId">{{title}}</label>
|
||||
<button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
|
||||
<small *ngIf="getSuggestions().length > 0">
|
||||
<span i18n>Suggestions:</span>
|
||||
<ng-container *ngFor="let s of getSuggestions()">
|
||||
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
|
||||
</ng-container>
|
||||
|
||||
|
||||
</small>
|
||||
<div [class.col-md-9]="horizontal">
|
||||
<div [class.input-group]="allowCreateNew || showFilter">
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[style.color]="textColor"
|
||||
[style.background]="backgroundColor"
|
||||
[class.private]="isPrivate"
|
||||
[clearable]="allowNull"
|
||||
[items]="items"
|
||||
[addTag]="allowCreateNew && addItemRef"
|
||||
addTagText="Add item"
|
||||
i18n-addTagText="Used for both types, correspondents, storage paths"
|
||||
[placeholder]="placeholder"
|
||||
[notFoundText]="notFoundText"
|
||||
[multiple]="multiple"
|
||||
[bindLabel]="bindLabel"
|
||||
bindValue="id"
|
||||
(change)="onChange(value)"
|
||||
(search)="onSearch($event)"
|
||||
(focus)="clearLastSearchTerm()"
|
||||
(clear)="clearLastSearchTerm()"
|
||||
(blur)="onBlur()">
|
||||
</ng-select>
|
||||
<button *ngIf="allowCreateNew" class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus" />
|
||||
</svg>
|
||||
</button>
|
||||
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#filter" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
|
||||
<small *ngIf="getSuggestions().length > 0">
|
||||
<span i18n>Suggestions:</span>
|
||||
<ng-container *ngFor="let s of getSuggestions()">
|
||||
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
|
||||
</ng-container>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -88,6 +88,12 @@ export class SelectComponent extends AbstractInputComponent<number> {
|
||||
@Input()
|
||||
showFilter: boolean = false
|
||||
|
||||
@Input()
|
||||
notFoundText: string = $localize`No items found`
|
||||
|
||||
@Input()
|
||||
disableCreateNew: boolean = false
|
||||
|
||||
@Output()
|
||||
createNew = new EventEmitter<string>()
|
||||
|
||||
@ -99,7 +105,7 @@ export class SelectComponent extends AbstractInputComponent<number> {
|
||||
private _lastSearchTerm: string
|
||||
|
||||
get allowCreateNew(): boolean {
|
||||
return this.createNew.observers.length > 0
|
||||
return !this.disableCreateNew && this.createNew.observers.length > 0
|
||||
}
|
||||
|
||||
get isPrivate(): boolean {
|
||||
|
@ -1,51 +1,53 @@
|
||||
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled">
|
||||
<label class="form-label" for="tags" i18n>{{title}}</label>
|
||||
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="suggestions">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
||||
<label class="form-label mb-md-0" for="tags" i18n>{{title}}</label>
|
||||
</div>
|
||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||
<div class="input-group flex-nowrap">
|
||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[multiple]="true"
|
||||
[closeOnSelect]="false"
|
||||
[clearSearchOnAdd]="true"
|
||||
[hideSelected]="tags.length > 0"
|
||||
[addTag]="allowCreate ? createTagRef : false"
|
||||
addTagText="Add tag"
|
||||
i18n-addTagText
|
||||
(change)="onChange(value)">
|
||||
|
||||
<div class="input-group flex-nowrap">
|
||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[multiple]="true"
|
||||
[closeOnSelect]="false"
|
||||
[clearSearchOnAdd]="true"
|
||||
[hideSelected]="tags.length > 0"
|
||||
[addTag]="allowCreate ? createTagRef : false"
|
||||
addTagText="Add tag"
|
||||
i18n-addTagText
|
||||
(change)="onChange(value)">
|
||||
|
||||
<ng-template ng-label-tmp let-item="item">
|
||||
<span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)">
|
||||
<svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
<ng-template ng-label-tmp let-item="item">
|
||||
<span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)">
|
||||
<svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg>
|
||||
<pngx-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag>
|
||||
</span>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
|
||||
<div class="tag-wrap">
|
||||
<pngx-tag *ngIf="item.id && tags" class="me-2" [tag]="getTag(item.id)"></pngx-tag>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
<button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus" />
|
||||
</svg>
|
||||
<pngx-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag>
|
||||
</span>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
|
||||
<div class="tag-wrap">
|
||||
<pngx-tag *ngIf="item.id && tags" class="me-2" [tag]="getTag(item.id)"></pngx-tag>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
<button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus" />
|
||||
</svg>
|
||||
</button>
|
||||
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="hasPrivate || this.value === null" i18n-title title="Filter documents with these Tags">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#filter" />
|
||||
</svg>
|
||||
</button>
|
||||
</button>
|
||||
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="hasPrivate || this.value === null" i18n-title title="Filter documents with these Tags">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#filter" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted" *ngIf="hint">{{hint}}</small>
|
||||
<small *ngIf="getSuggestions().length > 0" class="position-absolute top-100">
|
||||
<span i18n>Suggestions:</span>
|
||||
<ng-container *ngFor="let tag of getSuggestions()">
|
||||
<a (click)="addTag(tag.id)" [routerLink]="[]">{{tag?.name}}</a>
|
||||
</ng-container>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted" *ngIf="hint">{{hint}}</small>
|
||||
<small *ngIf="getSuggestions().length > 0">
|
||||
<span i18n>Suggestions:</span>
|
||||
<ng-container *ngFor="let tag of getSuggestions()">
|
||||
<a (click)="addTag(tag.id)" [routerLink]="[]">{{tag.name}}</a>
|
||||
</ng-container>
|
||||
|
||||
|
||||
</small>
|
||||
|
||||
</div>
|
||||
|
@ -77,6 +77,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
@Input()
|
||||
showFilter: boolean = false
|
||||
|
||||
@Input()
|
||||
horizontal: boolean = false
|
||||
|
||||
@Output()
|
||||
filterDocuments = new EventEmitter<PaperlessTag[]>()
|
||||
|
||||
|
@ -1,8 +1,19 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label" [for]="inputId">{{title}}</label>
|
||||
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
|
||||
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||
<div class="invalid-feedback">
|
||||
{{error}}
|
||||
<div class="mb-3" [class.pb-3]="error">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
<label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
|
||||
<button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
|
||||
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||
<div class="invalid-feedback position-absolute top-100">
|
||||
{{error}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -0,0 +1,26 @@
|
||||
<div class="mb-3" [class.pb-3]="error">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
<label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
|
||||
<button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div [class.col-md-9]="horizontal">
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<input #inputField type="url" class="form-control" [class.is-invalid]="error" placeholder="https://" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
|
||||
<a class="btn btn-outline-secondary rounded-end" title="Open link" i18n-title [href]="value" target="_blank">
|
||||
<svg class="buttonicon mb-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#box-arrow-up-right" />
|
||||
</svg>
|
||||
</a>
|
||||
<div class="invalid-feedback position-absolute top-100">
|
||||
{{error}}
|
||||
</div>
|
||||
</div>
|
||||
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,36 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NG_VALUE_ACCESSOR,
|
||||
} from '@angular/forms'
|
||||
import { UrlComponent } from './url.component'
|
||||
|
||||
describe('TextComponent', () => {
|
||||
let component: UrlComponent
|
||||
let fixture: ComponentFixture<UrlComponent>
|
||||
let input: HTMLInputElement
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [UrlComponent],
|
||||
providers: [],
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(UrlComponent)
|
||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
input = component.inputField.nativeElement
|
||||
})
|
||||
|
||||
it('should support use of input field', () => {
|
||||
expect(component.value).toBeUndefined()
|
||||
// TODO: why doesnt this work?
|
||||
// input.value = 'foo'
|
||||
// input.dispatchEvent(new Event('change'))
|
||||
// fixture.detectChanges()
|
||||
// expect(component.value).toEqual('foo')
|
||||
})
|
||||
})
|
21
src-ui/src/app/components/common/input/url/url.component.ts
Normal file
21
src-ui/src/app/components/common/input/url/url.component.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Component, forwardRef } from '@angular/core'
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { AbstractInputComponent } from '../abstract-input'
|
||||
|
||||
@Component({
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => UrlComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
selector: 'pngx-input-url',
|
||||
templateUrl: './url.component.html',
|
||||
styleUrls: ['./url.component.scss'],
|
||||
})
|
||||
export class UrlComponent extends AbstractInputComponent<string> {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
.h2 {
|
||||
h3 {
|
||||
min-height: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.h2 {
|
||||
h3 {
|
||||
min-height: 2.8rem;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||
<button class="btn btn-sm btn-outline-primary" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#link" />
|
||||
</svg>
|
||||
|
@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ngbDropdown>
|
||||
<div class="ms-auto" ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle>
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#three-dots" />
|
||||
@ -48,59 +48,88 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pngx-custom-fields-dropdown
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||
class="me-2"
|
||||
[documentId]="documentId"
|
||||
[disabled]="!userIsOwner"
|
||||
[existingFields]="document?.custom_fields"
|
||||
(created)="refreshCustomFields()"
|
||||
(added)="addField($event)">
|
||||
</pngx-custom-fields-dropdown>
|
||||
|
||||
<pngx-share-links-dropdown [documentId]="documentId" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Close" (click)="close()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Previous" (click)="previousDoc()" [disabled]="!hasPrevious()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-left" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" i18n-title title="Next" (click)="nextDoc()" [disabled]="!hasNext()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</pngx-page-header>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-xl-4 mb-4">
|
||||
|
||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||
|
||||
<ul ngbNav #nav="ngbNav" class="nav-tabs" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
|
||||
<div class="btn-toolbar mb-1 pb-3 border-bottom">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="previousDoc()" [disabled]="!hasPrevious()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-left" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextDoc()" [disabled]="!hasNext()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="btn-group ms-auto">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<button *ngIf="hasNext()" type="button" class="btn btn-sm btn-outline-primary" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & next</button>
|
||||
<button *ngIf="!hasNext()" type="button" class="btn btn-sm btn-outline-primary" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & close</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" class="nav-underline flex-nowrap flex-md-wrap overflow-auto" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
|
||||
<li [ngbNavItem]="DocumentDetailNavIDs.Details">
|
||||
<a ngbNavLink i18n>Details</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
|
||||
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" formControlName='archive_serial_number'></pngx-input-number>
|
||||
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
|
||||
[error]="error?.created_date"></pngx-input-date>
|
||||
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
|
||||
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
|
||||
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
|
||||
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
|
||||
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
|
||||
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
|
||||
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
||||
|
||||
<div>
|
||||
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
|
||||
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
|
||||
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
||||
[error]="error?.created_date"></pngx-input-date>
|
||||
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
||||
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
|
||||
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
||||
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
|
||||
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
||||
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
|
||||
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
||||
<ng-container *ngFor="let fieldInstance of document?.custom_fields; let i = index">
|
||||
<div [formGroup]="customFieldFormFields.controls[i]">
|
||||
<pngx-input-text formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.String" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-text>
|
||||
<pngx-input-date formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Date" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-date>
|
||||
<pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Integer" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
<pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Float" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".1" [error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
<pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Monetary" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
<pngx-input-check formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Boolean" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
|
||||
<pngx-input-url formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Url" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="DocumentDetailNavIDs.Content">
|
||||
<a ngbNavLink i18n>Content</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="mb-3">
|
||||
<div>
|
||||
<textarea class="form-control" id="content" rows="20" formControlName='content' [class.rtl]="isRTL"></textarea>
|
||||
</div>
|
||||
</ng-template>
|
||||
@ -198,16 +227,8 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
|
||||
<ng-container>
|
||||
<button type="button" class="btn btn-outline-secondary me-2" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<button *ngIf="hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & next</button>
|
||||
<button *ngIf="!hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & close</button>
|
||||
<button type="submit" class="btn btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
@ -67,6 +67,9 @@ import { PageHeaderComponent } from '../common/page-header/page-header.component
|
||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||
import { DocumentDetailComponent } from './document-detail.component'
|
||||
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
|
||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
import { PaperlessCustomFieldDataType } from 'src/app/data/paperless-custom-field'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
|
||||
const doc: PaperlessDocument = {
|
||||
id: 3,
|
||||
@ -94,8 +97,31 @@ const doc: PaperlessDocument = {
|
||||
user: 2,
|
||||
},
|
||||
],
|
||||
custom_fields: [
|
||||
{
|
||||
field: 0,
|
||||
document: 3,
|
||||
created: new Date(),
|
||||
value: 'custom foo bar',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const customFields = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Field 1',
|
||||
data_type: PaperlessCustomFieldDataType.String,
|
||||
created: new Date(),
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Custom Field 2',
|
||||
data_type: PaperlessCustomFieldDataType.Integer,
|
||||
created: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
describe('DocumentDetailComponent', () => {
|
||||
let component: DocumentDetailComponent
|
||||
let fixture: ComponentFixture<DocumentDetailComponent>
|
||||
@ -107,6 +133,7 @@ describe('DocumentDetailComponent', () => {
|
||||
let toastService: ToastService
|
||||
let documentListViewService: DocumentListViewService
|
||||
let settingsService: SettingsService
|
||||
let customFieldsService: CustomFieldsService
|
||||
|
||||
let currentUserCan = true
|
||||
let currentUserHasObjectPermissions = true
|
||||
@ -136,6 +163,7 @@ describe('DocumentDetailComponent', () => {
|
||||
PdfViewerComponent,
|
||||
SafeUrlPipe,
|
||||
ShareLinksDropdownComponent,
|
||||
CustomFieldsDropdownComponent,
|
||||
],
|
||||
providers: [
|
||||
DocumentTitlePipe,
|
||||
@ -199,6 +227,7 @@ describe('DocumentDetailComponent', () => {
|
||||
}),
|
||||
},
|
||||
},
|
||||
CustomFieldsService,
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
@ -234,6 +263,7 @@ describe('DocumentDetailComponent', () => {
|
||||
toastService = TestBed.inject(ToastService)
|
||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
fixture = TestBed.createComponent(DocumentDetailComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
@ -290,6 +320,13 @@ describe('DocumentDetailComponent', () => {
|
||||
it('should load already-opened document via param', () => {
|
||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(doc)
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
count: customFields.length,
|
||||
all: customFields.map((f) => f.id),
|
||||
results: customFields,
|
||||
})
|
||||
)
|
||||
fixture.detectChanges() // calls ngOnInit
|
||||
expect(component.document).toEqual(doc)
|
||||
})
|
||||
@ -797,12 +834,92 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error retrieving metadata', error)
|
||||
})
|
||||
|
||||
it('should display custom fields', () => {
|
||||
initNormally()
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain(
|
||||
customFields[0].name
|
||||
)
|
||||
})
|
||||
|
||||
it('should support add custom field, correctly send via post', () => {
|
||||
initNormally()
|
||||
const initialLength = doc.custom_fields.length
|
||||
expect(component.customFieldFormFields).toHaveLength(initialLength)
|
||||
component.addField(customFields[1])
|
||||
fixture.detectChanges()
|
||||
expect(component.document.custom_fields).toHaveLength(initialLength + 1)
|
||||
expect(component.customFieldFormFields).toHaveLength(initialLength + 1)
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain(
|
||||
customFields[1].name
|
||||
)
|
||||
const updateSpy = jest.spyOn(documentService, 'update')
|
||||
component.save(true)
|
||||
expect(updateSpy.mock.lastCall[0].custom_fields).toHaveLength(2)
|
||||
expect(updateSpy.mock.lastCall[0].custom_fields[1]).toEqual({
|
||||
field: customFields[1].id,
|
||||
value: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('should support remove custom field, correctly send via post', () => {
|
||||
initNormally()
|
||||
const initialLength = doc.custom_fields.length
|
||||
expect(component.customFieldFormFields).toHaveLength(initialLength)
|
||||
component.removeField(doc.custom_fields[0])
|
||||
fixture.detectChanges()
|
||||
expect(component.document.custom_fields).toHaveLength(initialLength - 1)
|
||||
expect(component.customFieldFormFields).toHaveLength(initialLength - 1)
|
||||
expect(fixture.debugElement.nativeElement.textContent).not.toContain(
|
||||
'Field 1'
|
||||
)
|
||||
const updateSpy = jest.spyOn(documentService, 'update')
|
||||
component.save(true)
|
||||
expect(updateSpy.mock.lastCall[0].custom_fields).toHaveLength(
|
||||
initialLength - 1
|
||||
)
|
||||
})
|
||||
|
||||
it('should show custom field errors', () => {
|
||||
initNormally()
|
||||
component.error = {
|
||||
custom_fields: [
|
||||
{},
|
||||
{},
|
||||
{ value: ['This field may not be null.'] },
|
||||
{},
|
||||
{ non_field_errors: ['Enter a valid URL.'] },
|
||||
],
|
||||
}
|
||||
expect(component.getCustomFieldError(2)).toEqual([
|
||||
'This field may not be null.',
|
||||
])
|
||||
expect(component.getCustomFieldError(4)).toEqual(['Enter a valid URL.'])
|
||||
})
|
||||
|
||||
it('should refresh custom fields when created', () => {
|
||||
initNormally()
|
||||
const refreshSpy = jest.spyOn(component, 'refreshCustomFields')
|
||||
fixture.debugElement
|
||||
.query(By.directive(CustomFieldsDropdownComponent))
|
||||
.triggerEventHandler('created')
|
||||
expect(refreshSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
function initNormally() {
|
||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValueOnce(of(Object.assign({}, doc)))
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
|
||||
jest
|
||||
.spyOn(openDocumentsService, 'openDocument')
|
||||
.mockReturnValueOnce(of(true))
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
count: customFields.length,
|
||||
all: customFields.map((f) => f.id),
|
||||
results: customFields,
|
||||
})
|
||||
)
|
||||
fixture.detectChanges()
|
||||
}
|
||||
})
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'
|
||||
import { FormControl, FormGroup } from '@angular/forms'
|
||||
import { FormArray, FormControl, FormGroup } from '@angular/forms'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import {
|
||||
NgbDateStruct,
|
||||
@ -63,7 +63,12 @@ import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
import { FilterRule } from 'src/app/data/filter-rule'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
|
||||
import {
|
||||
PaperlessCustomField,
|
||||
PaperlessCustomFieldDataType,
|
||||
} from 'src/app/data/paperless-custom-field'
|
||||
import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
|
||||
enum DocumentDetailNavIDs {
|
||||
Details = 1,
|
||||
@ -120,6 +125,7 @@ export class DocumentDetailComponent
|
||||
archive_serial_number: new FormControl(),
|
||||
tags: new FormControl([]),
|
||||
permissions_form: new FormControl(null),
|
||||
custom_fields: new FormArray([]),
|
||||
})
|
||||
|
||||
previewCurrentPage: number = 1
|
||||
@ -135,6 +141,9 @@ export class DocumentDetailComponent
|
||||
|
||||
ogDate: Date
|
||||
|
||||
customFields: PaperlessCustomField[]
|
||||
public readonly PaperlessCustomFieldDataType = PaperlessCustomFieldDataType
|
||||
|
||||
@ViewChild('nav') nav: NgbNav
|
||||
@ViewChild('pdfPreview') set pdfPreview(element) {
|
||||
// this gets called when compontent added or removed from DOM
|
||||
@ -166,6 +175,7 @@ export class DocumentDetailComponent
|
||||
private storagePathService: StoragePathService,
|
||||
private permissionsService: PermissionsService,
|
||||
private userService: UserService,
|
||||
private customFieldsService: CustomFieldsService,
|
||||
private http: HttpClient
|
||||
) {
|
||||
super()
|
||||
@ -232,6 +242,8 @@ export class DocumentDetailComponent
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => (this.users = result.results))
|
||||
|
||||
this.getCustomFields()
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
@ -324,6 +336,7 @@ export class DocumentDetailComponent
|
||||
owner: doc.owner,
|
||||
set_permissions: doc.permissions,
|
||||
},
|
||||
custom_fields: doc.custom_fields,
|
||||
})
|
||||
|
||||
this.isDirty$ = dirtyCheck(
|
||||
@ -385,6 +398,8 @@ export class DocumentDetailComponent
|
||||
updateComponent(doc: PaperlessDocument) {
|
||||
this.document = doc
|
||||
this.requiresPassword = false
|
||||
// this.customFields = doc.custom_fields.concat([])
|
||||
this.updateFormForCustomFields()
|
||||
this.documentsService
|
||||
.getMetadata(doc.id)
|
||||
.pipe(first())
|
||||
@ -433,6 +448,10 @@ export class DocumentDetailComponent
|
||||
if (!this.userCanEdit) this.documentForm.disable()
|
||||
}
|
||||
|
||||
get customFieldFormFields(): FormArray {
|
||||
return this.documentForm.get('custom_fields') as FormArray
|
||||
}
|
||||
|
||||
createDocumentType(newName: string) {
|
||||
var modal = this.modalService.open(DocumentTypeEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
@ -510,6 +529,7 @@ export class DocumentDetailComponent
|
||||
set_permissions: doc.permissions,
|
||||
}
|
||||
this.title = doc.title
|
||||
this.updateFormForCustomFields()
|
||||
this.documentForm.patchValue(doc)
|
||||
this.openDocumentService.setDirty(doc, false)
|
||||
},
|
||||
@ -533,6 +553,7 @@ export class DocumentDetailComponent
|
||||
close && this.close()
|
||||
this.networkActive = false
|
||||
this.error = null
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
@ -819,4 +840,61 @@ export class DocumentDetailComponent
|
||||
|
||||
this.documentListViewService.quickFilter(filterRules)
|
||||
}
|
||||
|
||||
private getCustomFields() {
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => (this.customFields = result.results))
|
||||
}
|
||||
|
||||
public refreshCustomFields() {
|
||||
this.customFieldsService.clearCache()
|
||||
this.getCustomFields()
|
||||
}
|
||||
|
||||
public getCustomFieldFromInstance(
|
||||
instance: PaperlessCustomFieldInstance
|
||||
): PaperlessCustomField {
|
||||
return this.customFields?.find((f) => f.id === instance.field)
|
||||
}
|
||||
|
||||
public getCustomFieldError(index: number) {
|
||||
const fieldError = this.error?.custom_fields?.[index]
|
||||
return fieldError?.['non_field_errors'] ?? fieldError?.['value']
|
||||
}
|
||||
|
||||
private updateFormForCustomFields(emitEvent: boolean = false) {
|
||||
this.customFieldFormFields.clear({ emitEvent: false })
|
||||
this.document.custom_fields?.forEach((fieldInstance) => {
|
||||
this.customFieldFormFields.push(
|
||||
new FormGroup({
|
||||
field: new FormControl(
|
||||
this.getCustomFieldFromInstance(fieldInstance)?.id
|
||||
),
|
||||
value: new FormControl(fieldInstance.value),
|
||||
}),
|
||||
{ emitEvent }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
public addField(field: PaperlessCustomField) {
|
||||
this.document.custom_fields.push({
|
||||
field: field.id,
|
||||
value: null,
|
||||
document: this.documentId,
|
||||
created: new Date(),
|
||||
})
|
||||
this.updateFormForCustomFields(true)
|
||||
}
|
||||
|
||||
public removeField(fieldInstance: PaperlessCustomFieldInstance) {
|
||||
this.document.custom_fields.splice(
|
||||
this.document.custom_fields.indexOf(fieldInstance),
|
||||
1
|
||||
)
|
||||
this.updateFormForCustomFields(true)
|
||||
this.documentForm.updateValueAndValidity()
|
||||
}
|
||||
}
|
||||
|
@ -46,6 +46,7 @@ import {
|
||||
FILTER_OWNER_ANY,
|
||||
FILTER_OWNER_DOES_NOT_INCLUDE,
|
||||
FILTER_OWNER_ISNULL,
|
||||
FILTER_CUSTOM_FIELDS,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
|
||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
|
||||
@ -240,6 +241,18 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.textFilterTarget).toEqual('asn') // TEXT_FILTER_TARGET_ASN
|
||||
}))
|
||||
|
||||
it('should ingest text filter rules for custom fields', fakeAsync(() => {
|
||||
expect(component.textFilter).toEqual(null)
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS,
|
||||
value: 'foo',
|
||||
},
|
||||
]
|
||||
expect(component.textFilter).toEqual('foo')
|
||||
expect(component.textFilterTarget).toEqual('custom-fields') // TEXT_FILTER_TARGET_CUSTOM_FIELDS
|
||||
}))
|
||||
|
||||
it('should ingest text filter rules for doc asn is null', fakeAsync(() => {
|
||||
expect(component.textFilterTarget).toEqual('title-content')
|
||||
expect(component.textFilterModifier).toEqual('equals') // TEXT_FILTER_MODIFIER_EQUALS
|
||||
@ -956,12 +969,30 @@ describe('FilterEditorComponent', () => {
|
||||
])
|
||||
}))
|
||||
|
||||
it('should convert user input to correct filter rules on full text query', fakeAsync(() => {
|
||||
it('should convert user input to correct filter rules on custom fields query', fakeAsync(() => {
|
||||
component.textFilterInput.nativeElement.value = 'foo'
|
||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(NgbDropdownItem)
|
||||
)[3]
|
||||
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_CUSTOM_FIELDS
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.textFilterTarget).toEqual('custom-fields')
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS,
|
||||
value: 'foo',
|
||||
},
|
||||
])
|
||||
}))
|
||||
|
||||
it('should convert user input to correct filter rules on full text query', fakeAsync(() => {
|
||||
component.textFilterInput.nativeElement.value = 'foo'
|
||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(NgbDropdownItem)
|
||||
)[4]
|
||||
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
|
@ -48,6 +48,7 @@ import {
|
||||
FILTER_OWNER_DOES_NOT_INCLUDE,
|
||||
FILTER_OWNER_ISNULL,
|
||||
FILTER_OWNER_ANY,
|
||||
FILTER_CUSTOM_FIELDS,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import {
|
||||
FilterableDropdownSelectionModel,
|
||||
@ -74,6 +75,7 @@ const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
|
||||
const TEXT_FILTER_TARGET_ASN = 'asn'
|
||||
const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
|
||||
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
|
||||
const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
|
||||
|
||||
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
|
||||
const TEXT_FILTER_MODIFIER_NULL = 'is null'
|
||||
@ -204,6 +206,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
||||
name: $localize`Title & content`,
|
||||
},
|
||||
{ id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` },
|
||||
{
|
||||
id: TEXT_FILTER_TARGET_CUSTOM_FIELDS,
|
||||
name: $localize`Custom fields`,
|
||||
},
|
||||
{
|
||||
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
|
||||
name: $localize`Advanced search`,
|
||||
@ -321,6 +327,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
||||
this._textFilter = rule.value
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
|
||||
break
|
||||
case FILTER_CUSTOM_FIELDS:
|
||||
this._textFilter = rule.value
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS
|
||||
break
|
||||
case FILTER_FULLTEXT_QUERY:
|
||||
let allQueryArgs = rule.value.split(',')
|
||||
let textQueryArgs = []
|
||||
@ -552,6 +562,15 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
this._textFilter &&
|
||||
this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_CUSTOM_FIELDS,
|
||||
value: this._textFilter,
|
||||
})
|
||||
}
|
||||
if (
|
||||
this._textFilter &&
|
||||
this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
||||
|
@ -0,0 +1,41 @@
|
||||
<pngx-page-header title="Custom Fields">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add Field</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<ul class="list-group">
|
||||
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col" i18n>Data Type</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li *ngFor="let field of fields" class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
|
||||
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
|
||||
</svg> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li *ngIf="fields.length === 0" class="list-group-item" i18n>No fields defined.</li>
|
||||
</ul>
|
@ -0,0 +1,162 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { CustomFieldsComponent } from './custom-fields.component'
|
||||
import {
|
||||
PaperlessCustomField,
|
||||
PaperlessCustomFieldDataType,
|
||||
} from 'src/app/data/paperless-custom-field'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbPaginationModule,
|
||||
NgbModalModule,
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
|
||||
const fields: PaperlessCustomField[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Field 1',
|
||||
data_type: PaperlessCustomFieldDataType.String,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Field 2',
|
||||
data_type: PaperlessCustomFieldDataType.Integer,
|
||||
},
|
||||
]
|
||||
|
||||
describe('CustomFieldsComponent', () => {
|
||||
let component: CustomFieldsComponent
|
||||
let fixture: ComponentFixture<CustomFieldsComponent>
|
||||
let customFieldsService: CustomFieldsService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
CustomFieldsComponent,
|
||||
IfPermissionsDirective,
|
||||
PageHeaderComponent,
|
||||
ConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
currentUserCan: () => true,
|
||||
currentUserHasObjectPermissions: () => true,
|
||||
currentUserOwnsObject: () => true,
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgbPaginationModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbModalModule,
|
||||
],
|
||||
})
|
||||
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
count: fields.length,
|
||||
all: fields.map((o) => o.id),
|
||||
results: fields,
|
||||
})
|
||||
)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
|
||||
fixture = TestBed.createComponent(CustomFieldsComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support create, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[0]
|
||||
createButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(fields[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support edit, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const editButton = fixture.debugElement.queryAll(By.css('button'))[1]
|
||||
editButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
expect(editDialog.object).toEqual(fields[0])
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error editing item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(fields[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support delete, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const deleteSpy = jest.spyOn(customFieldsService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[3]
|
||||
deleteButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as ConfirmDialogComponent
|
||||
|
||||
// fail first
|
||||
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
|
||||
editDialog.confirmClicked.emit()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
deleteSpy.mockReturnValueOnce(of(true))
|
||||
editDialog.confirmClicked.emit()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
@ -0,0 +1,98 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import {
|
||||
DATA_TYPE_LABELS,
|
||||
PaperlessCustomField,
|
||||
} from 'src/app/data/paperless-custom-field'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-fields',
|
||||
templateUrl: './custom-fields.component.html',
|
||||
styleUrls: ['./custom-fields.component.scss'],
|
||||
})
|
||||
export class CustomFieldsComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
public fields: PaperlessCustomField[] = []
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
constructor(
|
||||
private customFieldsService: CustomFieldsService,
|
||||
public permissionsService: PermissionsService,
|
||||
private modalService: NgbModal,
|
||||
private toastService: ToastService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((r) => {
|
||||
this.fields = r.results
|
||||
})
|
||||
}
|
||||
|
||||
editField(field: PaperlessCustomField) {
|
||||
const modal = this.modalService.open(CustomFieldEditDialogComponent)
|
||||
modal.componentInstance.dialogMode = field
|
||||
? EditDialogMode.EDIT
|
||||
: EditDialogMode.CREATE
|
||||
modal.componentInstance.object = field
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newField) => {
|
||||
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
|
||||
this.customFieldsService.clearCache()
|
||||
this.reload()
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving field.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
deleteField(field: PaperlessCustomField) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete field`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this field.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.customFieldsService.delete(field).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Deleted field`)
|
||||
this.customFieldsService.clearCache()
|
||||
this.reload()
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error deleting field.`, e)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getDataType(field: PaperlessCustomField): string {
|
||||
return DATA_TYPE_LABELS.find((l) => l.id === field.data_type).name
|
||||
}
|
||||
}
|
@ -46,6 +46,8 @@ export const FILTER_OWNER_ANY = 33
|
||||
export const FILTER_OWNER_ISNULL = 34
|
||||
export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
|
||||
|
||||
export const FILTER_CUSTOM_FIELDS = 36
|
||||
|
||||
export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
{
|
||||
id: FILTER_TITLE,
|
||||
@ -271,6 +273,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
datatype: 'number',
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
id: FILTER_CUSTOM_FIELDS,
|
||||
filtervar: 'custom_fields__icontains',
|
||||
datatype: 'string',
|
||||
multi: false,
|
||||
},
|
||||
]
|
||||
|
||||
export interface FilterRuleType {
|
||||
|
9
src-ui/src/app/data/paperless-custom-field-instance.ts
Normal file
9
src-ui/src/app/data/paperless-custom-field-instance.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
import { PaperlessCustomField } from './paperless-custom-field'
|
||||
|
||||
export interface PaperlessCustomFieldInstance extends ObjectWithId {
|
||||
document: number // PaperlessDocument
|
||||
field: number // PaperlessCustomField
|
||||
created: Date
|
||||
value?: any
|
||||
}
|
48
src-ui/src/app/data/paperless-custom-field.ts
Normal file
48
src-ui/src/app/data/paperless-custom-field.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export enum PaperlessCustomFieldDataType {
|
||||
String = 'string',
|
||||
Url = 'url',
|
||||
Date = 'date',
|
||||
Boolean = 'boolean',
|
||||
Integer = 'integer',
|
||||
Float = 'float',
|
||||
Monetary = 'monetary',
|
||||
}
|
||||
|
||||
export const DATA_TYPE_LABELS = [
|
||||
{
|
||||
id: PaperlessCustomFieldDataType.Boolean,
|
||||
name: $localize`Boolean`,
|
||||
},
|
||||
{
|
||||
id: PaperlessCustomFieldDataType.Date,
|
||||
name: $localize`Date`,
|
||||
},
|
||||
{
|
||||
id: PaperlessCustomFieldDataType.Integer,
|
||||
name: $localize`Integer`,
|
||||
},
|
||||
{
|
||||
id: PaperlessCustomFieldDataType.Float,
|
||||
name: $localize`Number`,
|
||||
},
|
||||
{
|
||||
id: PaperlessCustomFieldDataType.Monetary,
|
||||
name: $localize`Monetary`,
|
||||
},
|
||||
{
|
||||
id: PaperlessCustomFieldDataType.String,
|
||||
name: $localize`Text`,
|
||||
},
|
||||
{
|
||||
id: PaperlessCustomFieldDataType.Url,
|
||||
name: $localize`Url`,
|
||||
},
|
||||
]
|
||||
|
||||
export interface PaperlessCustomField extends ObjectWithId {
|
||||
data_type: PaperlessCustomFieldDataType
|
||||
name: string
|
||||
created?: Date
|
||||
}
|
@ -5,6 +5,7 @@ import { Observable } from 'rxjs'
|
||||
import { PaperlessStoragePath } from './paperless-storage-path'
|
||||
import { ObjectWithPermissions } from './object-with-permissions'
|
||||
import { PaperlessDocumentNote } from './paperless-document-note'
|
||||
import { PaperlessCustomFieldInstance } from './paperless-custom-field-instance'
|
||||
|
||||
export interface SearchHit {
|
||||
score?: number
|
||||
@ -58,4 +59,6 @@ export interface PaperlessDocument extends ObjectWithPermissions {
|
||||
notes?: PaperlessDocumentNote[]
|
||||
|
||||
__search_hit__?: SearchHit
|
||||
|
||||
custom_fields?: PaperlessCustomFieldInstance[]
|
||||
}
|
||||
|
@ -256,6 +256,10 @@ describe('PermissionsService', () => {
|
||||
'view_consumptiontemplate',
|
||||
'change_consumptiontemplate',
|
||||
'delete_consumptiontemplate',
|
||||
'add_customfield',
|
||||
'view_customfield',
|
||||
'change_customfield',
|
||||
'delete_customfield',
|
||||
],
|
||||
{
|
||||
username: 'testuser',
|
||||
|
@ -26,6 +26,7 @@ export enum PermissionType {
|
||||
Admin = '%s_logentry',
|
||||
ShareLink = '%s_sharelink',
|
||||
ConsumptionTemplate = '%s_consumptiontemplate',
|
||||
CustomField = '%s_customfield',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
14
src-ui/src/app/services/rest/custom-fields.service.spec.ts
Normal file
14
src-ui/src/app/services/rest/custom-fields.service.spec.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||
import { CustomFieldsService } from './custom-fields.service'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: CustomFieldsService
|
||||
let subscription: Subscription
|
||||
const endpoint = 'custom_fields'
|
||||
|
||||
// run common tests
|
||||
commonAbstractPaperlessServiceTests(endpoint, CustomFieldsService)
|
15
src-ui/src/app/services/rest/custom-fields.service.ts
Normal file
15
src-ui/src/app/services/rest/custom-fields.service.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
import { Observable } from 'rxjs'
|
||||
import { PaperlessCustomField } from 'src/app/data/paperless-custom-field'
|
||||
import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CustomFieldsService extends AbstractPaperlessService<PaperlessCustomField> {
|
||||
constructor(http: HttpClient) {
|
||||
super(http, 'custom_fields')
|
||||
}
|
||||
}
|
@ -384,6 +384,14 @@ ul.pagination {
|
||||
}
|
||||
}
|
||||
|
||||
.nav-underline {
|
||||
.nav-link {
|
||||
&.active, &:hover, &:focus {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ng-select-container,
|
||||
.ng-select.ng-select-opened > .ng-select-container,
|
||||
.ng-dropdown-panel,
|
||||
@ -661,3 +669,17 @@ code {
|
||||
.cdk-drag-animating {
|
||||
transition: transform 300ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hidden-button-container {
|
||||
button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
&:hover {
|
||||
button {
|
||||
opacity: 1;
|
||||
pointer-events: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ from django.contrib import admin
|
||||
from guardian.admin import GuardedModelAdmin
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import Note
|
||||
@ -144,6 +146,20 @@ class ShareLinksAdmin(GuardedModelAdmin):
|
||||
list_display_links = ("created",)
|
||||
|
||||
|
||||
class CustomFieldsAdmin(GuardedModelAdmin):
|
||||
fields = ("name", "created", "data_type")
|
||||
readonly_fields = ("created", "data_type")
|
||||
list_display = ("name", "created", "data_type")
|
||||
list_filter = ("created", "data_type")
|
||||
|
||||
|
||||
class CustomFieldInstancesAdmin(GuardedModelAdmin):
|
||||
fields = ("field", "document", "created", "value")
|
||||
readonly_fields = ("field", "document", "created", "value")
|
||||
list_display = ("field", "document", "value", "created")
|
||||
list_filter = ("document", "created")
|
||||
|
||||
|
||||
admin.site.register(Correspondent, CorrespondentAdmin)
|
||||
admin.site.register(Tag, TagAdmin)
|
||||
admin.site.register(DocumentType, DocumentTypeAdmin)
|
||||
@ -153,6 +169,8 @@ admin.site.register(StoragePath, StoragePathAdmin)
|
||||
admin.site.register(PaperlessTask, TaskAdmin)
|
||||
admin.site.register(Note, NotesAdmin)
|
||||
admin.site.register(ShareLink, ShareLinksAdmin)
|
||||
admin.site.register(CustomField, CustomFieldsAdmin)
|
||||
admin.site.register(CustomFieldInstance, CustomFieldInstancesAdmin)
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
|
||||
|
@ -82,6 +82,21 @@ class TitleContentFilter(Filter):
|
||||
return qs
|
||||
|
||||
|
||||
class CustomFieldsFilter(Filter):
|
||||
def filter(self, qs, value):
|
||||
if value:
|
||||
return (
|
||||
qs.filter(custom_fields__field__name__icontains=value)
|
||||
| qs.filter(custom_fields__value_text__icontains=value)
|
||||
| qs.filter(custom_fields__value_bool__icontains=value)
|
||||
| qs.filter(custom_fields__value_int__icontains=value)
|
||||
| qs.filter(custom_fields__value_date__icontains=value)
|
||||
| qs.filter(custom_fields__value_url__icontains=value)
|
||||
)
|
||||
else:
|
||||
return qs
|
||||
|
||||
|
||||
class DocumentFilterSet(FilterSet):
|
||||
is_tagged = BooleanFilter(
|
||||
label="Is tagged",
|
||||
@ -108,6 +123,8 @@ class DocumentFilterSet(FilterSet):
|
||||
|
||||
owner__id__none = ObjectFilter(field_name="owner", exclude=True)
|
||||
|
||||
custom_fields__icontains = CustomFieldsFilter()
|
||||
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = {
|
||||
@ -132,6 +149,7 @@ class DocumentFilterSet(FilterSet):
|
||||
"storage_path__name": CHAR_KWARGS,
|
||||
"owner": ["isnull"],
|
||||
"owner__id": ID_KWARGS,
|
||||
"custom_fields": ["icontains"],
|
||||
}
|
||||
|
||||
|
||||
|
@ -30,6 +30,8 @@ from whoosh.searching import ResultsPage
|
||||
from whoosh.searching import Searcher
|
||||
from whoosh.writing import AsyncWriter
|
||||
|
||||
# from documents.models import CustomMetadata
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import Note
|
||||
from documents.models import User
|
||||
@ -60,6 +62,8 @@ def get_schema():
|
||||
has_path=BOOLEAN(),
|
||||
notes=TEXT(),
|
||||
num_notes=NUMERIC(sortable=True, signed=False),
|
||||
custom_fields=TEXT(),
|
||||
custom_field_count=NUMERIC(sortable=True, signed=False),
|
||||
owner=TEXT(),
|
||||
owner_id=NUMERIC(),
|
||||
has_owner=BOOLEAN(),
|
||||
@ -69,7 +73,7 @@ def get_schema():
|
||||
)
|
||||
|
||||
|
||||
def open_index(recreate=False):
|
||||
def open_index(recreate=False) -> FileIndex:
|
||||
try:
|
||||
if exists_in(settings.INDEX_DIR) and not recreate:
|
||||
return open_dir(settings.INDEX_DIR, schema=get_schema())
|
||||
@ -82,7 +86,7 @@ def open_index(recreate=False):
|
||||
|
||||
|
||||
@contextmanager
|
||||
def open_index_writer(optimize=False):
|
||||
def open_index_writer(optimize=False) -> AsyncWriter:
|
||||
writer = AsyncWriter(open_index())
|
||||
|
||||
try:
|
||||
@ -95,7 +99,7 @@ def open_index_writer(optimize=False):
|
||||
|
||||
|
||||
@contextmanager
|
||||
def open_index_searcher():
|
||||
def open_index_searcher() -> Searcher:
|
||||
searcher = open_index().searcher()
|
||||
|
||||
try:
|
||||
@ -108,6 +112,9 @@ def update_document(writer: AsyncWriter, doc: Document):
|
||||
tags = ",".join([t.name for t in doc.tags.all()])
|
||||
tags_ids = ",".join([str(t.id) for t in doc.tags.all()])
|
||||
notes = ",".join([str(c.note) for c in Note.objects.filter(document=doc)])
|
||||
custom_fields = ",".join(
|
||||
[str(c) for c in CustomFieldInstance.objects.filter(document=doc)],
|
||||
)
|
||||
asn = doc.archive_serial_number
|
||||
if asn is not None and (
|
||||
asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
|
||||
@ -147,6 +154,8 @@ def update_document(writer: AsyncWriter, doc: Document):
|
||||
has_path=doc.storage_path is not None,
|
||||
notes=notes,
|
||||
num_notes=len(notes),
|
||||
custom_fields=custom_fields,
|
||||
custom_field_count=len(doc.custom_fields.all()),
|
||||
owner=doc.owner.username if doc.owner else None,
|
||||
owner_id=doc.owner.id if doc.owner else None,
|
||||
has_owner=doc.owner is not None,
|
||||
@ -156,20 +165,20 @@ def update_document(writer: AsyncWriter, doc: Document):
|
||||
)
|
||||
|
||||
|
||||
def remove_document(writer, doc):
|
||||
def remove_document(writer: AsyncWriter, doc: Document):
|
||||
remove_document_by_id(writer, doc.pk)
|
||||
|
||||
|
||||
def remove_document_by_id(writer, doc_id):
|
||||
def remove_document_by_id(writer: AsyncWriter, doc_id):
|
||||
writer.delete_by_term("id", doc_id)
|
||||
|
||||
|
||||
def add_or_update_document(document):
|
||||
def add_or_update_document(document: Document):
|
||||
with open_index_writer() as writer:
|
||||
update_document(writer, document)
|
||||
|
||||
|
||||
def remove_document_from_index(document):
|
||||
def remove_document_from_index(document: Document):
|
||||
with open_index_writer() as writer:
|
||||
remove_document(writer, document)
|
||||
|
||||
@ -185,6 +194,7 @@ class DelayedQuery:
|
||||
"created": ("created", ["date__lt", "date__gt"]),
|
||||
"checksum": ("checksum", ["icontains", "istartswith"]),
|
||||
"original_filename": ("original_filename", ["icontains", "istartswith"]),
|
||||
"custom_fields": ("custom_fields", ["icontains", "istartswith"]),
|
||||
}
|
||||
|
||||
def _get_query(self):
|
||||
@ -350,7 +360,15 @@ class DelayedFullTextQuery(DelayedQuery):
|
||||
def _get_query(self):
|
||||
q_str = self.query_params["query"]
|
||||
qp = MultifieldParser(
|
||||
["content", "title", "correspondent", "tag", "type", "notes"],
|
||||
[
|
||||
"content",
|
||||
"title",
|
||||
"correspondent",
|
||||
"tag",
|
||||
"type",
|
||||
"notes",
|
||||
"custom_fields",
|
||||
],
|
||||
self.searcher.ixreader.schema,
|
||||
)
|
||||
qp.add_plugin(DateParserPlugin(basedate=timezone.now()))
|
||||
|
@ -0,0 +1,131 @@
|
||||
# Generated by Django 4.2.6 on 2023-11-02 17:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "1039_consumptiontemplate"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CustomField",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=128)),
|
||||
(
|
||||
"data_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("string", "String"),
|
||||
("url", "URL"),
|
||||
("date", "Date"),
|
||||
("boolean", "Boolean"),
|
||||
("integer", "Integer"),
|
||||
("float", "Float"),
|
||||
("monetary", "Monetary"),
|
||||
],
|
||||
editable=False,
|
||||
max_length=50,
|
||||
verbose_name="data type",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "custom field",
|
||||
"verbose_name_plural": "custom fields",
|
||||
"ordering": ("created",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CustomFieldInstance",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
("value_text", models.CharField(max_length=128, null=True)),
|
||||
("value_bool", models.BooleanField(null=True)),
|
||||
("value_url", models.URLField(null=True)),
|
||||
("value_date", models.DateField(null=True)),
|
||||
("value_int", models.IntegerField(null=True)),
|
||||
("value_float", models.FloatField(null=True)),
|
||||
(
|
||||
"value_monetary",
|
||||
models.DecimalField(decimal_places=2, max_digits=12, null=True),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
editable=False,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="custom_fields",
|
||||
to="documents.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"field",
|
||||
models.ForeignKey(
|
||||
editable=False,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="fields",
|
||||
to="documents.customfield",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "custom field instance",
|
||||
"verbose_name_plural": "custom field instances",
|
||||
"ordering": ("created",),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="customfield",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("name",),
|
||||
name="documents_customfield_unique_name",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="customfieldinstance",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("document", "field"),
|
||||
name="documents_customfieldinstance_unique_document_field",
|
||||
),
|
||||
),
|
||||
]
|
@ -877,9 +877,139 @@ class ConsumptionTemplate(models.Model):
|
||||
return f"{self.name}"
|
||||
|
||||
|
||||
class CustomField(models.Model):
|
||||
"""
|
||||
Defines the name and type of a custom field
|
||||
"""
|
||||
|
||||
class FieldDataType(models.TextChoices):
|
||||
STRING = ("string", _("String"))
|
||||
URL = ("url", _("URL"))
|
||||
DATE = ("date", _("Date"))
|
||||
BOOL = ("boolean"), _("Boolean")
|
||||
INT = ("integer", _("Integer"))
|
||||
FLOAT = ("float", _("Float"))
|
||||
MONETARY = ("monetary", _("Monetary"))
|
||||
|
||||
created = models.DateTimeField(
|
||||
_("created"),
|
||||
default=timezone.now,
|
||||
db_index=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=128)
|
||||
|
||||
data_type = models.CharField(
|
||||
_("data type"),
|
||||
max_length=50,
|
||||
choices=FieldDataType.choices,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("created",)
|
||||
verbose_name = _("custom field")
|
||||
verbose_name_plural = _("custom fields")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name"],
|
||||
name="%(app_label)s_%(class)s_unique_name",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} : {self.data_type}"
|
||||
|
||||
|
||||
class CustomFieldInstance(models.Model):
|
||||
"""
|
||||
A single instance of a field, attached to a CustomField for the name and type
|
||||
and attached to a single Document to be metadata for it
|
||||
"""
|
||||
|
||||
created = models.DateTimeField(
|
||||
_("created"),
|
||||
default=timezone.now,
|
||||
db_index=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
document = models.ForeignKey(
|
||||
Document,
|
||||
blank=False,
|
||||
null=False,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="custom_fields",
|
||||
editable=False,
|
||||
)
|
||||
|
||||
field = models.ForeignKey(
|
||||
CustomField,
|
||||
blank=False,
|
||||
null=False,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="fields",
|
||||
editable=False,
|
||||
)
|
||||
|
||||
# Actual data storage
|
||||
value_text = models.CharField(max_length=128, null=True)
|
||||
|
||||
value_bool = models.BooleanField(null=True)
|
||||
|
||||
value_url = models.URLField(null=True)
|
||||
|
||||
value_date = models.DateField(null=True)
|
||||
|
||||
value_int = models.IntegerField(null=True)
|
||||
|
||||
value_float = models.FloatField(null=True)
|
||||
|
||||
value_monetary = models.DecimalField(null=True, decimal_places=2, max_digits=12)
|
||||
|
||||
class Meta:
|
||||
ordering = ("created",)
|
||||
verbose_name = _("custom field instance")
|
||||
verbose_name_plural = _("custom field instances")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["document", "field"],
|
||||
name="%(app_label)s_%(class)s_unique_document_field",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.field.name) + f" : {self.value}"
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
"""
|
||||
Based on the data type, access the actual value the instance stores
|
||||
A little shorthand/quick way to get what is actually here
|
||||
"""
|
||||
if self.field.data_type == CustomField.FieldDataType.STRING:
|
||||
return self.value_text
|
||||
elif self.field.data_type == CustomField.FieldDataType.URL:
|
||||
return self.value_url
|
||||
elif self.field.data_type == CustomField.FieldDataType.DATE:
|
||||
return self.value_date
|
||||
elif self.field.data_type == CustomField.FieldDataType.BOOL:
|
||||
return self.value_bool
|
||||
elif self.field.data_type == CustomField.FieldDataType.INT:
|
||||
return self.value_int
|
||||
elif self.field.data_type == CustomField.FieldDataType.FLOAT:
|
||||
return self.value_float
|
||||
elif self.field.data_type == CustomField.FieldDataType.MONETARY:
|
||||
return self.value_monetary
|
||||
raise NotImplementedError(self.field.data_type)
|
||||
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
auditlog.register(Document, m2m_fields={"tags"})
|
||||
auditlog.register(Correspondent)
|
||||
auditlog.register(Tag)
|
||||
auditlog.register(DocumentType)
|
||||
auditlog.register(Note)
|
||||
auditlog.register(CustomField)
|
||||
auditlog.register(CustomFieldInstance)
|
||||
|
@ -8,9 +8,11 @@ from celery import states
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import URLValidator
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_writable_nested.serializers import NestedUpdateMixin
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from rest_framework import fields
|
||||
@ -21,6 +23,8 @@ from documents import bulk_edit
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
@ -394,7 +398,92 @@ class StoragePathField(serializers.PrimaryKeyRelatedField):
|
||||
return StoragePath.objects.all()
|
||||
|
||||
|
||||
class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
|
||||
class CustomFieldSerializer(serializers.ModelSerializer):
|
||||
data_type = serializers.ChoiceField(
|
||||
choices=CustomField.FieldDataType,
|
||||
read_only=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"data_type",
|
||||
]
|
||||
|
||||
|
||||
class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
|
||||
"""
|
||||
Based on https://stackoverflow.com/a/62579804
|
||||
"""
|
||||
|
||||
def __init__(self, method_name=None, *args, **kwargs):
|
||||
self.method_name = method_name
|
||||
kwargs["source"] = "*"
|
||||
super(serializers.SerializerMethodField, self).__init__(*args, **kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return {self.field_name: data}
|
||||
|
||||
|
||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
||||
value = ReadWriteSerializerMethodField()
|
||||
|
||||
def create(self, validated_data):
|
||||
type_to_data_store_name_map = {
|
||||
CustomField.FieldDataType.STRING: "value_text",
|
||||
CustomField.FieldDataType.URL: "value_url",
|
||||
CustomField.FieldDataType.DATE: "value_date",
|
||||
CustomField.FieldDataType.BOOL: "value_bool",
|
||||
CustomField.FieldDataType.INT: "value_int",
|
||||
CustomField.FieldDataType.FLOAT: "value_float",
|
||||
CustomField.FieldDataType.MONETARY: "value_monetary",
|
||||
}
|
||||
# An instance is attached to a document
|
||||
document: Document = validated_data["document"]
|
||||
# And to a CustomField
|
||||
custom_field: CustomField = validated_data["field"]
|
||||
# This key must exist, as it is validated
|
||||
data_store_name = type_to_data_store_name_map[custom_field.data_type]
|
||||
|
||||
# Actually update or create the instance, providing the value
|
||||
# to fill in the correct attribute based on the type
|
||||
instance, _ = CustomFieldInstance.objects.update_or_create(
|
||||
document=document,
|
||||
field=custom_field,
|
||||
defaults={data_store_name: validated_data["value"]},
|
||||
)
|
||||
return instance
|
||||
|
||||
def get_value(self, obj: CustomFieldInstance):
|
||||
return obj.value
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
For some reason, URLField validation is not run against the value
|
||||
automatically. Force it to run against the value
|
||||
"""
|
||||
data = super().validate(data)
|
||||
field: CustomField = data["field"]
|
||||
if field.data_type == CustomField.FieldDataType.URL:
|
||||
URLValidator()(data["value"])
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldInstance
|
||||
fields = [
|
||||
"value",
|
||||
"field",
|
||||
]
|
||||
|
||||
|
||||
class DocumentSerializer(
|
||||
OwnedObjectSerializer,
|
||||
NestedUpdateMixin,
|
||||
DynamicFieldsModelSerializer,
|
||||
):
|
||||
correspondent = CorrespondentField(allow_null=True)
|
||||
tags = TagsField(many=True)
|
||||
document_type = DocumentTypeField(allow_null=True)
|
||||
@ -404,6 +493,8 @@ class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
|
||||
archived_file_name = SerializerMethodField()
|
||||
created_date = serializers.DateField(required=False)
|
||||
|
||||
custom_fields = CustomFieldInstanceSerializer(many=True, allow_null=True)
|
||||
|
||||
owner = serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
@ -425,7 +516,7 @@ class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
|
||||
doc["content"] = doc.get("content")[0:550]
|
||||
return doc
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
def update(self, instance: Document, validated_data):
|
||||
if "created_date" in validated_data and "created" not in validated_data:
|
||||
new_datetime = datetime.datetime.combine(
|
||||
validated_data.get("created_date"),
|
||||
@ -466,6 +557,7 @@ class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
|
||||
"user_can_change",
|
||||
"set_permissions",
|
||||
"notes",
|
||||
"custom_fields",
|
||||
)
|
||||
|
||||
|
||||
|
@ -35,6 +35,8 @@ from documents import index
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
@ -347,11 +349,36 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
tag_2 = Tag.objects.create(name="t2")
|
||||
tag_3 = Tag.objects.create(name="t3")
|
||||
|
||||
cf1 = CustomField.objects.create(
|
||||
name="stringfield",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
cf2 = CustomField.objects.create(
|
||||
name="numberfield",
|
||||
data_type=CustomField.FieldDataType.INT,
|
||||
)
|
||||
|
||||
doc1.tags.add(tag_inbox)
|
||||
doc2.tags.add(tag_2)
|
||||
doc3.tags.add(tag_2)
|
||||
doc3.tags.add(tag_3)
|
||||
|
||||
cf1_d1 = CustomFieldInstance.objects.create(
|
||||
document=doc1,
|
||||
field=cf1,
|
||||
value_text="foobard1",
|
||||
)
|
||||
CustomFieldInstance.objects.create(
|
||||
document=doc1,
|
||||
field=cf2,
|
||||
value_int=999,
|
||||
)
|
||||
cf1_d3 = CustomFieldInstance.objects.create(
|
||||
document=doc3,
|
||||
field=cf1,
|
||||
value_text="foobard3",
|
||||
)
|
||||
|
||||
response = self.client.get("/api/documents/?is_in_inbox=true")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
results = response.data["results"]
|
||||
@ -423,6 +450,31 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
results = response.data["results"]
|
||||
self.assertEqual(len(results), 0)
|
||||
|
||||
# custom field name
|
||||
response = self.client.get(
|
||||
f"/api/documents/?custom_fields__icontains={cf1.name}",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
results = response.data["results"]
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
# custom field value
|
||||
response = self.client.get(
|
||||
f"/api/documents/?custom_fields__icontains={cf1_d1.value}",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
results = response.data["results"]
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["id"], doc1.id)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/documents/?custom_fields__icontains={cf1_d3.value}",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
results = response.data["results"]
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["id"], doc3.id)
|
||||
|
||||
def test_document_checksum_filter(self):
|
||||
Document.objects.create(
|
||||
title="none1",
|
||||
@ -1146,6 +1198,14 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
dt2 = DocumentType.objects.create(name="type2")
|
||||
sp = StoragePath.objects.create(name="path")
|
||||
sp2 = StoragePath.objects.create(name="path2")
|
||||
cf1 = CustomField.objects.create(
|
||||
name="string field",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
cf2 = CustomField.objects.create(
|
||||
name="number field",
|
||||
data_type=CustomField.FieldDataType.INT,
|
||||
)
|
||||
|
||||
d1 = Document.objects.create(checksum="1", correspondent=c, content="test")
|
||||
d2 = Document.objects.create(checksum="2", document_type=dt, content="test")
|
||||
@ -1176,6 +1236,22 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
content="test",
|
||||
)
|
||||
|
||||
cf1_d1 = CustomFieldInstance.objects.create(
|
||||
document=d1,
|
||||
field=cf1,
|
||||
value_text="foobard1",
|
||||
)
|
||||
cf2_d1 = CustomFieldInstance.objects.create(
|
||||
document=d1,
|
||||
field=cf2,
|
||||
value_int=999,
|
||||
)
|
||||
cf1_d4 = CustomFieldInstance.objects.create(
|
||||
document=d4,
|
||||
field=cf1,
|
||||
value_text="foobard4",
|
||||
)
|
||||
|
||||
with AsyncWriter(index.open_index()) as writer:
|
||||
for doc in Document.objects.all():
|
||||
index.update_document(writer, doc)
|
||||
@ -1304,6 +1380,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
+ datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d"),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
d5.id,
|
||||
search_query(
|
||||
@ -1322,6 +1399,27 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
[d4.id, d5.id],
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
d1.id,
|
||||
search_query(
|
||||
"&custom_fields__icontains=" + cf1_d1.value,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
d1.id,
|
||||
search_query(
|
||||
"&custom_fields__icontains=" + str(cf2_d1.value),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
d4.id,
|
||||
search_query(
|
||||
"&custom_fields__icontains=" + cf1_d4.value,
|
||||
),
|
||||
)
|
||||
|
||||
def test_search_filtering_respect_owner(self):
|
||||
"""
|
||||
GIVEN:
|
||||
@ -2421,7 +2519,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
f"/api/documents/{doc.pk}/notes/",
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(resp.content, b"Insufficient permissions to view")
|
||||
self.assertEqual(resp.content, b"Insufficient permissions to view notes")
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
assign_perm("view_document", user1, doc)
|
||||
@ -2430,7 +2528,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
f"/api/documents/{doc.pk}/notes/",
|
||||
data={"note": "this is a posted note"},
|
||||
)
|
||||
self.assertEqual(resp.content, b"Insufficient permissions to create")
|
||||
self.assertEqual(resp.content, b"Insufficient permissions to create notes")
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
note = Note.objects.create(
|
||||
@ -2444,7 +2542,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.content, b"Insufficient permissions to delete")
|
||||
self.assertEqual(response.content, b"Insufficient permissions to delete notes")
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_delete_note(self):
|
||||
@ -2694,7 +2792,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
f"/api/documents/{doc.pk}/share_links/",
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(resp.content, b"Insufficient permissions")
|
||||
self.assertEqual(resp.content, b"Insufficient permissions to add share link")
|
||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
assign_perm("change_document", user1, doc)
|
||||
|
384
src/documents/tests/test_api_custom_fields.py
Normal file
384
src/documents/tests/test_api_custom_fields.py
Normal file
@ -0,0 +1,384 @@
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
|
||||
|
||||
class TestCustomField(DirectoriesMixin, APITestCase):
|
||||
ENDPOINT = "/api/custom_fields/"
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.client.force_authenticate(user=self.user)
|
||||
return super().setUp()
|
||||
|
||||
def test_create_custom_field(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Each of the supported data types is created
|
||||
WHEN:
|
||||
- API request to create custom metadata is made
|
||||
THEN:
|
||||
- the field is created
|
||||
- the field returns the correct fields
|
||||
"""
|
||||
for field_type, name in [
|
||||
("string", "Custom Text"),
|
||||
("url", "Wikipedia Link"),
|
||||
("date", "Invoiced Date"),
|
||||
("integer", "Invoice #"),
|
||||
("boolean", "Is Active"),
|
||||
("float", "Total Paid"),
|
||||
]:
|
||||
resp = self.client.post(
|
||||
self.ENDPOINT,
|
||||
data={
|
||||
"data_type": field_type,
|
||||
"name": name,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
data = resp.json()
|
||||
|
||||
self.assertEqual(len(data), 3)
|
||||
self.assertEqual(data["name"], name)
|
||||
self.assertEqual(data["data_type"], field_type)
|
||||
|
||||
def test_create_custom_field_instance(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Field of each data type is created
|
||||
WHEN:
|
||||
- API request to create custom metadata instance with each data type
|
||||
THEN:
|
||||
- the field instance is created
|
||||
- the field returns the correct fields and values
|
||||
- the field is attached to the given document
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="WOW",
|
||||
content="the content",
|
||||
checksum="123",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
custom_field_string = CustomField.objects.create(
|
||||
name="Test Custom Field String",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
custom_field_date = CustomField.objects.create(
|
||||
name="Test Custom Field Date",
|
||||
data_type=CustomField.FieldDataType.DATE,
|
||||
)
|
||||
custom_field_int = CustomField.objects.create(
|
||||
name="Test Custom Field Int",
|
||||
data_type=CustomField.FieldDataType.INT,
|
||||
)
|
||||
custom_field_boolean = CustomField.objects.create(
|
||||
name="Test Custom Field Boolean",
|
||||
data_type=CustomField.FieldDataType.BOOL,
|
||||
)
|
||||
custom_field_url = CustomField.objects.create(
|
||||
name="Test Custom Field Url",
|
||||
data_type=CustomField.FieldDataType.URL,
|
||||
)
|
||||
custom_field_float = CustomField.objects.create(
|
||||
name="Test Custom Field Float",
|
||||
data_type=CustomField.FieldDataType.FLOAT,
|
||||
)
|
||||
custom_field_monetary = CustomField.objects.create(
|
||||
name="Test Custom Field Monetary",
|
||||
data_type=CustomField.FieldDataType.MONETARY,
|
||||
)
|
||||
|
||||
date_value = date.today()
|
||||
|
||||
resp = self.client.patch(
|
||||
f"/api/documents/{doc.id}/",
|
||||
data={
|
||||
"custom_fields": [
|
||||
{
|
||||
"field": custom_field_string.id,
|
||||
"value": "test value",
|
||||
},
|
||||
{
|
||||
"field": custom_field_date.id,
|
||||
"value": date_value.isoformat(),
|
||||
},
|
||||
{
|
||||
"field": custom_field_int.id,
|
||||
"value": 3,
|
||||
},
|
||||
{
|
||||
"field": custom_field_boolean.id,
|
||||
"value": True,
|
||||
},
|
||||
{
|
||||
"field": custom_field_url.id,
|
||||
"value": "https://example.com",
|
||||
},
|
||||
{
|
||||
"field": custom_field_float.id,
|
||||
"value": 12.3456,
|
||||
},
|
||||
{
|
||||
"field": custom_field_monetary.id,
|
||||
"value": 11.10,
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
|
||||
resp_data = resp.json()["custom_fields"]
|
||||
|
||||
self.assertCountEqual(
|
||||
resp_data,
|
||||
[
|
||||
{"field": custom_field_string.id, "value": "test value"},
|
||||
{"field": custom_field_date.id, "value": date_value.isoformat()},
|
||||
{"field": custom_field_int.id, "value": 3},
|
||||
{"field": custom_field_boolean.id, "value": True},
|
||||
{"field": custom_field_url.id, "value": "https://example.com"},
|
||||
{"field": custom_field_float.id, "value": 12.3456},
|
||||
{"field": custom_field_monetary.id, "value": 11.10},
|
||||
],
|
||||
)
|
||||
|
||||
doc.refresh_from_db()
|
||||
self.assertEqual(len(doc.custom_fields.all()), 7)
|
||||
|
||||
def test_change_custom_field_instance_value(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Custom field instance is created and attached to document
|
||||
WHEN:
|
||||
- API request to create change the value of the custom field
|
||||
THEN:
|
||||
- the field instance is updated
|
||||
- the field returns the correct fields and values
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="WOW",
|
||||
content="the content",
|
||||
checksum="123",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
custom_field_string = CustomField.objects.create(
|
||||
name="Test Custom Field String",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
|
||||
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||
|
||||
# Create
|
||||
resp = self.client.patch(
|
||||
f"/api/documents/{doc.id}/",
|
||||
data={
|
||||
"custom_fields": [
|
||||
{
|
||||
"field": custom_field_string.id,
|
||||
"value": "test value",
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(CustomFieldInstance.objects.count(), 1)
|
||||
self.assertEqual(doc.custom_fields.first().value, "test value")
|
||||
|
||||
# Update
|
||||
resp = self.client.patch(
|
||||
f"/api/documents/{doc.id}/",
|
||||
data={
|
||||
"custom_fields": [
|
||||
{
|
||||
"field": custom_field_string.id,
|
||||
"value": "a new test value",
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(CustomFieldInstance.objects.count(), 1)
|
||||
self.assertEqual(doc.custom_fields.first().value, "a new test value")
|
||||
|
||||
def test_delete_custom_field_instance(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Multiple custom field instances are created and attached to document
|
||||
WHEN:
|
||||
- API request to remove a field
|
||||
THEN:
|
||||
- the field instance is removed
|
||||
- the other field remains unchanged
|
||||
- the field returns the correct fields and values
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="WOW",
|
||||
content="the content",
|
||||
checksum="123",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
custom_field_string = CustomField.objects.create(
|
||||
name="Test Custom Field String",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
custom_field_date = CustomField.objects.create(
|
||||
name="Test Custom Field Date",
|
||||
data_type=CustomField.FieldDataType.DATE,
|
||||
)
|
||||
|
||||
date_value = date.today()
|
||||
|
||||
resp = self.client.patch(
|
||||
f"/api/documents/{doc.id}/",
|
||||
data={
|
||||
"custom_fields": [
|
||||
{
|
||||
"field": custom_field_string.id,
|
||||
"value": "a new test value",
|
||||
},
|
||||
{
|
||||
"field": custom_field_date.id,
|
||||
"value": date_value.isoformat(),
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(CustomFieldInstance.objects.count(), 2)
|
||||
self.assertEqual(len(doc.custom_fields.all()), 2)
|
||||
|
||||
resp = self.client.patch(
|
||||
f"/api/documents/{doc.id}/",
|
||||
data={
|
||||
"custom_fields": [
|
||||
{
|
||||
"field": custom_field_date.id,
|
||||
"value": date_value.isoformat(),
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(CustomFieldInstance.objects.count(), 1)
|
||||
self.assertEqual(Document.objects.count(), 1)
|
||||
self.assertEqual(len(doc.custom_fields.all()), 1)
|
||||
self.assertEqual(doc.custom_fields.first().value, date_value)
|
||||
|
||||
def test_custom_field_validation(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Document exists with no fields
|
||||
WHEN:
|
||||
- API request to remove a field
|
||||
- API request is not valid
|
||||
THEN:
|
||||
- HTTP 400 is returned
|
||||
- No field created
|
||||
- No field attached to the document
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="WOW",
|
||||
content="the content",
|
||||
checksum="123",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
custom_field_string = CustomField.objects.create(
|
||||
name="Test Custom Field String",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
|
||||
resp = self.client.patch(
|
||||
f"/api/documents/{doc.id}/",
|
||||
data={
|
||||
"custom_fields": [
|
||||
{
|
||||
"field": custom_field_string.id,
|
||||
# Whoops, spelling
|
||||
"valeu": "a new test value",
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
from pprint import pprint
|
||||
|
||||
pprint(resp.json())
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||
self.assertEqual(len(doc.custom_fields.all()), 0)
|
||||
|
||||
def test_custom_field_value_validation(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Document & custom field exist
|
||||
WHEN:
|
||||
- API request to set a field value
|
||||
THEN:
|
||||
- HTTP 400 is returned
|
||||
- No field instance is created or attached to the document
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="WOW",
|
||||
content="the content",
|
||||
checksum="123",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
custom_field_url = CustomField.objects.create(
|
||||
name="Test Custom Field URL",
|
||||
data_type=CustomField.FieldDataType.URL,
|
||||
)
|
||||
custom_field_int = CustomField.objects.create(
|
||||
name="Test Custom Field INT",
|
||||
data_type=CustomField.FieldDataType.INT,
|
||||
)
|
||||
|
||||
resp = self.client.patch(
|
||||
f"/api/documents/{doc.id}/",
|
||||
data={
|
||||
"custom_fields": [
|
||||
{
|
||||
"field": custom_field_url.id,
|
||||
"value": "not a url",
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||
self.assertEqual(len(doc.custom_fields.all()), 0)
|
||||
|
||||
self.assertRaises(
|
||||
Exception,
|
||||
self.client.patch,
|
||||
f"/api/documents/{doc.id}/",
|
||||
data={
|
||||
"custom_fields": [
|
||||
{
|
||||
"field": custom_field_int.id,
|
||||
"value": "not an int",
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||
self.assertEqual(len(doc.custom_fields.all()), 0)
|
@ -153,7 +153,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
manifest = self._do_export(use_filename_format=use_filename_format)
|
||||
|
||||
self.assertEqual(len(manifest), 159)
|
||||
self.assertEqual(len(manifest), 169)
|
||||
|
||||
# dont include consumer or AnonymousUser users
|
||||
self.assertEqual(
|
||||
@ -247,7 +247,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
|
||||
self.assertEqual(GroupObjectPermission.objects.count(), 1)
|
||||
self.assertEqual(UserObjectPermission.objects.count(), 1)
|
||||
self.assertEqual(Permission.objects.count(), 116)
|
||||
self.assertEqual(Permission.objects.count(), 124)
|
||||
messages = check_sanity()
|
||||
# everything is alright after the test
|
||||
self.assertEqual(len(messages), 0)
|
||||
@ -676,15 +676,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
os.path.join(self.dirs.media_dir, "documents"),
|
||||
)
|
||||
|
||||
self.assertEqual(ContentType.objects.count(), 29)
|
||||
self.assertEqual(Permission.objects.count(), 116)
|
||||
self.assertEqual(ContentType.objects.count(), 31)
|
||||
self.assertEqual(Permission.objects.count(), 124)
|
||||
|
||||
manifest = self._do_export()
|
||||
|
||||
with paperless_environment():
|
||||
self.assertEqual(
|
||||
len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
|
||||
116,
|
||||
124,
|
||||
)
|
||||
# add 1 more to db to show objects are not re-created by import
|
||||
Permission.objects.create(
|
||||
@ -692,7 +692,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
codename="test_perm",
|
||||
content_type_id=1,
|
||||
)
|
||||
self.assertEqual(Permission.objects.count(), 117)
|
||||
self.assertEqual(Permission.objects.count(), 125)
|
||||
|
||||
# will cause an import error
|
||||
self.user.delete()
|
||||
@ -701,5 +701,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
with self.assertRaises(IntegrityError):
|
||||
call_command("document_importer", "--no-progress-bar", self.target)
|
||||
|
||||
self.assertEqual(ContentType.objects.count(), 29)
|
||||
self.assertEqual(Permission.objects.count(), 117)
|
||||
self.assertEqual(ContentType.objects.count(), 31)
|
||||
self.assertEqual(Permission.objects.count(), 125)
|
||||
|
@ -78,6 +78,7 @@ from documents.matching import match_storage_paths
|
||||
from documents.matching import match_tags
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import Note
|
||||
@ -99,6 +100,7 @@ from documents.serialisers import BulkEditObjectPermissionsSerializer
|
||||
from documents.serialisers import BulkEditSerializer
|
||||
from documents.serialisers import ConsumptionTemplateSerializer
|
||||
from documents.serialisers import CorrespondentSerializer
|
||||
from documents.serialisers import CustomFieldSerializer
|
||||
from documents.serialisers import DocumentListSerializer
|
||||
from documents.serialisers import DocumentSerializer
|
||||
from documents.serialisers import DocumentTypeSerializer
|
||||
@ -497,7 +499,7 @@ class DocumentViewSet(
|
||||
"view_document",
|
||||
doc,
|
||||
):
|
||||
return HttpResponseForbidden("Insufficient permissions to view")
|
||||
return HttpResponseForbidden("Insufficient permissions to view notes")
|
||||
except Document.DoesNotExist:
|
||||
raise Http404
|
||||
|
||||
@ -507,7 +509,7 @@ class DocumentViewSet(
|
||||
except Exception as e:
|
||||
logger.warning(f"An error occurred retrieving notes: {e!s}")
|
||||
return Response(
|
||||
{"error": "Error retreiving notes, check logs for more detail."},
|
||||
{"error": "Error retrieving notes, check logs for more detail."},
|
||||
)
|
||||
elif request.method == "POST":
|
||||
try:
|
||||
@ -516,7 +518,9 @@ class DocumentViewSet(
|
||||
"change_document",
|
||||
doc,
|
||||
):
|
||||
return HttpResponseForbidden("Insufficient permissions to create")
|
||||
return HttpResponseForbidden(
|
||||
"Insufficient permissions to create notes",
|
||||
)
|
||||
|
||||
c = Note.objects.create(
|
||||
document=doc,
|
||||
@ -558,7 +562,7 @@ class DocumentViewSet(
|
||||
"change_document",
|
||||
doc,
|
||||
):
|
||||
return HttpResponseForbidden("Insufficient permissions to delete")
|
||||
return HttpResponseForbidden("Insufficient permissions to delete notes")
|
||||
|
||||
note = Note.objects.get(id=int(request.GET.get("id")))
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
@ -599,7 +603,9 @@ class DocumentViewSet(
|
||||
"change_document",
|
||||
doc,
|
||||
):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
return HttpResponseForbidden(
|
||||
"Insufficient permissions to add share link",
|
||||
)
|
||||
except Document.DoesNotExist:
|
||||
raise Http404
|
||||
|
||||
@ -1071,47 +1077,6 @@ class BulkDownloadView(GenericAPIView):
|
||||
return response
|
||||
|
||||
|
||||
class RemoteVersionView(GenericAPIView):
|
||||
def get(self, request, format=None):
|
||||
remote_version = "0.0.0"
|
||||
is_greater_than_current = False
|
||||
current_version = packaging_version.parse(version.__full_version_str__)
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"https://api.github.com/repos/paperless-ngx/"
|
||||
"paperless-ngx/releases/latest",
|
||||
)
|
||||
# Ensure a JSON response
|
||||
req.add_header("Accept", "application/json")
|
||||
|
||||
with urllib.request.urlopen(req) as response:
|
||||
remote = response.read().decode("utf-8")
|
||||
try:
|
||||
remote_json = json.loads(remote)
|
||||
remote_version = remote_json["tag_name"]
|
||||
# Basically PEP 616 but that only went in 3.9
|
||||
if remote_version.startswith("ngx-"):
|
||||
remote_version = remote_version[len("ngx-") :]
|
||||
except ValueError:
|
||||
logger.debug("An error occurred parsing remote version json")
|
||||
except urllib.error.URLError:
|
||||
logger.debug("An error occurred checking for available updates")
|
||||
|
||||
is_greater_than_current = (
|
||||
packaging_version.parse(
|
||||
remote_version,
|
||||
)
|
||||
> current_version
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"version": remote_version,
|
||||
"update_available": is_greater_than_current,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class StoragePathViewSet(ModelViewSet, PassUserMixin):
|
||||
model = StoragePath
|
||||
|
||||
@ -1186,6 +1151,47 @@ class UiSettingsView(GenericAPIView):
|
||||
)
|
||||
|
||||
|
||||
class RemoteVersionView(GenericAPIView):
|
||||
def get(self, request, format=None):
|
||||
remote_version = "0.0.0"
|
||||
is_greater_than_current = False
|
||||
current_version = packaging_version.parse(version.__full_version_str__)
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"https://api.github.com/repos/paperlessngx/"
|
||||
"paperlessngx/releases/latest",
|
||||
)
|
||||
# Ensure a JSON response
|
||||
req.add_header("Accept", "application/json")
|
||||
|
||||
with urllib.request.urlopen(req) as response:
|
||||
remote = response.read().decode("utf8")
|
||||
try:
|
||||
remote_json = json.loads(remote)
|
||||
remote_version = remote_json["tag_name"]
|
||||
# Basically PEP 616 but that only went in 3.9
|
||||
if remote_version.startswith("ngx-"):
|
||||
remote_version = remote_version[len("ngx-") :]
|
||||
except ValueError:
|
||||
logger.debug("An error occurred parsing remote version json")
|
||||
except urllib.error.URLError:
|
||||
logger.debug("An error occurred checking for available updates")
|
||||
|
||||
is_greater_than_current = (
|
||||
packaging_version.parse(
|
||||
remote_version,
|
||||
)
|
||||
> current_version
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"version": remote_version,
|
||||
"update_available": is_greater_than_current,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TasksViewSet(ReadOnlyModelViewSet):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = TasksViewSerializer
|
||||
@ -1341,4 +1347,15 @@ class ConsumptionTemplateViewSet(ModelViewSet):
|
||||
|
||||
model = ConsumptionTemplate
|
||||
|
||||
queryset = ConsumptionTemplate.objects.all().order_by("order")
|
||||
queryset = ConsumptionTemplate.objects.all().order_by("name")
|
||||
|
||||
|
||||
class CustomFieldViewSet(ModelViewSet):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = CustomFieldSerializer
|
||||
pagination_class = StandardPagination
|
||||
|
||||
model = CustomField
|
||||
|
||||
queryset = CustomField.objects.all().order_by("-created")
|
||||
|
@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-10-31 15:23-0700\n"
|
||||
"POT-Creation-Date: 2023-11-04 20:12-0700\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@ -163,7 +163,7 @@ msgid "The checksum of the archived document."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:205 documents/models.py:385 documents/models.py:654
|
||||
#: documents/models.py:692
|
||||
#: documents/models.py:692 documents/models.py:895 documents/models.py:932
|
||||
msgid "created"
|
||||
msgstr ""
|
||||
|
||||
@ -648,21 +648,69 @@ msgstr ""
|
||||
msgid "consumption templates"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:98
|
||||
#: documents/models.py:886
|
||||
msgid "String"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:887
|
||||
msgid "URL"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:888
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:889
|
||||
msgid "Boolean"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:890
|
||||
msgid "Integer"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:891
|
||||
msgid "Float"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:892
|
||||
msgid "Monetary"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:904
|
||||
msgid "data type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:912
|
||||
msgid "custom field"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:913
|
||||
msgid "custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:973
|
||||
msgid "custom field instance"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:974
|
||||
msgid "custom field instances"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:102
|
||||
#, python-format
|
||||
msgid "Invalid regular expression: %(error)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:373
|
||||
#: documents/serialisers.py:377
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:749
|
||||
#: documents/serialisers.py:841
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:846
|
||||
#: documents/serialisers.py:938
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
@ -929,7 +977,7 @@ msgstr ""
|
||||
msgid "Chinese Simplified"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/urls.py:184
|
||||
#: paperless/urls.py:186
|
||||
msgid "Paperless-ngx administration"
|
||||
msgstr ""
|
||||
|
||||
|
@ -16,6 +16,7 @@ from documents.views import BulkEditObjectPermissionsView
|
||||
from documents.views import BulkEditView
|
||||
from documents.views import ConsumptionTemplateViewSet
|
||||
from documents.views import CorrespondentViewSet
|
||||
from documents.views import CustomFieldViewSet
|
||||
from documents.views import DocumentTypeViewSet
|
||||
from documents.views import IndexView
|
||||
from documents.views import LogViewSet
|
||||
@ -55,6 +56,7 @@ api_router.register(r"mail_accounts", MailAccountViewSet)
|
||||
api_router.register(r"mail_rules", MailRuleViewSet)
|
||||
api_router.register(r"share_links", ShareLinkViewSet)
|
||||
api_router.register(r"consumption_templates", ConsumptionTemplateViewSet)
|
||||
api_router.register(r"custom_fields", CustomFieldViewSet)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
Loading…
x
Reference in New Issue
Block a user