Compare commits
8 Commits
dev
...
feature-cf
Author | SHA1 | Date | |
---|---|---|---|
|
7b75333819 | ||
|
71fdc2a36d | ||
|
dbe58672ed | ||
|
8a907c2868 | ||
|
6dc6c6c7bb | ||
|
a632b6b711 | ||
|
b8c618abbe | ||
|
7a46806643 |
@ -372,17 +372,19 @@ currently-imported docs. This problem is common enough that there are
|
|||||||
tools for it.
|
tools for it.
|
||||||
|
|
||||||
```
|
```
|
||||||
document_retagger [-h] [-c] [-T] [-t] [-i] [--id-range] [--use-first] [-f]
|
document_retagger [-h] [-c] [-T] [-t] [-cf] [-i] [--id-range] [--use-first] [-f] [--suggest]
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-c, --correspondent
|
-c, --correspondent
|
||||||
-T, --tags
|
-T, --tags
|
||||||
-t, --document_type
|
-t, --document_type
|
||||||
-s, --storage_path
|
-s, --storage_path
|
||||||
|
-cf, --custom_fields
|
||||||
-i, --inbox-only
|
-i, --inbox-only
|
||||||
--id-range
|
--id-range
|
||||||
--use-first
|
--use-first
|
||||||
-f, --overwrite
|
-f, --overwrite
|
||||||
|
--suggest
|
||||||
```
|
```
|
||||||
|
|
||||||
Run this after changing or adding matching rules. It'll loop over all
|
Run this after changing or adding matching rules. It'll loop over all
|
||||||
@ -408,6 +410,8 @@ to override this behavior and just use the first correspondent or type
|
|||||||
it finds. This option does not apply to tags, since any amount of tags
|
it finds. This option does not apply to tags, since any amount of tags
|
||||||
can be applied to a document.
|
can be applied to a document.
|
||||||
|
|
||||||
|
If you want to suggest changes but not apply them, specify `--suggest`.
|
||||||
|
|
||||||
Finally, `-f` specifies that you wish to overwrite already assigned
|
Finally, `-f` specifies that you wish to overwrite already assigned
|
||||||
correspondents, types and/or tags. The default behavior is to not assign
|
correspondents, types and/or tags. The default behavior is to not assign
|
||||||
correspondents and types to documents that have this data already
|
correspondents and types to documents that have this data already
|
||||||
|
@ -12,7 +12,7 @@ import { DocumentAsnComponent } from './components/document-asn/document-asn.com
|
|||||||
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
|
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
|
||||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||||
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
|
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
|
||||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
import { CustomFieldsListComponent } from './components/manage/custom-fields-list/custom-fields-list.component'
|
||||||
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
|
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||||
@ -239,7 +239,7 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'customfields',
|
path: 'customfields',
|
||||||
component: CustomFieldsComponent,
|
component: CustomFieldsListComponent,
|
||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requiredPermission: {
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||||
<pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
|
<pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
|
||||||
@if (typeFieldDisabled) {
|
@if (typeFieldDisabled) {
|
||||||
<small class="d-block mt-n2" i18n>Data type cannot be changed after a field is created</small>
|
<small class="d-block mt-n2 fst-italic text-muted" i18n>Data type cannot be changed after a field is created</small>
|
||||||
}
|
}
|
||||||
<div [formGroup]="objectForm.controls.extra_data">
|
<div [formGroup]="objectForm.controls.extra_data">
|
||||||
@switch (objectForm.get('data_type').value) {
|
@switch (objectForm.get('data_type').value) {
|
||||||
@ -39,6 +39,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<hr/>
|
||||||
|
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
|
@if (patternRequired) {
|
||||||
|
<pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||||
|
}
|
||||||
|
@if (patternRequired) {
|
||||||
|
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive" novalidate></pngx-input-check>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||||
|
@ -21,6 +21,7 @@ import {
|
|||||||
CustomFieldDataType,
|
CustomFieldDataType,
|
||||||
DATA_TYPE_LABELS,
|
DATA_TYPE_LABELS,
|
||||||
} from 'src/app/data/custom-field'
|
} from 'src/app/data/custom-field'
|
||||||
|
import { MATCH_NONE, MATCHING_ALGORITHMS } from 'src/app/data/matching-model'
|
||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@ -28,6 +29,27 @@ import { SelectComponent } from '../../input/select/select.component'
|
|||||||
import { TextComponent } from '../../input/text/text.component'
|
import { TextComponent } from '../../input/text/text.component'
|
||||||
import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
|
import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
|
||||||
|
|
||||||
|
const FIELDS_WITH_DISCRETE_MATCHING = [
|
||||||
|
CustomFieldDataType.Boolean,
|
||||||
|
CustomFieldDataType.Select,
|
||||||
|
]
|
||||||
|
|
||||||
|
const MATCHING_ALGORITHMS_FOR_ALL_FIELDS = [
|
||||||
|
// MATCH_NONE
|
||||||
|
MATCHING_ALGORITHMS[6],
|
||||||
|
// MATCH_REGEX
|
||||||
|
MATCHING_ALGORITHMS[4],
|
||||||
|
]
|
||||||
|
|
||||||
|
const MATCHING_ALGORITHMS_FOR_DISCRETE_FIELDS = [
|
||||||
|
// MATCH_NONE
|
||||||
|
MATCHING_ALGORITHMS[6],
|
||||||
|
// MATCH_AUTO
|
||||||
|
MATCHING_ALGORITHMS[0],
|
||||||
|
// MATCH_REGEX
|
||||||
|
MATCHING_ALGORITHMS[4],
|
||||||
|
]
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-custom-field-edit-dialog',
|
selector: 'pngx-custom-field-edit-dialog',
|
||||||
templateUrl: './custom-field-edit-dialog.component.html',
|
templateUrl: './custom-field-edit-dialog.component.html',
|
||||||
@ -107,6 +129,9 @@ export class CustomFieldEditDialogComponent
|
|||||||
select_options: new FormArray([]),
|
select_options: new FormArray([]),
|
||||||
default_currency: new FormControl(null),
|
default_currency: new FormControl(null),
|
||||||
}),
|
}),
|
||||||
|
matching_algorithm: new FormControl(MATCH_NONE),
|
||||||
|
match: new FormControl(''),
|
||||||
|
is_insensitive: new FormControl(true),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,4 +152,15 @@ export class CustomFieldEditDialogComponent
|
|||||||
public removeSelectOption(index: number) {
|
public removeSelectOption(index: number) {
|
||||||
this.selectOptions.removeAt(index)
|
this.selectOptions.removeAt(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMatchingAlgorithms() {
|
||||||
|
if (
|
||||||
|
FIELDS_WITH_DISCRETE_MATCHING.includes(this.getForm().value.data_type) ||
|
||||||
|
FIELDS_WITH_DISCRETE_MATCHING.includes(this.object?.data_type)
|
||||||
|
) {
|
||||||
|
return MATCHING_ALGORITHMS_FOR_DISCRETE_FIELDS
|
||||||
|
} else {
|
||||||
|
return MATCHING_ALGORITHMS_FOR_ALL_FIELDS
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@ import { ToastService } from 'src/app/services/toast.service'
|
|||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
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 { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { CustomFieldsComponent } from './custom-fields.component'
|
import { CustomFieldsListComponent } from './custom-fields-list.component'
|
||||||
|
|
||||||
const fields: CustomField[] = [
|
const fields: CustomField[] = [
|
||||||
{
|
{
|
||||||
@ -43,9 +43,9 @@ const fields: CustomField[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('CustomFieldsComponent', () => {
|
describe('CustomFieldsListComponent', () => {
|
||||||
let component: CustomFieldsComponent
|
let component: CustomFieldsListComponent
|
||||||
let fixture: ComponentFixture<CustomFieldsComponent>
|
let fixture: ComponentFixture<CustomFieldsListComponent>
|
||||||
let customFieldsService: CustomFieldsService
|
let customFieldsService: CustomFieldsService
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
@ -61,7 +61,7 @@ describe('CustomFieldsComponent', () => {
|
|||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
CustomFieldsComponent,
|
CustomFieldsListComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
@ -94,7 +94,7 @@ describe('CustomFieldsComponent', () => {
|
|||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 0, username: 'test' }
|
settingsService.currentUser = { id: 0, username: 'test' }
|
||||||
|
|
||||||
fixture = TestBed.createComponent(CustomFieldsComponent)
|
fixture = TestBed.createComponent(CustomFieldsListComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
@ -106,7 +106,7 @@ describe('CustomFieldsComponent', () => {
|
|||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const reloadSpy = jest.spyOn(component, 'reload')
|
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||||
|
|
||||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
|
const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
|
||||||
createButton.triggerEventHandler('click')
|
createButton.triggerEventHandler('click')
|
||||||
@ -131,7 +131,7 @@ describe('CustomFieldsComponent', () => {
|
|||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const reloadSpy = jest.spyOn(component, 'reload')
|
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||||
|
|
||||||
const editButton = fixture.debugElement.queryAll(By.css('button'))[2]
|
const editButton = fixture.debugElement.queryAll(By.css('button'))[2]
|
||||||
editButton.triggerEventHandler('click')
|
editButton.triggerEventHandler('click')
|
||||||
@ -156,7 +156,7 @@ describe('CustomFieldsComponent', () => {
|
|||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const deleteSpy = jest.spyOn(customFieldsService, 'delete')
|
const deleteSpy = jest.spyOn(customFieldsService, 'delete')
|
||||||
const reloadSpy = jest.spyOn(component, 'reload')
|
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||||
|
|
||||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[5]
|
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[5]
|
||||||
deleteButton.triggerEventHandler('click')
|
deleteButton.triggerEventHandler('click')
|
@ -0,0 +1,96 @@
|
|||||||
|
import { NgClass, TitleCasePipe } from '@angular/common'
|
||||||
|
import { Component } from '@angular/core'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgbModal,
|
||||||
|
NgbPaginationModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
|
||||||
|
import {
|
||||||
|
CustomFieldQueryLogicalOperator,
|
||||||
|
CustomFieldQueryOperator,
|
||||||
|
} from 'src/app/data/custom-field-query'
|
||||||
|
import { FILTER_CUSTOM_FIELDS_QUERY } from 'src/app/data/filter-rule-type'
|
||||||
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
|
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||||
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
|
import {
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} 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 { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-custom-fields-list',
|
||||||
|
templateUrl: './../management-list/management-list.component.html',
|
||||||
|
styleUrls: ['./../management-list/management-list.component.scss'],
|
||||||
|
imports: [
|
||||||
|
SortableDirective,
|
||||||
|
PageHeaderComponent,
|
||||||
|
TitleCasePipe,
|
||||||
|
IfPermissionsDirective,
|
||||||
|
SafeHtmlPipe,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NgClass,
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgbPaginationModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class CustomFieldsListComponent extends ManagementListComponent<CustomField> {
|
||||||
|
permissionsDisabled = true
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
customFieldsService: CustomFieldsService,
|
||||||
|
modalService: NgbModal,
|
||||||
|
toastService: ToastService,
|
||||||
|
documentListViewService: DocumentListViewService,
|
||||||
|
permissionsService: PermissionsService
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
customFieldsService,
|
||||||
|
modalService,
|
||||||
|
CustomFieldEditDialogComponent,
|
||||||
|
toastService,
|
||||||
|
documentListViewService,
|
||||||
|
permissionsService,
|
||||||
|
0, // see filterDocuments override below
|
||||||
|
$localize`custom field`,
|
||||||
|
$localize`custom fields`,
|
||||||
|
PermissionType.CustomField,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: 'data_type',
|
||||||
|
name: $localize`Data Type`,
|
||||||
|
valueFn: (field: CustomField) => {
|
||||||
|
return DATA_TYPE_LABELS.find((l) => l.id === field.data_type).name
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterDocuments(field: CustomField) {
|
||||||
|
this.documentListViewService.quickFilter([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||||
|
value: JSON.stringify([
|
||||||
|
CustomFieldQueryLogicalOperator.Or,
|
||||||
|
[[field.id, CustomFieldQueryOperator.Exists, true]],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeleteMessage(object: CustomField) {
|
||||||
|
return $localize`Do you really want to delete the field "${object.name}"?`
|
||||||
|
}
|
||||||
|
}
|
@ -1,72 +0,0 @@
|
|||||||
<pngx-page-header
|
|
||||||
title="Custom Fields"
|
|
||||||
i18n-title
|
|
||||||
info="Customize the data fields that can be attached to documents."
|
|
||||||
i18n-info
|
|
||||||
infoLink="usage/#custom-fields"
|
|
||||||
>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
|
||||||
<i-bs name="plus-circle"></i-bs> <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>
|
|
||||||
|
|
||||||
@if (loading) {
|
|
||||||
<li class="list-group-item">
|
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
||||||
<ng-container i18n>Loading...</ng-container>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
|
|
||||||
@for (field of fields; track field) {
|
|
||||||
<li class="list-group-item">
|
|
||||||
<div class="row fade" [class.show]="show">
|
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" 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 d-block d-sm-none">
|
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
|
||||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
|
||||||
<i-bs name="three-dots-vertical"></i-bs>
|
|
||||||
</button>
|
|
||||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
|
||||||
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
|
|
||||||
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
|
|
||||||
@if (field.document_count > 0) {
|
|
||||||
<button (click)="filterDocuments(field)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ field.document_count }})</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group d-none d-sm-inline-block">
|
|
||||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
|
|
||||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <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)">
|
|
||||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@if (field.document_count > 0) {
|
|
||||||
<div class="btn-group d-none d-sm-inline-block ms-2">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="filterDocuments(field)">
|
|
||||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
@if (!loading && fields.length === 0) {
|
|
||||||
<li class="list-group-item" i18n>No fields defined.</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
@ -1,4 +0,0 @@
|
|||||||
// hide caret on mobile dropdown
|
|
||||||
.d-block.d-sm-none .dropdown-toggle::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
@ -1,148 +0,0 @@
|
|||||||
import { Component, OnInit } from '@angular/core'
|
|
||||||
import {
|
|
||||||
NgbDropdownModule,
|
|
||||||
NgbModal,
|
|
||||||
NgbPaginationModule,
|
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { delay, takeUntil, tap } from 'rxjs'
|
|
||||||
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
|
|
||||||
import {
|
|
||||||
CustomFieldQueryLogicalOperator,
|
|
||||||
CustomFieldQueryOperator,
|
|
||||||
} from 'src/app/data/custom-field-query'
|
|
||||||
import { FILTER_CUSTOM_FIELDS_QUERY } from 'src/app/data/filter-rule-type'
|
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
|
||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
|
||||||
import { SettingsService } from 'src/app/services/settings.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 { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-custom-fields',
|
|
||||||
templateUrl: './custom-fields.component.html',
|
|
||||||
styleUrls: ['./custom-fields.component.scss'],
|
|
||||||
imports: [
|
|
||||||
PageHeaderComponent,
|
|
||||||
IfPermissionsDirective,
|
|
||||||
NgbDropdownModule,
|
|
||||||
NgbPaginationModule,
|
|
||||||
NgxBootstrapIconsModule,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class CustomFieldsComponent
|
|
||||||
extends LoadingComponentWithPermissions
|
|
||||||
implements OnInit
|
|
||||||
{
|
|
||||||
public fields: CustomField[] = []
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private customFieldsService: CustomFieldsService,
|
|
||||||
public permissionsService: PermissionsService,
|
|
||||||
private modalService: NgbModal,
|
|
||||||
private toastService: ToastService,
|
|
||||||
private documentListViewService: DocumentListViewService,
|
|
||||||
private settingsService: SettingsService,
|
|
||||||
private documentService: DocumentService,
|
|
||||||
private savedViewService: SavedViewService
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
this.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
reload() {
|
|
||||||
this.customFieldsService
|
|
||||||
.listAll()
|
|
||||||
.pipe(
|
|
||||||
takeUntil(this.unsubscribeNotifier),
|
|
||||||
tap((r) => {
|
|
||||||
this.fields = r.results
|
|
||||||
}),
|
|
||||||
delay(100)
|
|
||||||
)
|
|
||||||
.subscribe(() => {
|
|
||||||
this.show = true
|
|
||||||
this.loading = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
editField(field: CustomField) {
|
|
||||||
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.settingsService.initializeDisplayFields()
|
|
||||||
this.documentService.reload()
|
|
||||||
this.reload()
|
|
||||||
})
|
|
||||||
modal.componentInstance.failed
|
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe((e) => {
|
|
||||||
this.toastService.showError($localize`Error saving field.`, e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteField(field: CustomField) {
|
|
||||||
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 "${field.name}"`)
|
|
||||||
this.customFieldsService.clearCache()
|
|
||||||
this.settingsService.initializeDisplayFields()
|
|
||||||
this.documentService.reload()
|
|
||||||
this.savedViewService.reload()
|
|
||||||
this.reload()
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error deleting field "${field.name}".`,
|
|
||||||
e
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getDataType(field: CustomField): string {
|
|
||||||
return DATA_TYPE_LABELS.find((l) => l.id === field.data_type).name
|
|
||||||
}
|
|
||||||
|
|
||||||
filterDocuments(field: CustomField) {
|
|
||||||
this.documentListViewService.quickFilter([
|
|
||||||
{
|
|
||||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
|
||||||
value: JSON.stringify([
|
|
||||||
CustomFieldQueryLogicalOperator.Or,
|
|
||||||
[[field.id, CustomFieldQueryOperator.Exists, true]],
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,7 +2,7 @@
|
|||||||
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
|
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
|
||||||
<i-bs name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
|
<i-bs name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
|
<button *ngIf="!permissionsDisabled" type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
|
||||||
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
|
||||||
|
@ -64,7 +64,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
private editDialogComponent: any,
|
private editDialogComponent: any,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
private documentListViewService: DocumentListViewService,
|
protected documentListViewService: DocumentListViewService,
|
||||||
private permissionsService: PermissionsService,
|
private permissionsService: PermissionsService,
|
||||||
protected filterRuleType: number,
|
protected filterRuleType: number,
|
||||||
public typeName: string,
|
public typeName: string,
|
||||||
@ -93,6 +93,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
public selectedObjects: Set<number> = new Set()
|
public selectedObjects: Set<number> = new Set()
|
||||||
public togggleAll: boolean = false
|
public togggleAll: boolean = false
|
||||||
|
|
||||||
|
protected permissionsDisabled: boolean = false
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.reloadData()
|
this.reloadData()
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ObjectWithId } from './object-with-id'
|
import { MatchingModel } from './matching-model'
|
||||||
|
|
||||||
export enum CustomFieldDataType {
|
export enum CustomFieldDataType {
|
||||||
String = 'string',
|
String = 'string',
|
||||||
@ -51,13 +51,11 @@ export const DATA_TYPE_LABELS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export interface CustomField extends ObjectWithId {
|
export interface CustomField extends MatchingModel {
|
||||||
data_type: CustomFieldDataType
|
data_type: CustomFieldDataType
|
||||||
name: string
|
|
||||||
created?: Date
|
created?: Date
|
||||||
extra_data?: {
|
extra_data?: {
|
||||||
select_options?: Array<{ label: string; id: string }>
|
select_options?: Array<{ label: string; id: string }>
|
||||||
default_currency?: string
|
default_currency?: string
|
||||||
}
|
}
|
||||||
document_count?: number
|
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { HttpClient } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { CustomField } from 'src/app/data/custom-field'
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
import { AbstractNameFilterService } from './abstract-name-filter-service'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class CustomFieldsService extends AbstractPaperlessService<CustomField> {
|
export class CustomFieldsService extends AbstractNameFilterService<CustomField> {
|
||||||
constructor(http: HttpClient) {
|
constructor(http: HttpClient) {
|
||||||
super(http, 'custom_fields')
|
super(http, 'custom_fields')
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ class DocumentsConfig(AppConfig):
|
|||||||
from documents.signals.handlers import run_workflows_added
|
from documents.signals.handlers import run_workflows_added
|
||||||
from documents.signals.handlers import run_workflows_updated
|
from documents.signals.handlers import run_workflows_updated
|
||||||
from documents.signals.handlers import set_correspondent
|
from documents.signals.handlers import set_correspondent
|
||||||
|
from documents.signals.handlers import set_custom_fields
|
||||||
from documents.signals.handlers import set_document_type
|
from documents.signals.handlers import set_document_type
|
||||||
from documents.signals.handlers import set_storage_path
|
from documents.signals.handlers import set_storage_path
|
||||||
from documents.signals.handlers import set_tags
|
from documents.signals.handlers import set_tags
|
||||||
@ -24,6 +25,7 @@ class DocumentsConfig(AppConfig):
|
|||||||
document_consumption_finished.connect(set_document_type)
|
document_consumption_finished.connect(set_document_type)
|
||||||
document_consumption_finished.connect(set_tags)
|
document_consumption_finished.connect(set_tags)
|
||||||
document_consumption_finished.connect(set_storage_path)
|
document_consumption_finished.connect(set_storage_path)
|
||||||
|
document_consumption_finished.connect(set_custom_fields)
|
||||||
document_consumption_finished.connect(add_to_index)
|
document_consumption_finished.connect(add_to_index)
|
||||||
document_consumption_finished.connect(run_workflows_added)
|
document_consumption_finished.connect(run_workflows_added)
|
||||||
document_updated.connect(run_workflows_updated)
|
document_updated.connect(run_workflows_updated)
|
||||||
|
@ -97,6 +97,8 @@ class DocumentClassifier:
|
|||||||
self.correspondent_classifier = None
|
self.correspondent_classifier = None
|
||||||
self.document_type_classifier = None
|
self.document_type_classifier = None
|
||||||
self.storage_path_classifier = None
|
self.storage_path_classifier = None
|
||||||
|
self.custom_fields_binarizer = None
|
||||||
|
self.custom_fields_classifier = None
|
||||||
|
|
||||||
self._stemmer = None
|
self._stemmer = None
|
||||||
self._stop_words = None
|
self._stop_words = None
|
||||||
@ -120,11 +122,12 @@ class DocumentClassifier:
|
|||||||
|
|
||||||
self.data_vectorizer = pickle.load(f)
|
self.data_vectorizer = pickle.load(f)
|
||||||
self.tags_binarizer = pickle.load(f)
|
self.tags_binarizer = pickle.load(f)
|
||||||
|
|
||||||
self.tags_classifier = pickle.load(f)
|
self.tags_classifier = pickle.load(f)
|
||||||
self.correspondent_classifier = pickle.load(f)
|
self.correspondent_classifier = pickle.load(f)
|
||||||
self.document_type_classifier = pickle.load(f)
|
self.document_type_classifier = pickle.load(f)
|
||||||
self.storage_path_classifier = pickle.load(f)
|
self.storage_path_classifier = pickle.load(f)
|
||||||
|
self.custom_fields_binarizer = pickle.load(f)
|
||||||
|
self.custom_fields_classifier = pickle.load(f)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
raise ClassifierModelCorruptError from err
|
raise ClassifierModelCorruptError from err
|
||||||
|
|
||||||
@ -162,6 +165,9 @@ class DocumentClassifier:
|
|||||||
pickle.dump(self.document_type_classifier, f)
|
pickle.dump(self.document_type_classifier, f)
|
||||||
pickle.dump(self.storage_path_classifier, f)
|
pickle.dump(self.storage_path_classifier, f)
|
||||||
|
|
||||||
|
pickle.dump(self.custom_fields_binarizer, f)
|
||||||
|
pickle.dump(self.custom_fields_classifier, f)
|
||||||
|
|
||||||
target_file_temp.rename(target_file)
|
target_file_temp.rename(target_file)
|
||||||
|
|
||||||
def train(self) -> bool:
|
def train(self) -> bool:
|
||||||
@ -183,6 +189,7 @@ class DocumentClassifier:
|
|||||||
labels_correspondent = []
|
labels_correspondent = []
|
||||||
labels_document_type = []
|
labels_document_type = []
|
||||||
labels_storage_path = []
|
labels_storage_path = []
|
||||||
|
labels_custom_fields = []
|
||||||
|
|
||||||
# Step 1: Extract and preprocess training data from the database.
|
# Step 1: Extract and preprocess training data from the database.
|
||||||
logger.debug("Gathering data from database...")
|
logger.debug("Gathering data from database...")
|
||||||
@ -218,13 +225,25 @@ class DocumentClassifier:
|
|||||||
hasher.update(y.to_bytes(4, "little", signed=True))
|
hasher.update(y.to_bytes(4, "little", signed=True))
|
||||||
labels_storage_path.append(y)
|
labels_storage_path.append(y)
|
||||||
|
|
||||||
labels_tags_unique = {tag for tags in labels_tags for tag in tags}
|
custom_fields = sorted(
|
||||||
|
cf.pk
|
||||||
|
for cf in doc.custom_fields.filter(
|
||||||
|
field__matching_algorithm=MatchingModel.MATCH_AUTO,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for cf in custom_fields:
|
||||||
|
hasher.update(cf.to_bytes(4, "little", signed=True))
|
||||||
|
labels_custom_fields.append(custom_fields)
|
||||||
|
|
||||||
|
labels_tags_unique = {tag for tags in labels_tags for tag in tags}
|
||||||
num_tags = len(labels_tags_unique)
|
num_tags = len(labels_tags_unique)
|
||||||
|
|
||||||
|
labels_custom_fields_unique = {cf for cfs in labels_custom_fields for cf in cfs}
|
||||||
|
num_custom_fields = len(labels_custom_fields_unique)
|
||||||
|
|
||||||
# Check if retraining is actually required.
|
# Check if retraining is actually required.
|
||||||
# A document has been updated since the classifier was trained
|
# A document has been updated since the classifier was trained
|
||||||
# New auto tags, types, correspondent, storage paths exist
|
# New auto tags, types, correspondent, storage paths or custom fields exist
|
||||||
latest_doc_change = docs_queryset.latest("modified").modified
|
latest_doc_change = docs_queryset.latest("modified").modified
|
||||||
if (
|
if (
|
||||||
self.last_doc_change_time is not None
|
self.last_doc_change_time is not None
|
||||||
@ -253,7 +272,8 @@ class DocumentClassifier:
|
|||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{docs_queryset.count()} documents, {num_tags} tag(s), {num_correspondents} correspondent(s), "
|
f"{docs_queryset.count()} documents, {num_tags} tag(s), {num_correspondents} correspondent(s), "
|
||||||
f"{num_document_types} document type(s). {num_storage_paths} storage path(s)",
|
f"{num_document_types} document type(s), {num_storage_paths} storage path(s), "
|
||||||
|
f"{num_custom_fields} custom field(s)",
|
||||||
)
|
)
|
||||||
|
|
||||||
from sklearn.feature_extraction.text import CountVectorizer
|
from sklearn.feature_extraction.text import CountVectorizer
|
||||||
@ -345,6 +365,39 @@ class DocumentClassifier:
|
|||||||
"There are no storage paths. Not training storage path classifier.",
|
"There are no storage paths. Not training storage path classifier.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if num_custom_fields > 0:
|
||||||
|
logger.debug("Training custom fields classifier...")
|
||||||
|
|
||||||
|
if num_custom_fields == 1:
|
||||||
|
# Special case where only one custom field has auto:
|
||||||
|
# Fallback to binary classification.
|
||||||
|
labels_custom_fields = [
|
||||||
|
label[0] if len(label) == 1 else -1
|
||||||
|
for label in labels_custom_fields
|
||||||
|
]
|
||||||
|
self.custom_fields_binarizer = LabelBinarizer()
|
||||||
|
labels_custom_fields_vectorized = (
|
||||||
|
self.custom_fields_binarizer.fit_transform(
|
||||||
|
labels_custom_fields,
|
||||||
|
).ravel()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.custom_fields_binarizer = MultiLabelBinarizer()
|
||||||
|
labels_custom_fields_vectorized = (
|
||||||
|
self.custom_fields_binarizer.fit_transform(labels_custom_fields)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.custom_fields_classifier = MLPClassifier(tol=0.01)
|
||||||
|
self.custom_fields_classifier.fit(
|
||||||
|
data_vectorized,
|
||||||
|
labels_custom_fields_vectorized,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.custom_fields_classifier = None
|
||||||
|
logger.debug(
|
||||||
|
"There are no custom fields. Not training custom fields classifier.",
|
||||||
|
)
|
||||||
|
|
||||||
self.last_doc_change_time = latest_doc_change
|
self.last_doc_change_time = latest_doc_change
|
||||||
self.last_auto_type_hash = hasher.digest()
|
self.last_auto_type_hash = hasher.digest()
|
||||||
|
|
||||||
@ -472,3 +525,29 @@ class DocumentClassifier:
|
|||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def predict_custom_fields(self, content: str) -> dict:
|
||||||
|
"""
|
||||||
|
Custom fields are a bit different from the other classifiers, as we
|
||||||
|
need to predict the values for the fields, not just the field itself.
|
||||||
|
"""
|
||||||
|
# TODO: can this return the value?
|
||||||
|
from sklearn.utils.multiclass import type_of_target
|
||||||
|
|
||||||
|
if self.custom_fields_classifier:
|
||||||
|
X = self.data_vectorizer.transform([self.preprocess_content(content)])
|
||||||
|
y = self.custom_fields_classifier.predict(X)
|
||||||
|
custom_fields_ids = self.custom_fields_binarizer.inverse_transform(y)[0]
|
||||||
|
if type_of_target(y).startswith("multilabel"):
|
||||||
|
# the usual case when there are multiple custom fields.
|
||||||
|
return list(custom_fields_ids)
|
||||||
|
elif type_of_target(y) == "binary" and custom_fields_ids != -1:
|
||||||
|
# This is for when we have binary classification with only one
|
||||||
|
# custom field and the result is to assign this custom field.
|
||||||
|
return [custom_fields_ids]
|
||||||
|
else:
|
||||||
|
# Usually binary as well with -1 as the result, but we're
|
||||||
|
# going to catch everything else here as well.
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
@ -7,6 +7,7 @@ from documents.classifier import load_classifier
|
|||||||
from documents.management.commands.mixins import ProgressBarMixin
|
from documents.management.commands.mixins import ProgressBarMixin
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.signals.handlers import set_correspondent
|
from documents.signals.handlers import set_correspondent
|
||||||
|
from documents.signals.handlers import set_custom_fields
|
||||||
from documents.signals.handlers import set_document_type
|
from documents.signals.handlers import set_document_type
|
||||||
from documents.signals.handlers import set_storage_path
|
from documents.signals.handlers import set_storage_path
|
||||||
from documents.signals.handlers import set_tags
|
from documents.signals.handlers import set_tags
|
||||||
@ -17,9 +18,9 @@ logger = logging.getLogger("paperless.management.retagger")
|
|||||||
class Command(ProgressBarMixin, BaseCommand):
|
class Command(ProgressBarMixin, BaseCommand):
|
||||||
help = (
|
help = (
|
||||||
"Using the current classification model, assigns correspondents, tags "
|
"Using the current classification model, assigns correspondents, tags "
|
||||||
"and document types to all documents, effectively allowing you to "
|
"document types, storage paths and custom fields to all documents, effectively"
|
||||||
"back-tag all previously indexed documents with metadata created (or "
|
"allowing you to back-tag all previously indexed documents with metadata created "
|
||||||
"modified) after their initial import."
|
"(or modified) after their initial import."
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
@ -27,6 +28,12 @@ class Command(ProgressBarMixin, BaseCommand):
|
|||||||
parser.add_argument("-T", "--tags", default=False, action="store_true")
|
parser.add_argument("-T", "--tags", default=False, action="store_true")
|
||||||
parser.add_argument("-t", "--document_type", default=False, action="store_true")
|
parser.add_argument("-t", "--document_type", default=False, action="store_true")
|
||||||
parser.add_argument("-s", "--storage_path", default=False, action="store_true")
|
parser.add_argument("-s", "--storage_path", default=False, action="store_true")
|
||||||
|
parser.add_argument(
|
||||||
|
"-cf",
|
||||||
|
"--custom_fields",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
parser.add_argument("-i", "--inbox-only", default=False, action="store_true")
|
parser.add_argument("-i", "--inbox-only", default=False, action="store_true")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--use-first",
|
"--use-first",
|
||||||
@ -134,3 +141,16 @@ class Command(ProgressBarMixin, BaseCommand):
|
|||||||
stdout=self.stdout,
|
stdout=self.stdout,
|
||||||
style_func=self.style,
|
style_func=self.style,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if options["custom_fields"]:
|
||||||
|
set_custom_fields(
|
||||||
|
sender=None,
|
||||||
|
document=document,
|
||||||
|
classifier=classifier,
|
||||||
|
replace=options["overwrite"],
|
||||||
|
use_first=options["use_first"],
|
||||||
|
suggest=options["suggest"],
|
||||||
|
base_url=options["base_url"],
|
||||||
|
stdout=self.stdout,
|
||||||
|
style_func=self.style,
|
||||||
|
)
|
||||||
|
@ -132,6 +132,50 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def match_custom_fields(
|
||||||
|
document: Document,
|
||||||
|
classifier: DocumentClassifier,
|
||||||
|
user=None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Custom fields work differently, we need the values for the match as well.
|
||||||
|
"""
|
||||||
|
# TODO: this needs to return values as well
|
||||||
|
predicted_custom_field_ids = (
|
||||||
|
classifier.predict_custom_fields(document.content) if classifier else []
|
||||||
|
)
|
||||||
|
|
||||||
|
fields = [instance.field for instance in document.custom_fields.all()]
|
||||||
|
|
||||||
|
matched_fields = {}
|
||||||
|
for field in fields:
|
||||||
|
if field.matching_algorithm == MatchingModel.MATCH_AUTO:
|
||||||
|
if field.pk in predicted_custom_field_ids:
|
||||||
|
matched_fields[field] = None
|
||||||
|
elif field.matching_algorithm == MatchingModel.MATCH_REGEX:
|
||||||
|
try:
|
||||||
|
match = re.search(
|
||||||
|
re.compile(field.matching_model.match),
|
||||||
|
document.content,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
matched_fields[field] = match.group()
|
||||||
|
except re.error:
|
||||||
|
logger.error(
|
||||||
|
f"Error while processing regular expression {field.matching_model.match}",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if match:
|
||||||
|
log_reason(
|
||||||
|
field.matching_model,
|
||||||
|
document,
|
||||||
|
f"the string {match.group()} matches the regular expression "
|
||||||
|
f"{field.matching_model.match}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return matched_fields
|
||||||
|
|
||||||
|
|
||||||
def matches(matching_model: MatchingModel, document: Document):
|
def matches(matching_model: MatchingModel, document: Document):
|
||||||
search_kwargs = {}
|
search_kwargs = {}
|
||||||
|
|
||||||
|
@ -0,0 +1,55 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-20 23:37
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "1065_workflowaction_assign_custom_fields_values"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customfield",
|
||||||
|
name="is_insensitive",
|
||||||
|
field=models.BooleanField(default=True, verbose_name="is insensitive"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customfield",
|
||||||
|
name="match",
|
||||||
|
field=models.CharField(blank=True, max_length=256, verbose_name="match"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customfield",
|
||||||
|
name="matching_algorithm",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
choices=[
|
||||||
|
(0, "None"),
|
||||||
|
(1, "Any word"),
|
||||||
|
(2, "All words"),
|
||||||
|
(3, "Exact match"),
|
||||||
|
(4, "Regular expression"),
|
||||||
|
(5, "Fuzzy word"),
|
||||||
|
(6, "Automatic"),
|
||||||
|
],
|
||||||
|
default=0,
|
||||||
|
verbose_name="matching algorithm",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customfield",
|
||||||
|
name="owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="owner",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -719,7 +719,7 @@ class ShareLink(SoftDeleteModel):
|
|||||||
return f"Share Link for {self.document.title}"
|
return f"Share Link for {self.document.title}"
|
||||||
|
|
||||||
|
|
||||||
class CustomField(models.Model):
|
class CustomField(MatchingModel):
|
||||||
"""
|
"""
|
||||||
Defines the name and type of a custom field
|
Defines the name and type of a custom field
|
||||||
"""
|
"""
|
||||||
@ -760,6 +760,12 @@ class CustomField(models.Model):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
matching_algorithm = models.PositiveIntegerField(
|
||||||
|
_("matching algorithm"),
|
||||||
|
choices=MatchingModel.MATCHING_ALGORITHMS,
|
||||||
|
default=MatchingModel.MATCH_NONE, # override with CustomField.FieldDataType.NONE
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("created",)
|
ordering = ("created",)
|
||||||
verbose_name = _("custom field")
|
verbose_name = _("custom field")
|
||||||
|
@ -582,7 +582,7 @@ class StoragePathField(serializers.PrimaryKeyRelatedField):
|
|||||||
return StoragePath.objects.all()
|
return StoragePath.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldSerializer(serializers.ModelSerializer):
|
class CustomFieldSerializer(MatchingModelSerializer, serializers.ModelSerializer):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
context = kwargs.get("context")
|
context = kwargs.get("context")
|
||||||
self.api_version = int(
|
self.api_version = int(
|
||||||
@ -597,8 +597,6 @@ class CustomFieldSerializer(serializers.ModelSerializer):
|
|||||||
read_only=False,
|
read_only=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
document_count = serializers.IntegerField(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomField
|
model = CustomField
|
||||||
fields = [
|
fields = [
|
||||||
@ -607,6 +605,9 @@ class CustomFieldSerializer(serializers.ModelSerializer):
|
|||||||
"data_type",
|
"data_type",
|
||||||
"extra_data",
|
"extra_data",
|
||||||
"document_count",
|
"document_count",
|
||||||
|
"match",
|
||||||
|
"matching_algorithm",
|
||||||
|
"is_insensitive",
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
@ -669,6 +670,19 @@ class CustomFieldSerializer(serializers.ModelSerializer):
|
|||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"error": "extra_data.default_currency must be a 3-character string"},
|
{"error": "extra_data.default_currency must be a 3-character string"},
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
"matching_algorithm" in attrs
|
||||||
|
and attrs["matching_algorithm"] != CustomField.MATCH_REGEX
|
||||||
|
and "data_type" in attrs
|
||||||
|
and attrs["data_type"]
|
||||||
|
not in [
|
||||||
|
CustomField.FieldDataType.SELECT,
|
||||||
|
CustomField.FieldDataType.BOOL,
|
||||||
|
]
|
||||||
|
):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"error": "Only discrete data types support matching"},
|
||||||
|
)
|
||||||
return super().validate(attrs)
|
return super().validate(attrs)
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
|
@ -318,6 +318,77 @@ def set_storage_path(
|
|||||||
document.save(update_fields=("storage_path",))
|
document.save(update_fields=("storage_path",))
|
||||||
|
|
||||||
|
|
||||||
|
def set_custom_fields(
|
||||||
|
document: Document,
|
||||||
|
logging_group=None,
|
||||||
|
classifier: DocumentClassifier | None = None,
|
||||||
|
base_url=None,
|
||||||
|
stdout=None,
|
||||||
|
style_func=None,
|
||||||
|
*,
|
||||||
|
replace=False,
|
||||||
|
suggest=False,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
if replace:
|
||||||
|
CustomFieldInstance.objects.filter(document=document).exclude(
|
||||||
|
Q(field__match="") & ~Q(field__matching_algorithm=CustomField.MATCH_AUTO),
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
current_fields = set([instance.field for instance in document.custom_fields.all()])
|
||||||
|
|
||||||
|
matched_fields_w_values: dict = matching.match_custom_fields(document, classifier)
|
||||||
|
matched_fields = matched_fields_w_values.keys()
|
||||||
|
|
||||||
|
relevant_fields = set(matched_fields) - current_fields
|
||||||
|
|
||||||
|
if suggest:
|
||||||
|
extra_fields = current_fields - set(matched_fields)
|
||||||
|
extra_fields = [
|
||||||
|
f for f in extra_fields if f.matching_algorithm == MatchingModel.MATCH_AUTO
|
||||||
|
]
|
||||||
|
if not relevant_fields and not extra_fields:
|
||||||
|
return
|
||||||
|
doc_str = style_func.SUCCESS(str(document))
|
||||||
|
if base_url:
|
||||||
|
stdout.write(doc_str)
|
||||||
|
stdout.write(f"{base_url}/documents/{document.pk}")
|
||||||
|
else:
|
||||||
|
stdout.write(doc_str + style_func.SUCCESS(f" [{document.pk}]"))
|
||||||
|
if relevant_fields:
|
||||||
|
stdout.write(
|
||||||
|
"Suggest custom fields: "
|
||||||
|
+ ", ".join([f.name for f in relevant_fields]),
|
||||||
|
)
|
||||||
|
if extra_fields:
|
||||||
|
stdout.write(
|
||||||
|
"Extra custom fields: " + ", ".join([f.name for f in extra_fields]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not relevant_fields:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = 'Assigning custom fields "{}" to "{}"'
|
||||||
|
logger.info(
|
||||||
|
message.format(document, ", ".join([f.name for f in relevant_fields])),
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in relevant_fields:
|
||||||
|
args = {
|
||||||
|
"field": field,
|
||||||
|
"document": document,
|
||||||
|
}
|
||||||
|
if field.pk in matched_fields_w_values:
|
||||||
|
value_field_name = CustomFieldInstance.get_value_field_name(
|
||||||
|
data_type=field.data_type,
|
||||||
|
)
|
||||||
|
args[value_field_name] = matched_fields_w_values[field.pk]
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
**args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# see empty_trash in documents/tasks.py for signal handling
|
# see empty_trash in documents/tasks.py for signal handling
|
||||||
def cleanup_document_deletion(sender, instance, **kwargs):
|
def cleanup_document_deletion(sender, instance, **kwargs):
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
with FileLock(settings.MEDIA_LOCK):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user