mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 23:08:14 +00:00
Remove the slop quick create CF stuff
This commit is contained in:
8 files changed
+107
-225
No files matched your search
+2
-32
@@ -77,46 +77,16 @@
|
||||
<pngx-input-text [(ngModel)]="zone.name" title="Name" i18n-title></pngx-input-text>
|
||||
|
||||
<pngx-input-select
|
||||
[items]="fieldItems"
|
||||
[items]="fieldItems()"
|
||||
[ngModel]="zoneFieldValue(zone)"
|
||||
(ngModelChange)="setZoneField(zone, $event)"
|
||||
(createNew)="openQuickCreate(selectedZoneIndex, $event)"
|
||||
(createNew)="createField($event)"
|
||||
title="Field"
|
||||
i18n-title
|
||||
hint="Where the extracted value is written."
|
||||
i18n-hint
|
||||
></pngx-input-select>
|
||||
|
||||
@if (showQuickCreate) {
|
||||
<div class="card mb-3 border-primary">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title" i18n>Create Custom Field</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small" i18n>Field Name</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
[(ngModel)]="quickCreateName" placeholder="e.g. Invoice Number" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small" i18n>Field Type</label>
|
||||
<select class="form-select form-select-sm" [(ngModel)]="quickCreateType">
|
||||
@for (t of quickCreateTypes; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" (click)="submitQuickCreate()"
|
||||
[disabled]="!quickCreateName.trim()" i18n>
|
||||
Create & Assign
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" (click)="cancelQuickCreate()" i18n>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isFieldShared(zone)) {
|
||||
<div class="card mb-3 border-info">
|
||||
<div class="card-body">
|
||||
|
||||
+54
-2
@@ -1,7 +1,9 @@
|
||||
import { ElementRef } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, Subject, throwError } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { OcrTemplateZone, OcrZoneTestResult } from 'src/app/data/ocr-template'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
@@ -39,6 +41,7 @@ describe('OcrTemplateEditorComponent', () => {
|
||||
testZone: jest.fn(),
|
||||
getPageImageUrl: (id: number, page: number) => `/preview/${id}/${page}`,
|
||||
}
|
||||
const modal = { open: jest.fn() }
|
||||
|
||||
beforeEach(async () => {
|
||||
documents.list.mockReset().mockReturnValue(of({ results: [] }))
|
||||
@@ -56,10 +59,17 @@ describe('OcrTemplateEditorComponent', () => {
|
||||
...[CustomFieldsService, DocumentTypeService, CorrespondentService].map(
|
||||
(provide) => ({
|
||||
provide,
|
||||
useValue: { listAll: () => of({ results: [] }) },
|
||||
useValue: {
|
||||
listAll: () => of({ results: [] }),
|
||||
clearCache: jest.fn(),
|
||||
},
|
||||
})
|
||||
),
|
||||
{ provide: ToastService, useValue: {} },
|
||||
{ provide: NgbModal, useValue: modal },
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: { showSuccess: jest.fn(), showError: jest.fn() },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(OcrTemplateEditorComponent, {
|
||||
@@ -216,6 +226,48 @@ describe('OcrTemplateEditorComponent', () => {
|
||||
expect(component.selectedZone).toBeNull()
|
||||
})
|
||||
|
||||
it('creates a custom field in the dialog and assigns it to the zone', () => {
|
||||
const succeeded = new Subject<CustomField>()
|
||||
const dialog = {
|
||||
dialogMode: { set: jest.fn() },
|
||||
object: null,
|
||||
succeeded,
|
||||
failed: new Subject(),
|
||||
}
|
||||
modal.open.mockReturnValue({ componentInstance: dialog })
|
||||
const created = {
|
||||
id: 5,
|
||||
name: 'Total',
|
||||
data_type: CustomFieldDataType.Monetary,
|
||||
}
|
||||
TestBed.inject(CustomFieldsService).listAll = () =>
|
||||
of({
|
||||
count: 2,
|
||||
all: [5, 6],
|
||||
results: [
|
||||
created,
|
||||
{ id: 6, name: 'Status', data_type: CustomFieldDataType.Select },
|
||||
],
|
||||
})
|
||||
component.template.zones = [zone()]
|
||||
component.selectZone(0)
|
||||
|
||||
component.createField()
|
||||
expect(dialog.object).toEqual({ name: 'Invoice number' })
|
||||
succeeded.next(created)
|
||||
|
||||
expect(component.template.zones[0]).toMatchObject({
|
||||
target: 'custom_field',
|
||||
custom_field: 5,
|
||||
})
|
||||
expect(component.fieldItems().map((item) => item.id)).toEqual([
|
||||
'title',
|
||||
'asn',
|
||||
'created',
|
||||
5,
|
||||
])
|
||||
})
|
||||
|
||||
it('hides results when extraction settings or geometry change', () => {
|
||||
const selected = zone()
|
||||
component.template.zones = [selected]
|
||||
|
||||
+37
-65
@@ -13,6 +13,7 @@ import {
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbNavModule,
|
||||
NgbPopoverModule,
|
||||
NgbTypeaheadModule,
|
||||
@@ -31,12 +32,14 @@ import {
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { NumberComponent } from 'src/app/components/common/input/number/number.component'
|
||||
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
||||
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
|
||||
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
||||
import { PageHeaderComponent } from 'src/app/components/common/page-header/page-header.component'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import {
|
||||
@@ -47,6 +50,7 @@ import {
|
||||
isOcrBuiltinTarget,
|
||||
OCR_BUILTIN_TARGETS,
|
||||
OCR_LANGUAGE_OPTIONS,
|
||||
OCR_SUPPORTED_FIELD_TYPES,
|
||||
OCR_ZONE_TARGET,
|
||||
OCR_ZONE_TRANSFORM,
|
||||
OcrBuiltinTarget,
|
||||
@@ -155,6 +159,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
private readonly correspondentService = inject(CorrespondentService)
|
||||
private readonly documentService = inject(DocumentService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly changeDetector = inject(ChangeDetectorRef)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly cancelZoneTest$ = new Subject<void>()
|
||||
@@ -192,10 +197,10 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private _customFields: CustomField[] = []
|
||||
/** Items for the zone field select: built-in targets, then custom fields. */
|
||||
fieldItems: { id: ZoneFieldSelection; name: string }[] = [
|
||||
/** Items for the zone field select: built-in targets, then custom fields OCR can write to. */
|
||||
readonly fieldItems = signal<{ id: ZoneFieldSelection; name: string }[]>([
|
||||
...OCR_BUILTIN_TARGETS,
|
||||
]
|
||||
])
|
||||
|
||||
get customFields(): CustomField[] {
|
||||
return this._customFields
|
||||
@@ -203,10 +208,12 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
|
||||
set customFields(fields: CustomField[]) {
|
||||
this._customFields = fields
|
||||
this.fieldItems = [
|
||||
this.fieldItems.set([
|
||||
...OCR_BUILTIN_TARGETS,
|
||||
...fields.map((cf) => ({ id: cf.id, name: cf.name })),
|
||||
]
|
||||
...fields
|
||||
.filter((cf) => OCR_SUPPORTED_FIELD_TYPES.includes(cf.data_type))
|
||||
.map((cf) => ({ id: cf.id, name: cf.name })),
|
||||
])
|
||||
}
|
||||
documentTypes: DocumentType[] = []
|
||||
transformOptions = TRANSFORM_OPTIONS
|
||||
@@ -266,21 +273,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
return this.isCurrentZoneTest() && this.zoneTest().result === null
|
||||
}
|
||||
|
||||
showQuickCreate = false
|
||||
quickCreateName = ''
|
||||
quickCreateType = CustomFieldDataType.String
|
||||
quickCreateForZoneIndex: number | null = null
|
||||
quickCreateTypes = [
|
||||
{ id: CustomFieldDataType.String, name: $localize`String` },
|
||||
{ id: CustomFieldDataType.Integer, name: $localize`Integer` },
|
||||
{ id: CustomFieldDataType.Float, name: $localize`Float` },
|
||||
{ id: CustomFieldDataType.Date, name: $localize`Date` },
|
||||
{ id: CustomFieldDataType.Monetary, name: $localize`Monetary` },
|
||||
{ id: CustomFieldDataType.Boolean, name: $localize`Boolean` },
|
||||
{ id: CustomFieldDataType.Url, name: $localize`URL` },
|
||||
{ id: CustomFieldDataType.LongText, name: $localize`Long Text` },
|
||||
]
|
||||
|
||||
get selectedZone(): OcrTemplateZone | null {
|
||||
return this.selectedZoneIndex !== null
|
||||
? (this.template.zones[this.selectedZoneIndex] ?? null)
|
||||
@@ -1092,50 +1084,30 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
return dt ? dt.name : `Type #${id}`
|
||||
}
|
||||
|
||||
openQuickCreate(zoneIndex: number | null, name?: string) {
|
||||
if (zoneIndex === null) return
|
||||
this.quickCreateForZoneIndex = zoneIndex
|
||||
this.quickCreateName = name || this.template.zones[zoneIndex]?.name || ''
|
||||
this.quickCreateType = CustomFieldDataType.String
|
||||
this.showQuickCreate = true
|
||||
}
|
||||
|
||||
cancelQuickCreate() {
|
||||
this.showQuickCreate = false
|
||||
this.quickCreateForZoneIndex = null
|
||||
}
|
||||
|
||||
submitQuickCreate() {
|
||||
if (!this.quickCreateName.trim()) return
|
||||
|
||||
this.templateService
|
||||
.quickCreateField(this.quickCreateName.trim(), this.quickCreateType)
|
||||
createField(name?: string) {
|
||||
const zone = this.selectedZone
|
||||
const modal = this.modalService.open(CustomFieldEditDialogComponent)
|
||||
modal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
|
||||
modal.componentInstance.object = { name: name || zone?.name }
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.customFieldsService.clearCache()
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
this.customFields = r.results
|
||||
if (this.quickCreateForZoneIndex !== null) {
|
||||
this.template.zones[this.quickCreateForZoneIndex].custom_field =
|
||||
result.id
|
||||
this.template.zones[this.quickCreateForZoneIndex].target =
|
||||
OCR_ZONE_TARGET.CustomField
|
||||
}
|
||||
this.showQuickCreate = false
|
||||
this.quickCreateForZoneIndex = null
|
||||
this.changeDetector.markForCheck()
|
||||
})
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastService.showError(
|
||||
$localize`Failed to create custom field.`,
|
||||
err
|
||||
)
|
||||
},
|
||||
.subscribe((field: CustomField) => {
|
||||
this.toastService.showSuccess($localize`Saved field "${field.name}".`)
|
||||
this.customFieldsService.clearCache()
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
if (zone && OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type)) {
|
||||
this.setZoneField(zone, field.id)
|
||||
}
|
||||
this.customFields = r.results
|
||||
})
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving field.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomFieldDataType } from './custom-field'
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export type OcrZoneTarget = 'custom_field' | 'title' | 'asn' | 'created'
|
||||
@@ -42,6 +43,18 @@ export function isOcrBuiltinTarget(value: unknown): value is OcrBuiltinTarget {
|
||||
)
|
||||
}
|
||||
|
||||
/** Custom field types zone OCR can write to. See models.py OCR_SUPPORTED_FIELD_TYPES. */
|
||||
export const OCR_SUPPORTED_FIELD_TYPES: CustomFieldDataType[] = [
|
||||
CustomFieldDataType.String,
|
||||
CustomFieldDataType.Url,
|
||||
CustomFieldDataType.Date,
|
||||
CustomFieldDataType.Integer,
|
||||
CustomFieldDataType.Float,
|
||||
CustomFieldDataType.Monetary,
|
||||
CustomFieldDataType.LongText,
|
||||
CustomFieldDataType.Boolean,
|
||||
]
|
||||
|
||||
export const OCR_BUILTIN_TARGETS = [
|
||||
{ id: OCR_ZONE_TARGET.Title, name: $localize`Title` },
|
||||
{ id: OCR_ZONE_TARGET.Asn, name: $localize`Archive serial number` },
|
||||
|
||||
@@ -7,13 +7,6 @@ import {
|
||||
} from '../../data/ocr-template'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
export interface QuickCreateFieldResult {
|
||||
id: number
|
||||
name: string
|
||||
data_type: string
|
||||
created: boolean
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OcrTemplateService extends AbstractPaperlessService<OcrTemplate> {
|
||||
constructor() {
|
||||
@@ -34,14 +27,4 @@ export class OcrTemplateService extends AbstractPaperlessService<OcrTemplate> {
|
||||
{ document: docId, zone }
|
||||
)
|
||||
}
|
||||
|
||||
quickCreateField(
|
||||
name: string,
|
||||
dataType: string
|
||||
): Observable<QuickCreateFieldResult> {
|
||||
return this.http.post<QuickCreateFieldResult>(
|
||||
`${this.baseUrl}${this.resourceName}/quick-create-field/`,
|
||||
{ name, data_type: dataType }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2381,7 +2381,7 @@ class OcrTemplateZone(models.Model):
|
||||
|
||||
# Custom field data types that zone OCR can extract into. DOCUMENTLINK and
|
||||
# SELECT are excluded (they reference other objects, not free text). Single
|
||||
# source of truth for the serializer, the quick-create endpoint and the engine.
|
||||
# source of truth for the serializer and the engine.
|
||||
OCR_SUPPORTED_FIELD_TYPES = frozenset(
|
||||
{
|
||||
CustomField.FieldDataType.STRING,
|
||||
|
||||
@@ -395,55 +395,3 @@ class TestOcrTemplatesAPI(DirectoriesMixin, APITestCase):
|
||||
resp.status_code,
|
||||
(status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN),
|
||||
)
|
||||
|
||||
# --- Quick create field ---
|
||||
|
||||
def test_quick_create_field(self):
|
||||
"""Creating a custom field inline from the template editor."""
|
||||
resp = self.client.post(
|
||||
f"{self.ENDPOINT}quick-create-field/",
|
||||
data=json.dumps({"name": "New Field", "data_type": "string"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||
data = resp.json()
|
||||
self.assertEqual(data["name"], "New Field")
|
||||
self.assertEqual(data["data_type"], "string")
|
||||
self.assertTrue(data["created"])
|
||||
self.assertTrue(CustomField.objects.filter(name="New Field").exists())
|
||||
|
||||
def test_quick_create_field_existing(self):
|
||||
"""If a field with the same name exists, return it without creating."""
|
||||
resp = self.client.post(
|
||||
f"{self.ENDPOINT}quick-create-field/",
|
||||
data=json.dumps({"name": "Invoice Number", "data_type": "string"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
data = resp.json()
|
||||
self.assertEqual(data["id"], self.custom_field_text.pk)
|
||||
self.assertFalse(data["created"])
|
||||
|
||||
def test_quick_create_field_empty_name_rejected(self):
|
||||
resp = self.client.post(
|
||||
f"{self.ENDPOINT}quick-create-field/",
|
||||
data=json.dumps({"name": "", "data_type": "string"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_quick_create_field_unsupported_type_rejected(self):
|
||||
resp = self.client.post(
|
||||
f"{self.ENDPOINT}quick-create-field/",
|
||||
data=json.dumps({"name": "Bad Field", "data_type": "documentlink"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_quick_create_field_select_type_rejected(self):
|
||||
resp = self.client.post(
|
||||
f"{self.ENDPOINT}quick-create-field/",
|
||||
data=json.dumps({"name": "Bad Field", "data_type": "select"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
@@ -151,7 +151,6 @@ from documents.matching import match_correspondents
|
||||
from documents.matching import match_document_types
|
||||
from documents.matching import match_storage_paths
|
||||
from documents.matching import match_tags
|
||||
from documents.models import OCR_SUPPORTED_FIELD_TYPES
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -5953,58 +5952,3 @@ class OcrTemplateViewSet(ModelViewSet):
|
||||
"regex_match": regex_match,
|
||||
},
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="quick-create-field")
|
||||
def quick_create_field(self, request):
|
||||
"""Create a custom field inline from the template editor.
|
||||
|
||||
Accepts: {"name": "Invoice Number", "data_type": "string"}
|
||||
Returns the created field so the frontend can immediately use it.
|
||||
"""
|
||||
name = request.data.get("name", "").strip()
|
||||
data_type = request.data.get("data_type", "").strip()
|
||||
|
||||
if not name:
|
||||
return Response(
|
||||
{"error": "Field name is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if data_type not in OCR_SUPPORTED_FIELD_TYPES:
|
||||
return Response(
|
||||
{
|
||||
"error": f"Unsupported data type '{data_type}'. "
|
||||
f"Supported: {', '.join(sorted(OCR_SUPPORTED_FIELD_TYPES))}",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if field already exists
|
||||
existing = CustomField.objects.filter(name=name).first()
|
||||
if existing:
|
||||
return Response(
|
||||
{
|
||||
"id": existing.pk,
|
||||
"name": existing.name,
|
||||
"data_type": existing.data_type,
|
||||
"created": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Check user has permission to create custom fields
|
||||
if not request.user.has_perm("documents.add_customfield"):
|
||||
return Response(
|
||||
{"error": "You don't have permission to create custom fields"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
field = CustomField.objects.create(name=name, data_type=data_type)
|
||||
return Response(
|
||||
{
|
||||
"id": field.pk,
|
||||
"name": field.name,
|
||||
"data_type": field.data_type,
|
||||
"created": True,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
Reference in new issue
Block a user