mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-11 12:18:02 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551445773a | ||
|
|
c54cb4a84b | ||
|
|
af61044b63 | ||
|
|
1ef5a6d3cf | ||
|
|
4da311f91f | ||
|
|
cbb84649c7 | ||
|
|
8da734a495 | ||
|
|
351671011d | ||
|
|
15c95c251d |
@@ -1,58 +0,0 @@
|
|||||||
#!/command/with-contenv /usr/bin/bash
|
|
||||||
# shellcheck shell=bash
|
|
||||||
declare -r log_prefix="[init-compile-bytecode]"
|
|
||||||
|
|
||||||
# PYTHONDONTWRITEBYTECODE=1 is set for the whole container. This unit compiles a
|
|
||||||
# scoped set of libraries anyway, to speed up startup without bloating image size.
|
|
||||||
|
|
||||||
# Handle the people using a read only file system
|
|
||||||
if [[ "${S6_READ_ONLY_ROOT}" == "1" ]]; then
|
|
||||||
echo "${log_prefix} S6_READ_ONLY_ROOT=1, skipping (nothing to write bytecode to)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# When running as a non-root user, site-packages is still root-owned and unwritable,
|
|
||||||
# so this step would just fail loudly on every container start. Skip it.
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
|
||||||
echo "${log_prefix} USER_IS_NON_ROOT is set, skipping (site-packages is not writable)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
declare -r site_packages="$(python3 -c 'import site; print(site.getsitepackages()[0])')"
|
|
||||||
|
|
||||||
# Deliberately scoped to packages that paperless.settings/paperless/__init__.py import
|
|
||||||
# unconditionally on every manage.py invocation (Django itself, the always-loaded
|
|
||||||
# INSTALLED_APPS, and celery). This is NOT "compile everything" - the optional AI stack
|
|
||||||
# (torch, llama-index, sentence-transformers, ...) is intentionally excluded since it is
|
|
||||||
# lazy-imported and large.
|
|
||||||
declare -a scope=(
|
|
||||||
"${PAPERLESS_SRC_DIR}"
|
|
||||||
"${site_packages}/django"
|
|
||||||
"${site_packages}/celery"
|
|
||||||
"${site_packages}/kombu"
|
|
||||||
"${site_packages}/rest_framework"
|
|
||||||
"${site_packages}/django_filters"
|
|
||||||
"${site_packages}/whitenoise"
|
|
||||||
"${site_packages}/corsheaders"
|
|
||||||
"${site_packages}/django_extensions"
|
|
||||||
"${site_packages}/guardian"
|
|
||||||
"${site_packages}/allauth"
|
|
||||||
"${site_packages}/drf_spectacular"
|
|
||||||
"${site_packages}/drf_spectacular_sidecar"
|
|
||||||
"${site_packages}/treenode"
|
|
||||||
"${site_packages}/compression_middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
declare -a existing_scope=()
|
|
||||||
for path in "${scope[@]}"; do
|
|
||||||
[[ -d "${path}" ]] && existing_scope+=("${path}")
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "${log_prefix} Compiling bytecode for: ${existing_scope[*]}"
|
|
||||||
declare -r start_seconds=${SECONDS}
|
|
||||||
|
|
||||||
if ! PYTHONDONTWRITEBYTECODE= python3 -m compileall -q "${existing_scope[@]}"; then
|
|
||||||
echo "${log_prefix} WARNING: compileall reported errors (read-only filesystem or unwritable site-packages?); continuing without a bytecode cache"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "${log_prefix} Done in $((SECONDS - start_seconds))s"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
oneshot
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/etc/s6-overlay/s6-rc.d/init-compile-bytecode/run
|
|
||||||
@@ -1200,15 +1200,6 @@ still perform some basic text pre-processing before matching.
|
|||||||
|
|
||||||
Defaults to true, enabling the feature.
|
Defaults to true, enabling the feature.
|
||||||
|
|
||||||
#### [`PAPERLESS_CLASSIFIER_MATCH_THRESHOLD=<float>`](#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD) {#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD}
|
|
||||||
|
|
||||||
: Sets the minimum confidence score (0.0-1.0) required for the automatic
|
|
||||||
classifier to assign a correspondent, document type, or storage path to a
|
|
||||||
document. Predictions below this threshold are discarded and the field is
|
|
||||||
left unassigned, preventing low-confidence guesses from being applied.
|
|
||||||
|
|
||||||
Defaults to 0.6.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
||||||
|
|
||||||
: Specifies which language Paperless should use when parsing dates from documents.
|
: Specifies which language Paperless should use when parsing dates from documents.
|
||||||
|
|||||||
+203
-250
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
|
|||||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
|
import { OcrTemplateEditorComponent } from './components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component'
|
||||||
|
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
|
||||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
import { NotFoundComponent } from './components/not-found/not-found.component'
|
import { NotFoundComponent } from './components/not-found/not-found.component'
|
||||||
@@ -274,6 +276,42 @@ export const routes: Routes = [
|
|||||||
componentName: 'WorkflowsComponent',
|
componentName: 'WorkflowsComponent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ocr-templates',
|
||||||
|
component: OcrTemplatesComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.OcrTemplate,
|
||||||
|
},
|
||||||
|
componentName: 'OcrTemplatesComponent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'ocr-templates/new',
|
||||||
|
component: OcrTemplateEditorComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.Add,
|
||||||
|
type: PermissionType.OcrTemplate,
|
||||||
|
},
|
||||||
|
componentName: 'OcrTemplateEditorComponent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'ocr-templates/:id',
|
||||||
|
component: OcrTemplateEditorComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.Change,
|
||||||
|
type: PermissionType.OcrTemplate,
|
||||||
|
},
|
||||||
|
componentName: 'OcrTemplateEditorComponent',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'mail',
|
path: 'mail',
|
||||||
component: MailComponent,
|
component: MailComponent,
|
||||||
|
|||||||
@@ -112,22 +112,6 @@
|
|||||||
|
|
||||||
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
||||||
|
|
||||||
<p class="mb-2 mt-3" i18n>Sidebar items to show:</p>
|
|
||||||
@for (option of sidebarItemOptions; track option.id) {
|
|
||||||
<div class="form-check">
|
|
||||||
<input
|
|
||||||
class="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
[id]="'sidebar-item-setting-' + option.id"
|
|
||||||
[checked]="isSidebarItemShown(option.id)"
|
|
||||||
(change)="toggleSidebarItem(option.id, $event.target.checked)"
|
|
||||||
/>
|
|
||||||
<label class="form-check-label" [for]="'sidebar-item-setting-' + option.id">
|
|
||||||
{{ option.label }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
@@ -209,45 +209,6 @@ describe('SettingsComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
it('supports configuring sidebar items and canceling changes', () => {
|
|
||||||
completeSetup()
|
|
||||||
|
|
||||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
|
||||||
HideableSidebarItemID.Workflows
|
|
||||||
)
|
|
||||||
|
|
||||||
settingsService.updateSidebarItemVisibility(
|
|
||||||
HideableSidebarItemID.Mail,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
|
||||||
HideableSidebarItemID.Mail
|
|
||||||
)
|
|
||||||
|
|
||||||
component.reset()
|
|
||||||
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
|
||||||
HideableSidebarItemID.Workflows
|
|
||||||
)
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
|
||||||
HideableSidebarItemID.Mail
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('enables sidebar item controls on general settings until destroyed', () => {
|
|
||||||
completeSetup()
|
|
||||||
|
|
||||||
expect(settingsService.organizingSidebarItems()).toBe(true)
|
|
||||||
|
|
||||||
component.ngOnDestroy()
|
|
||||||
|
|
||||||
expect(settingsService.organizingSidebarItems()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
@@ -288,7 +249,6 @@ describe('SettingsComponent', () => {
|
|||||||
|
|
||||||
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSpy = jest.spyOn(toastService, 'show')
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
||||||
@@ -307,10 +267,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(34)
|
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
])
|
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
|
|||||||
@@ -39,12 +39,7 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import {
|
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
GlobalSearchType,
|
|
||||||
HIDEABLE_SIDEBAR_ITEM_IDS,
|
|
||||||
HideableSidebarItemID,
|
|
||||||
SETTINGS_KEYS,
|
|
||||||
} from 'src/app/data/ui-settings'
|
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -107,14 +102,6 @@ const documentDetailFieldOptions = [
|
|||||||
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
||||||
]
|
]
|
||||||
|
|
||||||
const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
|
||||||
[HideableSidebarItemID.Dashboard]: $localize`Dashboard`,
|
|
||||||
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
|
||||||
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
|
||||||
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
|
||||||
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-settings',
|
selector: 'pngx-settings',
|
||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
@@ -162,7 +149,6 @@ export class SettingsComponent
|
|||||||
bulkEditApplyOnClose: new FormControl(null),
|
bulkEditApplyOnClose: new FormControl(null),
|
||||||
documentListItemPerPage: new FormControl(null),
|
documentListItemPerPage: new FormControl(null),
|
||||||
slimSidebarEnabled: new FormControl(null),
|
slimSidebarEnabled: new FormControl(null),
|
||||||
sidebarHiddenItems: new FormControl<HideableSidebarItemID[]>([]),
|
|
||||||
darkModeUseSystem: new FormControl(null),
|
darkModeUseSystem: new FormControl(null),
|
||||||
darkModeEnabled: new FormControl(null),
|
darkModeEnabled: new FormControl(null),
|
||||||
darkModeInvertThumbs: new FormControl(null),
|
darkModeInvertThumbs: new FormControl(null),
|
||||||
@@ -200,7 +186,6 @@ export class SettingsComponent
|
|||||||
|
|
||||||
store: BehaviorSubject<any>
|
store: BehaviorSubject<any>
|
||||||
storeSub: Subscription
|
storeSub: Subscription
|
||||||
sidebarItemsSub: Subscription
|
|
||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
isDirty: boolean = false
|
isDirty: boolean = false
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
@@ -218,10 +203,6 @@ export class SettingsComponent
|
|||||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
|
||||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||||
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
|
||||||
id,
|
|
||||||
label: sidebarItemLabels[id],
|
|
||||||
}))
|
|
||||||
|
|
||||||
get systemStatusHasErrors(): boolean {
|
get systemStatusHasErrors(): boolean {
|
||||||
const status = this.systemStatus()
|
const status = this.systemStatus()
|
||||||
@@ -249,10 +230,6 @@ export class SettingsComponent
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.sidebarItemsSub =
|
|
||||||
this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) =>
|
|
||||||
this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems)
|
|
||||||
)
|
|
||||||
this.settings.settingsSaved.subscribe(() => {
|
this.settings.settingsSaved.subscribe(() => {
|
||||||
if (!this.savePending) this.initialize()
|
if (!this.savePending) this.initialize()
|
||||||
this.savedViewsService.maybeRefreshDocumentCounts()
|
this.savedViewsService.maybeRefreshDocumentCounts()
|
||||||
@@ -302,21 +279,14 @@ export class SettingsComponent
|
|||||||
|
|
||||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||||
const section = paramMap.get('section')
|
const section = paramMap.get('section')
|
||||||
let navID = SettingsNavIDs.General
|
|
||||||
if (section) {
|
if (section) {
|
||||||
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
||||||
(navID) => navID.toLowerCase() == section
|
(navID) => navID.toLowerCase() == section
|
||||||
)
|
)
|
||||||
if (navIDKey) {
|
if (navIDKey) {
|
||||||
navID = SettingsNavIDs[navIDKey]
|
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.activeNavID.set(navID)
|
|
||||||
this.settings.sidebarHiddenItemsEditing.set(
|
|
||||||
navID === SettingsNavIDs.General
|
|
||||||
? [...this.settingsForm.controls.sidebarHiddenItems.value]
|
|
||||||
: null
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +310,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
||||||
),
|
),
|
||||||
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
||||||
sidebarHiddenItems: this.settings.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS),
|
|
||||||
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
||||||
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
||||||
darkModeInvertThumbs: this.settings.get(
|
darkModeInvertThumbs: this.settings.get(
|
||||||
@@ -467,12 +436,6 @@ export class SettingsComponent
|
|||||||
this.settingsForm.patchValue(currentFormValue)
|
this.settingsForm.patchValue(currentFormValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.settings.organizingSidebarItems()) {
|
|
||||||
this.settings.sidebarHiddenItemsEditing.set([
|
|
||||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.canViewSystemStatus) {
|
if (this.canViewSystemStatus) {
|
||||||
this.systemStatusService.get().subscribe((status) => {
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
this.systemStatus.set(status)
|
this.systemStatus.set(status)
|
||||||
@@ -481,18 +444,8 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.settings.sidebarHiddenItemsEditing.set(null)
|
|
||||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
||||||
this.storeSub && this.storeSub.unsubscribe()
|
this.storeSub && this.storeSub.unsubscribe()
|
||||||
this.sidebarItemsSub.unsubscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
isSidebarItemShown(item: HideableSidebarItemID): boolean {
|
|
||||||
return !(this.settingsForm.value.sidebarHiddenItems || []).includes(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSidebarItem(item: HideableSidebarItemID, checked: boolean): void {
|
|
||||||
this.settings.updateSidebarItemVisibility(item, checked)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public saveSettings() {
|
public saveSettings() {
|
||||||
@@ -520,10 +473,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||||
this.settingsForm.value.slimSidebarEnabled
|
this.settingsForm.value.slimSidebarEnabled
|
||||||
)
|
)
|
||||||
this.settings.set(
|
|
||||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
|
||||||
this.settingsForm.value.sidebarHiddenItems
|
|
||||||
)
|
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
||||||
this.settingsForm.value.darkModeUseSystem
|
this.settingsForm.value.darkModeUseSystem
|
||||||
@@ -683,11 +632,6 @@ export class SettingsComponent
|
|||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.settingsForm.patchValue(this.store.getValue())
|
this.settingsForm.patchValue(this.store.getValue())
|
||||||
if (this.settings.organizingSidebarItems()) {
|
|
||||||
this.settings.sidebarHiddenItemsEditing.set([
|
|
||||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearThemeColor() {
|
clearThemeColor() {
|
||||||
|
|||||||
@@ -86,15 +86,12 @@
|
|||||||
}
|
}
|
||||||
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||||
@@ -240,38 +237,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
|
||||||
|
<a class="nav-link" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
|
||||||
|
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-2" name="file-earmark-break"></i-bs><span><ng-container i18n>OCR Templates</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
@@ -334,16 +330,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()"
|
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import {
|
import {
|
||||||
@@ -287,82 +287,6 @@ describe('AppFrameComponent', () => {
|
|||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should hide configured sidebar items', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
|
||||||
HideableSidebarItemID.Dashboard,
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
])
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
|
||||||
.parentElement.classList
|
|
||||||
).toContain('d-none')
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="workflows"]')
|
|
||||||
.parentElement.classList
|
|
||||||
).toContain('d-none')
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
|
||||||
.classList
|
|
||||||
).not.toContain('d-none')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show hidden items and visibility switches while customizing', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
|
||||||
HideableSidebarItemID.Dashboard,
|
|
||||||
])
|
|
||||||
settingsService.sidebarHiddenItemsEditing.set([
|
|
||||||
HideableSidebarItemID.Dashboard,
|
|
||||||
])
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
|
||||||
).toBe(5)
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
|
||||||
.parentElement.classList
|
|
||||||
).not.toContain('d-none')
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
|
||||||
).toContain('opacity-50')
|
|
||||||
|
|
||||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, true)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
|
||||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
|
||||||
).not.toContain('pe-5')
|
|
||||||
|
|
||||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
|
|
||||||
component.slimSidebarAnimating.set(true)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
|
||||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
|
||||||
).toBe(true)
|
|
||||||
|
|
||||||
component.slimSidebarAnimating.set(false)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
|
||||||
).every((toggle: HTMLElement) => !toggle.classList.contains('d-none'))
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
|
||||||
).toContain('pe-5')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error on toggle slim sidebar if store settings fails', () => {
|
it('should show error on toggle slim sidebar if store settings fails', () => {
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
@@ -22,11 +21,7 @@ import { Observable } from 'rxjs'
|
|||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import {
|
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
CollapsibleSection,
|
|
||||||
HideableSidebarItemID,
|
|
||||||
SETTINGS_KEYS,
|
|
||||||
} from 'src/app/data/ui-settings'
|
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -53,7 +48,6 @@ import { ChatComponent } from '../chat/chat/chat.component'
|
|||||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
@@ -82,8 +76,6 @@ const SCROLL_THRESHOLD = 16
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
FormsModule,
|
|
||||||
SwitchComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
@@ -106,7 +98,6 @@ export class AppFrameComponent
|
|||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
readonly slimSidebarAnimating = signal(false)
|
readonly slimSidebarAnimating = signal(false)
|
||||||
readonly mobileSearchHidden = signal(false)
|
readonly mobileSearchHidden = signal(false)
|
||||||
readonly HideableSidebarItemID = HideableSidebarItemID
|
|
||||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||||
SETTINGS_KEYS.VERSION
|
SETTINGS_KEYS.VERSION
|
||||||
)
|
)
|
||||||
@@ -204,10 +195,6 @@ export class AppFrameComponent
|
|||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
|
|
||||||
this.settingsService.updateSidebarItemVisibility(item, visible)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleAttributesSections(event?: Event): void {
|
toggleAttributesSections(event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div [class.mb-3]="!compact">
|
<div class="mb-3">
|
||||||
<div [class.row]="!compact">
|
<div class="row">
|
||||||
@if (!horizontal && !compact) {
|
@if (!horizontal) {
|
||||||
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
||||||
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||||
{{title}}
|
{{title}}
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
}
|
}
|
||||||
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled" [attr.aria-label]="compact ? title : null">
|
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||||
@if (horizontal && !compact) {
|
@if (horizontal) {
|
||||||
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||||
{{title}}
|
{{title}}
|
||||||
@if (showUnsetNote && isUnset) {
|
@if (showUnsetNote && isUnset) {
|
||||||
|
|||||||
@@ -48,14 +48,4 @@ describe('SwitchComponent', () => {
|
|||||||
component.value = undefined
|
component.value = undefined
|
||||||
expect(component.isUnset).toBeTruthy()
|
expect(component.isUnset).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support a compact layout', () => {
|
|
||||||
component.compact = true
|
|
||||||
component.title = 'Test switch'
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(fixture.nativeElement.querySelector('.mb-3')).toBeNull()
|
|
||||||
expect(fixture.nativeElement.querySelector('.row')).toBeNull()
|
|
||||||
expect(input.getAttribute('aria-label')).toEqual('Test switch')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
|
|||||||
@Input()
|
@Input()
|
||||||
showUnsetNote: boolean = false
|
showUnsetNote: boolean = false
|
||||||
|
|
||||||
@Input()
|
|
||||||
compact: boolean = false
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,23 @@
|
|||||||
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container>
|
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ngbDropdownItem
|
||||||
|
(click)="runZoneOcr()"
|
||||||
|
[disabled]="!userCanEdit || !document?.document_type"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }"
|
||||||
|
>
|
||||||
|
<i-bs width="1em" height="1em" name="file-earmark-ruled" class="me-1"></i-bs><span i18n>Run Zone OCR</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ngbDropdownItem
|
||||||
|
(click)="createOcrTemplate()"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }"
|
||||||
|
>
|
||||||
|
<i-bs width="1em" height="1em" name="file-earmark-medical" class="me-1"></i-bs><span i18n>Create OCR Template</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
@if (userIsOwner && (requiresPassword || password)) {
|
@if (userIsOwner && (requiresPassword || password)) {
|
||||||
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
||||||
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container>
|
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container>
|
||||||
|
|||||||
@@ -1449,6 +1449,48 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runZoneOcr() {
|
||||||
|
this.documentsService.runZoneOcr(this.document.id).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
const results = res.results ?? []
|
||||||
|
if (results.length) {
|
||||||
|
const failed = results.filter(
|
||||||
|
(r) =>
|
||||||
|
r.value === null ||
|
||||||
|
r.value === undefined ||
|
||||||
|
`${r.value}`.trim() === ''
|
||||||
|
)
|
||||||
|
const filled = results.length - failed.length
|
||||||
|
let msg = $localize`Filled ${filled} of ${results.length} fields`
|
||||||
|
if (failed.length) {
|
||||||
|
const names = failed.map((r) => r.zone).join(', ')
|
||||||
|
msg = `${msg}. ${$localize`Failed to match zones: ${names}`}`
|
||||||
|
}
|
||||||
|
this.toastService.showInfo(msg)
|
||||||
|
} else {
|
||||||
|
this.toastService.showInfo(
|
||||||
|
$localize`Zone OCR ran but no results extracted.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.documentsService
|
||||||
|
.get(this.documentId)
|
||||||
|
.subscribe((doc) => this.updateComponent(doc))
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError($localize`Zone OCR failed`, error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createOcrTemplate() {
|
||||||
|
this.router.navigate(['/ocr-templates', 'new'], {
|
||||||
|
queryParams: {
|
||||||
|
document_type: this.document.document_type,
|
||||||
|
sample_document: this.document.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private getSelectedNonLatestVersionId(): number | null {
|
private getSelectedNonLatestVersionId(): number | null {
|
||||||
const versions = this.document()?.versions ?? []
|
const versions = this.document()?.versions ?? []
|
||||||
if (!versions.length || !this.selectedVersionId()) {
|
if (!versions.length || !this.selectedVersionId()) {
|
||||||
|
|||||||
@@ -98,6 +98,9 @@
|
|||||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||||
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll || list.allSelected">
|
||||||
|
<i-bs name="file-earmark-ruled" class="me-1"></i-bs><ng-container i18n>Run Zone OCR</ng-container>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,15 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { saveAs } from 'file-saver'
|
import { saveAs } from 'file-saver'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
import {
|
||||||
|
first,
|
||||||
|
forkJoin,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
Subject,
|
||||||
|
switchMap,
|
||||||
|
takeUntil,
|
||||||
|
} from 'rxjs'
|
||||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||||
import { CustomField } from 'src/app/data/custom-field'
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
@@ -939,6 +947,27 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runZoneOcrSelected() {
|
||||||
|
const ids = Array.from(this.list.selected)
|
||||||
|
if (!ids.length) return
|
||||||
|
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Run Zone OCR`
|
||||||
|
modal.componentInstance.messageBold = $localize`Run zone OCR on ${this.getSelectionSize()} selected document(s)?`
|
||||||
|
modal.componentInstance.message = $localize`Each document's type template (if it has one) is applied, overwriting the mapped fields.`
|
||||||
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
|
modal.componentInstance.confirmClicked
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
modal.componentInstance.buttonsEnabled = false
|
||||||
|
this.executeDocumentAction(
|
||||||
|
modal,
|
||||||
|
forkJoin(ids.map((id) => this.documentService.runZoneOcr(id)))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
setPermissions() {
|
setPermissions() {
|
||||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
@if (zones.length === 0) {
|
||||||
|
<p class="text-muted" i18n>
|
||||||
|
No zones defined. Load a document preview and draw rectangles to add zones.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="list-group">
|
||||||
|
@for (zone of zones; track $index; let i = $index) {
|
||||||
|
<div
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
||||||
|
[style.box-shadow]="selectedZoneIndex === i ? 'inset 3px 0 0 0 var(--bs-primary)' : null"
|
||||||
|
>
|
||||||
|
<div class="flex-grow-1" role="button" style="cursor: pointer;" (click)="zoneSelected.emit(i)">
|
||||||
|
<div>
|
||||||
|
<strong [class.text-primary]="selectedZoneIndex === i">
|
||||||
|
{{ zone.name }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
{{ getZoneTargetName(zone) }} - {{ zone.width }}x{{ zone.height }}px
|
||||||
|
<ng-container i18n>p.</ng-container>{{ zonePage(zone) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="zoneSelected.emit(i)" title="Edit" i18n-title>
|
||||||
|
<i-bs name="pencil"></i-bs>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="zoneRemoved.emit(i)" title="Delete" i18n-title>
|
||||||
|
<i-bs name="trash"></i-bs>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
|
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||||
|
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list.component'
|
||||||
|
|
||||||
|
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||||
|
return {
|
||||||
|
name: 'Zone 1',
|
||||||
|
target: 'custom_field',
|
||||||
|
custom_field: 7,
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 30,
|
||||||
|
height: 40,
|
||||||
|
page: 1,
|
||||||
|
ocr_language: 'eng',
|
||||||
|
transform: 'strip',
|
||||||
|
validation_regex: '',
|
||||||
|
order: 0,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OcrTemplateEditorZoneListComponent', () => {
|
||||||
|
let fixture: ComponentFixture<OcrTemplateEditorZoneListComponent>
|
||||||
|
let component: OcrTemplateEditorZoneListComponent
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
OcrTemplateEditorZoneListComponent,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(OcrTemplateEditorZoneListComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no zones are defined', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('No zones defined')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders zone target, size, and page', () => {
|
||||||
|
component.zones = [zone()]
|
||||||
|
component.customFields = [{ id: 7, name: 'Invoice Number' } as CustomField]
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const text = fixture.nativeElement.textContent
|
||||||
|
expect(text).toContain('Zone 1')
|
||||||
|
expect(text).toContain('Invoice Number')
|
||||||
|
expect(text).toContain('30x40px')
|
||||||
|
expect(text).toContain('p.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits select and remove events', () => {
|
||||||
|
component.zones = [zone()]
|
||||||
|
const selectSpy = jest.spyOn(component.zoneSelected, 'emit')
|
||||||
|
const removeSpy = jest.spyOn(component.zoneRemoved, 'emit')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const buttons = fixture.nativeElement.querySelectorAll('button')
|
||||||
|
buttons[0].click()
|
||||||
|
buttons[1].click()
|
||||||
|
|
||||||
|
expect(selectSpy).toHaveBeenCalledWith(0)
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
|
import { OCR_BUILTIN_TARGETS, OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||||
|
import { getZonePage } from '../zone-geometry'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-ocr-template-zone-list',
|
||||||
|
imports: [NgxBootstrapIconsModule],
|
||||||
|
templateUrl: './ocr-template-editor-zone-list.component.html',
|
||||||
|
})
|
||||||
|
export class OcrTemplateEditorZoneListComponent {
|
||||||
|
@Input() zones: OcrTemplateZone[] = []
|
||||||
|
@Input() selectedZoneIndex: number | null = null
|
||||||
|
@Input() previewPage = 0
|
||||||
|
@Input() previewPageCount: number | null = null
|
||||||
|
@Input() customFields: CustomField[] = []
|
||||||
|
|
||||||
|
@Output() zoneSelected = new EventEmitter<number>()
|
||||||
|
@Output() zoneRemoved = new EventEmitter<number>()
|
||||||
|
|
||||||
|
zonePage(zone: OcrTemplateZone): number {
|
||||||
|
return getZonePage(zone, this.previewPage, this.previewPageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
getZoneTargetName(zone: OcrTemplateZone): string {
|
||||||
|
const target = zone.target || 'custom_field'
|
||||||
|
if (target === 'custom_field') {
|
||||||
|
return zone.custom_field
|
||||||
|
? this.getCustomFieldName(zone.custom_field)
|
||||||
|
: $localize`(no field)`
|
||||||
|
}
|
||||||
|
return OCR_BUILTIN_TARGETS.find((t) => t.id === target)?.name ?? target
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCustomFieldName(id: number): string {
|
||||||
|
return (
|
||||||
|
this.customFields.find((field) => field.id === id)?.name ?? `Field #${id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+442
@@ -0,0 +1,442 @@
|
|||||||
|
<pngx-page-header [title]="pageTitle" [id]="template.id">
|
||||||
|
<div class="input-group input-group-sm me-5 align-items-center">
|
||||||
|
<div class="input-group-text">
|
||||||
|
<i-bs name="file-text"></i-bs>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="previewDocModel"
|
||||||
|
[ngbTypeahead]="searchDocuments"
|
||||||
|
[inputFormatter]="documentFormatter"
|
||||||
|
[resultFormatter]="documentFormatter"
|
||||||
|
(selectItem)="onPreviewDocSelected($event)"
|
||||||
|
[editable]="false"
|
||||||
|
placeholder="Search documents by title..."
|
||||||
|
i18n-placeholder
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||||
|
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
|
||||||
|
<div class="input-group-text" i18n>Page</div>
|
||||||
|
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewPageCount" [(ngModel)]="previewPageDisplay" />
|
||||||
|
<div class="input-group-text" i18n>of {{previewPageCount}}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="prevPage()" [disabled]="!pageImageUrl || previewPage <= 0">
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="arrow-left"></i-bs>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextPage()" [disabled]="!pageImageUrl || previewPage >= (previewPageCount ?? 1) - 1">
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="arrow-right"></i-bs>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<button class="btn btn-outline-secondary" (click)="zoomOut()" i18n>-</button>
|
||||||
|
<span class="input-group-text">{{ zoom * 100 | number: '1.0-0' }}%</span>
|
||||||
|
<button class="btn btn-outline-secondary" (click)="zoomIn()" i18n>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</pngx-page-header>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="btn-toolbar mb-1 border-bottom">
|
||||||
|
<div class="btn-group pb-3">
|
||||||
|
<a routerLink="/ocr-templates" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
|
||||||
|
<span class="ms-1" i18n>Close</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group ms-auto pb-3">
|
||||||
|
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
|
||||||
|
@if (saving) {
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
}
|
||||||
|
<span i18n>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
|
||||||
|
<li ngbNavItem="settings">
|
||||||
|
<a ngbNavLink i18n>Settings</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-9">
|
||||||
|
<pngx-input-text [(ngModel)]="template.name" title="Template name" i18n-title></pngx-input-text>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<pngx-input-switch [(ngModel)]="template.enabled" title="Enabled" i18n-title></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pngx-input-select [(ngModel)]="template.document_type" [items]="documentTypes" bindLabel="name" bindValue="id" title="Document type" i18n-title></pngx-input-select>
|
||||||
|
|
||||||
|
<small class="text-muted" i18n>
|
||||||
|
Draw rectangles on the preview to define extraction zones. Use the
|
||||||
|
page controls above the preview to add zones on different pages.
|
||||||
|
</small>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li ngbNavItem="zones">
|
||||||
|
<a ngbNavLink><ng-container i18n>Zones</ng-container> <span class="badge bg-primary ms-2">{{ template.zones.length }}</span></a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<pngx-ocr-template-zone-list
|
||||||
|
[zones]="template.zones"
|
||||||
|
[selectedZoneIndex]="selectedZoneIndex"
|
||||||
|
[previewPage]="previewPage"
|
||||||
|
[previewPageCount]="previewPageCount"
|
||||||
|
[customFields]="customFields"
|
||||||
|
(zoneSelected)="selectZone($event)"
|
||||||
|
(zoneRemoved)="removeZone($event)"
|
||||||
|
></pngx-ocr-template-zone-list>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li ngbNavItem="zone">
|
||||||
|
<a ngbNavLink i18n>Zone</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
@if (selectedZone; as zone) {
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<strong>{{ zone.name }}</strong>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
|
||||||
|
@if (saving) {
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
}
|
||||||
|
<span i18n>Save</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()">
|
||||||
|
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete zone</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" i18n>Zone Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="zone.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" i18n>Page</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="zone.page"
|
||||||
|
min="-1"
|
||||||
|
/>
|
||||||
|
<small class="text-muted" i18n>Page this zone is on. Use -1 for the last page. Set automatically when you draw it.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" i18n>Field</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<select class="form-select" [ngModel]="zoneFieldValue(zone)" (ngModelChange)="setZoneField(zone, $event)">
|
||||||
|
<optgroup label="Built-in fields" i18n-label>
|
||||||
|
@for (t of builtinTargets; track t.id) {
|
||||||
|
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Custom fields" i18n-label>
|
||||||
|
@for (cf of customFields; track cf.id) {
|
||||||
|
<option [ngValue]="cf.id">{{ cf.name }} ({{ cf.data_type }})</option>
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
(click)="openQuickCreate(selectedZoneIndex)"
|
||||||
|
title="Create new custom field"
|
||||||
|
i18n-title
|
||||||
|
>
|
||||||
|
<i-bs name="plus"></i-bs>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted" i18n>Write the extracted value to a custom field, or to a built-in field (Title, ASN, Date created).</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isFieldShared(zone)) {
|
||||||
|
<div class="card mb-3 border-info">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title d-flex align-items-center gap-2">
|
||||||
|
<i-bs name="braces"></i-bs>
|
||||||
|
<span i18n>Combine zones into this field</span>
|
||||||
|
</h6>
|
||||||
|
<p class="small text-muted mb-2" i18n>
|
||||||
|
More than one zone writes to this field. Build the combined
|
||||||
|
value below: click a zone to insert its token, and type any
|
||||||
|
separators or literal text between tokens.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||||
|
@for (z of zonesForField(zone); track $index) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-info"
|
||||||
|
(click)="insertCombineToken(zone, z)"
|
||||||
|
title="Insert token"
|
||||||
|
i18n-title
|
||||||
|
>
|
||||||
|
+ {{ z.name || 'Zone' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control font-monospace"
|
||||||
|
[ngModel]="getCombineFormat(zone)"
|
||||||
|
(ngModelChange)="setCombineFormat(zone, $event)"
|
||||||
|
placeholder="{Zone 1} - {Zone 2}"
|
||||||
|
/>
|
||||||
|
<small class="text-muted" i18n>
|
||||||
|
Tokens are matched by zone name. An empty zone leaves its
|
||||||
|
token blank and the stray separator is trimmed. Leave empty
|
||||||
|
to just join the zones in order with a space.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" i18n>OCR Language</label>
|
||||||
|
<ng-select
|
||||||
|
[items]="ocrLanguageOptions"
|
||||||
|
bindLabel="name"
|
||||||
|
bindValue="id"
|
||||||
|
[multiple]="true"
|
||||||
|
[closeOnSelect]="false"
|
||||||
|
[ngModel]="ocrLanguageArray(zone)"
|
||||||
|
(ngModelChange)="setOcrLanguages(zone, $event)"
|
||||||
|
placeholder="Select languages"
|
||||||
|
i18n-placeholder
|
||||||
|
></ng-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" i18n>Transform</label>
|
||||||
|
<select class="form-select" [(ngModel)]="zone.transform">
|
||||||
|
@for (opt of transformOptions; track opt.id) {
|
||||||
|
<option [ngValue]="opt.id">{{ opt.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (zone.transform === dateTransform) {
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" i18n>Date format</label>
|
||||||
|
<select class="form-select" [ngModel]="dateFormatChoice(zone)" (ngModelChange)="setDateFormatChoice(zone, $event)">
|
||||||
|
@for (opt of dateFormatOptions; track opt.id) {
|
||||||
|
<option [ngValue]="opt.id">{{ opt.name }}</option>
|
||||||
|
}
|
||||||
|
<option [ngValue]="customDateFormatChoice" i18n>Custom...</option>
|
||||||
|
</select>
|
||||||
|
@if (usesCustomDateFormat(zone)) {
|
||||||
|
<div class="input-group mt-2">
|
||||||
|
<input type="text" class="form-control font-monospace" [(ngModel)]="zone.date_format" placeholder="%d.%m.%Y" />
|
||||||
|
<button class="btn btn-outline-secondary" type="button" [ngbPopover]="dateFmtHelp" [autoClose]="true" title="Date format help" i18n-title>
|
||||||
|
<i-bs name="question-circle"></i-bs>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ng-template #dateFmtHelp>
|
||||||
|
<p class="mb-1" i18n>Python date codes:</p>
|
||||||
|
<ul class="mb-1 ps-3">
|
||||||
|
<li><code>%d</code> <ng-container i18n>day (01-31)</ng-container></li>
|
||||||
|
<li><code>%m</code> <ng-container i18n>month (01-12)</ng-container></li>
|
||||||
|
<li><code>%Y</code> <ng-container i18n>year, 4-digit</ng-container></li>
|
||||||
|
<li><code>%y</code> <ng-container i18n>year, 2-digit</ng-container></li>
|
||||||
|
<li><code>%b</code> <ng-container i18n>month name (Jan)</ng-container></li>
|
||||||
|
</ul>
|
||||||
|
<span i18n>Example:</span> <code>%d.%m.%Y</code> -> 03.03.2026
|
||||||
|
</ng-template>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" i18n>Validation Regex</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control font-monospace"
|
||||||
|
[(ngModel)]="zone.validation_regex"
|
||||||
|
placeholder="e.g. \d{2}\.\d{2}\.\d{4}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-muted small">
|
||||||
|
{{ zone.x }}, {{ zone.y }} - {{ zone.width }}x{{ zone.height }}px
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-3" />
|
||||||
|
<h6 i18n>Test</h6>
|
||||||
|
@if (!previewDocId) {
|
||||||
|
<p class="text-muted small mb-0" i18n>
|
||||||
|
Load a document in the Settings tab to test this zone.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="testZone()" [disabled]="zoneTesting">
|
||||||
|
@if (zoneTesting) {
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
}
|
||||||
|
<span i18n>Test this zone</span>
|
||||||
|
</button>
|
||||||
|
@if (zoneTestResult) {
|
||||||
|
@if (zoneTestResult.error) {
|
||||||
|
<div class="alert alert-warning py-2 mt-2 mb-0 small">{{ zoneTestResult.error }}</div>
|
||||||
|
} @else {
|
||||||
|
<dl class="row small mt-2 mb-0">
|
||||||
|
<dt class="col-sm-4" i18n>OCR text</dt>
|
||||||
|
<dd class="col-sm-8"><code>{{ zoneTestResult.raw_text || '(nothing detected)' }}</code></dd>
|
||||||
|
<dt class="col-sm-4" i18n>Value</dt>
|
||||||
|
<dd class="col-sm-8"><code>{{ zoneTestResult.value || '(empty)' }}</code></dd>
|
||||||
|
@if (zoneTestResult.regex) {
|
||||||
|
<dt class="col-sm-4" i18n>Validation</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
@if (zoneTestResult.regex_match) {
|
||||||
|
<span class="badge bg-success" i18n>Regex matches</span>
|
||||||
|
} @else {
|
||||||
|
<span class="badge bg-danger" i18n>Regex does not match</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<p class="text-muted" i18n>
|
||||||
|
Select a zone from the Zones tab, or draw a rectangle on the document to create one.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: Document preview with zone overlay -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
@if (pageImageUrl) {
|
||||||
|
<div class="zone-preview-scroll border">
|
||||||
|
<div class="zone-preview-stage" [style.width.%]="zoom * 100">
|
||||||
|
<img
|
||||||
|
#pageImage
|
||||||
|
[src]="pageImageUrl"
|
||||||
|
(load)="onImageLoad()"
|
||||||
|
class="zone-preview-image"
|
||||||
|
[style.visibility]="imageLoaded ? 'visible' : 'hidden'"
|
||||||
|
crossorigin="use-credentials"
|
||||||
|
/>
|
||||||
|
@if (imageLoaded) {
|
||||||
|
<svg
|
||||||
|
#zoneOverlay
|
||||||
|
class="zone-overlay"
|
||||||
|
[attr.viewBox]="overlayViewBox()"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
[style.cursor]="overlayCursor"
|
||||||
|
(mousedown)="onOverlayMouseDown($event)"
|
||||||
|
(mousemove)="onOverlayMouseMove($event)"
|
||||||
|
(mouseup)="onOverlayMouseUp($event)"
|
||||||
|
>
|
||||||
|
@for (zone of template.zones; track $index; let i = $index) {
|
||||||
|
@if (zoneDisplayRect(i); as rect) {
|
||||||
|
<g>
|
||||||
|
<rect
|
||||||
|
class="zone-rect"
|
||||||
|
[class.zone-rect-selected]="selectedZoneIndex === i"
|
||||||
|
[attr.x]="rect.x"
|
||||||
|
[attr.y]="rect.y"
|
||||||
|
[attr.width]="rect.w"
|
||||||
|
[attr.height]="rect.h"
|
||||||
|
[attr.stroke]="zoneColor(i)"
|
||||||
|
[attr.fill]="zoneFill(i)"
|
||||||
|
></rect>
|
||||||
|
<text
|
||||||
|
class="zone-label"
|
||||||
|
[attr.x]="rect.x + overlayUnitSize(6)"
|
||||||
|
[attr.y]="zoneLabelY(rect)"
|
||||||
|
[attr.font-size]="overlayFontSize()"
|
||||||
|
[attr.fill]="zoneColor(i)"
|
||||||
|
>{{ zoneLabel(zone, i) }}</text>
|
||||||
|
|
||||||
|
@if (selectedZoneIndex === i) {
|
||||||
|
@for (handle of resizeHandles(rect); track handle.handle) {
|
||||||
|
<rect
|
||||||
|
class="zone-resize-handle"
|
||||||
|
[attr.x]="handle.x - overlayHandleSize() / 2"
|
||||||
|
[attr.y]="handle.y - overlayHandleSize() / 2"
|
||||||
|
[attr.width]="overlayHandleSize()"
|
||||||
|
[attr.height]="overlayHandleSize()"
|
||||||
|
[attr.fill]="zoneColor(i)"
|
||||||
|
></rect>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</g>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (drawingRect(); as rect) {
|
||||||
|
<rect
|
||||||
|
class="zone-drawing-rect"
|
||||||
|
[attr.x]="rect.x"
|
||||||
|
[attr.y]="rect.y"
|
||||||
|
[attr.width]="rect.w"
|
||||||
|
[attr.height]="rect.h"
|
||||||
|
></rect>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
@if (!imageLoaded) {
|
||||||
|
<div class="d-flex justify-content-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden" i18n>Loading page...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="border rounded p-5 text-center text-muted">
|
||||||
|
<i-bs name="file-earmark-image" width="48" height="48"></i-bs>
|
||||||
|
<p class="mt-3" i18n>
|
||||||
|
Enter a document ID and click "Load" to preview a page and draw extraction zones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-preview-scroll {
|
||||||
|
max-height: 78vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-preview-stage {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-preview-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-overlay {
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
position: absolute;
|
||||||
|
touch-action: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-rect,
|
||||||
|
.zone-drawing-rect {
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-rect {
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-rect-selected {
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-label {
|
||||||
|
font-family: var(--bs-font-sans-serif);
|
||||||
|
font-weight: 600;
|
||||||
|
paint-order: stroke;
|
||||||
|
pointer-events: none;
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 4px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-resize-handle {
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-drawing-rect {
|
||||||
|
fill: rgba(105, 219, 124, 0.25);
|
||||||
|
stroke: #69db7c;
|
||||||
|
stroke-dasharray: 5 5;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
+962
@@ -0,0 +1,962 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
|
import {
|
||||||
|
NgbNavModule,
|
||||||
|
NgbPopoverModule,
|
||||||
|
NgbTypeaheadModule,
|
||||||
|
NgbTypeaheadSelectItemEvent,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import {
|
||||||
|
catchError,
|
||||||
|
debounceTime,
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
Subject,
|
||||||
|
switchMap,
|
||||||
|
takeUntil,
|
||||||
|
} from 'rxjs'
|
||||||
|
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 { Document } from 'src/app/data/document'
|
||||||
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
|
import {
|
||||||
|
DATE_FORMAT_OPTIONS,
|
||||||
|
DEFAULT_OCR_ZONE_LANGUAGE,
|
||||||
|
DEFAULT_OCR_ZONE_TARGET,
|
||||||
|
DEFAULT_OCR_ZONE_TRANSFORM,
|
||||||
|
isOcrBuiltinTarget,
|
||||||
|
OCR_BUILTIN_TARGETS,
|
||||||
|
OCR_LANGUAGE_OPTIONS,
|
||||||
|
OCR_ZONE_TARGET,
|
||||||
|
OCR_ZONE_TRANSFORM,
|
||||||
|
OcrBuiltinTarget,
|
||||||
|
OcrTemplate,
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTestResult,
|
||||||
|
TRANSFORM_OPTIONS,
|
||||||
|
ZoneTestRequest,
|
||||||
|
} from 'src/app/data/ocr-template'
|
||||||
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list/ocr-template-editor-zone-list.component'
|
||||||
|
import {
|
||||||
|
DisplayRect,
|
||||||
|
DrawingRect,
|
||||||
|
findHandleAt,
|
||||||
|
findZoneAt,
|
||||||
|
getZoneDisplayRect,
|
||||||
|
getZonePage,
|
||||||
|
HANDLE_SIZE,
|
||||||
|
isZoneOnPage,
|
||||||
|
MoveStart,
|
||||||
|
moveZone,
|
||||||
|
Point,
|
||||||
|
ResizeHandle,
|
||||||
|
resizeZone,
|
||||||
|
} from './zone-geometry'
|
||||||
|
|
||||||
|
type ActiveTab = 'settings' | 'zones' | 'zone'
|
||||||
|
type ZoneFieldSelection = OcrBuiltinTarget | number | null
|
||||||
|
type OverlayInteraction =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'drawing'; rect: DrawingRect }
|
||||||
|
| { kind: 'moving'; zoneIndex: number; start: MoveStart }
|
||||||
|
| { kind: 'resizing'; zoneIndex: number; handle: ResizeHandle }
|
||||||
|
interface ResizeHandleMarker extends Point {
|
||||||
|
handle: ResizeHandle
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUSTOM_DATE_FORMAT_CHOICE = 'custom'
|
||||||
|
const MIN_DRAWN_ZONE_SIZE = 10
|
||||||
|
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
|
||||||
|
const ZONE_COLORS = [
|
||||||
|
'#4f8ff7',
|
||||||
|
'#ff6b6b',
|
||||||
|
'#51cf66',
|
||||||
|
'#ffd43b',
|
||||||
|
'#cc5de8',
|
||||||
|
'#ff922b',
|
||||||
|
'#20c997',
|
||||||
|
'#e599f7',
|
||||||
|
]
|
||||||
|
const RESIZE_CURSOR: Record<ResizeHandle, string> = {
|
||||||
|
nw: 'nw-resize',
|
||||||
|
ne: 'ne-resize',
|
||||||
|
sw: 'sw-resize',
|
||||||
|
se: 'se-resize',
|
||||||
|
n: 'n-resize',
|
||||||
|
s: 's-resize',
|
||||||
|
w: 'w-resize',
|
||||||
|
e: 'e-resize',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-ocr-template-editor',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
PageHeaderComponent,
|
||||||
|
TextComponent,
|
||||||
|
SelectComponent,
|
||||||
|
SwitchComponent,
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
NgbNavModule,
|
||||||
|
NgbPopoverModule,
|
||||||
|
NgbTypeaheadModule,
|
||||||
|
NgSelectModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
OcrTemplateEditorZoneListComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './ocr-template-editor.component.html',
|
||||||
|
styleUrls: ['./ocr-template-editor.component.scss'],
|
||||||
|
})
|
||||||
|
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||||
|
private readonly route = inject(ActivatedRoute)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
private readonly templateService = inject(OcrTemplateService)
|
||||||
|
private readonly customFieldsService = inject(CustomFieldsService)
|
||||||
|
private readonly documentTypeService = inject(DocumentTypeService)
|
||||||
|
private readonly correspondentService = inject(CorrespondentService)
|
||||||
|
private readonly documentService = inject(DocumentService)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
private readonly destroy$ = new Subject<void>()
|
||||||
|
private readonly customDateFormatZones = new WeakSet<OcrTemplateZone>()
|
||||||
|
|
||||||
|
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
|
||||||
|
@ViewChild('pageImage') imageRef: ElementRef<HTMLImageElement>
|
||||||
|
|
||||||
|
template: OcrTemplate = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
document_type: null,
|
||||||
|
sample_document: null,
|
||||||
|
source_width: 0,
|
||||||
|
source_height: 0,
|
||||||
|
enabled: true,
|
||||||
|
combine_formats: {},
|
||||||
|
zones: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
customFields: CustomField[] = []
|
||||||
|
documentTypes: DocumentType[] = []
|
||||||
|
transformOptions = TRANSFORM_OPTIONS
|
||||||
|
builtinTargets = OCR_BUILTIN_TARGETS
|
||||||
|
dateFormatOptions = DATE_FORMAT_OPTIONS
|
||||||
|
ocrLanguageOptions = OCR_LANGUAGE_OPTIONS
|
||||||
|
dateTransform = OCR_ZONE_TRANSFORM.Date
|
||||||
|
customDateFormatChoice = CUSTOM_DATE_FORMAT_CHOICE
|
||||||
|
isNew = true
|
||||||
|
saving = false
|
||||||
|
|
||||||
|
previewDocId: number | null = null
|
||||||
|
previewPage = 0
|
||||||
|
previewPageCount: number | null = null
|
||||||
|
private pageCountForDoc: number | null = null
|
||||||
|
pageImageUrl: string | null = null
|
||||||
|
imageLoaded = false
|
||||||
|
zoom = 1
|
||||||
|
previewDocModel: Document | string = ''
|
||||||
|
private correspondentNames = new Map<number, string>()
|
||||||
|
|
||||||
|
public get previewPageDisplay(): number {
|
||||||
|
return this.previewPage + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public set previewPageDisplay(value: number) {
|
||||||
|
this.goToPage(value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTab: ActiveTab = 'settings'
|
||||||
|
|
||||||
|
selectedZoneIndex: number | null = null
|
||||||
|
private overlayInteraction: OverlayInteraction = NO_OVERLAY_INTERACTION
|
||||||
|
overlayCursor = 'crosshair'
|
||||||
|
|
||||||
|
zoneTestResult: OcrZoneTestResult | null = null
|
||||||
|
zoneTesting = false
|
||||||
|
|
||||||
|
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)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageTitle(): string {
|
||||||
|
return this.isNew
|
||||||
|
? $localize`New OCR Template`
|
||||||
|
: $localize`Edit OCR Template`
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.customFieldsService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((r) => (this.customFields = r.results))
|
||||||
|
|
||||||
|
this.documentTypeService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((r) => (this.documentTypes = r.results))
|
||||||
|
|
||||||
|
this.correspondentService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((r) => {
|
||||||
|
this.correspondentNames = new Map(r.results.map((c) => [c.id, c.name]))
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = this.route.snapshot.paramMap.get('id')
|
||||||
|
if (id && id !== 'new') {
|
||||||
|
this.isNew = false
|
||||||
|
this.templateService
|
||||||
|
.get(parseInt(id))
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((t) => {
|
||||||
|
this.template = t
|
||||||
|
this.template.combine_formats ??= {}
|
||||||
|
if (t.sample_document) {
|
||||||
|
this.previewDocId = t.sample_document
|
||||||
|
this.loadPreview()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const qp = this.route.snapshot.queryParams
|
||||||
|
if (qp['document_type']) {
|
||||||
|
this.template.document_type = parseInt(qp['document_type'])
|
||||||
|
}
|
||||||
|
if (qp['sample_document']) {
|
||||||
|
const docId = parseInt(qp['sample_document'])
|
||||||
|
this.template.sample_document = docId
|
||||||
|
this.previewDocId = docId
|
||||||
|
this.loadPreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchDocuments = (text$: Observable<string>): Observable<Document[]> =>
|
||||||
|
text$.pipe(
|
||||||
|
debounceTime(250),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((term) => {
|
||||||
|
if (!term || term.trim().length < 2) return of([])
|
||||||
|
const params: { title__icontains: string; document_type__id?: number } =
|
||||||
|
{ title__icontains: term.trim() }
|
||||||
|
if (this.template.document_type) {
|
||||||
|
params['document_type__id'] = this.template.document_type
|
||||||
|
}
|
||||||
|
return this.documentService.list(1, 10, 'created', true, params).pipe(
|
||||||
|
map((r) => r.results),
|
||||||
|
catchError(() => of([]))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
documentFormatter = (doc: Document | string): string => {
|
||||||
|
if (typeof doc === 'string') return doc
|
||||||
|
const corr = doc.correspondent
|
||||||
|
? this.correspondentNames.get(doc.correspondent)
|
||||||
|
: null
|
||||||
|
return corr
|
||||||
|
? `#${doc.id} ${doc.title} (${corr})`
|
||||||
|
: `#${doc.id} ${doc.title}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onPreviewDocSelected(event: NgbTypeaheadSelectItemEvent<Document>) {
|
||||||
|
event.preventDefault()
|
||||||
|
const doc: Document = event.item
|
||||||
|
this.previewDocModel = doc
|
||||||
|
this.previewDocId = doc.id
|
||||||
|
if (!this.template.document_type && doc.document_type) {
|
||||||
|
this.template.document_type = doc.document_type
|
||||||
|
}
|
||||||
|
this.previewPage = 0
|
||||||
|
this.loadPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPreviewDoc() {
|
||||||
|
this.previewDocModel = ''
|
||||||
|
this.previewDocId = null
|
||||||
|
this.previewPageCount = null
|
||||||
|
this.pageCountForDoc = null
|
||||||
|
this.previewPage = 0
|
||||||
|
this.pageImageUrl = null
|
||||||
|
this.imageLoaded = false
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreview() {
|
||||||
|
if (!this.previewDocId) return
|
||||||
|
if (this.pageCountForDoc !== this.previewDocId) {
|
||||||
|
this.pageCountForDoc = this.previewDocId
|
||||||
|
this.previewPageCount = null
|
||||||
|
this.documentService
|
||||||
|
.get(this.previewDocId)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (doc) => {
|
||||||
|
this.previewPageCount = doc?.page_count ?? null
|
||||||
|
if (doc && !this.previewDocModel) this.previewDocModel = doc
|
||||||
|
},
|
||||||
|
error: () => (this.previewPageCount = null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.pageImageUrl = this.templateService.getPageImageUrl(
|
||||||
|
this.previewDocId,
|
||||||
|
this.previewPage
|
||||||
|
)
|
||||||
|
this.imageLoaded = false
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPage(page: number) {
|
||||||
|
if (!Number.isFinite(page)) return
|
||||||
|
const max = this.previewPageCount ? this.previewPageCount - 1 : page
|
||||||
|
const clamped = Math.max(0, Math.min(page, max))
|
||||||
|
if (clamped === this.previewPage) return
|
||||||
|
this.previewPage = clamped
|
||||||
|
this.loadPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
prevPage() {
|
||||||
|
this.goToPage(this.previewPage - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPage() {
|
||||||
|
this.goToPage(this.previewPage + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomIn() {
|
||||||
|
this.zoom = Math.min(4, Math.round((this.zoom + 0.25) * 100) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomOut() {
|
||||||
|
this.zoom = Math.max(0.5, Math.round((this.zoom - 0.25) * 100) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetZoom() {
|
||||||
|
this.zoom = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
zonePage(zone: OcrTemplateZone): number {
|
||||||
|
return getZonePage(zone, this.previewPage, this.previewPageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOnCurrentPage(zone: OcrTemplateZone): boolean {
|
||||||
|
return isZoneOnPage(zone, this.previewPage, this.previewPageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageLoad() {
|
||||||
|
this.imageLoaded = true
|
||||||
|
const img = this.imageRef.nativeElement
|
||||||
|
this.template.source_width = img.naturalWidth
|
||||||
|
this.template.source_height = img.naturalHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayMouseDown(event: MouseEvent) {
|
||||||
|
const point = this.svgPointFromEvent(event)
|
||||||
|
if (!point) return
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (this.selectedZoneIndex !== null) {
|
||||||
|
const handle = this.findHandleAt(point, this.selectedZoneIndex)
|
||||||
|
if (handle) {
|
||||||
|
this.overlayInteraction = {
|
||||||
|
kind: 'resizing',
|
||||||
|
zoneIndex: this.selectedZoneIndex,
|
||||||
|
handle,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickedIdx = this.findZoneAt(point)
|
||||||
|
if (clickedIdx !== null && !event.shiftKey) {
|
||||||
|
this.selectZone(clickedIdx)
|
||||||
|
const zone = this.template.zones[clickedIdx]
|
||||||
|
this.overlayInteraction = {
|
||||||
|
kind: 'moving',
|
||||||
|
zoneIndex: clickedIdx,
|
||||||
|
start: {
|
||||||
|
mouseX: point.x,
|
||||||
|
mouseY: point.y,
|
||||||
|
zoneX: zone.x,
|
||||||
|
zoneY: zone.y,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift+click or click on empty area starts a new zone.
|
||||||
|
this.overlayInteraction = {
|
||||||
|
kind: 'drawing',
|
||||||
|
rect: {
|
||||||
|
startX: point.x,
|
||||||
|
startY: point.y,
|
||||||
|
endX: point.x,
|
||||||
|
endY: point.y,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.selectedZoneIndex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayMouseMove(event: MouseEvent) {
|
||||||
|
const point = this.svgPointFromEvent(event)
|
||||||
|
if (!point) return
|
||||||
|
|
||||||
|
if (this.overlayInteraction.kind === 'resizing') {
|
||||||
|
this.applyResize(
|
||||||
|
this.overlayInteraction.zoneIndex,
|
||||||
|
this.overlayInteraction.handle,
|
||||||
|
point
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.overlayInteraction.kind === 'moving') {
|
||||||
|
moveZone(
|
||||||
|
this.template.zones[this.overlayInteraction.zoneIndex],
|
||||||
|
point,
|
||||||
|
this.overlayInteraction.start,
|
||||||
|
this.imageNaturalSize(),
|
||||||
|
this.imageNaturalSize()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.overlayInteraction.kind === 'drawing') {
|
||||||
|
this.overlayInteraction.rect.endX = point.x
|
||||||
|
this.overlayInteraction.rect.endY = point.y
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateOverlayCursor(point)
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateOverlayCursor(point: Point) {
|
||||||
|
if (this.selectedZoneIndex !== null) {
|
||||||
|
const handle = this.findHandleAt(point, this.selectedZoneIndex)
|
||||||
|
if (handle) {
|
||||||
|
this.overlayCursor = RESIZE_CURSOR[handle] || 'crosshair'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.overlayCursor = this.findZoneAt(point) !== null ? 'move' : 'crosshair'
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayMouseUp(_event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
this.overlayInteraction.kind === 'moving' ||
|
||||||
|
this.overlayInteraction.kind === 'resizing'
|
||||||
|
) {
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.overlayInteraction.kind !== 'drawing') return
|
||||||
|
const drawingRect = this.overlayInteraction.rect
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
|
||||||
|
const rect = this.sourceRectFromDrawing(drawingRect)
|
||||||
|
|
||||||
|
// Ignore tiny accidental clicks.
|
||||||
|
if (rect.w < MIN_DRAWN_ZONE_SIZE || rect.h < MIN_DRAWN_ZONE_SIZE) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.template.zones.push(this.createZoneFromRect(rect))
|
||||||
|
this.selectZone(this.template.zones.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createZoneFromRect(rect: DisplayRect): OcrTemplateZone {
|
||||||
|
const imageSize = this.imageNaturalSize()
|
||||||
|
return {
|
||||||
|
name: `Zone ${this.template.zones.length + 1}`,
|
||||||
|
target: DEFAULT_OCR_ZONE_TARGET,
|
||||||
|
custom_field: this.defaultCustomFieldId(),
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.w,
|
||||||
|
height: rect.h,
|
||||||
|
page: this.previewPageDisplay,
|
||||||
|
ocr_language: DEFAULT_OCR_ZONE_LANGUAGE,
|
||||||
|
transform: DEFAULT_OCR_ZONE_TRANSFORM,
|
||||||
|
date_format: '',
|
||||||
|
validation_regex: '',
|
||||||
|
order: this.template.zones.length,
|
||||||
|
zone_source_width: imageSize.width,
|
||||||
|
zone_source_height: imageSize.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultCustomFieldId(): number | null {
|
||||||
|
return this.customFields[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:mouseup')
|
||||||
|
onDocumentMouseUp() {
|
||||||
|
if (this.overlayInteraction.kind === 'idle') return
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopOverlayInteraction() {
|
||||||
|
this.overlayInteraction = NO_OVERLAY_INTERACTION
|
||||||
|
this.overlayCursor = 'crosshair'
|
||||||
|
}
|
||||||
|
|
||||||
|
drawingRect(): DisplayRect | null {
|
||||||
|
return this.overlayInteraction.kind === 'drawing'
|
||||||
|
? this.displayRectFromDrawing(this.overlayInteraction.rect)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneDisplayRect(zoneIdx: number): DisplayRect | null {
|
||||||
|
const img = this.imageRef?.nativeElement
|
||||||
|
if (!img || !img.naturalWidth) return null
|
||||||
|
const zone = this.template.zones[zoneIdx]
|
||||||
|
if (!zone) return null
|
||||||
|
if (!this.isOnCurrentPage(zone)) return null
|
||||||
|
return getZoneDisplayRect(
|
||||||
|
zone,
|
||||||
|
this.imageNaturalSize(),
|
||||||
|
this.imageNaturalSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private findHandleAt(point: Point, zoneIdx: number): ResizeHandle | null {
|
||||||
|
const r = this.zoneDisplayRect(zoneIdx)
|
||||||
|
if (!r) return null
|
||||||
|
return findHandleAt(point, r, this.overlayHandleSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyResize(zoneIndex: number, handle: ResizeHandle, point: Point) {
|
||||||
|
const zone = this.template.zones[zoneIndex]
|
||||||
|
if (!zone) return
|
||||||
|
resizeZone(
|
||||||
|
zone,
|
||||||
|
handle,
|
||||||
|
point,
|
||||||
|
this.imageNaturalSize(),
|
||||||
|
this.imageNaturalSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private findZoneAt(point: Point): number | null {
|
||||||
|
const img = this.imageRef.nativeElement
|
||||||
|
if (!img.naturalWidth) return null
|
||||||
|
|
||||||
|
return findZoneAt(
|
||||||
|
point,
|
||||||
|
this.template.zones,
|
||||||
|
this.previewPage,
|
||||||
|
this.previewPageCount,
|
||||||
|
this.imageNaturalSize(),
|
||||||
|
this.imageNaturalSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayViewBox(): string {
|
||||||
|
const imageSize = this.imageNaturalSize()
|
||||||
|
return `0 0 ${imageSize.width} ${imageSize.height}`
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneColor(index: number): string {
|
||||||
|
return ZONE_COLORS[index % ZONE_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneFill(index: number): string {
|
||||||
|
return `${this.zoneColor(index)}33`
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneLabel(zone: OcrTemplateZone, index: number): string {
|
||||||
|
return zone.name || `Zone ${index + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneLabelY(rect: DisplayRect): number {
|
||||||
|
return Math.max(this.overlayUnitSize(14), rect.y - this.overlayUnitSize(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeHandles(rect: DisplayRect): ResizeHandleMarker[] {
|
||||||
|
return [
|
||||||
|
{ handle: 'nw', x: rect.x, y: rect.y },
|
||||||
|
{ handle: 'n', x: rect.x + rect.w / 2, y: rect.y },
|
||||||
|
{ handle: 'ne', x: rect.x + rect.w, y: rect.y },
|
||||||
|
{ handle: 'w', x: rect.x, y: rect.y + rect.h / 2 },
|
||||||
|
{ handle: 'e', x: rect.x + rect.w, y: rect.y + rect.h / 2 },
|
||||||
|
{ handle: 'sw', x: rect.x, y: rect.y + rect.h },
|
||||||
|
{ handle: 's', x: rect.x + rect.w / 2, y: rect.y + rect.h },
|
||||||
|
{ handle: 'se', x: rect.x + rect.w, y: rect.y + rect.h },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayHandleSize(): number {
|
||||||
|
return this.overlayUnitSize(HANDLE_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayFontSize(): number {
|
||||||
|
return this.overlayUnitSize(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayUnitSize(screenPixels: number): number {
|
||||||
|
const img = this.imageRef?.nativeElement
|
||||||
|
if (!img?.naturalWidth || !img.clientWidth) return screenPixels
|
||||||
|
return (screenPixels * img.naturalWidth) / img.clientWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
private svgPointFromEvent(event: MouseEvent): Point | null {
|
||||||
|
const svg = this.overlayRef?.nativeElement
|
||||||
|
const matrix = svg?.getScreenCTM()
|
||||||
|
if (!svg || !matrix) return null
|
||||||
|
|
||||||
|
const point = svg.createSVGPoint()
|
||||||
|
point.x = event.clientX
|
||||||
|
point.y = event.clientY
|
||||||
|
|
||||||
|
const svgPoint = point.matrixTransform(matrix.inverse())
|
||||||
|
return { x: svgPoint.x, y: svgPoint.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
private displayRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||||
|
return {
|
||||||
|
x: Math.min(rect.startX, rect.endX),
|
||||||
|
y: Math.min(rect.startY, rect.endY),
|
||||||
|
w: Math.abs(rect.endX - rect.startX),
|
||||||
|
h: Math.abs(rect.endY - rect.startY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sourceRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||||
|
const displayRect = this.displayRectFromDrawing(rect)
|
||||||
|
return {
|
||||||
|
x: Math.round(displayRect.x),
|
||||||
|
y: Math.round(displayRect.y),
|
||||||
|
w: Math.round(displayRect.w),
|
||||||
|
h: Math.round(displayRect.h),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private imageNaturalSize() {
|
||||||
|
const img = this.imageRef.nativeElement
|
||||||
|
return { width: img.naturalWidth, height: img.naturalHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
removeZone(index: number) {
|
||||||
|
this.template.zones.splice(index, 1)
|
||||||
|
if (this.selectedZoneIndex === index) {
|
||||||
|
this.selectedZoneIndex = null
|
||||||
|
} else if (this.selectedZoneIndex > index) {
|
||||||
|
this.selectedZoneIndex--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectZone(index: number) {
|
||||||
|
this.selectedZoneIndex = index
|
||||||
|
this.activeTab = 'zone'
|
||||||
|
this.zoneTestResult = null
|
||||||
|
const zone = this.template.zones[index]
|
||||||
|
if (zone) {
|
||||||
|
this.seedCombineDefault(zone)
|
||||||
|
this.goToPage(this.zonePage(zone) - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testZone() {
|
||||||
|
const zone = this.selectedZone
|
||||||
|
if (!zone || !this.previewDocId) return
|
||||||
|
this.zoneTesting = true
|
||||||
|
this.zoneTestResult = null
|
||||||
|
this.templateService
|
||||||
|
.testZone(this.previewDocId, this.zoneTestRequest(zone))
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.zoneTestResult = res
|
||||||
|
this.zoneTesting = false
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.zoneTestResult = {
|
||||||
|
error: err.error?.error || $localize`Test failed`,
|
||||||
|
}
|
||||||
|
this.zoneTesting = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoneTestRequest(zone: OcrTemplateZone): ZoneTestRequest {
|
||||||
|
return {
|
||||||
|
name: zone.name,
|
||||||
|
x: zone.x,
|
||||||
|
y: zone.y,
|
||||||
|
width: zone.width,
|
||||||
|
height: zone.height,
|
||||||
|
page: zone.page ?? 1,
|
||||||
|
ocr_language: zone.ocr_language,
|
||||||
|
transform: zone.transform,
|
||||||
|
date_format: zone.date_format,
|
||||||
|
validation_regex: zone.validation_regex,
|
||||||
|
zone_source_width: zone.zone_source_width,
|
||||||
|
zone_source_height: zone.zone_source_height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSelectedZone() {
|
||||||
|
if (this.selectedZoneIndex === null) return
|
||||||
|
this.removeZone(this.selectedZoneIndex)
|
||||||
|
this.activeTab = 'zones'
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
this.saving = true
|
||||||
|
this.pruneCombineFormats()
|
||||||
|
this.template.sample_document = this.previewDocId
|
||||||
|
const obs = this.isNew
|
||||||
|
? this.templateService.create(this.template)
|
||||||
|
: this.templateService.update(this.template)
|
||||||
|
|
||||||
|
obs.pipe(takeUntil(this.destroy$)).subscribe({
|
||||||
|
next: (saved) => {
|
||||||
|
const idx = this.selectedZoneIndex
|
||||||
|
this.template = saved
|
||||||
|
this.isNew = false
|
||||||
|
this.selectedZoneIndex = idx
|
||||||
|
this.saving = false
|
||||||
|
this.toastService.showInfo($localize`OCR template saved.`)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.saving = false
|
||||||
|
this.toastService.showError($localize`Error saving OCR template.`, e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private ocrLangCache = new WeakMap<
|
||||||
|
OcrTemplateZone,
|
||||||
|
{ src: string; arr: string[] }
|
||||||
|
>()
|
||||||
|
|
||||||
|
ocrLanguageArray(zone: OcrTemplateZone): string[] {
|
||||||
|
const src = zone.ocr_language || ''
|
||||||
|
const cached = this.ocrLangCache.get(zone)
|
||||||
|
if (cached && cached.src === src) return cached.arr
|
||||||
|
const arr = src ? src.split('+').filter(Boolean) : []
|
||||||
|
this.ocrLangCache.set(zone, { src, arr })
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
setOcrLanguages(zone: OcrTemplateZone, langs: string[]) {
|
||||||
|
zone.ocr_language = (langs || []).join('+')
|
||||||
|
this.ocrLangCache.set(zone, {
|
||||||
|
src: zone.ocr_language,
|
||||||
|
arr: langs ? [...langs] : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getCustomFieldName(id: number): string {
|
||||||
|
const cf = this.customFields.find((f) => f.id === id)
|
||||||
|
return cf ? cf.name : `Field #${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Value bound to the field select: a built-in id string or a custom-field id. */
|
||||||
|
zoneFieldValue(zone: OcrTemplateZone): ZoneFieldSelection {
|
||||||
|
const target = zone.target || DEFAULT_OCR_ZONE_TARGET
|
||||||
|
return target === OCR_ZONE_TARGET.CustomField ? zone.custom_field : target
|
||||||
|
}
|
||||||
|
|
||||||
|
setZoneField(zone: OcrTemplateZone, value: ZoneFieldSelection) {
|
||||||
|
if (isOcrBuiltinTarget(value)) {
|
||||||
|
zone.target = value
|
||||||
|
zone.custom_field = null
|
||||||
|
} else {
|
||||||
|
zone.target = OCR_ZONE_TARGET.CustomField
|
||||||
|
zone.custom_field = typeof value === 'number' ? value : null
|
||||||
|
}
|
||||||
|
this.seedCombineDefault(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldKeyFor(zone: OcrTemplateZone): string | null {
|
||||||
|
const v = this.zoneFieldValue(zone)
|
||||||
|
return v === null || v === undefined ? null : String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
zonesForField(zone: OcrTemplateZone): OcrTemplateZone[] {
|
||||||
|
const key = this.fieldKeyFor(zone)
|
||||||
|
if (!key) return []
|
||||||
|
return this.template.zones.filter((z) => this.fieldKeyFor(z) === key)
|
||||||
|
}
|
||||||
|
|
||||||
|
isFieldShared(zone: OcrTemplateZone): boolean {
|
||||||
|
return this.zonesForField(zone).length > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
getCombineFormat(zone: OcrTemplateZone): string {
|
||||||
|
const key = this.fieldKeyFor(zone)
|
||||||
|
return (key && this.template.combine_formats?.[key]) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
setCombineFormat(zone: OcrTemplateZone, value: string) {
|
||||||
|
const key = this.fieldKeyFor(zone)
|
||||||
|
if (!key) return
|
||||||
|
this.template.combine_formats ??= {}
|
||||||
|
this.template.combine_formats[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
insertCombineToken(zone: OcrTemplateZone, tokenZone: OcrTemplateZone) {
|
||||||
|
const token = `{${tokenZone.name}}`
|
||||||
|
const current = this.getCombineFormat(zone)
|
||||||
|
const sep = current && !current.endsWith(' ') ? ' ' : ''
|
||||||
|
this.setCombineFormat(zone, `${current}${sep}${token}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private seedCombineDefault(zone: OcrTemplateZone) {
|
||||||
|
const key = this.fieldKeyFor(zone)
|
||||||
|
if (!key) return
|
||||||
|
const shared = this.zonesForField(zone)
|
||||||
|
if (shared.length <= 1) return
|
||||||
|
this.template.combine_formats ??= {}
|
||||||
|
if (!this.template.combine_formats[key]) {
|
||||||
|
this.template.combine_formats[key] = shared
|
||||||
|
.map((z) => `{${z.name}}`)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneCombineFormats() {
|
||||||
|
const formats = this.template.combine_formats
|
||||||
|
if (!formats) return
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const z of this.template.zones) {
|
||||||
|
const key = this.fieldKeyFor(z)
|
||||||
|
if (key) counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(formats)) {
|
||||||
|
if ((counts.get(key) ?? 0) <= 1) delete formats[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Value bound to the date-format select: a preset, '' (auto), or 'custom'. */
|
||||||
|
dateFormatChoice(zone: OcrTemplateZone): string {
|
||||||
|
return this.usesCustomDateFormat(zone)
|
||||||
|
? CUSTOM_DATE_FORMAT_CHOICE
|
||||||
|
: zone.date_format || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
setDateFormatChoice(zone: OcrTemplateZone, value: string) {
|
||||||
|
if (value === CUSTOM_DATE_FORMAT_CHOICE) {
|
||||||
|
this.customDateFormatZones.add(zone)
|
||||||
|
zone.date_format ||= ''
|
||||||
|
} else {
|
||||||
|
this.customDateFormatZones.delete(zone)
|
||||||
|
zone.date_format = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usesCustomDateFormat(zone: OcrTemplateZone): boolean {
|
||||||
|
return (
|
||||||
|
this.customDateFormatZones.has(zone) ||
|
||||||
|
(!!zone.date_format &&
|
||||||
|
!this.dateFormatOptions.some(
|
||||||
|
(option) => option.id === zone.date_format
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getZoneTargetName(zone: OcrTemplateZone): string {
|
||||||
|
const target = zone.target || DEFAULT_OCR_ZONE_TARGET
|
||||||
|
if (target === OCR_ZONE_TARGET.CustomField) {
|
||||||
|
return zone.custom_field
|
||||||
|
? this.getCustomFieldName(zone.custom_field)
|
||||||
|
: $localize`(no field)`
|
||||||
|
}
|
||||||
|
return this.builtinTargets.find((t) => t.id === target)?.name ?? target
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocumentTypeName(id: number): string {
|
||||||
|
const dt = this.documentTypes.find((d) => d.id === id)
|
||||||
|
return dt ? dt.name : `Type #${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
openQuickCreate(zoneIndex: number | null) {
|
||||||
|
if (zoneIndex === null) return
|
||||||
|
this.quickCreateForZoneIndex = zoneIndex
|
||||||
|
this.quickCreateName = 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)
|
||||||
|
.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
|
||||||
|
})
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Failed to create custom field.`,
|
||||||
|
err
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.destroy$.next()
|
||||||
|
this.destroy$.complete()
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||||
|
import {
|
||||||
|
findHandleAt,
|
||||||
|
findZoneAt,
|
||||||
|
getZoneDisplayRect,
|
||||||
|
getZonePage,
|
||||||
|
isZoneOnPage,
|
||||||
|
moveZone,
|
||||||
|
resizeZone,
|
||||||
|
sourceRectFromDrawing,
|
||||||
|
} from './zone-geometry'
|
||||||
|
|
||||||
|
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||||
|
return {
|
||||||
|
name: 'Zone',
|
||||||
|
target: 'custom_field',
|
||||||
|
custom_field: 1,
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
width: 300,
|
||||||
|
height: 400,
|
||||||
|
page: 1,
|
||||||
|
ocr_language: 'eng',
|
||||||
|
transform: 'strip',
|
||||||
|
validation_regex: '',
|
||||||
|
order: 0,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OCR template editor geometry', () => {
|
||||||
|
it('normalizes zone pages', () => {
|
||||||
|
expect(getZonePage(zone({ page: 2 }), 0, 5)).toBe(2)
|
||||||
|
expect(getZonePage(zone({ page: -1 }), 0, 5)).toBe(5)
|
||||||
|
expect(getZonePage(zone({ page: -1 }), 2, null)).toBe(3)
|
||||||
|
expect(getZonePage(zone({ page: 0 }), 0, 5)).toBe(1)
|
||||||
|
expect(getZonePage(zone({ page: undefined }), 0, 5)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks whether a zone is on the current preview page', () => {
|
||||||
|
expect(isZoneOnPage(zone({ page: 2 }), 1, 5)).toBe(true)
|
||||||
|
expect(isZoneOnPage(zone({ page: 2 }), 0, 5)).toBe(false)
|
||||||
|
expect(isZoneOnPage(zone({ page: -1 }), 4, 5)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales source coordinates to canvas display coordinates', () => {
|
||||||
|
expect(
|
||||||
|
getZoneDisplayRect(
|
||||||
|
zone({ x: 100, y: 200, width: 300, height: 400 }),
|
||||||
|
{ width: 500, height: 1000 },
|
||||||
|
{ width: 1000, height: 2000 }
|
||||||
|
)
|
||||||
|
).toEqual({ x: 50, y: 100, w: 150, h: 200 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses per-zone source dimensions when present', () => {
|
||||||
|
expect(
|
||||||
|
getZoneDisplayRect(
|
||||||
|
zone({
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
zone_source_width: 1000,
|
||||||
|
zone_source_height: 1000,
|
||||||
|
}),
|
||||||
|
{ width: 500, height: 500 },
|
||||||
|
{ width: 2000, height: 2000 }
|
||||||
|
)
|
||||||
|
).toEqual({ x: 50, y: 50, w: 50, h: 50 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds zones from topmost to bottommost on the current page', () => {
|
||||||
|
const zones = [
|
||||||
|
zone({ name: 'first', x: 0, y: 0, width: 100, height: 100, page: 1 }),
|
||||||
|
zone({ name: 'second', x: 0, y: 0, width: 50, height: 50, page: 1 }),
|
||||||
|
zone({ name: 'third', x: 0, y: 0, width: 50, height: 50, page: 2 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(
|
||||||
|
findZoneAt(
|
||||||
|
{ x: 25, y: 25 },
|
||||||
|
zones,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
{ width: 100, height: 100 },
|
||||||
|
{ width: 100, height: 100 }
|
||||||
|
)
|
||||||
|
).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds resize handles around a display rect', () => {
|
||||||
|
const rect = { x: 10, y: 20, w: 100, h: 200 }
|
||||||
|
|
||||||
|
expect(findHandleAt({ x: 10, y: 20 }, rect)).toBe('nw')
|
||||||
|
expect(findHandleAt({ x: 110, y: 220 }, rect)).toBe('se')
|
||||||
|
expect(findHandleAt({ x: 60, y: 20 }, rect)).toBe('n')
|
||||||
|
expect(findHandleAt({ x: 90, y: 160 }, rect)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves zones without leaving source image bounds', () => {
|
||||||
|
const z = zone({ x: 50, y: 50, width: 100, height: 100 })
|
||||||
|
|
||||||
|
moveZone(
|
||||||
|
z,
|
||||||
|
{ x: 500, y: 500 },
|
||||||
|
{ mouseX: 50, mouseY: 50, zoneX: 50, zoneY: 50 },
|
||||||
|
{ width: 500, height: 500 },
|
||||||
|
{ width: 500, height: 500 }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(z.x).toBe(400)
|
||||||
|
expect(z.y).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resizes zones without leaving source image bounds', () => {
|
||||||
|
const z = zone({ x: 50, y: 50, width: 100, height: 100 })
|
||||||
|
|
||||||
|
resizeZone(
|
||||||
|
z,
|
||||||
|
'se',
|
||||||
|
{ x: 500, y: 500 },
|
||||||
|
{ width: 500, height: 500 },
|
||||||
|
{ width: 200, height: 200 }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(z.width).toBe(150)
|
||||||
|
expect(z.height).toBe(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts drawn canvas rectangles to source rectangles', () => {
|
||||||
|
expect(
|
||||||
|
sourceRectFromDrawing(
|
||||||
|
{ startX: 100, startY: 200, endX: 50, endY: 100 },
|
||||||
|
{ width: 500, height: 1000 },
|
||||||
|
{ width: 1000, height: 2000 }
|
||||||
|
)
|
||||||
|
).toEqual({ x: 100, y: 200, w: 100, h: 200 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||||
|
|
||||||
|
export interface DrawingRect {
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
endX: number
|
||||||
|
endY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dimensions {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayRect {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveStart {
|
||||||
|
mouseX: number
|
||||||
|
mouseY: number
|
||||||
|
zoneX: number
|
||||||
|
zoneY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||||
|
|
||||||
|
export const HANDLE_SIZE = 8
|
||||||
|
export const MIN_ZONE_SIZE = 10
|
||||||
|
|
||||||
|
export function getZonePage(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
previewPage: number,
|
||||||
|
previewPageCount: number | null
|
||||||
|
): number {
|
||||||
|
const page = zone.page ?? 1
|
||||||
|
if (page === -1) return previewPageCount ?? previewPage + 1
|
||||||
|
return page >= 1 ? page : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isZoneOnPage(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
previewPage: number,
|
||||||
|
previewPageCount: number | null
|
||||||
|
): boolean {
|
||||||
|
return getZonePage(zone, previewPage, previewPageCount) === previewPage + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZoneSourceSize(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
imageSize: Dimensions
|
||||||
|
): Dimensions {
|
||||||
|
return {
|
||||||
|
width: zone.zone_source_width || imageSize.width,
|
||||||
|
height: zone.zone_source_height || imageSize.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZoneDisplayRect(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
canvasSize: Dimensions,
|
||||||
|
imageSize: Dimensions
|
||||||
|
): DisplayRect {
|
||||||
|
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||||
|
const scaleX = canvasSize.width / sourceSize.width
|
||||||
|
const scaleY = canvasSize.height / sourceSize.height
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: zone.x * scaleX,
|
||||||
|
y: zone.y * scaleY,
|
||||||
|
w: zone.width * scaleX,
|
||||||
|
h: zone.height * scaleY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findHandleAt(
|
||||||
|
point: Point,
|
||||||
|
rect: DisplayRect,
|
||||||
|
handleSize = HANDLE_SIZE
|
||||||
|
): ResizeHandle | null {
|
||||||
|
const handles: [ResizeHandle, number, number][] = [
|
||||||
|
['nw', rect.x, rect.y],
|
||||||
|
['n', rect.x + rect.w / 2, rect.y],
|
||||||
|
['ne', rect.x + rect.w, rect.y],
|
||||||
|
['w', rect.x, rect.y + rect.h / 2],
|
||||||
|
['e', rect.x + rect.w, rect.y + rect.h / 2],
|
||||||
|
['sw', rect.x, rect.y + rect.h],
|
||||||
|
['s', rect.x + rect.w / 2, rect.y + rect.h],
|
||||||
|
['se', rect.x + rect.w, rect.y + rect.h],
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
handles.find(
|
||||||
|
([, x, y]) =>
|
||||||
|
Math.abs(point.x - x) <= handleSize &&
|
||||||
|
Math.abs(point.y - y) <= handleSize
|
||||||
|
)?.[0] ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findZoneAt(
|
||||||
|
point: Point,
|
||||||
|
zones: OcrTemplateZone[],
|
||||||
|
previewPage: number,
|
||||||
|
previewPageCount: number | null,
|
||||||
|
canvasSize: Dimensions,
|
||||||
|
imageSize: Dimensions
|
||||||
|
): number | null {
|
||||||
|
for (let i = zones.length - 1; i >= 0; i--) {
|
||||||
|
const zone = zones[i]
|
||||||
|
if (!isZoneOnPage(zone, previewPage, previewPageCount)) continue
|
||||||
|
const rect = getZoneDisplayRect(zone, canvasSize, imageSize)
|
||||||
|
|
||||||
|
if (
|
||||||
|
point.x >= rect.x &&
|
||||||
|
point.x <= rect.x + rect.w &&
|
||||||
|
point.y >= rect.y &&
|
||||||
|
point.y <= rect.y + rect.h
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveZone(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
point: Point,
|
||||||
|
moveStart: MoveStart,
|
||||||
|
canvasSize: Dimensions,
|
||||||
|
imageSize: Dimensions
|
||||||
|
) {
|
||||||
|
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||||
|
const scaleX = sourceSize.width / canvasSize.width
|
||||||
|
const scaleY = sourceSize.height / canvasSize.height
|
||||||
|
const dx = Math.round((point.x - moveStart.mouseX) * scaleX)
|
||||||
|
const dy = Math.round((point.y - moveStart.mouseY) * scaleY)
|
||||||
|
|
||||||
|
zone.x = clamp(moveStart.zoneX + dx, 0, sourceSize.width - zone.width)
|
||||||
|
zone.y = clamp(moveStart.zoneY + dy, 0, sourceSize.height - zone.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeZone(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
handle: ResizeHandle,
|
||||||
|
point: Point,
|
||||||
|
canvasSize: Dimensions,
|
||||||
|
imageSize: Dimensions
|
||||||
|
) {
|
||||||
|
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||||
|
const scaleX = sourceSize.width / canvasSize.width
|
||||||
|
const scaleY = sourceSize.height / canvasSize.height
|
||||||
|
const imageX = clamp(Math.round(point.x * scaleX), 0, sourceSize.width)
|
||||||
|
const imageY = clamp(Math.round(point.y * scaleY), 0, sourceSize.height)
|
||||||
|
|
||||||
|
if (handle.includes('w')) {
|
||||||
|
const right = Math.min(zone.x + zone.width, sourceSize.width)
|
||||||
|
zone.x = clamp(imageX, 0, right - MIN_ZONE_SIZE)
|
||||||
|
zone.width = right - zone.x
|
||||||
|
}
|
||||||
|
if (handle.includes('e')) {
|
||||||
|
zone.width = Math.max(MIN_ZONE_SIZE, imageX - zone.x)
|
||||||
|
}
|
||||||
|
if (handle.includes('n')) {
|
||||||
|
const bottom = Math.min(zone.y + zone.height, sourceSize.height)
|
||||||
|
zone.y = clamp(imageY, 0, bottom - MIN_ZONE_SIZE)
|
||||||
|
zone.height = bottom - zone.y
|
||||||
|
}
|
||||||
|
if (handle.includes('s')) {
|
||||||
|
zone.height = Math.max(MIN_ZONE_SIZE, imageY - zone.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceRectFromDrawing(
|
||||||
|
rect: DrawingRect,
|
||||||
|
canvasSize: Dimensions,
|
||||||
|
imageSize: Dimensions
|
||||||
|
): DisplayRect {
|
||||||
|
const scaleX = imageSize.width / canvasSize.width
|
||||||
|
const scaleY = imageSize.height / canvasSize.height
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.round(Math.min(rect.startX, rect.endX) * scaleX),
|
||||||
|
y: Math.round(Math.min(rect.startY, rect.endY) * scaleY),
|
||||||
|
w: Math.round(Math.abs(rect.endX - rect.startX) * scaleX),
|
||||||
|
h: Math.round(Math.abs(rect.endY - rect.startY) * scaleY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(value, max))
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<pngx-page-header
|
||||||
|
title="OCR Templates"
|
||||||
|
i18n-title
|
||||||
|
info="Define extraction zones on document types to automatically populate custom fields via OCR."
|
||||||
|
i18n-info
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="createTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }">
|
||||||
|
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Create Template</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 d-none d-sm-flex" i18n>Document Type</div>
|
||||||
|
<div class="col d-none d-sm-flex" i18n>Zones</div>
|
||||||
|
<div class="col" i18n>Status</div>
|
||||||
|
<div class="col" i18n>Actions</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
@if (loading && templates.length === 0) {
|
||||||
|
<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 (t of templates; track t.id) {
|
||||||
|
<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)="editTemplate(t)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.OcrTemplate)">{{t.name}}</button></div>
|
||||||
|
<div class="col d-flex align-items-center d-none d-sm-flex">{{getDocumentTypeName(t)}}</div>
|
||||||
|
<div class="col d-flex align-items-center d-none d-sm-flex"><code>{{t.zones?.length || 0}}</code></div>
|
||||||
|
<div class="col d-flex align-items-center">
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
<input type="checkbox" class="form-check-input cursor-pointer" [id]="t.id+'_enable'" [(ngModel)]="t.enabled" (change)="toggleTemplate(t)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }">
|
||||||
|
<label class="form-check-label cursor-pointer" [for]="t.id+'_enable'">
|
||||||
|
<code> @if(t.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</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{{t.id}}" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
|
<i-bs name="three-dots-vertical"></i-bs>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile{{t.id}}">
|
||||||
|
<button (click)="editTemplate(t)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Edit</button>
|
||||||
|
<button (click)="deleteTemplate(t)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(t)">
|
||||||
|
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
||||||
|
</button>
|
||||||
|
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(t)">
|
||||||
|
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (!loading && templates.length === 0) {
|
||||||
|
<li class="list-group-item" [class.show]="show" i18n>No OCR templates defined.</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { delay, takeUntil, tap } from 'rxjs'
|
||||||
|
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||||
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.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 { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-ocr-templates',
|
||||||
|
templateUrl: './ocr-templates.component.html',
|
||||||
|
imports: [
|
||||||
|
PageHeaderComponent,
|
||||||
|
IfPermissionsDirective,
|
||||||
|
FormsModule,
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class OcrTemplatesComponent
|
||||||
|
extends LoadingComponentWithPermissions
|
||||||
|
implements OnInit
|
||||||
|
{
|
||||||
|
private readonly service = inject(OcrTemplateService)
|
||||||
|
private readonly documentTypeService = inject(DocumentTypeService)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
private readonly modalService = inject(NgbModal)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
permissionsService = inject(PermissionsService)
|
||||||
|
|
||||||
|
public templates: OcrTemplate[] = []
|
||||||
|
private documentTypeNames: Map<number, string> = new Map()
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.documentTypeService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((r) => {
|
||||||
|
this.documentTypeNames = new Map(
|
||||||
|
r.results.map((dt) => [dt.id, dt.name])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
this.loading = true
|
||||||
|
this.service
|
||||||
|
.listAll()
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeNotifier),
|
||||||
|
tap((r) => (this.templates = r.results)),
|
||||||
|
delay(100)
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.show = true
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocumentTypeName(t: OcrTemplate): string {
|
||||||
|
return (
|
||||||
|
this.documentTypeNames.get(t.document_type) ?? `${t.document_type ?? ''}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createTemplate() {
|
||||||
|
this.router.navigate(['/ocr-templates', 'new'])
|
||||||
|
}
|
||||||
|
|
||||||
|
editTemplate(t: OcrTemplate) {
|
||||||
|
this.router.navigate(['/ocr-templates', t.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTemplate(t: OcrTemplate) {
|
||||||
|
// ngModel has already flipped t.enabled; restore it if persistence fails.
|
||||||
|
const enabled = t.enabled
|
||||||
|
this.service.patch(t).subscribe({
|
||||||
|
error: (error) => {
|
||||||
|
t.enabled = !enabled
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error updating OCR template.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTemplate(t: OcrTemplate) {
|
||||||
|
const modal = this.modalService.open(ConfirmDialogComponent)
|
||||||
|
modal.componentInstance.title = $localize`Delete OCR Template`
|
||||||
|
modal.componentInstance.messageBoldPart = t.name
|
||||||
|
modal.componentInstance.message = $localize`Do you really want to delete this OCR template?`
|
||||||
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
|
modal.componentInstance.btnCaption = $localize`Delete`
|
||||||
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
this.service.delete(t).subscribe(() => this.reload())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { ObjectWithId } from './object-with-id'
|
||||||
|
|
||||||
|
export type OcrZoneTarget = 'custom_field' | 'title' | 'asn' | 'created'
|
||||||
|
export type OcrBuiltinTarget = Exclude<OcrZoneTarget, 'custom_field'>
|
||||||
|
export type OcrZoneTransform =
|
||||||
|
| 'none'
|
||||||
|
| 'strip'
|
||||||
|
| 'uppercase'
|
||||||
|
| 'lowercase'
|
||||||
|
| 'numeric'
|
||||||
|
| 'strip_punctuation'
|
||||||
|
| 'date'
|
||||||
|
| 'qr_code'
|
||||||
|
|
||||||
|
export const OCR_ZONE_TARGET = {
|
||||||
|
CustomField: 'custom_field',
|
||||||
|
Title: 'title',
|
||||||
|
Asn: 'asn',
|
||||||
|
Created: 'created',
|
||||||
|
} as const satisfies Record<string, OcrZoneTarget>
|
||||||
|
|
||||||
|
export const OCR_ZONE_TRANSFORM = {
|
||||||
|
None: 'none',
|
||||||
|
Strip: 'strip',
|
||||||
|
Uppercase: 'uppercase',
|
||||||
|
Lowercase: 'lowercase',
|
||||||
|
Numeric: 'numeric',
|
||||||
|
StripPunctuation: 'strip_punctuation',
|
||||||
|
Date: 'date',
|
||||||
|
QrCode: 'qr_code',
|
||||||
|
} as const satisfies Record<string, OcrZoneTransform>
|
||||||
|
|
||||||
|
export const DEFAULT_OCR_ZONE_TARGET = OCR_ZONE_TARGET.CustomField
|
||||||
|
export const DEFAULT_OCR_ZONE_TRANSFORM = OCR_ZONE_TRANSFORM.Strip
|
||||||
|
export const DEFAULT_OCR_ZONE_LANGUAGE = 'deu+eng'
|
||||||
|
|
||||||
|
export function isOcrBuiltinTarget(value: unknown): value is OcrBuiltinTarget {
|
||||||
|
return (
|
||||||
|
value === OCR_ZONE_TARGET.Title ||
|
||||||
|
value === OCR_ZONE_TARGET.Asn ||
|
||||||
|
value === OCR_ZONE_TARGET.Created
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OCR_BUILTIN_TARGETS = [
|
||||||
|
{ id: OCR_ZONE_TARGET.Title, name: $localize`Title` },
|
||||||
|
{ id: OCR_ZONE_TARGET.Asn, name: $localize`Archive serial number` },
|
||||||
|
{ id: OCR_ZONE_TARGET.Created, name: $localize`Date created` },
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface OcrTemplateZone {
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
|
target?: OcrZoneTarget
|
||||||
|
custom_field: number | null
|
||||||
|
page?: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
ocr_language: string
|
||||||
|
transform: OcrZoneTransform
|
||||||
|
date_format?: string
|
||||||
|
validation_regex: string
|
||||||
|
order: number
|
||||||
|
zone_source_width?: number
|
||||||
|
zone_source_height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRANSFORM_OPTIONS = [
|
||||||
|
{ id: OCR_ZONE_TRANSFORM.None, name: $localize`None` },
|
||||||
|
{ id: OCR_ZONE_TRANSFORM.Strip, name: $localize`Strip whitespace` },
|
||||||
|
{ id: OCR_ZONE_TRANSFORM.Uppercase, name: $localize`Uppercase` },
|
||||||
|
{ id: OCR_ZONE_TRANSFORM.Lowercase, name: $localize`Lowercase` },
|
||||||
|
{ id: OCR_ZONE_TRANSFORM.Numeric, name: $localize`Numeric only` },
|
||||||
|
{
|
||||||
|
id: OCR_ZONE_TRANSFORM.StripPunctuation,
|
||||||
|
name: $localize`Remove leading/trailing punctuation`,
|
||||||
|
},
|
||||||
|
{ id: OCR_ZONE_TRANSFORM.Date, name: $localize`Parse date` },
|
||||||
|
{ id: OCR_ZONE_TRANSFORM.QrCode, name: $localize`Read QR/barcode` },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const OCR_LANGUAGE_OPTIONS = [
|
||||||
|
{ id: 'eng', name: $localize`English` },
|
||||||
|
{ id: 'deu', name: $localize`German` },
|
||||||
|
{ id: 'fra', name: $localize`French` },
|
||||||
|
{ id: 'ita', name: $localize`Italian` },
|
||||||
|
{ id: 'spa', name: $localize`Spanish` },
|
||||||
|
{ id: 'por', name: $localize`Portuguese` },
|
||||||
|
{ id: 'nld', name: $localize`Dutch` },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DATE_FORMAT_OPTIONS = [
|
||||||
|
{ id: '', name: $localize`Auto-detect` },
|
||||||
|
{ id: '%d.%m.%Y', name: 'DD.MM.YYYY' },
|
||||||
|
{ id: '%Y/%m/%d', name: 'YYYY/MM/DD' },
|
||||||
|
{ id: '%d/%m/%Y', name: 'DD/MM/YYYY' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface OcrTemplate extends ObjectWithId {
|
||||||
|
name: string
|
||||||
|
document_type: number
|
||||||
|
sample_document: number | null
|
||||||
|
source_width: number
|
||||||
|
source_height: number
|
||||||
|
enabled: boolean
|
||||||
|
combine_formats?: Record<string, string>
|
||||||
|
created?: string
|
||||||
|
updated?: string
|
||||||
|
zones: OcrTemplateZone[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoneTestRequest {
|
||||||
|
name: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
page: number
|
||||||
|
ocr_language: string
|
||||||
|
transform: OcrZoneTransform
|
||||||
|
date_format?: string
|
||||||
|
validation_regex: string
|
||||||
|
zone_source_width?: number
|
||||||
|
zone_source_height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrZoneTestResult {
|
||||||
|
raw_text?: string | null
|
||||||
|
value?: string | null
|
||||||
|
regex?: string
|
||||||
|
regex_match?: boolean | null
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrZoneRunResult {
|
||||||
|
template: string
|
||||||
|
zone: string
|
||||||
|
custom_field: string
|
||||||
|
value: string | number | null
|
||||||
|
}
|
||||||
@@ -24,16 +24,6 @@ export enum CollapsibleSection {
|
|||||||
ATTRIBUTES = 'attributes',
|
ATTRIBUTES = 'attributes',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum HideableSidebarItemID {
|
|
||||||
Dashboard = 'dashboard',
|
|
||||||
SavedViews = 'saved_views',
|
|
||||||
Workflows = 'workflows',
|
|
||||||
Mail = 'mail',
|
|
||||||
Documentation = 'documentation',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HIDEABLE_SIDEBAR_ITEM_IDS = Object.values(HideableSidebarItemID)
|
|
||||||
|
|
||||||
export const PAPERLESS_GREEN_HEX = '#17541f'
|
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||||
|
|
||||||
export const SETTINGS_KEYS = {
|
export const SETTINGS_KEYS = {
|
||||||
@@ -66,7 +56,6 @@ export const SETTINGS_KEYS = {
|
|||||||
NOTES_ENABLED: 'general-settings:notes-enabled',
|
NOTES_ENABLED: 'general-settings:notes-enabled',
|
||||||
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
||||||
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
||||||
SIDEBAR_HIDDEN_ITEMS: 'general-settings:sidebar:hidden-items',
|
|
||||||
ATTRIBUTES_SECTIONS_COLLAPSED:
|
ATTRIBUTES_SECTIONS_COLLAPSED:
|
||||||
'general-settings:attributes-sections-collapsed',
|
'general-settings:attributes-sections-collapsed',
|
||||||
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
||||||
@@ -138,11 +127,6 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
|
||||||
type: 'array',
|
|
||||||
default: [],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export enum PermissionType {
|
|||||||
ShareLinkBundle = '%s_sharelinkbundle',
|
ShareLinkBundle = '%s_sharelinkbundle',
|
||||||
CustomField = '%s_customfield',
|
CustomField = '%s_customfield',
|
||||||
Workflow = '%s_workflow',
|
Workflow = '%s_workflow',
|
||||||
|
OcrTemplate = '%s_ocrtemplate',
|
||||||
ProcessedMail = '%s_processedmail',
|
ProcessedMail = '%s_processedmail',
|
||||||
GlobalStatistics = '%s_global_statistics',
|
GlobalStatistics = '%s_global_statistics',
|
||||||
SystemMonitoring = '%s_system_monitoring',
|
SystemMonitoring = '%s_system_monitoring',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||||
import { FilterRule } from 'src/app/data/filter-rule'
|
import { FilterRule } from 'src/app/data/filter-rule'
|
||||||
|
import { OcrZoneRunResult } from 'src/app/data/ocr-template'
|
||||||
import { Results, SelectionData } from 'src/app/data/results'
|
import { Results, SelectionData } from 'src/app/data/results'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||||
@@ -359,6 +360,13 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runZoneOcr(id: number): Observable<{ results: OcrZoneRunResult[] }> {
|
||||||
|
return this.http.post<{ results: OcrZoneRunResult[] }>(
|
||||||
|
this.getResourceUrl(id, 'run-zone-ocr'),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
rotateDocuments(
|
rotateDocuments(
|
||||||
selection: DocumentSelectionQuery,
|
selection: DocumentSelectionQuery,
|
||||||
degrees: number,
|
degrees: number,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import { Observable } from 'rxjs'
|
||||||
|
import {
|
||||||
|
OcrTemplate,
|
||||||
|
OcrZoneTestResult,
|
||||||
|
ZoneTestRequest,
|
||||||
|
} 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() {
|
||||||
|
super()
|
||||||
|
this.resourceName = 'ocr_templates'
|
||||||
|
}
|
||||||
|
|
||||||
|
getPageImageUrl(docId: number, page: number): string {
|
||||||
|
return `${this.baseUrl}${this.resourceName}/document-page-image/${docId}/${page}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
testZone(
|
||||||
|
docId: number,
|
||||||
|
zone: ZoneTestRequest
|
||||||
|
): Observable<OcrZoneTestResult> {
|
||||||
|
return this.http.post<OcrZoneTestResult>(
|
||||||
|
`${this.baseUrl}${this.resourceName}/test-zone/`,
|
||||||
|
{ 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,7 @@ import { CustomFieldDataType } from '../data/custom-field'
|
|||||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||||
import {
|
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
||||||
HideableSidebarItemID,
|
|
||||||
SETTINGS_KEYS,
|
|
||||||
UiSettings,
|
|
||||||
} from '../data/ui-settings'
|
|
||||||
import { PermissionsService } from './permissions.service'
|
import { PermissionsService } from './permissions.service'
|
||||||
import { CustomFieldsService } from './rest/custom-fields.service'
|
import { CustomFieldsService } from './rest/custom-fields.service'
|
||||||
import { SettingsService } from './settings.service'
|
import { SettingsService } from './settings.service'
|
||||||
@@ -234,35 +230,6 @@ describe('SettingsService', () => {
|
|||||||
expect(notesEnabled()).toBeFalsy()
|
expect(notesEnabled()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates sidebar item visibility', () => {
|
|
||||||
httpTestingController
|
|
||||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
|
||||||
.flush(ui_settings)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
|
||||||
).toBe(false)
|
|
||||||
|
|
||||||
settingsService.updateSidebarItemVisibility(
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
|
||||||
).toBe(true)
|
|
||||||
expect(settingsService.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS)).toEqual([])
|
|
||||||
|
|
||||||
settingsService.updateSidebarItemVisibility(
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates setting signals when settings are reinitialized', () => {
|
it('updates setting signals when settings are reinitialized', () => {
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}ui_settings/`
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
|||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import {
|
import {
|
||||||
HideableSidebarItemID,
|
|
||||||
PAPERLESS_GREEN_HEX,
|
PAPERLESS_GREEN_HEX,
|
||||||
SETTINGS,
|
SETTINGS,
|
||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
@@ -314,18 +313,6 @@ export class SettingsService {
|
|||||||
readonly globalDropzoneEnabled = signal(true)
|
readonly globalDropzoneEnabled = signal(true)
|
||||||
readonly globalDropzoneActive = signal(false)
|
readonly globalDropzoneActive = signal(false)
|
||||||
readonly organizingSidebarSavedViews = signal(false)
|
readonly organizingSidebarSavedViews = signal(false)
|
||||||
readonly sidebarHiddenItemsEditing = signal<HideableSidebarItemID[] | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
readonly organizingSidebarItems = computed(
|
|
||||||
() => this.sidebarHiddenItemsEditing() !== null
|
|
||||||
)
|
|
||||||
readonly sidebarHiddenItemsEditingChanged = new EventEmitter<
|
|
||||||
HideableSidebarItemID[]
|
|
||||||
>()
|
|
||||||
readonly hiddenSidebarItems = this.getSignal<HideableSidebarItemID[]>(
|
|
||||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS
|
|
||||||
)
|
|
||||||
|
|
||||||
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
||||||
DEFAULT_DISPLAY_FIELDS
|
DEFAULT_DISPLAY_FIELDS
|
||||||
@@ -762,29 +749,6 @@ export class SettingsService {
|
|||||||
return this.storeSettings()
|
return this.storeSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
sidebarItemIsHidden(item: HideableSidebarItemID): boolean {
|
|
||||||
return (
|
|
||||||
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
|
||||||
).includes(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSidebarItemVisibility(
|
|
||||||
item: HideableSidebarItemID,
|
|
||||||
visible: boolean
|
|
||||||
): void {
|
|
||||||
const hiddenItems = new Set(
|
|
||||||
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
|
||||||
)
|
|
||||||
if (visible) {
|
|
||||||
hiddenItems.delete(item)
|
|
||||||
} else {
|
|
||||||
hiddenItems.add(item)
|
|
||||||
}
|
|
||||||
const updatedHiddenItems = [...hiddenItems]
|
|
||||||
this.sidebarHiddenItemsEditing.set(updatedHiddenItems)
|
|
||||||
this.sidebarHiddenItemsEditingChanged.emit(updatedHiddenItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSavedViewsVisibility(
|
updateSavedViewsVisibility(
|
||||||
dashboardVisibleViewIds: number[],
|
dashboardVisibleViewIds: number[],
|
||||||
sidebarVisibleViewIds: number[]
|
sidebarVisibleViewIds: number[]
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ import {
|
|||||||
exclamationTriangleFill,
|
exclamationTriangleFill,
|
||||||
eye,
|
eye,
|
||||||
fileEarmark,
|
fileEarmark,
|
||||||
|
fileEarmarkBreak,
|
||||||
fileEarmarkCheck,
|
fileEarmarkCheck,
|
||||||
fileEarmarkDiff,
|
fileEarmarkDiff,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
@@ -99,6 +100,7 @@ import {
|
|||||||
fileEarmarkPlus,
|
fileEarmarkPlus,
|
||||||
fileEarmarkRichtext,
|
fileEarmarkRichtext,
|
||||||
fileEarmarkSpreadsheet,
|
fileEarmarkSpreadsheet,
|
||||||
|
fileEarmarkRuled,
|
||||||
fileText,
|
fileText,
|
||||||
files,
|
files,
|
||||||
filter,
|
filter,
|
||||||
@@ -338,6 +340,7 @@ const icons = {
|
|||||||
exclamationTriangleFill,
|
exclamationTriangleFill,
|
||||||
eye,
|
eye,
|
||||||
fileEarmark,
|
fileEarmark,
|
||||||
|
fileEarmarkBreak,
|
||||||
fileEarmarkCheck,
|
fileEarmarkCheck,
|
||||||
fileEarmarkDiff,
|
fileEarmarkDiff,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
@@ -348,6 +351,7 @@ const icons = {
|
|||||||
fileEarmarkPlus,
|
fileEarmarkPlus,
|
||||||
fileEarmarkRichtext,
|
fileEarmarkRichtext,
|
||||||
fileEarmarkSpreadsheet,
|
fileEarmarkSpreadsheet,
|
||||||
|
fileEarmarkRuled,
|
||||||
files,
|
files,
|
||||||
fileText,
|
fileText,
|
||||||
filter,
|
filter,
|
||||||
|
|||||||
+13
-1
@@ -13,8 +13,11 @@ class DocumentsConfig(AppConfig):
|
|||||||
from documents.signals.handlers import add_inbox_tags
|
from documents.signals.handlers import add_inbox_tags
|
||||||
from documents.signals.handlers import add_or_update_document_in_llm_index
|
from documents.signals.handlers import add_or_update_document_in_llm_index
|
||||||
from documents.signals.handlers import add_to_index
|
from documents.signals.handlers import add_to_index
|
||||||
|
from documents.signals.handlers import capture_old_document_type
|
||||||
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 run_zone_ocr_extraction
|
||||||
|
from documents.signals.handlers import run_zone_ocr_on_type_change
|
||||||
from documents.signals.handlers import send_websocket_document_updated
|
from documents.signals.handlers import send_websocket_document_updated
|
||||||
from documents.signals.handlers import set_correspondent
|
from documents.signals.handlers import set_correspondent
|
||||||
from documents.signals.handlers import set_document_type
|
from documents.signals.handlers import set_document_type
|
||||||
@@ -28,8 +31,17 @@ class DocumentsConfig(AppConfig):
|
|||||||
document_consumption_finished.connect(set_storage_path)
|
document_consumption_finished.connect(set_storage_path)
|
||||||
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_consumption_finished.connect(add_to_index)
|
|
||||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||||
|
document_consumption_finished.connect(run_zone_ocr_extraction)
|
||||||
|
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.db.models.signals import pre_save
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
pre_save.connect(capture_old_document_type, sender=Document)
|
||||||
|
post_save.connect(run_zone_ocr_on_type_change, sender=Document)
|
||||||
|
|
||||||
document_updated.connect(run_workflows_updated)
|
document_updated.connect(run_workflows_updated)
|
||||||
document_updated.connect(send_websocket_document_updated)
|
document_updated.connect(send_websocket_document_updated)
|
||||||
document_updated.connect(add_or_update_document_in_llm_index)
|
document_updated.connect(add_or_update_document_in_llm_index)
|
||||||
|
|||||||
+39
-47
@@ -28,7 +28,7 @@ from documents.models import DocumentType
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
@@ -299,55 +299,53 @@ def modify_custom_fields(
|
|||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
# Ensure add_custom_fields is a list of (int, value) tuples, supports old API
|
# Ensure add_custom_fields is a list of tuples, supports old API
|
||||||
add_custom_fields = (
|
add_custom_fields = (
|
||||||
[(int(field), value) for field, value in add_custom_fields.items()]
|
add_custom_fields.items()
|
||||||
if isinstance(add_custom_fields, dict)
|
if isinstance(add_custom_fields, dict)
|
||||||
else [(int(field), None) for field in add_custom_fields]
|
else [(field, None) for field in add_custom_fields]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resolved once, instead of re-querying the same field for every document
|
custom_fields = CustomField.objects.filter(
|
||||||
custom_fields_by_id: dict[int, CustomField] = CustomField.objects.in_bulk(
|
id__in=[int(field) for field, _ in add_custom_fields],
|
||||||
[field_id for field_id, _ in add_custom_fields],
|
).distinct()
|
||||||
)
|
|
||||||
# Passed to update_or_create() below rather than a bare id, so the FK is
|
|
||||||
# cached on the created instance and auditlog's post_save receiver does
|
|
||||||
# not reload it per row. Only needed for additions. content is deferred:
|
|
||||||
# the one field here that is both large and unused.
|
|
||||||
docs_by_id: dict[int, Document] = (
|
|
||||||
Document.objects.defer("content").in_bulk(affected_docs)
|
|
||||||
if add_custom_fields
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
for field_id, value in add_custom_fields:
|
for field_id, value in add_custom_fields:
|
||||||
custom_field = custom_fields_by_id[field_id]
|
|
||||||
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
|
||||||
custom_field.data_type
|
|
||||||
]
|
|
||||||
is_doclink = custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
|
||||||
for doc_id in affected_docs:
|
for doc_id in affected_docs:
|
||||||
if is_doclink and value and doc_id in value:
|
defaults = {}
|
||||||
# Prevent self-linking
|
custom_field = custom_fields.get(id=field_id)
|
||||||
continue
|
if custom_field:
|
||||||
|
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
||||||
|
custom_field.data_type
|
||||||
|
]
|
||||||
|
defaults[value_field] = value
|
||||||
|
if (
|
||||||
|
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
||||||
|
and value
|
||||||
|
and doc_id in value
|
||||||
|
):
|
||||||
|
# Prevent self-linking
|
||||||
|
continue
|
||||||
CustomFieldInstance.objects.update_or_create(
|
CustomFieldInstance.objects.update_or_create(
|
||||||
document=docs_by_id[doc_id],
|
document_id=doc_id,
|
||||||
field=custom_field,
|
field_id=field_id,
|
||||||
defaults={value_field: value},
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
if is_doclink:
|
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||||
reflect_doclinks(docs_by_id[doc_id], custom_field, value)
|
doc = Document.objects.get(id=doc_id)
|
||||||
|
reflect_doclinks(doc, custom_field, value)
|
||||||
|
|
||||||
# For doc link fields that are being removed, remove symmetrical links.
|
# For doc link fields that are being removed, remove symmetrical links
|
||||||
# select_related avoids a per-instance reload of the document and field.
|
|
||||||
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
||||||
document_id__in=affected_docs,
|
document_id__in=affected_docs,
|
||||||
field__id__in=remove_custom_fields,
|
field__id__in=remove_custom_fields,
|
||||||
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
value_document_ids__isnull=False,
|
value_document_ids__isnull=False,
|
||||||
).select_related("field", "document"):
|
):
|
||||||
for target_doc_id in doclink_being_removed_instance.value:
|
for target_doc_id in doclink_being_removed_instance.value:
|
||||||
remove_doclink(
|
remove_doclink(
|
||||||
document=doclink_being_removed_instance.document,
|
document=Document.objects.get(
|
||||||
|
id=doclink_being_removed_instance.document.id,
|
||||||
|
),
|
||||||
field=doclink_being_removed_instance.field,
|
field=doclink_being_removed_instance.field,
|
||||||
target_doc_id=target_doc_id,
|
target_doc_id=target_doc_id,
|
||||||
)
|
)
|
||||||
@@ -433,13 +431,10 @@ def set_permissions(
|
|||||||
else:
|
else:
|
||||||
qs.update(owner=owner)
|
qs.update(owner=owner)
|
||||||
|
|
||||||
|
for doc in qs:
|
||||||
|
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
|
||||||
|
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
set_permissions_for_objects(
|
|
||||||
permissions=set_permissions,
|
|
||||||
model=Document,
|
|
||||||
pks=affected_docs,
|
|
||||||
merge=merge,
|
|
||||||
)
|
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
bulk_update_documents.apply_async(
|
||||||
kwargs={"document_ids": affected_docs},
|
kwargs={"document_ids": affected_docs},
|
||||||
@@ -1183,13 +1178,10 @@ def remove_doclink(
|
|||||||
"""
|
"""
|
||||||
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
||||||
"""
|
"""
|
||||||
# select_related: a signal receiver (auditlog) touches .document/.field on
|
target_doc_field_instance = CustomFieldInstance.objects.filter(
|
||||||
# the save() below, without this that is a per-call reload query
|
document_id=target_doc_id,
|
||||||
target_doc_field_instance = (
|
field=field,
|
||||||
CustomFieldInstance.objects.filter(document_id=target_doc_id, field=field)
|
).first()
|
||||||
.select_related("document", "field")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
target_doc_field_instance is not None
|
target_doc_field_instance is not None
|
||||||
and document.id in target_doc_field_instance.value
|
and document.id in target_doc_field_instance.value
|
||||||
|
|||||||
+28
-66
@@ -34,27 +34,6 @@ from paperless.signed_pickle import signed_pickle_loads
|
|||||||
|
|
||||||
logger = logging.getLogger("paperless.classifier")
|
logger = logging.getLogger("paperless.classifier")
|
||||||
|
|
||||||
|
|
||||||
def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
|
|
||||||
"""
|
|
||||||
Return the predicted class id, or None if:
|
|
||||||
- the prediction is -1 (no match), or
|
|
||||||
- the winning class probability is below the configured threshold.
|
|
||||||
|
|
||||||
Using predict_proba() instead of predict() lets us apply a minimum-confidence
|
|
||||||
cutoff so that uncertain predictions are discarded rather than assigned.
|
|
||||||
"""
|
|
||||||
probas = classifier.predict_proba(X)[0]
|
|
||||||
best_idx = int(probas.argmax())
|
|
||||||
best_class = int(classifier.classes_[best_idx])
|
|
||||||
|
|
||||||
if best_class == -1:
|
|
||||||
return None
|
|
||||||
if threshold > 0.0 and probas[best_idx] < threshold:
|
|
||||||
return None
|
|
||||||
return best_class
|
|
||||||
|
|
||||||
|
|
||||||
ADVANCED_TEXT_PROCESSING_ENABLED = (
|
ADVANCED_TEXT_PROCESSING_ENABLED = (
|
||||||
settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED
|
settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED
|
||||||
)
|
)
|
||||||
@@ -123,8 +102,7 @@ class DocumentClassifier:
|
|||||||
# v8 - Added storage path classifier
|
# v8 - Added storage path classifier
|
||||||
# v9 - Changed from hashing to time/ids for re-train check
|
# v9 - Changed from hashing to time/ids for re-train check
|
||||||
# v10 - HMAC-signed model file
|
# v10 - HMAC-signed model file
|
||||||
# v11 - Use sample_weight for balanced training; predict_proba with threshold
|
FORMAT_VERSION = 10
|
||||||
FORMAT_VERSION = 11
|
|
||||||
|
|
||||||
HMAC_SIZE = 32 # SHA-256 digest length
|
HMAC_SIZE = 32 # SHA-256 digest length
|
||||||
|
|
||||||
@@ -346,13 +324,6 @@ class DocumentClassifier:
|
|||||||
from sklearn.preprocessing import LabelBinarizer
|
from sklearn.preprocessing import LabelBinarizer
|
||||||
from sklearn.preprocessing import MultiLabelBinarizer
|
from sklearn.preprocessing import MultiLabelBinarizer
|
||||||
|
|
||||||
# MLPClassifier does not support class_weight directly
|
|
||||||
# (https://github.com/scikit-learn/scikit-learn/issues/9113), so we use
|
|
||||||
# compute_sample_weight to balance classes during training and prevent
|
|
||||||
# over-represented correspondents from dominating predictions.
|
|
||||||
# https://scikit-learn.org/stable/modules/generated/sklearn.utils.class_weight.compute_sample_weight.html
|
|
||||||
from sklearn.utils.class_weight import compute_sample_weight
|
|
||||||
|
|
||||||
# Step 2: vectorize data
|
# Step 2: vectorize data
|
||||||
logger.debug("Vectorizing data...")
|
logger.debug("Vectorizing data...")
|
||||||
notify("Vectorizing document content...")
|
notify("Vectorizing document content...")
|
||||||
@@ -398,7 +369,7 @@ class DocumentClassifier:
|
|||||||
self.tags_binarizer = MultiLabelBinarizer()
|
self.tags_binarizer = MultiLabelBinarizer()
|
||||||
labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags)
|
labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags)
|
||||||
|
|
||||||
self.tags_classifier = MLPClassifier(tol=0.01, random_state=0)
|
self.tags_classifier = MLPClassifier(tol=0.01)
|
||||||
self.tags_classifier.fit(data_vectorized, labels_tags_vectorized)
|
self.tags_classifier.fit(data_vectorized, labels_tags_vectorized)
|
||||||
else:
|
else:
|
||||||
self.tags_classifier = None
|
self.tags_classifier = None
|
||||||
@@ -409,12 +380,8 @@ class DocumentClassifier:
|
|||||||
notify(
|
notify(
|
||||||
f"Training correspondent classifier ({num_correspondents} correspondent(s))...",
|
f"Training correspondent classifier ({num_correspondents} correspondent(s))...",
|
||||||
)
|
)
|
||||||
self.correspondent_classifier = MLPClassifier(tol=0.01, random_state=0)
|
self.correspondent_classifier = MLPClassifier(tol=0.01)
|
||||||
self.correspondent_classifier.fit(
|
self.correspondent_classifier.fit(data_vectorized, labels_correspondent)
|
||||||
data_vectorized,
|
|
||||||
labels_correspondent,
|
|
||||||
sample_weight=compute_sample_weight("balanced", labels_correspondent),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.correspondent_classifier = None
|
self.correspondent_classifier = None
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -426,12 +393,8 @@ class DocumentClassifier:
|
|||||||
notify(
|
notify(
|
||||||
f"Training document type classifier ({num_document_types} type(s))...",
|
f"Training document type classifier ({num_document_types} type(s))...",
|
||||||
)
|
)
|
||||||
self.document_type_classifier = MLPClassifier(tol=0.01, random_state=0)
|
self.document_type_classifier = MLPClassifier(tol=0.01)
|
||||||
self.document_type_classifier.fit(
|
self.document_type_classifier.fit(data_vectorized, labels_document_type)
|
||||||
data_vectorized,
|
|
||||||
labels_document_type,
|
|
||||||
sample_weight=compute_sample_weight("balanced", labels_document_type),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.document_type_classifier = None
|
self.document_type_classifier = None
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -443,11 +406,10 @@ class DocumentClassifier:
|
|||||||
"Training storage paths classifier...",
|
"Training storage paths classifier...",
|
||||||
)
|
)
|
||||||
notify(f"Training storage path classifier ({num_storage_paths} path(s))...")
|
notify(f"Training storage path classifier ({num_storage_paths} path(s))...")
|
||||||
self.storage_path_classifier = MLPClassifier(tol=0.01, random_state=0)
|
self.storage_path_classifier = MLPClassifier(tol=0.01)
|
||||||
self.storage_path_classifier.fit(
|
self.storage_path_classifier.fit(
|
||||||
data_vectorized,
|
data_vectorized,
|
||||||
labels_storage_path,
|
labels_storage_path,
|
||||||
sample_weight=compute_sample_weight("balanced", labels_storage_path),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.storage_path_classifier = None
|
self.storage_path_classifier = None
|
||||||
@@ -584,24 +546,24 @@ class DocumentClassifier:
|
|||||||
def predict_correspondent(self, content: str) -> int | None:
|
def predict_correspondent(self, content: str) -> int | None:
|
||||||
if self.correspondent_classifier:
|
if self.correspondent_classifier:
|
||||||
X = self._vectorize(content)
|
X = self._vectorize(content)
|
||||||
predicted_id = _predict_with_threshold(
|
correspondent_id = self.correspondent_classifier.predict(X)
|
||||||
self.correspondent_classifier,
|
if correspondent_id != -1:
|
||||||
X,
|
return correspondent_id
|
||||||
settings.CLASSIFIER_MATCH_THRESHOLD,
|
else:
|
||||||
)
|
return None
|
||||||
return predicted_id
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def predict_document_type(self, content: str) -> int | None:
|
def predict_document_type(self, content: str) -> int | None:
|
||||||
if self.document_type_classifier:
|
if self.document_type_classifier:
|
||||||
X = self._vectorize(content)
|
X = self._vectorize(content)
|
||||||
predicted_id = _predict_with_threshold(
|
document_type_id = self.document_type_classifier.predict(X)
|
||||||
self.document_type_classifier,
|
if document_type_id != -1:
|
||||||
X,
|
return document_type_id
|
||||||
settings.CLASSIFIER_MATCH_THRESHOLD,
|
else:
|
||||||
)
|
return None
|
||||||
return predicted_id
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def predict_tags(self, content: str) -> list[int]:
|
def predict_tags(self, content: str) -> list[int]:
|
||||||
from sklearn.utils.multiclass import type_of_target
|
from sklearn.utils.multiclass import type_of_target
|
||||||
@@ -627,10 +589,10 @@ class DocumentClassifier:
|
|||||||
def predict_storage_path(self, content: str) -> int | None:
|
def predict_storage_path(self, content: str) -> int | None:
|
||||||
if self.storage_path_classifier:
|
if self.storage_path_classifier:
|
||||||
X = self._vectorize(content)
|
X = self._vectorize(content)
|
||||||
predicted_id = _predict_with_threshold(
|
storage_path_id = self.storage_path_classifier.predict(X)
|
||||||
self.storage_path_classifier,
|
if storage_path_id != -1:
|
||||||
X,
|
return storage_path_id
|
||||||
settings.CLASSIFIER_MATCH_THRESHOLD,
|
else:
|
||||||
)
|
return None
|
||||||
return predicted_id
|
else:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-06-16 17:36
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
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", "0021_widen_workflow_integer_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="OcrTemplate",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=128, verbose_name="name")),
|
||||||
|
(
|
||||||
|
"source_width",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
help_text="Width of the image the zones were drawn on (px)",
|
||||||
|
validators=[django.core.validators.MinValueValidator(1)],
|
||||||
|
verbose_name="source width",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"source_height",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
help_text="Height of the image the zones were drawn on (px)",
|
||||||
|
validators=[django.core.validators.MinValueValidator(1)],
|
||||||
|
verbose_name="source height",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("enabled", models.BooleanField(default=True, verbose_name="enabled")),
|
||||||
|
(
|
||||||
|
"combine_formats",
|
||||||
|
models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=dict,
|
||||||
|
help_text="Per-target format strings for combining several zones into one field, keyed by target (custom field id, or 'title'/'asn'/'created'). Tokens like {Zone Name} are replaced with that zone's value.",
|
||||||
|
verbose_name="combine formats",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
models.DateTimeField(
|
||||||
|
db_index=True,
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="updated"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"document_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ocr_templates",
|
||||||
|
to="documents.documenttype",
|
||||||
|
verbose_name="document type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sample_document",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Document used for previewing zones in the editor",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
to="documents.document",
|
||||||
|
verbose_name="sample document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "OCR template",
|
||||||
|
"verbose_name_plural": "OCR templates",
|
||||||
|
"ordering": ("name",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="OcrTemplateZone",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="Descriptive name for this zone (e.g. 'Invoice Number')",
|
||||||
|
max_length=128,
|
||||||
|
verbose_name="zone name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("custom_field", "Custom field"),
|
||||||
|
("title", "Title"),
|
||||||
|
("asn", "Archive serial number"),
|
||||||
|
("created", "Date created"),
|
||||||
|
],
|
||||||
|
default="custom_field",
|
||||||
|
help_text="Where the extracted value is written: a custom field, or a built-in document field (title, ASN, created date)",
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="target",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"page",
|
||||||
|
models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Page (1 = first, -1 = last; blank uses the template default)",
|
||||||
|
null=True,
|
||||||
|
verbose_name="page",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"x",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
help_text="Left edge (px)",
|
||||||
|
verbose_name="x",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"y",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
help_text="Top edge (px)",
|
||||||
|
verbose_name="y",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"width",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
help_text="Zone width (px)",
|
||||||
|
validators=[django.core.validators.MinValueValidator(1)],
|
||||||
|
verbose_name="width",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"height",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
help_text="Zone height (px)",
|
||||||
|
validators=[django.core.validators.MinValueValidator(1)],
|
||||||
|
verbose_name="height",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"zone_source_width",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Width of the page image this zone was drawn on (px). Falls back to template source_width if unset.",
|
||||||
|
null=True,
|
||||||
|
verbose_name="zone source width",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"zone_source_height",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Height of the page image this zone was drawn on (px). Falls back to template source_height if unset.",
|
||||||
|
null=True,
|
||||||
|
verbose_name="zone source height",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ocr_language",
|
||||||
|
models.CharField(
|
||||||
|
default="deu+eng",
|
||||||
|
help_text="Tesseract language code(s), e.g. 'deu+eng'",
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="OCR language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"transform",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("none", "None"),
|
||||||
|
("strip", "Strip whitespace"),
|
||||||
|
("uppercase", "Uppercase"),
|
||||||
|
("lowercase", "Lowercase"),
|
||||||
|
("numeric", "Numeric only"),
|
||||||
|
(
|
||||||
|
"strip_punctuation",
|
||||||
|
"Remove leading/trailing punctuation",
|
||||||
|
),
|
||||||
|
("date", "Parse date"),
|
||||||
|
("qr_code", "Read QR/barcode"),
|
||||||
|
],
|
||||||
|
default="strip",
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="transform",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"date_format",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Python strptime format for the 'Parse date' transform (e.g. %d.%m.%Y). Blank = auto-detect.",
|
||||||
|
max_length=64,
|
||||||
|
verbose_name="date format",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"validation_regex",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Optional regex pattern — extracted text is only accepted if it matches",
|
||||||
|
max_length=256,
|
||||||
|
verbose_name="validation regex",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("order", models.PositiveIntegerField(default=0, verbose_name="order")),
|
||||||
|
(
|
||||||
|
"custom_field",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Target custom field (only used when target is 'custom_field')",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ocr_zones",
|
||||||
|
to="documents.customfield",
|
||||||
|
verbose_name="custom field",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"template",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="zones",
|
||||||
|
to="documents.ocrtemplate",
|
||||||
|
verbose_name="template",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "OCR template zone",
|
||||||
|
"verbose_name_plural": "OCR template zones",
|
||||||
|
"ordering": ("template", "order"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
+245
-14
@@ -375,7 +375,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
If the queryset already annotated ``effective_content``, that value is used.
|
If the queryset already annotated ``effective_content``, that value is used.
|
||||||
"""
|
"""
|
||||||
# Here to avoid circular import
|
# Here to avoid circular import
|
||||||
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
|
|
||||||
@@ -385,19 +384,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
if self.root_document_id is not None or self.pk is None:
|
if self.root_document_id is not None or self.pk is None:
|
||||||
return self.content
|
return self.content
|
||||||
|
|
||||||
latest_version_prefetch = getattr(
|
|
||||||
self,
|
|
||||||
LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if latest_version_prefetch is not None:
|
|
||||||
# Empty list means prefetch ran and found no versions — use own content.
|
|
||||||
return (
|
|
||||||
latest_version_prefetch[0].content
|
|
||||||
if latest_version_prefetch
|
|
||||||
else self.content
|
|
||||||
)
|
|
||||||
|
|
||||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
||||||
prefetched_versions = (
|
prefetched_versions = (
|
||||||
prefetched_cache.get("versions")
|
prefetched_cache.get("versions")
|
||||||
@@ -2047,3 +2033,248 @@ class WorkflowRun(SoftDeleteModel):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"WorkflowRun of {self.workflow} at {self.run_at} on {self.document}"
|
return f"WorkflowRun of {self.workflow} at {self.run_at} on {self.document}"
|
||||||
|
|
||||||
|
|
||||||
|
class OcrTemplate(models.Model):
|
||||||
|
"""
|
||||||
|
Defines a set of OCR extraction zones for a specific document type.
|
||||||
|
|
||||||
|
When a document of that type is consumed, each zone in the template is
|
||||||
|
cropped from the document image and OCR'd separately. The extracted text
|
||||||
|
is written to the configured custom field or built-in document field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
_("name"),
|
||||||
|
max_length=128,
|
||||||
|
)
|
||||||
|
|
||||||
|
document_type = models.ForeignKey(
|
||||||
|
"documents.DocumentType",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="ocr_templates",
|
||||||
|
verbose_name=_("document type"),
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
source_width = models.PositiveIntegerField(
|
||||||
|
_("source width"),
|
||||||
|
validators=[MinValueValidator(1)],
|
||||||
|
help_text=_("Width of the image the zones were drawn on (px)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
source_height = models.PositiveIntegerField(
|
||||||
|
_("source height"),
|
||||||
|
validators=[MinValueValidator(1)],
|
||||||
|
help_text=_("Height of the image the zones were drawn on (px)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
sample_document = models.ForeignKey(
|
||||||
|
"documents.Document",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="+",
|
||||||
|
verbose_name=_("sample document"),
|
||||||
|
help_text=_("Document used for previewing zones in the editor"),
|
||||||
|
)
|
||||||
|
|
||||||
|
enabled = models.BooleanField(_("enabled"), default=True)
|
||||||
|
|
||||||
|
combine_formats = models.JSONField(
|
||||||
|
_("combine formats"),
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text=_(
|
||||||
|
"Per-target format strings for combining several zones into one "
|
||||||
|
"field, keyed by target (custom field id, or 'title'/'asn'/'created'). "
|
||||||
|
"Tokens like {Zone Name} are replaced with that zone's value.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
created = models.DateTimeField(
|
||||||
|
_("created"),
|
||||||
|
default=timezone.now,
|
||||||
|
db_index=True,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = models.DateTimeField(
|
||||||
|
_("updated"),
|
||||||
|
auto_now=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("name",)
|
||||||
|
verbose_name = _("OCR template")
|
||||||
|
verbose_name_plural = _("OCR templates")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name} ({self.document_type})"
|
||||||
|
|
||||||
|
|
||||||
|
class OcrTemplateZone(models.Model):
|
||||||
|
"""
|
||||||
|
A rectangular region within a document page to OCR and extract into a custom
|
||||||
|
field or built-in document field. Coordinates are relative to the source
|
||||||
|
image dimensions stored on the template.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = models.ForeignKey(
|
||||||
|
OcrTemplate,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="zones",
|
||||||
|
verbose_name=_("template"),
|
||||||
|
)
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
_("zone name"),
|
||||||
|
max_length=128,
|
||||||
|
help_text=_("Descriptive name for this zone (e.g. 'Invoice Number')"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class TargetType(models.TextChoices):
|
||||||
|
CUSTOM_FIELD = ("custom_field", _("Custom field"))
|
||||||
|
TITLE = ("title", _("Title"))
|
||||||
|
ASN = ("asn", _("Archive serial number"))
|
||||||
|
CREATED = ("created", _("Date created"))
|
||||||
|
|
||||||
|
target = models.CharField(
|
||||||
|
_("target"),
|
||||||
|
max_length=20,
|
||||||
|
choices=TargetType.choices,
|
||||||
|
default=TargetType.CUSTOM_FIELD,
|
||||||
|
help_text=_(
|
||||||
|
"Where the extracted value is written: a custom field, or a "
|
||||||
|
"built-in document field (title, ASN, created date)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_field = models.ForeignKey(
|
||||||
|
"documents.CustomField",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="ocr_zones",
|
||||||
|
verbose_name=_("custom field"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Target custom field (only used when target is 'custom_field')"),
|
||||||
|
)
|
||||||
|
|
||||||
|
page = models.IntegerField(
|
||||||
|
_("page"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Page (1 = first, -1 = last; blank uses the template default)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
x = models.PositiveIntegerField(_("x"), help_text=_("Left edge (px)"))
|
||||||
|
y = models.PositiveIntegerField(_("y"), help_text=_("Top edge (px)"))
|
||||||
|
width = models.PositiveIntegerField(
|
||||||
|
_("width"),
|
||||||
|
validators=[MinValueValidator(1)],
|
||||||
|
help_text=_("Zone width (px)"),
|
||||||
|
)
|
||||||
|
height = models.PositiveIntegerField(
|
||||||
|
_("height"),
|
||||||
|
validators=[MinValueValidator(1)],
|
||||||
|
help_text=_("Zone height (px)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-zone source dimensions for coordinate scaling.
|
||||||
|
# Stored from the page image the zone was drawn on.
|
||||||
|
# If null, falls back to the template's source_width/source_height.
|
||||||
|
# This handles PDFs with mixed page sizes (e.g. landscape + portrait,
|
||||||
|
# or different paper formats across pages).
|
||||||
|
zone_source_width = models.PositiveIntegerField(
|
||||||
|
_("zone source width"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_(
|
||||||
|
"Width of the page image this zone was drawn on (px). "
|
||||||
|
"Falls back to template source_width if unset.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
zone_source_height = models.PositiveIntegerField(
|
||||||
|
_("zone source height"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_(
|
||||||
|
"Height of the page image this zone was drawn on (px). "
|
||||||
|
"Falls back to template source_height if unset.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ocr_language = models.CharField(
|
||||||
|
_("OCR language"),
|
||||||
|
max_length=20,
|
||||||
|
default="deu+eng",
|
||||||
|
help_text=_("Tesseract language code(s), e.g. 'deu+eng'"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class TransformType(models.TextChoices):
|
||||||
|
NONE = ("none", _("None"))
|
||||||
|
STRIP = ("strip", _("Strip whitespace"))
|
||||||
|
UPPERCASE = ("uppercase", _("Uppercase"))
|
||||||
|
LOWERCASE = ("lowercase", _("Lowercase"))
|
||||||
|
NUMERIC = ("numeric", _("Numeric only"))
|
||||||
|
STRIP_PUNCTUATION = (
|
||||||
|
"strip_punctuation",
|
||||||
|
_("Remove leading/trailing punctuation"),
|
||||||
|
)
|
||||||
|
DATE = ("date", _("Parse date"))
|
||||||
|
QR_CODE = ("qr_code", _("Read QR/barcode"))
|
||||||
|
|
||||||
|
transform = models.CharField(
|
||||||
|
_("transform"),
|
||||||
|
max_length=20,
|
||||||
|
choices=TransformType.choices,
|
||||||
|
default=TransformType.STRIP,
|
||||||
|
)
|
||||||
|
|
||||||
|
date_format = models.CharField(
|
||||||
|
_("date format"),
|
||||||
|
max_length=64,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text=_(
|
||||||
|
"Python strptime format for the 'Parse date' transform "
|
||||||
|
"(e.g. %d.%m.%Y). Blank = auto-detect.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
validation_regex = models.CharField(
|
||||||
|
_("validation regex"),
|
||||||
|
max_length=256,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text=_(
|
||||||
|
"Optional regex pattern — extracted text is only accepted if it matches",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.PositiveIntegerField(_("order"), default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("template", "order")
|
||||||
|
verbose_name = _("OCR template zone")
|
||||||
|
verbose_name_plural = _("OCR template zones")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.template.name} -> {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
OCR_SUPPORTED_FIELD_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
CustomField.FieldDataType.STRING,
|
||||||
|
CustomField.FieldDataType.URL,
|
||||||
|
CustomField.FieldDataType.DATE,
|
||||||
|
CustomField.FieldDataType.INT,
|
||||||
|
CustomField.FieldDataType.FLOAT,
|
||||||
|
CustomField.FieldDataType.MONETARY,
|
||||||
|
CustomField.FieldDataType.LONG_TEXT,
|
||||||
|
CustomField.FieldDataType.BOOL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -173,179 +173,6 @@ def set_permissions_for_object(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
|
||||||
"""
|
|
||||||
Resolves `codenames` to Permission rows, raising like the single-object
|
|
||||||
assign_perm() this bulk path replaces does (via a `.get()` internally)
|
|
||||||
if any codename doesn't exist -- e.g. a client-supplied action name that
|
|
||||||
was never validated (BulkEditObjectsSerializer._validate_permissions
|
|
||||||
calls validate_set_permissions() only for its side-effecting id checks
|
|
||||||
and discards the filtered dict it returns, so an unrecognized action key
|
|
||||||
reaches this function as-is). A plain `.filter()` with no existence
|
|
||||||
check would otherwise silently build zero rows and no-op instead of
|
|
||||||
reporting the bad input.
|
|
||||||
"""
|
|
||||||
permission_objs = list(
|
|
||||||
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
|
||||||
)
|
|
||||||
missing = codenames - {p.codename for p in permission_objs}
|
|
||||||
if missing:
|
|
||||||
raise Permission.DoesNotExist(
|
|
||||||
f"Permission matching query does not exist for codename(s): "
|
|
||||||
f"{', '.join(sorted(missing))}",
|
|
||||||
)
|
|
||||||
return permission_objs
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_bulk_permission_entry(
|
|
||||||
*,
|
|
||||||
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
|
||||||
identity_model: type[User] | type[Group],
|
|
||||||
identity_field: str,
|
|
||||||
ids: list[int],
|
|
||||||
codename: str,
|
|
||||||
permission_objs: list[Permission],
|
|
||||||
ctype: ContentType,
|
|
||||||
object_pks: list[str],
|
|
||||||
merge: bool,
|
|
||||||
) -> None:
|
|
||||||
# Only the ids are needed to build permission rows (via `<field>_id=`),
|
|
||||||
# so avoid fetching full User/Group rows for identities that may not
|
|
||||||
# even end up being granted anything new.
|
|
||||||
add_ids = set(
|
|
||||||
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not merge:
|
|
||||||
existing_ids = set(
|
|
||||||
perm_model.objects.filter(
|
|
||||||
content_type=ctype,
|
|
||||||
object_pk__in=object_pks,
|
|
||||||
permission__codename=codename,
|
|
||||||
)
|
|
||||||
.values_list(f"{identity_field}_id", flat=True)
|
|
||||||
.distinct(),
|
|
||||||
)
|
|
||||||
remove_ids = existing_ids - add_ids
|
|
||||||
if remove_ids:
|
|
||||||
perm_model.objects.filter(
|
|
||||||
content_type=ctype,
|
|
||||||
object_pk__in=object_pks,
|
|
||||||
permission__codename=codename,
|
|
||||||
**{f"{identity_field}_id__in": remove_ids},
|
|
||||||
).delete()
|
|
||||||
|
|
||||||
if not add_ids:
|
|
||||||
return
|
|
||||||
|
|
||||||
rows = [
|
|
||||||
perm_model(
|
|
||||||
content_type=ctype,
|
|
||||||
object_pk=pk,
|
|
||||||
permission=permission_obj,
|
|
||||||
**{f"{identity_field}_id": identity_id},
|
|
||||||
)
|
|
||||||
for permission_obj in permission_objs
|
|
||||||
for pk in object_pks
|
|
||||||
for identity_id in add_ids
|
|
||||||
]
|
|
||||||
# ignore_conflicts skips only rows that already exist as an exact
|
|
||||||
# (identity, permission, object) match -- the same de-dup the
|
|
||||||
# underlying (user|group, permission, object_pk) unique constraint
|
|
||||||
# already enforces for the single-object assign_perm() this replaces,
|
|
||||||
# so it doesn't change what counts as "already granted". batch_size
|
|
||||||
# caps how many rows go into a single INSERT statement.
|
|
||||||
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
|
||||||
|
|
||||||
|
|
||||||
def set_permissions_for_objects(
|
|
||||||
permissions: dict,
|
|
||||||
model: type[Model],
|
|
||||||
pks: QuerySet | list,
|
|
||||||
*,
|
|
||||||
merge: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Bulk equivalent of set_permissions_for_object: applies the same
|
|
||||||
permission changes to every object identified by `pks` at once.
|
|
||||||
|
|
||||||
Takes a model + pks (rather than model instances) deliberately -- the
|
|
||||||
permission rows built below only ever need `pk`, `content_type`, and
|
|
||||||
identity ids, so callers shouldn't have to fetch full rows (with every
|
|
||||||
other field) just to hand them to this function.
|
|
||||||
|
|
||||||
Deliberately does not use guardian's queryset/list-aware assign_perm:
|
|
||||||
passing a list as the object routes to bulk_assign_perm, which skips
|
|
||||||
creating a direct permission row for anyone who already has the
|
|
||||||
permission via ANY group membership (it checks
|
|
||||||
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
|
|
||||||
unlike the single-object assign_perm this replaces, which always
|
|
||||||
ensures a direct row via get_or_create regardless of group-derived
|
|
||||||
access. Losing that guarantee would mean a later revocation of the
|
|
||||||
group's grant silently strips access an admin explicitly asked to be
|
|
||||||
direct. Bulk-creating rows straight against the permission models
|
|
||||||
instead (see _apply_bulk_permission_entry) preserves the original
|
|
||||||
always-create-a-direct-row semantics while still batching every object
|
|
||||||
and every identity into one query per action, rather than one query per
|
|
||||||
(object, user) pair.
|
|
||||||
"""
|
|
||||||
object_pks = [str(pk) for pk in pks]
|
|
||||||
if not object_pks: # pragma: no cover
|
|
||||||
return
|
|
||||||
|
|
||||||
model_name = model.__name__.lower()
|
|
||||||
ctype = ContentType.objects.get_for_model(model)
|
|
||||||
|
|
||||||
# Every action is resolved up front, before anything is written, so an
|
|
||||||
# unrecognized action name (see _resolve_permissions) aborts the whole
|
|
||||||
# call instead of leaving the actions ahead of it already applied --
|
|
||||||
# BulkEditObjectsSerializer lets unknown keys through and its view turns
|
|
||||||
# the exception into a 400, so a half-applied change would otherwise be
|
|
||||||
# reported to the client as a failure.
|
|
||||||
permissions_by_action: dict[str, list[Permission]] = {}
|
|
||||||
for action, entry in permissions.items():
|
|
||||||
if "users" not in entry and "groups" not in entry:
|
|
||||||
continue
|
|
||||||
implied_codenames = {f"{action}_{model_name}"}
|
|
||||||
if action == "change":
|
|
||||||
# change gives view too
|
|
||||||
implied_codenames.add(f"view_{model_name}")
|
|
||||||
permissions_by_action[action] = _resolve_permissions(
|
|
||||||
implied_codenames,
|
|
||||||
ctype,
|
|
||||||
)
|
|
||||||
|
|
||||||
for action, entry in permissions.items():
|
|
||||||
codename = f"{action}_{model_name}"
|
|
||||||
permission_objs = permissions_by_action.get(action, [])
|
|
||||||
|
|
||||||
if "users" in entry:
|
|
||||||
_apply_bulk_permission_entry(
|
|
||||||
perm_model=UserObjectPermission,
|
|
||||||
identity_model=User,
|
|
||||||
identity_field="user",
|
|
||||||
ids=entry["users"],
|
|
||||||
codename=codename,
|
|
||||||
permission_objs=permission_objs,
|
|
||||||
ctype=ctype,
|
|
||||||
object_pks=object_pks,
|
|
||||||
merge=merge,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "groups" in entry:
|
|
||||||
_apply_bulk_permission_entry(
|
|
||||||
perm_model=GroupObjectPermission,
|
|
||||||
identity_model=Group,
|
|
||||||
identity_field="group",
|
|
||||||
ids=entry["groups"],
|
|
||||||
codename=codename,
|
|
||||||
permission_objs=permission_objs,
|
|
||||||
ctype=ctype,
|
|
||||||
object_pks=object_pks,
|
|
||||||
merge=merge,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def permitted_object_ids(
|
def permitted_object_ids(
|
||||||
user: User | None,
|
user: User | None,
|
||||||
model: type[Model],
|
model: type[Model],
|
||||||
|
|||||||
+153
-32
@@ -56,6 +56,7 @@ if settings.AUDIT_LOG_ENABLED:
|
|||||||
from documents import bulk_edit
|
from documents import bulk_edit
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
from documents.filters import CustomFieldQueryParser
|
from documents.filters import CustomFieldQueryParser
|
||||||
|
from documents.models import OCR_SUPPORTED_FIELD_TYPES
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
@@ -63,6 +64,8 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import MatchingModel
|
from documents.models import MatchingModel
|
||||||
from documents.models import Note
|
from documents.models import Note
|
||||||
|
from documents.models import OcrTemplate
|
||||||
|
from documents.models import OcrTemplateZone
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import SavedView
|
from documents.models import SavedView
|
||||||
from documents.models import SavedViewFilterRule
|
from documents.models import SavedViewFilterRule
|
||||||
@@ -89,7 +92,6 @@ from documents.templating.utils import convert_format_str_to_template_format
|
|||||||
from documents.templating.workflows import validate_workflow_template
|
from documents.templating.workflows import validate_workflow_template
|
||||||
from documents.validators import uri_validator
|
from documents.validators import uri_validator
|
||||||
from documents.validators import url_validator
|
from documents.validators import url_validator
|
||||||
from documents.versioning import has_prefetched_effective_content
|
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -1153,14 +1155,8 @@ class DocumentSerializer(
|
|||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
doc = super().to_representation(instance)
|
doc = super().to_representation(instance)
|
||||||
if "content" in self.fields and has_prefetched_effective_content(instance):
|
if "content" in self.fields and hasattr(instance, "effective_content"):
|
||||||
# Only resolve version-aware content when it's cheap: an SQL
|
doc["content"] = getattr(instance, "effective_content") or ""
|
||||||
# annotation or a versions prefetch is already on the instance.
|
|
||||||
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
|
|
||||||
# which build their own querysets) gets the document's own,
|
|
||||||
# unresolved content instead of paying for an extra per-instance
|
|
||||||
# query -- same as before effective_content resolution existed.
|
|
||||||
doc["content"] = instance.get_effective_content() or ""
|
|
||||||
if self.truncate_content and "content" in self.fields:
|
if self.truncate_content and "content" in self.fields:
|
||||||
doc["content"] = doc.get("content")[0:550]
|
doc["content"] = doc.get("content")[0:550]
|
||||||
return doc
|
return doc
|
||||||
@@ -1254,31 +1250,30 @@ class DocumentSerializer(
|
|||||||
|
|
||||||
validated_data["tags"] = list(final_tags)
|
validated_data["tags"] = list(final_tags)
|
||||||
if validated_data.get("remove_inbox_tags"):
|
if validated_data.get("remove_inbox_tags"):
|
||||||
current_tag_ids = {t.pk for t in instance.tags.all()}
|
tag_ids_being_added = (
|
||||||
tags = (
|
[
|
||||||
validated_data["tags"]
|
tag.id
|
||||||
|
for tag in validated_data["tags"]
|
||||||
|
if tag not in instance.tags.all()
|
||||||
|
]
|
||||||
if "tags" in validated_data
|
if "tags" in validated_data
|
||||||
else list(instance.tags.all())
|
else []
|
||||||
)
|
)
|
||||||
|
inbox_tags_not_being_added = Tag.objects.filter(is_inbox_tag=True).exclude(
|
||||||
# Tags newly added in this update, plus their ancestors, are kept
|
id__in=tag_ids_being_added,
|
||||||
keep_ids: set[int] = set()
|
)
|
||||||
for tag in tags:
|
if "tags" in validated_data:
|
||||||
if tag.pk not in current_tag_ids:
|
validated_data["tags"] = [
|
||||||
keep_ids.add(tag.pk)
|
tag
|
||||||
keep_ids.update(int(pk) for pk in tag.get_ancestors_pks())
|
for tag in validated_data["tags"]
|
||||||
|
if tag not in inbox_tags_not_being_added
|
||||||
# Remove inbox tags and their descendants, except those being kept
|
]
|
||||||
remove_ids: set[int] = set()
|
else:
|
||||||
for inbox_tag in (
|
validated_data["tags"] = [
|
||||||
Tag.objects.filter(is_inbox_tag=True)
|
tag
|
||||||
.exclude(pk__in=keep_ids)
|
for tag in instance.tags.all()
|
||||||
.only("pk", "tn_descendants_pks")
|
if tag not in inbox_tags_not_being_added
|
||||||
):
|
]
|
||||||
remove_ids.add(inbox_tag.pk)
|
|
||||||
remove_ids.update(int(pk) for pk in inbox_tag.get_descendants_pks())
|
|
||||||
|
|
||||||
validated_data["tags"] = [t for t in tags if t.pk not in remove_ids]
|
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
with set_actor(self.user):
|
with set_actor(self.user):
|
||||||
@@ -3670,3 +3665,129 @@ class StoragePathTestSerializer(SerializerWithPerms):
|
|||||||
document_field.queryset = Document.objects.filter(
|
document_field.queryset = Document.objects.filter(
|
||||||
id__in=permitted_document_ids(user),
|
id__in=permitted_document_ids(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OcrTemplateZoneSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = OcrTemplateZone
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"target",
|
||||||
|
"custom_field",
|
||||||
|
"page",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"ocr_language",
|
||||||
|
"transform",
|
||||||
|
"date_format",
|
||||||
|
"order",
|
||||||
|
"zone_source_width",
|
||||||
|
"zone_source_height",
|
||||||
|
"validation_regex",
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate_width(self, value):
|
||||||
|
if value < 1:
|
||||||
|
raise serializers.ValidationError("Width must be at least 1.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_height(self, value):
|
||||||
|
if value < 1:
|
||||||
|
raise serializers.ValidationError("Height must be at least 1.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_custom_field(self, value):
|
||||||
|
if value is None:
|
||||||
|
# Built-in target (title/asn/created) — no custom field required.
|
||||||
|
return value
|
||||||
|
if value.data_type not in OCR_SUPPORTED_FIELD_TYPES:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Custom field type '{value.data_type}' is not supported for OCR extraction. "
|
||||||
|
f"Use string, integer, float, date, monetary, boolean, URL, or long text.",
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class OcrTemplateSerializer(serializers.ModelSerializer):
|
||||||
|
zones = OcrTemplateZoneSerializer(many=True, required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OcrTemplate
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"document_type",
|
||||||
|
"source_width",
|
||||||
|
"source_height",
|
||||||
|
"sample_document",
|
||||||
|
"enabled",
|
||||||
|
"combine_formats",
|
||||||
|
"created",
|
||||||
|
"updated",
|
||||||
|
"zones",
|
||||||
|
]
|
||||||
|
read_only_fields = ["created", "updated"]
|
||||||
|
|
||||||
|
def validate_source_width(self, value):
|
||||||
|
if value < 1:
|
||||||
|
raise serializers.ValidationError("Source width must be at least 1.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_source_height(self, value):
|
||||||
|
if value < 1:
|
||||||
|
raise serializers.ValidationError("Source height must be at least 1.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_zones(self, zones_data):
|
||||||
|
"""Validate zone coordinates are within the source dimensions."""
|
||||||
|
# source_width/height may not be in initial_data during partial updates
|
||||||
|
source_width = self.initial_data.get("source_width") or (
|
||||||
|
self.instance.source_width if self.instance else None
|
||||||
|
)
|
||||||
|
source_height = self.initial_data.get("source_height") or (
|
||||||
|
self.instance.source_height if self.instance else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_width and source_height:
|
||||||
|
for zone in zones_data:
|
||||||
|
x = zone.get("x", 0)
|
||||||
|
y = zone.get("y", 0)
|
||||||
|
w = zone.get("width", 0)
|
||||||
|
h = zone.get("height", 0)
|
||||||
|
if x + w > int(source_width):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Zone '{zone.get('name', '?')}' extends beyond source width "
|
||||||
|
f"({x + w} > {source_width}).",
|
||||||
|
)
|
||||||
|
if y + h > int(source_height):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Zone '{zone.get('name', '?')}' extends beyond source height "
|
||||||
|
f"({y + h} > {source_height}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
return zones_data
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
zones_data = validated_data.pop("zones", [])
|
||||||
|
template = OcrTemplate.objects.create(**validated_data)
|
||||||
|
for zone_data in zones_data:
|
||||||
|
OcrTemplateZone.objects.create(template=template, **zone_data)
|
||||||
|
return template
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
zones_data = validated_data.pop("zones", None)
|
||||||
|
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
if zones_data is not None:
|
||||||
|
# Replace all zones with the new set
|
||||||
|
instance.zones.all().delete()
|
||||||
|
for zone_data in zones_data:
|
||||||
|
OcrTemplateZone.objects.create(template=instance, **zone_data)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|||||||
@@ -1398,6 +1398,76 @@ def close_connection_pool_on_worker_init(**kwargs) -> None:
|
|||||||
conn.close_pool()
|
conn.close_pool()
|
||||||
|
|
||||||
|
|
||||||
|
def run_zone_ocr_extraction(sender, document, original_file=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Run zone-based OCR extraction if the document's type has an active template.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from documents.zone_ocr import run_zone_extraction
|
||||||
|
|
||||||
|
run_zone_extraction(document, Path(original_file) if original_file else None)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Zone OCR extraction failed for document %s",
|
||||||
|
document.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def capture_old_document_type(sender, instance, **kwargs):
|
||||||
|
"""pre_save: remember the document's previous type so the post_save handler
|
||||||
|
can tell whether the type actually changed (vs. every other save)."""
|
||||||
|
if instance.pk:
|
||||||
|
instance._old_document_type_id = (
|
||||||
|
Document.objects.filter(pk=instance.pk)
|
||||||
|
.values_list("document_type_id", flat=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
instance._old_document_type_id = None
|
||||||
|
|
||||||
|
|
||||||
|
def run_zone_ocr_on_type_change(sender, instance, *, created=False, **kwargs):
|
||||||
|
"""
|
||||||
|
Run zone OCR only when a document's TYPE actually changes (and the new type
|
||||||
|
has an enabled template). NOT on every save — zone OCR overwrites fields, so
|
||||||
|
re-running it on each edit would clobber the user's changes. Newly created
|
||||||
|
documents are handled by the consumption signal, and the user can always
|
||||||
|
trigger extraction manually via the run-zone-ocr action.
|
||||||
|
"""
|
||||||
|
if created or not instance.pk or not instance.document_type_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only proceed if the type changed compared to what was in the DB before.
|
||||||
|
old_type = getattr(instance, "_old_document_type_id", None)
|
||||||
|
if old_type == instance.document_type_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
from documents.models import OcrTemplate
|
||||||
|
|
||||||
|
if not OcrTemplate.objects.filter(
|
||||||
|
document_type_id=instance.document_type_id,
|
||||||
|
enabled=True,
|
||||||
|
).exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from documents.zone_ocr import run_zone_extraction
|
||||||
|
|
||||||
|
doc_path = instance.archive_path or instance.source_path
|
||||||
|
if doc_path and Path(doc_path).is_file():
|
||||||
|
logger.info(
|
||||||
|
"Zone OCR: running extraction for document %d (type %d)",
|
||||||
|
instance.pk,
|
||||||
|
instance.document_type_id,
|
||||||
|
)
|
||||||
|
run_zone_extraction(instance, None)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Zone OCR extraction failed for document %s",
|
||||||
|
instance.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@worker_process_shutdown.connect
|
@worker_process_shutdown.connect
|
||||||
def close_connection_pool_on_worker_shutdown(**kwargs) -> None: # pragma: no cover
|
def close_connection_pool_on_worker_shutdown(**kwargs) -> None: # pragma: no cover
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -38,42 +38,6 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
def test_answer_is_not_compressed(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A client that accepts compressed responses
|
|
||||||
WHEN:
|
|
||||||
- It asks the chat endpoint a question
|
|
||||||
THEN:
|
|
||||||
- The answer is streamed unencoded, chunk for chunk
|
|
||||||
|
|
||||||
The stream compressors buffer, so a compressed answer arrives in one
|
|
||||||
piece. The view cannot opt out by flagging the request: DRF's request
|
|
||||||
wrapper proxies reads but keeps writes to itself, so the flag never
|
|
||||||
reaches the Django request the middleware sees.
|
|
||||||
"""
|
|
||||||
chunks = [f"token{i} " for i in range(40)]
|
|
||||||
with (
|
|
||||||
mock.patch(
|
|
||||||
"documents.views.AIConfig",
|
|
||||||
return_value=self._mock_ai_enabled(),
|
|
||||||
),
|
|
||||||
mock.patch(
|
|
||||||
"documents.views.stream_chat_with_documents",
|
|
||||||
return_value=iter(chunks),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
resp = self.client.post(
|
|
||||||
"/api/documents/chat/",
|
|
||||||
{"q": "What is in my archive?"},
|
|
||||||
format="json",
|
|
||||||
HTTP_ACCEPT_ENCODING="gzip, deflate, br, zstd",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert not resp.has_header("Content-Encoding")
|
|
||||||
assert list(resp.streaming_content) == [c.encode() for c in chunks]
|
|
||||||
|
|
||||||
def test_missing_question_is_rejected(self) -> None:
|
def test_missing_question_is_rejected(self) -> None:
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"documents.views.AIConfig",
|
"documents.views.AIConfig",
|
||||||
|
|||||||
@@ -2,15 +2,10 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import connection
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
|
||||||
from guardian.shortcuts import get_users_with_perms
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -847,66 +842,6 @@ class TestBulkEditObjects(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(StoragePath.objects.count(), 0)
|
self.assertEqual(StoragePath.objects.count(), 0)
|
||||||
|
|
||||||
def test_bulk_objects_set_permissions_batched_across_object_count(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Many tags are being bulk-edited to set permissions at once
|
|
||||||
WHEN:
|
|
||||||
- bulk_edit_objects API endpoint is called with set_permissions
|
|
||||||
operation over a small batch vs. a much larger one
|
|
||||||
THEN:
|
|
||||||
- Permissions are applied correctly at both scales
|
|
||||||
- Query count does not grow with the number of tags, i.e. each
|
|
||||||
user/group is applied across all tags with one batched call
|
|
||||||
rather than one call per (tag, identity) pair
|
|
||||||
"""
|
|
||||||
group1 = Group.objects.create(name="perm-group")
|
|
||||||
permissions = {
|
|
||||||
"view": {"users": [self.user1.id, self.user2.id], "groups": [group1.id]},
|
|
||||||
"change": {"users": [self.user1.id], "groups": [group1.id]},
|
|
||||||
}
|
|
||||||
|
|
||||||
def run_with_n_tags(n: int) -> int:
|
|
||||||
tags = [Tag.objects.create(name=f"perm-tag-{n}-{i}") for i in range(n)]
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/bulk_edit_objects/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"objects": [t.id for t in tags],
|
|
||||||
"object_type": "tags",
|
|
||||||
"operation": "set_permissions",
|
|
||||||
"permissions": permissions,
|
|
||||||
"merge": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
for tag in tags:
|
|
||||||
self.assertEqual(get_users_with_perms(tag).count(), 2)
|
|
||||||
self.assertEqual(get_groups_with_perms(tag).count(), 1)
|
|
||||||
return len(ctx.captured_queries)
|
|
||||||
|
|
||||||
small_batch_queries = run_with_n_tags(5)
|
|
||||||
large_batch_queries = run_with_n_tags(50)
|
|
||||||
|
|
||||||
# A tolerance rather than equality, matching the N+1 check in
|
|
||||||
# test_views.py: bulk_create's batch_size caps rows per INSERT, so a
|
|
||||||
# large enough selection does legitimately add statements, and the
|
|
||||||
# per-process ContentType cache makes the first run carry an extra
|
|
||||||
# query. Neither can hide a regression to per-object assignment,
|
|
||||||
# which would be ~10x the small-batch count here.
|
|
||||||
self.assertLessEqual(
|
|
||||||
large_batch_queries,
|
|
||||||
small_batch_queries + 5,
|
|
||||||
"Permission assignment appears to scale with object count: "
|
|
||||||
f"{small_batch_queries} queries for 5 tags vs. "
|
|
||||||
f"{large_batch_queries} for 50",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_bulk_objects_delete_all_filtered(self) -> None:
|
def test_bulk_objects_delete_all_filtered(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
"""Tests for the OCR Template API."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
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 DocumentType
|
||||||
|
from documents.models import OcrTemplate
|
||||||
|
from documents.models import OcrTemplateZone
|
||||||
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TestOcrTemplatesAPI(DirectoriesMixin, APITestCase):
|
||||||
|
ENDPOINT = "/api/ocr_templates/"
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = User.objects.create_superuser(username="temp_admin")
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
self.doc_type = DocumentType.objects.create(name="Invoice")
|
||||||
|
self.custom_field_text = CustomField.objects.create(
|
||||||
|
name="Invoice Number",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
self.custom_field_date = CustomField.objects.create(
|
||||||
|
name="Invoice Date",
|
||||||
|
data_type=CustomField.FieldDataType.DATE,
|
||||||
|
)
|
||||||
|
self.custom_field_int = CustomField.objects.create(
|
||||||
|
name="Amount",
|
||||||
|
data_type=CustomField.FieldDataType.INT,
|
||||||
|
)
|
||||||
|
self.custom_field_doclink = CustomField.objects.create(
|
||||||
|
name="Related Docs",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().setUp()
|
||||||
|
|
||||||
|
def _make_template_data(self, **overrides):
|
||||||
|
data = {
|
||||||
|
"name": "Invoice Template",
|
||||||
|
"document_type": self.doc_type.pk,
|
||||||
|
"default_page": 0,
|
||||||
|
"source_width": 2480,
|
||||||
|
"source_height": 3508,
|
||||||
|
"enabled": True,
|
||||||
|
"zones": [],
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _make_zone_data(self, **overrides):
|
||||||
|
data = {
|
||||||
|
"name": "Zone 1",
|
||||||
|
"custom_field": self.custom_field_text.pk,
|
||||||
|
"x": 100,
|
||||||
|
"y": 100,
|
||||||
|
"width": 200,
|
||||||
|
"height": 50,
|
||||||
|
"ocr_language": "deu+eng",
|
||||||
|
"transform": "strip",
|
||||||
|
"order": 0,
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# --- Create ---
|
||||||
|
|
||||||
|
def test_create_template(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document type and custom fields exist
|
||||||
|
WHEN:
|
||||||
|
- API request to create an OCR template with one zone
|
||||||
|
THEN:
|
||||||
|
- The template and zone are created
|
||||||
|
"""
|
||||||
|
data = self._make_template_data(
|
||||||
|
zones=[
|
||||||
|
self._make_zone_data(
|
||||||
|
name="Invoice Number",
|
||||||
|
x=1500,
|
||||||
|
y=200,
|
||||||
|
width=800,
|
||||||
|
height=100,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
result = resp.json()
|
||||||
|
self.assertEqual(result["name"], "Invoice Template")
|
||||||
|
self.assertEqual(result["document_type"], self.doc_type.pk)
|
||||||
|
self.assertEqual(len(result["zones"]), 1)
|
||||||
|
self.assertEqual(result["zones"][0]["name"], "Invoice Number")
|
||||||
|
self.assertEqual(OcrTemplate.objects.count(), 1)
|
||||||
|
self.assertEqual(OcrTemplateZone.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_create_template_multiple_zones(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Multiple custom fields exist
|
||||||
|
WHEN:
|
||||||
|
- A template with multiple zones is created
|
||||||
|
THEN:
|
||||||
|
- All zones are created
|
||||||
|
"""
|
||||||
|
data = self._make_template_data(
|
||||||
|
zones=[
|
||||||
|
self._make_zone_data(
|
||||||
|
name="Invoice Number",
|
||||||
|
custom_field=self.custom_field_text.pk,
|
||||||
|
),
|
||||||
|
self._make_zone_data(
|
||||||
|
name="Invoice Date",
|
||||||
|
custom_field=self.custom_field_date.pk,
|
||||||
|
order=1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(len(resp.json()["zones"]), 2)
|
||||||
|
self.assertEqual(OcrTemplateZone.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_create_template_no_zones(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Valid template data without zones
|
||||||
|
WHEN:
|
||||||
|
- Template is created
|
||||||
|
THEN:
|
||||||
|
- Template is created with no zones
|
||||||
|
"""
|
||||||
|
data = self._make_template_data()
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(len(resp.json()["zones"]), 0)
|
||||||
|
|
||||||
|
# --- Validation ---
|
||||||
|
|
||||||
|
def test_create_template_zero_source_width_rejected(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Template data with source_width=0
|
||||||
|
WHEN:
|
||||||
|
- Create is attempted
|
||||||
|
THEN:
|
||||||
|
- 400 error is returned
|
||||||
|
"""
|
||||||
|
data = self._make_template_data(source_width=0)
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_create_template_zero_source_height_rejected(self):
|
||||||
|
data = self._make_template_data(source_height=0)
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_create_zone_zero_width_rejected(self):
|
||||||
|
data = self._make_template_data(
|
||||||
|
zones=[self._make_zone_data(width=0)],
|
||||||
|
)
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_create_zone_zero_height_rejected(self):
|
||||||
|
data = self._make_template_data(
|
||||||
|
zones=[self._make_zone_data(height=0)],
|
||||||
|
)
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_create_zone_exceeds_source_width_rejected(self):
|
||||||
|
"""Zone that extends beyond the source image width should be rejected."""
|
||||||
|
data = self._make_template_data(
|
||||||
|
source_width=1000,
|
||||||
|
zones=[self._make_zone_data(x=800, width=300)], # 800+300 > 1000
|
||||||
|
)
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_create_zone_exceeds_source_height_rejected(self):
|
||||||
|
data = self._make_template_data(
|
||||||
|
source_height=1000,
|
||||||
|
zones=[self._make_zone_data(y=900, height=200)], # 900+200 > 1000
|
||||||
|
)
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_create_zone_unsupported_custom_field_type_rejected(self):
|
||||||
|
"""DOCUMENTLINK and SELECT fields can't be populated via OCR."""
|
||||||
|
data = self._make_template_data(
|
||||||
|
zones=[self._make_zone_data(custom_field=self.custom_field_doclink.pk)],
|
||||||
|
)
|
||||||
|
resp = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# --- List ---
|
||||||
|
|
||||||
|
def test_list_templates(self):
|
||||||
|
template = OcrTemplate.objects.create(
|
||||||
|
name="Test Template",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
source_width=2480,
|
||||||
|
source_height=3508,
|
||||||
|
)
|
||||||
|
OcrTemplateZone.objects.create(
|
||||||
|
template=template,
|
||||||
|
name="Zone 1",
|
||||||
|
custom_field=self.custom_field_text,
|
||||||
|
x=100,
|
||||||
|
y=100,
|
||||||
|
width=200,
|
||||||
|
height=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = self.client.get(self.ENDPOINT)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data["count"], 1)
|
||||||
|
self.assertEqual(len(data["results"][0]["zones"]), 1)
|
||||||
|
|
||||||
|
def test_list_empty(self):
|
||||||
|
resp = self.client.get(self.ENDPOINT)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(resp.json()["count"], 0)
|
||||||
|
|
||||||
|
# --- Update ---
|
||||||
|
|
||||||
|
def test_update_template_replaces_zones(self):
|
||||||
|
"""PUT should replace all zones with the new set."""
|
||||||
|
template = OcrTemplate.objects.create(
|
||||||
|
name="Old Name",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
source_width=2480,
|
||||||
|
source_height=3508,
|
||||||
|
)
|
||||||
|
OcrTemplateZone.objects.create(
|
||||||
|
template=template,
|
||||||
|
name="Old Zone",
|
||||||
|
custom_field=self.custom_field_text,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
width=100,
|
||||||
|
height=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = self._make_template_data(
|
||||||
|
name="New Name",
|
||||||
|
zones=[
|
||||||
|
self._make_zone_data(
|
||||||
|
name="New Zone",
|
||||||
|
custom_field=self.custom_field_date.pk,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resp = self.client.put(
|
||||||
|
f"{self.ENDPOINT}{template.pk}/",
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
template.refresh_from_db()
|
||||||
|
self.assertEqual(template.name, "New Name")
|
||||||
|
self.assertEqual(OcrTemplateZone.objects.count(), 1)
|
||||||
|
self.assertEqual(OcrTemplateZone.objects.first().name, "New Zone")
|
||||||
|
|
||||||
|
# --- Delete ---
|
||||||
|
|
||||||
|
def test_delete_template_cascades_zones(self):
|
||||||
|
template = OcrTemplate.objects.create(
|
||||||
|
name="To Delete",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
source_width=2480,
|
||||||
|
source_height=3508,
|
||||||
|
)
|
||||||
|
OcrTemplateZone.objects.create(
|
||||||
|
template=template,
|
||||||
|
name="Zone",
|
||||||
|
custom_field=self.custom_field_text,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
width=100,
|
||||||
|
height=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = self.client.delete(f"{self.ENDPOINT}{template.pk}/")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
|
||||||
|
self.assertEqual(OcrTemplate.objects.count(), 0)
|
||||||
|
self.assertEqual(OcrTemplateZone.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_delete_nonexistent_returns_404(self):
|
||||||
|
resp = self.client.delete(f"{self.ENDPOINT}99999/")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# --- Patch ---
|
||||||
|
|
||||||
|
def test_patch_toggle_enabled(self):
|
||||||
|
template = OcrTemplate.objects.create(
|
||||||
|
name="Toggle Test",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
source_width=2480,
|
||||||
|
source_height=3508,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{template.pk}/",
|
||||||
|
data=json.dumps({"enabled": False}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
template.refresh_from_db()
|
||||||
|
self.assertFalse(template.enabled)
|
||||||
|
|
||||||
|
def test_patch_preserves_zones(self):
|
||||||
|
"""PATCH without zones field should not delete existing zones."""
|
||||||
|
template = OcrTemplate.objects.create(
|
||||||
|
name="Patch Test",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
source_width=2480,
|
||||||
|
source_height=3508,
|
||||||
|
)
|
||||||
|
OcrTemplateZone.objects.create(
|
||||||
|
template=template,
|
||||||
|
name="Existing Zone",
|
||||||
|
custom_field=self.custom_field_text,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
width=100,
|
||||||
|
height=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{template.pk}/",
|
||||||
|
data=json.dumps({"name": "Updated Name"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(OcrTemplateZone.objects.count(), 1)
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
|
||||||
|
def test_unauthenticated_rejected(self):
|
||||||
|
self.client.logout()
|
||||||
|
resp = self.client.get(self.ENDPOINT)
|
||||||
|
self.assertIn(
|
||||||
|
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)
|
||||||
@@ -5,11 +5,8 @@ from unittest import mock
|
|||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import connection
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
@@ -22,7 +19,6 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import set_permissions_for_objects
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -519,178 +515,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(groups_with_perms.count(), 2)
|
self.assertEqual(groups_with_perms.count(), 2)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
|
|
||||||
def test_set_permissions_batched_across_document_count(
|
|
||||||
self,
|
|
||||||
m,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Many documents are being bulk-edited to set permissions at once
|
|
||||||
WHEN:
|
|
||||||
- set_permissions runs over a small batch vs. a much larger one
|
|
||||||
THEN:
|
|
||||||
- Permissions are applied correctly at both scales
|
|
||||||
- Query count does not grow with the number of documents, i.e.
|
|
||||||
each user/group is applied across all documents with one
|
|
||||||
batched call rather than one call per (document, identity)
|
|
||||||
pair
|
|
||||||
"""
|
|
||||||
permissions = {
|
|
||||||
"view": {
|
|
||||||
"users": [self.user1.id, self.user2.id],
|
|
||||||
"groups": [self.group2.id],
|
|
||||||
},
|
|
||||||
"change": {
|
|
||||||
"users": [self.user1.id],
|
|
||||||
"groups": [self.group2.id],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def run_with_n_documents(n: int) -> int:
|
|
||||||
docs = [
|
|
||||||
Document.objects.create(checksum=f"perm-{n}-{i}", title=f"perm-{n}-{i}")
|
|
||||||
for i in range(n)
|
|
||||||
]
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
bulk_edit.set_permissions(
|
|
||||||
[doc.id for doc in docs],
|
|
||||||
set_permissions=permissions,
|
|
||||||
owner=self.owner,
|
|
||||||
merge=False,
|
|
||||||
)
|
|
||||||
for doc in docs:
|
|
||||||
self.assertEqual(get_users_with_perms(doc).count(), 2)
|
|
||||||
self.assertEqual(get_groups_with_perms(doc).count(), 1)
|
|
||||||
return len(ctx.captured_queries)
|
|
||||||
|
|
||||||
small_batch_queries = run_with_n_documents(5)
|
|
||||||
large_batch_queries = run_with_n_documents(50)
|
|
||||||
|
|
||||||
# A tolerance rather than equality, matching the N+1 check in
|
|
||||||
# test_views.py: bulk_create's batch_size caps rows per INSERT, so a
|
|
||||||
# large enough selection does legitimately add statements, and the
|
|
||||||
# per-process ContentType cache makes the first run carry an extra
|
|
||||||
# query. Neither can hide a regression to per-document assignment,
|
|
||||||
# which would be ~10x the small-batch count here.
|
|
||||||
self.assertLessEqual(
|
|
||||||
large_batch_queries,
|
|
||||||
small_batch_queries + 5,
|
|
||||||
"Permission assignment appears to scale with document count: "
|
|
||||||
f"{small_batch_queries} queries for 5 documents vs. "
|
|
||||||
f"{large_batch_queries} for 50",
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
|
|
||||||
def test_set_permissions_grants_direct_perm_even_if_already_granted_via_group(
|
|
||||||
self,
|
|
||||||
m,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A user already has view access to a document via group
|
|
||||||
membership, with no direct grant of their own
|
|
||||||
WHEN:
|
|
||||||
- set_permissions explicitly grants that same user direct view
|
|
||||||
access via bulk_edit
|
|
||||||
THEN:
|
|
||||||
- A direct permission grant is created for the user, not skipped
|
|
||||||
because they already have equivalent access via the group
|
|
||||||
|
|
||||||
Regression test: guardian's queryset-aware assign_perm() (routed to
|
|
||||||
when the target is a list/queryset) skips creating a direct row for
|
|
||||||
anyone whose ObjectPermissionChecker.has_perm() already returns True
|
|
||||||
-- which includes group-derived access. The single-object assign_perm
|
|
||||||
this bulk path replaces has no such check; it always ensures a
|
|
||||||
direct row via get_or_create. Losing that guarantee would mean
|
|
||||||
revoking the group's grant later silently strips access that was
|
|
||||||
supposed to be explicit.
|
|
||||||
"""
|
|
||||||
self.doc1.owner = self.user1
|
|
||||||
self.doc1.save()
|
|
||||||
self.user1.groups.add(self.group1)
|
|
||||||
assign_perm("view_document", self.group1, self.doc1)
|
|
||||||
|
|
||||||
bulk_edit.set_permissions(
|
|
||||||
[self.doc1.id],
|
|
||||||
set_permissions={
|
|
||||||
"view": {"users": [self.user1.id], "groups": []},
|
|
||||||
},
|
|
||||||
merge=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
direct_users = get_users_with_perms(
|
|
||||||
self.doc1,
|
|
||||||
only_with_perms_in=["view_document"],
|
|
||||||
with_group_users=False,
|
|
||||||
)
|
|
||||||
self.assertIn(self.user1, direct_users)
|
|
||||||
|
|
||||||
def test_set_permissions_for_objects_raises_for_unknown_action(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An unrecognized permission action name with users to grant it
|
|
||||||
to
|
|
||||||
WHEN:
|
|
||||||
- set_permissions_for_objects is called
|
|
||||||
THEN:
|
|
||||||
- Permission.DoesNotExist is raised, not a silent no-op
|
|
||||||
|
|
||||||
Regression test: the endpoint that calls this
|
|
||||||
(BulkEditObjectPermissionsView) never actually validates action
|
|
||||||
names against the raw client-supplied permissions dict --
|
|
||||||
BulkEditObjectsSerializer._validate_permissions calls
|
|
||||||
validate_set_permissions() only for its side-effecting user/group id
|
|
||||||
checks and discards the filtered dict it returns -- so a bogus
|
|
||||||
action key reaches this function as-is. Resolving the Permission via
|
|
||||||
a bare `.filter()` (which returns empty instead of raising) would
|
|
||||||
silently drop the grant and report success.
|
|
||||||
"""
|
|
||||||
with self.assertRaises(Permission.DoesNotExist):
|
|
||||||
set_permissions_for_objects(
|
|
||||||
{"not_a_real_action": {"users": [self.user1.id], "groups": []}},
|
|
||||||
Document,
|
|
||||||
[self.doc1.pk],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_set_permissions_for_objects_unknown_action_applies_nothing(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A permissions dict with a valid action ordered ahead of an
|
|
||||||
unrecognized one
|
|
||||||
WHEN:
|
|
||||||
- set_permissions_for_objects is called
|
|
||||||
THEN:
|
|
||||||
- Permission.DoesNotExist is raised
|
|
||||||
- The valid action ahead of it is not applied either
|
|
||||||
|
|
||||||
Every action is resolved before any row is written, so a bad action
|
|
||||||
name cannot leave a half-applied change behind. That matters because
|
|
||||||
BulkEditObjectsView turns this exception into a 400: without the
|
|
||||||
up-front resolution the client would be told the request failed
|
|
||||||
while the leading action had already been committed.
|
|
||||||
"""
|
|
||||||
with self.assertRaises(Permission.DoesNotExist):
|
|
||||||
set_permissions_for_objects(
|
|
||||||
{
|
|
||||||
"view": {"users": [self.user1.id], "groups": []},
|
|
||||||
"not_a_real_action": {"users": [self.user1.id], "groups": []},
|
|
||||||
},
|
|
||||||
Document,
|
|
||||||
[self.doc1.pk],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertNotIn(
|
|
||||||
self.user1,
|
|
||||||
get_users_with_perms(
|
|
||||||
self.doc1,
|
|
||||||
only_with_perms_in=["view_document"],
|
|
||||||
with_group_users=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.models.Document.delete")
|
@mock.patch("documents.models.Document.delete")
|
||||||
def test_delete_documents_old_uuid_field(self, m) -> None:
|
def test_delete_documents_old_uuid_field(self, m) -> None:
|
||||||
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import warnings
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@@ -12,7 +11,6 @@ from django.test import override_settings
|
|||||||
from documents.classifier import ClassifierModelCorruptError
|
from documents.classifier import ClassifierModelCorruptError
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
from documents.classifier import IncompatibleClassifierVersionError
|
from documents.classifier import IncompatibleClassifierVersionError
|
||||||
from documents.classifier import _predict_with_threshold
|
|
||||||
from documents.classifier import load_classifier
|
from documents.classifier import load_classifier
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
@@ -627,103 +625,6 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk)
|
self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk)
|
||||||
self.assertIsNone(self.classifier.predict_storage_path(doc2.content))
|
self.assertIsNone(self.classifier.predict_storage_path(doc2.content))
|
||||||
|
|
||||||
def test_predict_rejects_prediction_below_match_threshold(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Classifiers trained against test data with confident predictions
|
|
||||||
WHEN:
|
|
||||||
- CLASSIFIER_MATCH_THRESHOLD exceeds the model's confidence
|
|
||||||
THEN:
|
|
||||||
- Every predict_* method discards the match in favor of no match
|
|
||||||
"""
|
|
||||||
c1 = Correspondent.objects.create(
|
|
||||||
name="c1",
|
|
||||||
matching_algorithm=Correspondent.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
dt1 = DocumentType.objects.create(
|
|
||||||
name="dt1",
|
|
||||||
matching_algorithm=DocumentType.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
sp1 = StoragePath.objects.create(
|
|
||||||
name="sp1",
|
|
||||||
matching_algorithm=StoragePath.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
|
|
||||||
doc1 = Document.objects.create(
|
|
||||||
title="doc1",
|
|
||||||
content="this is a document from c1",
|
|
||||||
correspondent=c1,
|
|
||||||
document_type=dt1,
|
|
||||||
storage_path=sp1,
|
|
||||||
checksum="A",
|
|
||||||
)
|
|
||||||
Document.objects.create(
|
|
||||||
title="doc2",
|
|
||||||
content="this is a document from no one",
|
|
||||||
checksum="B",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.classifier.train()
|
|
||||||
|
|
||||||
predictors = {
|
|
||||||
"correspondent": self.classifier.predict_correspondent,
|
|
||||||
"document_type": self.classifier.predict_document_type,
|
|
||||||
"storage_path": self.classifier.predict_storage_path,
|
|
||||||
}
|
|
||||||
# No real prediction can reach a confidence this high, so this
|
|
||||||
# isolates the threshold check from the model's actual output.
|
|
||||||
with override_settings(CLASSIFIER_MATCH_THRESHOLD=0.999999):
|
|
||||||
for name, predict in predictors.items():
|
|
||||||
with self.subTest(field=name):
|
|
||||||
self.assertIsNone(predict(doc1.content))
|
|
||||||
|
|
||||||
def test_train_uses_balanced_sample_weight(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A training set with correspondents, document types and storage paths
|
|
||||||
WHEN:
|
|
||||||
- The classifier is trained
|
|
||||||
THEN:
|
|
||||||
- Each MLP classifier is fit with balanced sample weights, so that
|
|
||||||
over-represented classes don't dominate predictions
|
|
||||||
"""
|
|
||||||
c1 = Correspondent.objects.create(
|
|
||||||
name="c1",
|
|
||||||
matching_algorithm=Correspondent.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
dt1 = DocumentType.objects.create(
|
|
||||||
name="dt1",
|
|
||||||
matching_algorithm=DocumentType.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
sp1 = StoragePath.objects.create(
|
|
||||||
name="sp1",
|
|
||||||
matching_algorithm=StoragePath.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
|
|
||||||
Document.objects.create(
|
|
||||||
title="doc1",
|
|
||||||
content="this is a document from c1",
|
|
||||||
correspondent=c1,
|
|
||||||
document_type=dt1,
|
|
||||||
storage_path=sp1,
|
|
||||||
checksum="A",
|
|
||||||
)
|
|
||||||
Document.objects.create(
|
|
||||||
title="doc2",
|
|
||||||
content="this is a document from no one",
|
|
||||||
checksum="B",
|
|
||||||
)
|
|
||||||
|
|
||||||
with mock.patch(
|
|
||||||
"sklearn.utils.class_weight.compute_sample_weight",
|
|
||||||
return_value=None,
|
|
||||||
) as mocked_compute_sample_weight:
|
|
||||||
self.classifier.train()
|
|
||||||
|
|
||||||
self.assertEqual(mocked_compute_sample_weight.call_count, 3)
|
|
||||||
for call in mocked_compute_sample_weight.call_args_list:
|
|
||||||
self.assertEqual(call.args[0], "balanced")
|
|
||||||
|
|
||||||
def test_one_tag_predict(self) -> None:
|
def test_one_tag_predict(self) -> None:
|
||||||
t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12)
|
t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12)
|
||||||
|
|
||||||
@@ -909,52 +810,6 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
|||||||
load_classifier(raise_exception=True)
|
load_classifier(raise_exception=True)
|
||||||
|
|
||||||
|
|
||||||
class _StubProbaClassifier:
|
|
||||||
"""
|
|
||||||
A fake scikit-learn classifier exposing just enough of the API for
|
|
||||||
`_predict_with_threshold`: `classes_` and `predict_proba`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, classes: list[int], probabilities: list[float]) -> None:
|
|
||||||
self.classes_ = np.array(classes)
|
|
||||||
self._probabilities = np.array([probabilities])
|
|
||||||
|
|
||||||
def predict_proba(self, X) -> np.ndarray:
|
|
||||||
return self._probabilities
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("classes", "probabilities", "threshold", "expected"),
|
|
||||||
[
|
|
||||||
# confident prediction above the threshold is returned
|
|
||||||
([-1, 3], [0.1, 0.9], 0.6, 3),
|
|
||||||
# prediction below the threshold is discarded
|
|
||||||
([-1, 3], [0.45, 0.55], 0.6, None),
|
|
||||||
# boundary: exactly at the threshold is accepted, not discarded
|
|
||||||
([-1, 3], [0.4, 0.6], 0.6, 3),
|
|
||||||
# the winning class is the "no match" pseudo-class, regardless of its
|
|
||||||
# own confidence
|
|
||||||
([-1, 3], [0.99, 0.01], 0.0, None),
|
|
||||||
# threshold of 0.0 disables the confidence check entirely
|
|
||||||
([-1, 3], [0.45, 0.55], 0.0, 3),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_predict_with_threshold(classes, probabilities, threshold, expected) -> None:
|
|
||||||
classifier = _StubProbaClassifier(classes, probabilities)
|
|
||||||
result = _predict_with_threshold(classifier, X=None, threshold=threshold)
|
|
||||||
assert result == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_classifier_match_threshold_default() -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- No PAPERLESS_CLASSIFIER_MATCH_THRESHOLD environment variable is set
|
|
||||||
THEN:
|
|
||||||
- The classifier match threshold defaults to 0.6
|
|
||||||
"""
|
|
||||||
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.6
|
|
||||||
|
|
||||||
|
|
||||||
def test_preprocess_content() -> None:
|
def test_preprocess_content() -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -1,457 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.db import connection
|
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from rest_framework import status
|
|
||||||
|
|
||||||
from documents.models import Document
|
|
||||||
from documents.tests.factories import DocumentFactory
|
|
||||||
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
|
||||||
from documents.versioning import has_prefetched_effective_content
|
|
||||||
from documents.versioning import latest_version_content_prefetch
|
|
||||||
from documents.views import DocumentViewSet
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
|
|
||||||
class TestNeedsEffectiveContentAnnotation:
|
|
||||||
"""
|
|
||||||
DocumentViewSet._needs_effective_content_annotation() decides whether
|
|
||||||
the effective_content correlated subquery is worth attaching to the
|
|
||||||
queryset at all -- see TestDocumentListEffectiveContentAnnotation below
|
|
||||||
for why. This only checks that decision's own logic (a plain query-param
|
|
||||||
membership test), not that Django/DRF's filtering machinery works.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("params", "expected"),
|
|
||||||
[
|
|
||||||
({}, False),
|
|
||||||
({"ordering": "-added"}, False),
|
|
||||||
({"tags__id__in": "1,2"}, False),
|
|
||||||
({"search": ""}, False),
|
|
||||||
({"search": " "}, False),
|
|
||||||
({"content__icontains": ""}, False),
|
|
||||||
({"search": "foo"}, True),
|
|
||||||
({"title_content": "foo"}, True),
|
|
||||||
({"content__istartswith": "foo"}, True),
|
|
||||||
({"content__iendswith": "foo"}, True),
|
|
||||||
({"content__icontains": "foo"}, True),
|
|
||||||
({"content__iexact": "foo"}, True),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_detects_content_filter_params(
|
|
||||||
self,
|
|
||||||
params: dict[str, str],
|
|
||||||
expected: bool, # noqa: FBT001
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A view bound to a request carrying the given query params
|
|
||||||
WHEN:
|
|
||||||
- Checking whether the effective_content annotation is needed
|
|
||||||
THEN:
|
|
||||||
- It is needed only for requests that actually filter on it
|
|
||||||
"""
|
|
||||||
view = DocumentViewSet()
|
|
||||||
view.request = SimpleNamespace(query_params=params)
|
|
||||||
|
|
||||||
assert view._needs_effective_content_annotation() is expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestNeedsEffectiveContentPrefetch:
|
|
||||||
"""
|
|
||||||
DocumentViewSet._needs_effective_content_prefetch() decides whether the
|
|
||||||
single-version content prefetch is worth attaching. It has to read the
|
|
||||||
`fields` param exactly the way get_serializer() does, or a request whose
|
|
||||||
response includes content ends up without the prefetch and pays
|
|
||||||
get_effective_content()'s per-instance fallback instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("params", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param({}, True, id="no-fields-param-keeps-every-field"),
|
|
||||||
pytest.param({"fields": ""}, True, id="blank-fields-keeps-every-field"),
|
|
||||||
pytest.param(
|
|
||||||
{"fields": "id,content"},
|
|
||||||
True,
|
|
||||||
id="content-among-requested-fields",
|
|
||||||
),
|
|
||||||
pytest.param({"fields": "content"}, True, id="content-only"),
|
|
||||||
pytest.param({"fields": "id"}, False, id="content-not-requested"),
|
|
||||||
pytest.param(
|
|
||||||
{"fields": "id,title"},
|
|
||||||
False,
|
|
||||||
id="several-fields-without-content",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_detects_whether_content_can_reach_the_response(
|
|
||||||
self,
|
|
||||||
params: dict[str, str],
|
|
||||||
expected: bool, # noqa: FBT001
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A view bound to a request carrying the given query params
|
|
||||||
WHEN:
|
|
||||||
- Checking whether the content prefetch is needed
|
|
||||||
THEN:
|
|
||||||
- It is needed exactly when get_serializer() would emit content,
|
|
||||||
which treats a blank `fields` the same as an absent one
|
|
||||||
"""
|
|
||||||
view = DocumentViewSet()
|
|
||||||
view.request = SimpleNamespace(query_params=params)
|
|
||||||
|
|
||||||
assert view._needs_effective_content_prefetch() is expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestDocumentListEffectiveContentAnnotation:
|
|
||||||
"""
|
|
||||||
DocumentViewSet.get_queryset() only attaches the effective_content
|
|
||||||
correlated subquery when a request actually filters on it. Attaching it
|
|
||||||
unconditionally re-executes it once per candidate row before the page's
|
|
||||||
LIMIT is applied -- fine on SQLite/Postgres, but pathological on
|
|
||||||
MariaDB's default cardinality estimation for the root_document_id
|
|
||||||
self-join once candidate counts get large (see the root_document_id /
|
|
||||||
effective_content perf investigation).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_list_without_content_filter_skips_annotation_but_returns_latest_content(
|
|
||||||
self,
|
|
||||||
admin_client: APIClient,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A root document whose latest version has different content
|
|
||||||
WHEN:
|
|
||||||
- Listing documents with no search/content-filter param
|
|
||||||
THEN:
|
|
||||||
- The response still reflects the latest version's content
|
|
||||||
- The database never evaluates effective_content per row
|
|
||||||
"""
|
|
||||||
root = DocumentFactory(content="old-root-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content="new-version-content",
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = admin_client.get("/api/documents/?fields=id,content")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert response.data["results"] == [
|
|
||||||
{"id": root.id, "content": "new-version-content"},
|
|
||||||
]
|
|
||||||
assert not any(
|
|
||||||
"effective_content" in query["sql"] for query in ctx.captured_queries
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"fields_param",
|
|
||||||
[
|
|
||||||
pytest.param("", id="blank-fields"),
|
|
||||||
pytest.param("id,content", id="content-requested"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_content_resolves_without_a_query_per_document(
|
|
||||||
self,
|
|
||||||
admin_client: APIClient,
|
|
||||||
fields_param: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- One versioned root document, then two more
|
|
||||||
WHEN:
|
|
||||||
- Listing documents with a `fields` param that keeps content
|
|
||||||
THEN:
|
|
||||||
- Every root's content resolves to its latest version's
|
|
||||||
- The query count does not grow with the number of documents,
|
|
||||||
i.e. a blank `fields` does not skip the prefetch and fall back
|
|
||||||
to loading each root's deferred version content
|
|
||||||
"""
|
|
||||||
first = DocumentFactory(content="first-root-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=first,
|
|
||||||
version_index=1,
|
|
||||||
content="first-version-content",
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as one_document:
|
|
||||||
response = admin_client.get(f"/api/documents/?fields={fields_param}")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert [r["content"] for r in response.data["results"]] == [
|
|
||||||
"first-version-content",
|
|
||||||
]
|
|
||||||
|
|
||||||
for index in range(2):
|
|
||||||
root = DocumentFactory(content=f"root-content-{index}")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content=f"version-content-{index}",
|
|
||||||
)
|
|
||||||
with CaptureQueriesContext(connection) as three_documents:
|
|
||||||
response = admin_client.get(f"/api/documents/?fields={fields_param}")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert sorted(r["content"] for r in response.data["results"]) == [
|
|
||||||
"first-version-content",
|
|
||||||
"version-content-0",
|
|
||||||
"version-content-1",
|
|
||||||
]
|
|
||||||
assert len(_get_document_queries(three_documents)) == len(
|
|
||||||
_get_document_queries(one_document),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_list_without_content_field_skips_prefetch_and_omits_content(
|
|
||||||
self,
|
|
||||||
admin_client: APIClient,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A versioned root document
|
|
||||||
WHEN:
|
|
||||||
- Listing documents without asking for content
|
|
||||||
THEN:
|
|
||||||
- Content is neither serialized nor resolved
|
|
||||||
- Nothing pays for the prefetch or the per-instance fallback
|
|
||||||
"""
|
|
||||||
root = DocumentFactory(content="root-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content="version-content",
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = admin_client.get("/api/documents/?fields=id")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert response.data["results"] == [{"id": root.id}]
|
|
||||||
assert _get_effective_content_fallback_queries(ctx) == []
|
|
||||||
# Only the list query itself reads a content column: no extra query
|
|
||||||
# for the skipped prefetch, none for a per-instance fallback
|
|
||||||
content_queries = [
|
|
||||||
query
|
|
||||||
for query in ctx.captured_queries
|
|
||||||
if '"documents_document"."content"' in query["sql"]
|
|
||||||
]
|
|
||||||
assert len(content_queries) == 1
|
|
||||||
|
|
||||||
def test_latest_version_content_prefetch_carries_only_the_newest_version(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A root document with two versions
|
|
||||||
WHEN:
|
|
||||||
- Fetching the root through latest_version_content_prefetch()
|
|
||||||
THEN:
|
|
||||||
- The prefetch carries only the single newest version, not every
|
|
||||||
historical version's content (the whole point of not reusing
|
|
||||||
the metadata-only "versions" prefetch for this)
|
|
||||||
"""
|
|
||||||
root = DocumentFactory(content="root-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content="older-version-content",
|
|
||||||
)
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=2,
|
|
||||||
content="newest-version-content",
|
|
||||||
)
|
|
||||||
|
|
||||||
fetched_root = (
|
|
||||||
Document.objects.filter(pk=root.pk)
|
|
||||||
.prefetch_related(
|
|
||||||
latest_version_content_prefetch(),
|
|
||||||
)
|
|
||||||
.get()
|
|
||||||
)
|
|
||||||
|
|
||||||
latest = getattr(fetched_root, LATEST_VERSION_CONTENT_PREFETCH_ATTR)
|
|
||||||
assert [v.content for v in latest] == ["newest-version-content"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestHasPrefetchedEffectiveContent:
|
|
||||||
"""
|
|
||||||
DocumentSerializer.to_representation() only calls get_effective_content()
|
|
||||||
when has_prefetched_effective_content() says it's cheap -- otherwise a
|
|
||||||
caller that never set up an annotation or prefetch (TrashView,
|
|
||||||
GlobalSearchView, which build their own querysets and don't display
|
|
||||||
content at all) would pay for a per-instance query nobody asked for.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_false_with_no_annotation_or_prefetch(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document the ORM never annotated or prefetched for
|
|
||||||
WHEN:
|
|
||||||
- Asking whether its effective content is already resolved
|
|
||||||
THEN:
|
|
||||||
- It is not, so the serializer must leave it alone
|
|
||||||
"""
|
|
||||||
document = DocumentFactory.build()
|
|
||||||
|
|
||||||
assert has_prefetched_effective_content(document) is False
|
|
||||||
|
|
||||||
def test_true_with_effective_content_annotation(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document carrying the queryset's effective_content annotation
|
|
||||||
WHEN:
|
|
||||||
- Asking whether its effective content is already resolved
|
|
||||||
THEN:
|
|
||||||
- It is, straight off the annotation
|
|
||||||
"""
|
|
||||||
document = DocumentFactory.build()
|
|
||||||
document.effective_content = "resolved"
|
|
||||||
|
|
||||||
assert has_prefetched_effective_content(document) is True
|
|
||||||
|
|
||||||
def test_true_with_lean_prefetch_attr_even_when_empty(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document the lean content prefetch ran for, finding no versions
|
|
||||||
WHEN:
|
|
||||||
- Asking whether its effective content is already resolved
|
|
||||||
THEN:
|
|
||||||
- It is: an empty prefetch is an answer, not a missing one
|
|
||||||
"""
|
|
||||||
document = DocumentFactory.build()
|
|
||||||
setattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, [])
|
|
||||||
|
|
||||||
assert has_prefetched_effective_content(document) is True
|
|
||||||
|
|
||||||
def test_true_with_metadata_versions_prefetch_cache(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document carrying only the metadata "versions" prefetch
|
|
||||||
WHEN:
|
|
||||||
- Asking whether its effective content is already resolved
|
|
||||||
THEN:
|
|
||||||
- It is, via get_effective_content()'s prefetch-cache branch
|
|
||||||
"""
|
|
||||||
document = DocumentFactory.build()
|
|
||||||
document._prefetched_objects_cache = {"versions": []}
|
|
||||||
|
|
||||||
assert has_prefetched_effective_content(document) is True
|
|
||||||
|
|
||||||
|
|
||||||
def _get_document_queries(
|
|
||||||
ctx: CaptureQueriesContext,
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
"""
|
|
||||||
The queries a list request spends on the documents themselves, i.e.
|
|
||||||
everything but the one-time django_content_type lookup guardian's
|
|
||||||
permission filtering makes. That lookup is process-cached, and the
|
|
||||||
autouse fixture in conftest clears the cache before every test, so it
|
|
||||||
lands in whichever request happens to run first and never repeats --
|
|
||||||
counting it makes a request look like it costs one query more than the
|
|
||||||
identical request after it.
|
|
||||||
"""
|
|
||||||
return [q for q in ctx.captured_queries if '"django_content_type"' not in q["sql"]]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_effective_content_fallback_queries(
|
|
||||||
ctx: CaptureQueriesContext,
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
"""
|
|
||||||
Document.get_effective_content()'s per-instance fallback (no annotation,
|
|
||||||
no prefetch) is a `.values_list("content", flat=True).first()` query --
|
|
||||||
a SELECT of just the content column. Distinct from get_versions()'s own,
|
|
||||||
unrelated per-instance metadata query (id/checksum/added/etc, no
|
|
||||||
content) run to build the "versions" response field, which isn't part
|
|
||||||
of what this test file covers.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
q
|
|
||||||
for q in ctx.captured_queries
|
|
||||||
if q["sql"].startswith('SELECT "documents_document"."content" FROM')
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestTrashAndGlobalSearchEffectiveContentIsNeverPerInstance:
|
|
||||||
"""
|
|
||||||
TrashView and GlobalSearchView serialize Document instances with
|
|
||||||
DocumentSerializer too, but build their querysets independently of
|
|
||||||
DocumentViewSet.get_queryset(). TrashView doesn't display content at all,
|
|
||||||
so it keeps the document's own unresolved content; GlobalSearchView
|
|
||||||
annotates effective_content itself, so it shows the latest version's.
|
|
||||||
Neither should ever fall back to a per-instance query.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_trash_list_shows_unresolved_content_with_no_extra_query(
|
|
||||||
self,
|
|
||||||
admin_client: APIClient,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A trashed root document whose own content differs from what a
|
|
||||||
version would have had (also trashed, deletion cascades)
|
|
||||||
WHEN:
|
|
||||||
- Listing trash
|
|
||||||
THEN:
|
|
||||||
- The response shows the document's own content
|
|
||||||
- Nothing ever queries for versions to resolve it
|
|
||||||
"""
|
|
||||||
root = DocumentFactory(content="own-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content="version-content",
|
|
||||||
)
|
|
||||||
root.delete()
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = admin_client.get("/api/trash/")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
[result] = [r for r in response.data["results"] if r["id"] == root.id]
|
|
||||||
assert result["content"] == "own-content"
|
|
||||||
assert _get_effective_content_fallback_queries(ctx) == []
|
|
||||||
|
|
||||||
def test_global_search_db_only_shows_latest_version_content_with_no_extra_query(
|
|
||||||
self,
|
|
||||||
admin_client: APIClient,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A root document, findable by title, whose own content differs
|
|
||||||
from its latest version's
|
|
||||||
WHEN:
|
|
||||||
- Using the global search endpoint's db_only mode
|
|
||||||
THEN:
|
|
||||||
- The response shows the latest version's content, resolved by
|
|
||||||
GlobalSearchView's own effective_content annotation
|
|
||||||
- There is no per-instance fallback query
|
|
||||||
"""
|
|
||||||
root = DocumentFactory(title="findme", content="own-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content="version-content",
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = admin_client.get(
|
|
||||||
"/api/search/?query=findme&db_only=true",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
[result] = [d for d in response.data["documents"] if d["id"] == root.id]
|
|
||||||
assert result["content"] == "version-content"
|
|
||||||
assert _get_effective_content_fallback_queries(ctx) == []
|
|
||||||
@@ -2,7 +2,6 @@ from unittest import mock
|
|||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents import bulk_edit
|
from documents import bulk_edit
|
||||||
@@ -109,44 +108,6 @@ class TestTagHierarchy(DirectoriesMixin, APITestCase):
|
|||||||
self.document.refresh_from_db()
|
self.document.refresh_from_db()
|
||||||
assert self.document.tags.count() == 0
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
def test_remove_inbox_tags_removes_nested_children(self) -> None:
|
|
||||||
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True)
|
|
||||||
nested = Tag.objects.create(name="Nested", tn_parent=inbox)
|
|
||||||
self.document.add_nested_tags([nested])
|
|
||||||
|
|
||||||
resp = self.client.patch(
|
|
||||||
f"/api/documents/{self.document.pk}/",
|
|
||||||
{"title": "new title", "remove_inbox_tags": True},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
self.document.refresh_from_db()
|
|
||||||
assert self.document.tags.count() == 0
|
|
||||||
|
|
||||||
# A subsequent save must not re-add the inbox tag as an ancestor
|
|
||||||
resp = self.client.patch(
|
|
||||||
f"/api/documents/{self.document.pk}/",
|
|
||||||
{"title": "another title", "tags": [], "remove_inbox_tags": True},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
self.document.refresh_from_db()
|
|
||||||
assert self.document.tags.count() == 0
|
|
||||||
|
|
||||||
def test_remove_inbox_tags_keeps_inbox_when_nested_child_added(self) -> None:
|
|
||||||
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True)
|
|
||||||
nested = Tag.objects.create(name="Nested", tn_parent=inbox)
|
|
||||||
self.document.add_nested_tags([inbox])
|
|
||||||
|
|
||||||
self.client.patch(
|
|
||||||
f"/api/documents/{self.document.pk}/",
|
|
||||||
{"tags": [nested.pk], "remove_inbox_tags": True},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.document.refresh_from_db()
|
|
||||||
tags = set(self.document.tags.values_list("pk", flat=True))
|
|
||||||
assert tags == {inbox.pk, nested.pk}
|
|
||||||
|
|
||||||
def test_bulk_edit_respects_hierarchy(self) -> None:
|
def test_bulk_edit_respects_hierarchy(self) -> None:
|
||||||
bulk_edit.add_tag([self.document.pk], self.child.pk)
|
bulk_edit.add_tag([self.document.pk], self.child.pk)
|
||||||
self.document.refresh_from_db()
|
self.document.refresh_from_db()
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
"""Tests for the zone-based OCR extraction engine."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentType
|
||||||
|
from documents.models import OcrTemplate
|
||||||
|
from documents.models import OcrTemplateZone
|
||||||
|
from documents.zone_ocr import _apply_transform
|
||||||
|
from documents.zone_ocr import _convert_value
|
||||||
|
from documents.zone_ocr import _detect_mime
|
||||||
|
from documents.zone_ocr import _resolve_doc_path
|
||||||
|
from documents.zone_ocr import run_zone_extraction
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyTransform(TestCase):
|
||||||
|
"""Tests for the _apply_transform function."""
|
||||||
|
|
||||||
|
def test_strip(self):
|
||||||
|
self.assertEqual(_apply_transform(" hello ", "strip"), "hello")
|
||||||
|
|
||||||
|
def test_none_transform(self):
|
||||||
|
self.assertEqual(_apply_transform(" hello ", "none"), "hello")
|
||||||
|
|
||||||
|
def test_uppercase(self):
|
||||||
|
self.assertEqual(_apply_transform("hello world", "uppercase"), "HELLO WORLD")
|
||||||
|
|
||||||
|
def test_lowercase(self):
|
||||||
|
self.assertEqual(_apply_transform("HELLO WORLD", "lowercase"), "hello world")
|
||||||
|
|
||||||
|
def test_numeric_basic(self):
|
||||||
|
self.assertEqual(_apply_transform("INV-2026-001", "numeric"), "2026-001")
|
||||||
|
|
||||||
|
def test_numeric_with_currency(self):
|
||||||
|
self.assertEqual(_apply_transform("€1,234.56", "numeric"), "1,234.56")
|
||||||
|
|
||||||
|
def test_numeric_empty_result_falls_back(self):
|
||||||
|
self.assertEqual(_apply_transform("abc", "numeric"), "abc")
|
||||||
|
|
||||||
|
def test_date_dmy_dots(self):
|
||||||
|
self.assertEqual(_apply_transform("13.04.2026", "date_dmy"), "2026-04-13")
|
||||||
|
|
||||||
|
def test_date_dmy_slashes(self):
|
||||||
|
self.assertEqual(_apply_transform("01/12/2025", "date_dmy"), "2025-12-01")
|
||||||
|
|
||||||
|
def test_date_dmy_two_digit_year(self):
|
||||||
|
self.assertEqual(_apply_transform("13.04.26", "date_dmy"), "2026-04-13")
|
||||||
|
|
||||||
|
def test_date_dmy_with_prefix(self):
|
||||||
|
self.assertEqual(_apply_transform("Date: 01/12/2025", "date_dmy"), "2025-12-01")
|
||||||
|
|
||||||
|
def test_date_dmy_invalid_falls_back(self):
|
||||||
|
self.assertEqual(_apply_transform("32.13.2026", "date_dmy"), "32.13.2026")
|
||||||
|
|
||||||
|
def test_date_dmy_no_match_falls_back(self):
|
||||||
|
self.assertEqual(_apply_transform("not a date", "date_dmy"), "not a date")
|
||||||
|
|
||||||
|
def test_date_ymd_dashes(self):
|
||||||
|
self.assertEqual(_apply_transform("2026-04-13", "date_ymd"), "2026-04-13")
|
||||||
|
|
||||||
|
def test_date_ymd_slashes(self):
|
||||||
|
self.assertEqual(_apply_transform("2026/04/13", "date_ymd"), "2026-04-13")
|
||||||
|
|
||||||
|
def test_date_ymd_invalid_falls_back(self):
|
||||||
|
self.assertEqual(_apply_transform("2026-13-32", "date_ymd"), "2026-13-32")
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
self.assertEqual(_apply_transform("", "strip"), "")
|
||||||
|
|
||||||
|
def test_whitespace_only(self):
|
||||||
|
self.assertEqual(_apply_transform(" ", "strip"), "")
|
||||||
|
|
||||||
|
def test_unknown_transform_strips(self):
|
||||||
|
self.assertEqual(_apply_transform(" hello ", "unknown"), "hello")
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertValue(TestCase):
|
||||||
|
"""Tests for the _convert_value function."""
|
||||||
|
|
||||||
|
def test_string(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_convert_value("Hello", CustomField.FieldDataType.STRING),
|
||||||
|
"Hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_string_truncation(self):
|
||||||
|
result = _convert_value("x" * 200, CustomField.FieldDataType.STRING)
|
||||||
|
self.assertEqual(len(result), 128)
|
||||||
|
|
||||||
|
def test_url(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_convert_value("https://example.com", CustomField.FieldDataType.URL),
|
||||||
|
"https://example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_long_text(self):
|
||||||
|
long = "x" * 500
|
||||||
|
self.assertEqual(
|
||||||
|
_convert_value(long, CustomField.FieldDataType.LONG_TEXT),
|
||||||
|
long,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_int_simple(self):
|
||||||
|
self.assertEqual(_convert_value("42", CustomField.FieldDataType.INT), 42)
|
||||||
|
|
||||||
|
def test_int_with_noise(self):
|
||||||
|
self.assertEqual(_convert_value("INV-123", CustomField.FieldDataType.INT), 123)
|
||||||
|
|
||||||
|
def test_int_negative(self):
|
||||||
|
self.assertEqual(_convert_value("-42", CustomField.FieldDataType.INT), -42)
|
||||||
|
|
||||||
|
def test_int_empty_returns_none(self):
|
||||||
|
self.assertIsNone(_convert_value("abc", CustomField.FieldDataType.INT))
|
||||||
|
|
||||||
|
def test_int_only_dash_returns_none(self):
|
||||||
|
self.assertIsNone(_convert_value("-", CustomField.FieldDataType.INT))
|
||||||
|
|
||||||
|
def test_float_simple(self):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
_convert_value("1234.56", CustomField.FieldDataType.FLOAT),
|
||||||
|
1234.56,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_float_european_format(self):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
_convert_value("1.234,56", CustomField.FieldDataType.FLOAT),
|
||||||
|
1234.56,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_float_us_format(self):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
_convert_value("1,234.56", CustomField.FieldDataType.FLOAT),
|
||||||
|
1234.56,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_float_comma_only(self):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
_convert_value("1234,56", CustomField.FieldDataType.FLOAT),
|
||||||
|
1234.56,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_float_empty_returns_none(self):
|
||||||
|
self.assertIsNone(_convert_value("abc", CustomField.FieldDataType.FLOAT))
|
||||||
|
|
||||||
|
def test_float_only_separator_returns_none(self):
|
||||||
|
self.assertIsNone(_convert_value(",", CustomField.FieldDataType.FLOAT))
|
||||||
|
|
||||||
|
def test_date_iso(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_convert_value("2026-04-13", CustomField.FieldDataType.DATE),
|
||||||
|
"2026-04-13",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_date_invalid_returns_none(self):
|
||||||
|
self.assertIsNone(_convert_value("not a date", CustomField.FieldDataType.DATE))
|
||||||
|
|
||||||
|
def test_date_invalid_values_returns_none(self):
|
||||||
|
self.assertIsNone(_convert_value("2026-13-32", CustomField.FieldDataType.DATE))
|
||||||
|
|
||||||
|
def test_monetary_simple(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_convert_value("123.45", CustomField.FieldDataType.MONETARY),
|
||||||
|
"123.45",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_monetary_european(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_convert_value("1.234,56", CustomField.FieldDataType.MONETARY),
|
||||||
|
"1234.56",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_monetary_with_currency_symbol(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_convert_value("€1,234.56", CustomField.FieldDataType.MONETARY),
|
||||||
|
"1234.56",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_monetary_empty_returns_none(self):
|
||||||
|
self.assertIsNone(_convert_value("CHF", CustomField.FieldDataType.MONETARY))
|
||||||
|
|
||||||
|
def test_bool_true(self):
|
||||||
|
for val in ("true", "True", "yes", "1", "ja", "x", "X"):
|
||||||
|
self.assertTrue(
|
||||||
|
_convert_value(val, CustomField.FieldDataType.BOOL),
|
||||||
|
f"Expected True for {val!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bool_false(self):
|
||||||
|
for val in ("false", "False", "no", "0", "nein"):
|
||||||
|
self.assertFalse(
|
||||||
|
_convert_value(val, CustomField.FieldDataType.BOOL),
|
||||||
|
f"Expected False for {val!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bool_unknown_returns_none(self):
|
||||||
|
self.assertIsNone(_convert_value("maybe", CustomField.FieldDataType.BOOL))
|
||||||
|
|
||||||
|
def test_unsupported_type_returns_none(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
_convert_value("test", CustomField.FieldDataType.DOCUMENTLINK),
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
_convert_value("test", CustomField.FieldDataType.SELECT),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_string_returns_none(self):
|
||||||
|
self.assertIsNone(_convert_value("", CustomField.FieldDataType.STRING))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectMime(TestCase):
|
||||||
|
"""Tests for _detect_mime."""
|
||||||
|
|
||||||
|
def test_pdf_extension(self):
|
||||||
|
self.assertEqual(_detect_mime(Path("test.pdf")), "application/pdf")
|
||||||
|
|
||||||
|
def test_png_extension(self):
|
||||||
|
self.assertEqual(_detect_mime(Path("test.png")), "image/png")
|
||||||
|
|
||||||
|
def test_jpg_extension(self):
|
||||||
|
self.assertEqual(_detect_mime(Path("test.jpg")), "image/jpeg")
|
||||||
|
|
||||||
|
def test_unknown_extension(self):
|
||||||
|
self.assertIsNone(_detect_mime(Path("test.xyz")))
|
||||||
|
|
||||||
|
def test_webp_extension(self):
|
||||||
|
self.assertEqual(_detect_mime(Path("test.webp")), "image/webp")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveDocPath(TestCase):
|
||||||
|
"""Tests for _resolve_doc_path."""
|
||||||
|
|
||||||
|
def test_returns_none_when_no_files_exist(self):
|
||||||
|
doc = MagicMock()
|
||||||
|
doc.has_archive_version = False
|
||||||
|
doc.source_path = Path("/nonexistent/source.pdf")
|
||||||
|
result = _resolve_doc_path(doc, None)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_returns_original_file_as_fallback(self):
|
||||||
|
doc = MagicMock()
|
||||||
|
doc.has_archive_version = False
|
||||||
|
doc.source_path = Path("/nonexistent/source.pdf")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||||
|
result = _resolve_doc_path(doc, Path(f.name))
|
||||||
|
self.assertEqual(result, Path(f.name))
|
||||||
|
|
||||||
|
def test_returns_none_for_none_original_file(self):
|
||||||
|
doc = MagicMock()
|
||||||
|
doc.has_archive_version = False
|
||||||
|
doc.source_path = Path("/nonexistent/source.pdf")
|
||||||
|
result = _resolve_doc_path(doc, None)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunZoneExtraction(TestCase):
|
||||||
|
"""Tests for the full extraction pipeline."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.doc_type = DocumentType.objects.create(name="Invoice")
|
||||||
|
self.custom_field = CustomField.objects.create(
|
||||||
|
name="Invoice Number",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_skips_document_without_type(self):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="No Type",
|
||||||
|
content="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
run_zone_extraction(doc, Path("/nonexistent"))
|
||||||
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_skips_document_without_matching_template(self):
|
||||||
|
other_type = DocumentType.objects.create(name="Other")
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="No Template",
|
||||||
|
content="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
document_type=other_type,
|
||||||
|
)
|
||||||
|
run_zone_extraction(doc, Path("/nonexistent"))
|
||||||
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_skips_disabled_template(self):
|
||||||
|
template = OcrTemplate.objects.create(
|
||||||
|
name="Disabled",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
source_width=2480,
|
||||||
|
source_height=3508,
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
OcrTemplateZone.objects.create(
|
||||||
|
template=template,
|
||||||
|
name="Zone",
|
||||||
|
custom_field=self.custom_field,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
width=100,
|
||||||
|
height=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Test",
|
||||||
|
content="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
)
|
||||||
|
run_zone_extraction(doc, Path("/nonexistent"))
|
||||||
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_skips_template_with_no_zones(self):
|
||||||
|
OcrTemplate.objects.create(
|
||||||
|
name="Empty",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
source_width=2480,
|
||||||
|
source_height=3508,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Test",
|
||||||
|
content="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||||
|
f.write(b"%PDF-1.4 fake")
|
||||||
|
f.flush()
|
||||||
|
run_zone_extraction(doc, Path(f.name))
|
||||||
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||||
|
|
||||||
|
@patch("documents.zone_ocr._process_template")
|
||||||
|
def test_calls_process_for_enabled_template(self, mock_process):
|
||||||
|
template = OcrTemplate.objects.create(
|
||||||
|
name="Active",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
source_width=2480,
|
||||||
|
source_height=3508,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
OcrTemplateZone.objects.create(
|
||||||
|
template=template,
|
||||||
|
name="Zone",
|
||||||
|
custom_field=self.custom_field,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
width=100,
|
||||||
|
height=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Test",
|
||||||
|
content="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||||
|
f.write(b"%PDF-1.4 fake")
|
||||||
|
f.flush()
|
||||||
|
run_zone_extraction(doc, Path(f.name))
|
||||||
|
|
||||||
|
self.assertTrue(mock_process.called)
|
||||||
|
|
||||||
|
@patch("documents.zone_ocr._process_template")
|
||||||
|
def test_handles_process_exception_gracefully(self, mock_process):
|
||||||
|
"""A failing template should not prevent other templates from running."""
|
||||||
|
mock_process.side_effect = RuntimeError("test error")
|
||||||
|
|
||||||
|
template = OcrTemplate.objects.create(
|
||||||
|
name="Failing",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
source_width=2480,
|
||||||
|
source_height=3508,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
OcrTemplateZone.objects.create(
|
||||||
|
template=template,
|
||||||
|
name="Zone",
|
||||||
|
custom_field=self.custom_field,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
width=100,
|
||||||
|
height=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Test",
|
||||||
|
content="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||||
|
f.write(b"%PDF-1.4 fake")
|
||||||
|
f.flush()
|
||||||
|
# Should not raise
|
||||||
|
run_zone_extraction(doc, Path(f.name))
|
||||||
|
|
||||||
|
def test_handles_none_original_file(self):
|
||||||
|
"""Should not crash when original_file is None."""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Test",
|
||||||
|
content="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
)
|
||||||
|
# No template, so it exits early — but shouldn't crash on None
|
||||||
|
run_zone_extraction(doc, None)
|
||||||
|
|
||||||
|
@patch("documents.zone_ocr._process_template")
|
||||||
|
def test_multiple_templates_all_process(self, mock_process):
|
||||||
|
"""Multiple enabled templates for the same type should all run."""
|
||||||
|
for i in range(3):
|
||||||
|
template = OcrTemplate.objects.create(
|
||||||
|
name=f"Template {i}",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
source_width=2480,
|
||||||
|
source_height=3508,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
OcrTemplateZone.objects.create(
|
||||||
|
template=template,
|
||||||
|
name=f"Zone {i}",
|
||||||
|
custom_field=self.custom_field,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
width=100,
|
||||||
|
height=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Test",
|
||||||
|
content="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
document_type=self.doc_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||||
|
f.write(b"%PDF-1.4 fake")
|
||||||
|
f.flush()
|
||||||
|
run_zone_extraction(doc, Path(f.name))
|
||||||
|
|
||||||
|
self.assertEqual(mock_process.call_count, 3)
|
||||||
@@ -7,12 +7,9 @@ from typing import Any
|
|||||||
|
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.db.models import OuterRef
|
from django.db.models import OuterRef
|
||||||
from django.db.models import Prefetch
|
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.db.models import Subquery
|
from django.db.models import Subquery
|
||||||
from django.db.models import Window
|
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db.models.functions import RowNumber
|
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
@@ -49,68 +46,6 @@ def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Docume
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
LATEST_VERSION_CONTENT_PREFETCH_ATTR = "_latest_version_content_prefetch"
|
|
||||||
|
|
||||||
|
|
||||||
def latest_version_content_prefetch() -> Prefetch:
|
|
||||||
"""
|
|
||||||
A Prefetch for Document.versions scoped to just the newest version's
|
|
||||||
content, for get_effective_content()'s fallback when no SQL annotation
|
|
||||||
is present.
|
|
||||||
|
|
||||||
Deliberately not merged into a metadata-only "versions" prefetch (the one
|
|
||||||
used for the serialized versions list): that one fetches every historical
|
|
||||||
version of every document, and pulling full OCR content for versions
|
|
||||||
nobody will read wastes DB transfer/memory at scale. This one is windowed
|
|
||||||
down to a single row per root, then bounded by Prefetch's own IN-list to
|
|
||||||
whatever page/result set it's attached to -- one cheap bulk query total,
|
|
||||||
not one per document and not one per version.
|
|
||||||
"""
|
|
||||||
return Prefetch(
|
|
||||||
"versions",
|
|
||||||
queryset=(
|
|
||||||
Document.objects.filter(
|
|
||||||
root_document_id__isnull=False,
|
|
||||||
deleted_at__isnull=True,
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
rn=Window(
|
|
||||||
RowNumber(),
|
|
||||||
partition_by=F("root_document_id"),
|
|
||||||
order_by=[
|
|
||||||
F("version_index").desc(nulls_last=True),
|
|
||||||
F("id").desc(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter(rn=1)
|
|
||||||
.only("id", "root_document_id", "content")
|
|
||||||
),
|
|
||||||
to_attr=LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def has_prefetched_effective_content(document: Document) -> bool:
|
|
||||||
"""
|
|
||||||
True if document.get_effective_content() can answer without an extra
|
|
||||||
per-instance query -- an SQL ``effective_content`` annotation, the lean
|
|
||||||
latest_version_content_prefetch(), or the metadata-only "versions"
|
|
||||||
prefetch is already present on the instance.
|
|
||||||
|
|
||||||
Callers that haven't set any of those up (e.g. views that build their
|
|
||||||
own querysets independently of DocumentViewSet.get_queryset(), like
|
|
||||||
TrashView or GlobalSearchView) intentionally don't pay for version-aware
|
|
||||||
content resolution -- see DocumentSerializer.to_representation(), which
|
|
||||||
uses this to decide whether to call get_effective_content() at all.
|
|
||||||
"""
|
|
||||||
if hasattr(document, "effective_content"):
|
|
||||||
return True
|
|
||||||
if getattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, None) is not None:
|
|
||||||
return True
|
|
||||||
prefetched_cache = getattr(document, "_prefetched_objects_cache", None)
|
|
||||||
return isinstance(prefetched_cache, dict) and "versions" in prefetched_cache
|
|
||||||
|
|
||||||
|
|
||||||
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
||||||
"""
|
"""
|
||||||
Same sorting as versions_newest_first()
|
Same sorting as versions_newest_first()
|
||||||
|
|||||||
+331
-90
@@ -3,6 +3,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -36,6 +37,7 @@ from django.db.migrations.recorder import MigrationRecorder
|
|||||||
from django.db.models import Avg
|
from django.db.models import Avg
|
||||||
from django.db.models import Case
|
from django.db.models import Case
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
from django.db.models import F
|
||||||
from django.db.models import IntegerField
|
from django.db.models import IntegerField
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
@@ -136,26 +138,26 @@ from documents.filters import CustomFieldFilterSet
|
|||||||
from documents.filters import DocumentFilterSet
|
from documents.filters import DocumentFilterSet
|
||||||
from documents.filters import DocumentsOrderingFilter
|
from documents.filters import DocumentsOrderingFilter
|
||||||
from documents.filters import DocumentTypeFilterSet
|
from documents.filters import DocumentTypeFilterSet
|
||||||
from documents.filters import EffectiveContentFilter
|
|
||||||
from documents.filters import PaperlessTaskFilterSet
|
from documents.filters import PaperlessTaskFilterSet
|
||||||
from documents.filters import PermittedObjectsFilter
|
from documents.filters import PermittedObjectsFilter
|
||||||
from documents.filters import ShareLinkBundleFilterSet
|
from documents.filters import ShareLinkBundleFilterSet
|
||||||
from documents.filters import ShareLinkFilterSet
|
from documents.filters import ShareLinkFilterSet
|
||||||
from documents.filters import StoragePathFilterSet
|
from documents.filters import StoragePathFilterSet
|
||||||
from documents.filters import TagFilterSet
|
from documents.filters import TagFilterSet
|
||||||
from documents.filters import TitleContentFilter
|
|
||||||
from documents.mail import EmailAttachment
|
from documents.mail import EmailAttachment
|
||||||
from documents.mail import send_email
|
from documents.mail import send_email
|
||||||
from documents.matching import match_correspondents
|
from documents.matching import match_correspondents
|
||||||
from documents.matching import match_document_types
|
from documents.matching import match_document_types
|
||||||
from documents.matching import match_storage_paths
|
from documents.matching import match_storage_paths
|
||||||
from documents.matching import match_tags
|
from documents.matching import match_tags
|
||||||
|
from documents.models import OCR_SUPPORTED_FIELD_TYPES
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import Note
|
from documents.models import Note
|
||||||
|
from documents.models import OcrTemplate
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import SavedView
|
from documents.models import SavedView
|
||||||
from documents.models import ShareLink
|
from documents.models import ShareLink
|
||||||
@@ -180,7 +182,7 @@ from documents.permissions import has_perms_owner_aware
|
|||||||
from documents.permissions import has_system_status_permission
|
from documents.permissions import has_system_status_permission
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.permissions import user_is_unrestricted
|
from documents.permissions import user_is_unrestricted
|
||||||
from documents.plugins.date_parsing import get_date_parser
|
from documents.plugins.date_parsing import get_date_parser
|
||||||
from documents.schema import generate_object_with_permissions_schema
|
from documents.schema import generate_object_with_permissions_schema
|
||||||
@@ -202,6 +204,7 @@ from documents.serialisers import EmailSerializer
|
|||||||
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
||||||
from documents.serialisers import MergeDocumentsSerializer
|
from documents.serialisers import MergeDocumentsSerializer
|
||||||
from documents.serialisers import NotesSerializer
|
from documents.serialisers import NotesSerializer
|
||||||
|
from documents.serialisers import OcrTemplateSerializer
|
||||||
from documents.serialisers import PostDocumentSerializer
|
from documents.serialisers import PostDocumentSerializer
|
||||||
from documents.serialisers import RemovePasswordDocumentsSerializer
|
from documents.serialisers import RemovePasswordDocumentsSerializer
|
||||||
from documents.serialisers import ReprocessDocumentsSerializer
|
from documents.serialisers import ReprocessDocumentsSerializer
|
||||||
@@ -237,7 +240,6 @@ from documents.versioning import annotate_effective_content
|
|||||||
from documents.versioning import get_latest_version_for_root
|
from documents.versioning import get_latest_version_for_root
|
||||||
from documents.versioning import get_request_version_param
|
from documents.versioning import get_request_version_param
|
||||||
from documents.versioning import get_root_document
|
from documents.versioning import get_root_document
|
||||||
from documents.versioning import latest_version_content_prefetch
|
|
||||||
from documents.versioning import resolve_requested_version_for_root
|
from documents.versioning import resolve_requested_version_for_root
|
||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
from paperless import version
|
from paperless import version
|
||||||
@@ -1086,59 +1088,12 @@ class DocumentViewSet(
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _content_filter_params(cls) -> tuple[str, ...]:
|
|
||||||
"""
|
|
||||||
Query params whose filtering needs effective_content evaluated in SQL
|
|
||||||
against every candidate row -- see
|
|
||||||
_needs_effective_content_annotation(). Derived rather than
|
|
||||||
hand-maintained so a new content-filtering param counts automatically.
|
|
||||||
"""
|
|
||||||
params = [
|
|
||||||
name
|
|
||||||
for name, f in DocumentFilterSet.declared_filters.items()
|
|
||||||
if isinstance(f, (TitleContentFilter, EffectiveContentFilter))
|
|
||||||
]
|
|
||||||
if "effective_content" in cls.search_fields:
|
|
||||||
params.append(SearchFilter().search_param)
|
|
||||||
return tuple(params)
|
|
||||||
|
|
||||||
def _needs_effective_content_annotation(self) -> bool:
|
|
||||||
# effective_content is a per-row correlated subquery resolving each
|
|
||||||
# document's latest version. Filtering *on* it forces the database to
|
|
||||||
# evaluate it for every candidate row before reaching the LIMIT, which
|
|
||||||
# the root_document_id self-join makes pathological on MariaDB
|
|
||||||
# specifically once real candidate counts get large; otherwise the
|
|
||||||
# "versions" prefetch + Document.get_effective_content() resolves only
|
|
||||||
# the page that survives pagination. Every param here is deprecated in
|
|
||||||
# favor of the Tantivy-backed search endpoint (see filters.py's
|
|
||||||
# TitleContentFilter/EffectiveContentFilter docs), so pay that cost
|
|
||||||
# only when one is actually used. Blank values don't count, matching
|
|
||||||
# how those filters themselves no-op on them -- an empty `?search=`
|
|
||||||
# applies no predicate.
|
|
||||||
params = self.request.query_params
|
|
||||||
return any(
|
|
||||||
params.get(param, "").strip() for param in self._content_filter_params()
|
|
||||||
)
|
|
||||||
|
|
||||||
def _requested_fields(self) -> list[str] | None:
|
|
||||||
# The sparse-fieldset `fields` param, as DynamicFieldsModelSerializer
|
|
||||||
# wants it: None means "no restriction, serialize everything", which
|
|
||||||
# a blank value means too. get_queryset() and get_serializer() both
|
|
||||||
# branch on this, and they have to read it identically -- a queryset
|
|
||||||
# that skips the content prefetch for a response that still
|
|
||||||
# serializes content reintroduces get_effective_content()'s
|
|
||||||
# per-instance fallback.
|
|
||||||
fields_param = self.request.query_params.get("fields")
|
|
||||||
return fields_param.split(",") if fields_param else None
|
|
||||||
|
|
||||||
def _needs_effective_content_prefetch(self) -> bool:
|
|
||||||
# The prefetch spares get_effective_content() a per-instance fallback
|
|
||||||
# query, but only earns itself when content can reach the response.
|
|
||||||
fields = self._requested_fields()
|
|
||||||
return fields is None or "content" in fields
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
latest_version_content = Subquery(
|
||||||
|
versions_newest_first(
|
||||||
|
Document.objects.filter(root_document=OuterRef("pk")),
|
||||||
|
).values("content")[:1],
|
||||||
|
)
|
||||||
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
||||||
# be, which forced a GROUP BY aggregate over every matching document
|
# be, which forced a GROUP BY aggregate over every matching document
|
||||||
# before the query could even be sorted or limited.
|
# before the query could even be sorted or limited.
|
||||||
@@ -1158,43 +1113,40 @@ class DocumentViewSet(
|
|||||||
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
||||||
# database to fully sort and dedupe every visible document before
|
# database to fully sort and dedupe every visible document before
|
||||||
# it can apply LIMIT, which is disastrous at scale.
|
# it can apply LIMIT, which is disastrous at scale.
|
||||||
prefetches = [
|
return (
|
||||||
Prefetch(
|
|
||||||
"versions",
|
|
||||||
queryset=Document.objects.only(
|
|
||||||
"id",
|
|
||||||
"added",
|
|
||||||
"checksum",
|
|
||||||
"version_label",
|
|
||||||
"root_document_id",
|
|
||||||
"version_index",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
"tags",
|
|
||||||
Prefetch(
|
|
||||||
"custom_fields",
|
|
||||||
queryset=CustomFieldInstance.objects.select_related("field"),
|
|
||||||
),
|
|
||||||
# NotesSerializer nests the author, this avoids query per note
|
|
||||||
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
|
||||||
]
|
|
||||||
if self._needs_effective_content_prefetch():
|
|
||||||
prefetches.append(latest_version_content_prefetch())
|
|
||||||
queryset = (
|
|
||||||
Document.objects.filter(root_document__isnull=True)
|
Document.objects.filter(root_document__isnull=True)
|
||||||
.order_by("-created", "-id")
|
.order_by("-created", "-id")
|
||||||
|
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
|
||||||
.annotate(num_notes=Coalesce(note_count, 0))
|
.annotate(num_notes=Coalesce(note_count, 0))
|
||||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||||
.prefetch_related(*prefetches)
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"versions",
|
||||||
|
queryset=Document.objects.only(
|
||||||
|
"id",
|
||||||
|
"added",
|
||||||
|
"checksum",
|
||||||
|
"version_label",
|
||||||
|
"root_document_id",
|
||||||
|
"version_index",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"tags",
|
||||||
|
Prefetch(
|
||||||
|
"custom_fields",
|
||||||
|
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||||
|
),
|
||||||
|
# NotesSerializer nests the author, this avoids query per note
|
||||||
|
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if self._needs_effective_content_annotation():
|
|
||||||
queryset = annotate_effective_content(queryset)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
fields_param = self.request.query_params.get("fields", None)
|
||||||
|
fields = fields_param.split(",") if fields_param else None
|
||||||
truncate_content = self.request.query_params.get("truncate_content", "False")
|
truncate_content = self.request.query_params.get("truncate_content", "False")
|
||||||
kwargs.setdefault("context", self.get_serializer_context())
|
kwargs.setdefault("context", self.get_serializer_context())
|
||||||
kwargs.setdefault("fields", self._requested_fields())
|
kwargs.setdefault("fields", fields)
|
||||||
kwargs.setdefault("truncate_content", truncate_content.lower() in ["true", "1"])
|
kwargs.setdefault("truncate_content", truncate_content.lower() in ["true", "1"])
|
||||||
try:
|
try:
|
||||||
full_perms = get_boolean(
|
full_perms = get_boolean(
|
||||||
@@ -2220,6 +2172,73 @@ class DocumentViewSet(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@action(methods=["post"], detail=True, url_path="run-zone-ocr")
|
||||||
|
def run_zone_ocr(self, request, pk=None):
|
||||||
|
"""Run zone-based OCR extraction on this document."""
|
||||||
|
try:
|
||||||
|
document = Document.objects.get(pk=pk)
|
||||||
|
except Document.DoesNotExist:
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
if not document.document_type_id:
|
||||||
|
return Response(
|
||||||
|
{"error": "Document has no type assigned"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = OcrTemplate.objects.filter(
|
||||||
|
document_type_id=document.document_type_id,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
if not templates.exists():
|
||||||
|
return Response(
|
||||||
|
{"error": "No OCR templates found for this document type"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc_path = document.archive_path or document.source_path
|
||||||
|
if not doc_path or not Path(doc_path).is_file():
|
||||||
|
return Response(
|
||||||
|
{"error": "Document file not found"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
from documents.zone_ocr import run_zone_extraction
|
||||||
|
|
||||||
|
run_zone_extraction(document, None)
|
||||||
|
|
||||||
|
# Collect results
|
||||||
|
results = []
|
||||||
|
builtin_labels = {"title": "Title", "asn": "ASN", "created": "Created"}
|
||||||
|
for template in templates.prefetch_related("zones", "zones__custom_field"):
|
||||||
|
for zone in template.zones.all():
|
||||||
|
target = getattr(zone, "target", None) or "custom_field"
|
||||||
|
if target == "custom_field" and zone.custom_field_id:
|
||||||
|
cf_instance = document.custom_fields.filter(
|
||||||
|
field=zone.custom_field,
|
||||||
|
).first()
|
||||||
|
field_name = zone.custom_field.name
|
||||||
|
value = cf_instance.value if cf_instance else None
|
||||||
|
else:
|
||||||
|
field_name = builtin_labels.get(target, target)
|
||||||
|
value = {
|
||||||
|
"title": document.title,
|
||||||
|
"asn": document.archive_serial_number,
|
||||||
|
"created": document.created.isoformat()
|
||||||
|
if document.created
|
||||||
|
else None,
|
||||||
|
}.get(target)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"template": template.name,
|
||||||
|
"zone": zone.name,
|
||||||
|
"custom_field": field_name,
|
||||||
|
"value": value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({"results": results})
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
methods=["delete"],
|
methods=["delete"],
|
||||||
detail=True,
|
detail=True,
|
||||||
@@ -2380,6 +2399,7 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
serializer_class = ChatStreamingSerializer
|
serializer_class = ChatStreamingSerializer
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
request.compress_exempt = True
|
||||||
ai_config = AIConfig()
|
ai_config = AIConfig()
|
||||||
if not ai_config.ai_enabled:
|
if not ai_config.ai_enabled:
|
||||||
return HttpResponseBadRequest("AI is required for this feature")
|
return HttpResponseBadRequest("AI is required for this feature")
|
||||||
@@ -5018,12 +5038,12 @@ class BulkEditObjectsView(PassUserMixin):
|
|||||||
qs_owner_update.update(owner=owner)
|
qs_owner_update.update(owner=owner)
|
||||||
|
|
||||||
if "permissions" in serializer.validated_data:
|
if "permissions" in serializer.validated_data:
|
||||||
set_permissions_for_objects(
|
for obj in qs:
|
||||||
permissions=permissions,
|
set_permissions_for_object(
|
||||||
model=object_class,
|
permissions=permissions,
|
||||||
pks=qs.values_list("pk", flat=True),
|
object=obj,
|
||||||
merge=merge,
|
merge=merge,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -5593,3 +5613,224 @@ def serve_logo(request: HttpRequest, filename: str | None = None) -> FileRespons
|
|||||||
filename=logo_name,
|
filename=logo_name,
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OcrTemplateViewSet(ModelViewSet):
|
||||||
|
"""CRUD for OCR templates with zone definitions."""
|
||||||
|
|
||||||
|
queryset = (
|
||||||
|
OcrTemplate.objects.all()
|
||||||
|
.prefetch_related(
|
||||||
|
"zones",
|
||||||
|
"zones__custom_field",
|
||||||
|
)
|
||||||
|
.order_by("name")
|
||||||
|
)
|
||||||
|
serializer_class = OcrTemplateSerializer
|
||||||
|
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||||
|
pagination_class = StandardPagination
|
||||||
|
|
||||||
|
@action(
|
||||||
|
detail=False,
|
||||||
|
methods=["get"],
|
||||||
|
url_path=r"document-page-image/(?P<doc_id>[0-9]+)/(?P<page>[0-9]+)",
|
||||||
|
)
|
||||||
|
def document_page_image(self, request, doc_id=None, page=None):
|
||||||
|
"""Render a specific page of a document as a PNG image.
|
||||||
|
|
||||||
|
Used by the frontend template editor to display document pages
|
||||||
|
as images that users can draw zones on.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
document = Document.objects.get(pk=doc_id)
|
||||||
|
except Document.DoesNotExist:
|
||||||
|
raise Http404("Document not found")
|
||||||
|
|
||||||
|
page_num = int(page)
|
||||||
|
|
||||||
|
# Validate page number
|
||||||
|
if document.page_count and page_num >= document.page_count:
|
||||||
|
raise Http404(
|
||||||
|
f"Page {page_num} out of range (document has {document.page_count} pages)",
|
||||||
|
)
|
||||||
|
|
||||||
|
doc_path = document.archive_path or document.source_path
|
||||||
|
if not doc_path or not Path(doc_path).is_file():
|
||||||
|
raise Http404("Document file not found")
|
||||||
|
|
||||||
|
# Check if document is an image (single page, no PDF rendering needed)
|
||||||
|
if document.mime_type and document.mime_type.startswith("image/"):
|
||||||
|
content = Path(doc_path).read_bytes()
|
||||||
|
return HttpResponse(content, content_type=document.mime_type)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(dir=settings.SCRATCH_DIR) as tmp_dir:
|
||||||
|
output_prefix = Path(tmp_dir) / "page"
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"pdftoppm",
|
||||||
|
"-png",
|
||||||
|
"-r",
|
||||||
|
"150", # Lower DPI for preview
|
||||||
|
"-f",
|
||||||
|
str(page_num + 1),
|
||||||
|
"-l",
|
||||||
|
str(page_num + 1),
|
||||||
|
str(doc_path),
|
||||||
|
str(output_prefix),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise Http404(
|
||||||
|
f"Failed to render page: {e.stderr.decode(errors='replace')[:200]}",
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise Http404("pdftoppm not available - is poppler-utils installed?")
|
||||||
|
|
||||||
|
rendered = sorted(Path(tmp_dir).glob("page-*.png"))
|
||||||
|
if not rendered:
|
||||||
|
raise Http404("No rendered page found")
|
||||||
|
|
||||||
|
content = rendered[0].read_bytes()
|
||||||
|
|
||||||
|
return HttpResponse(content, content_type="image/png")
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="test-zone")
|
||||||
|
def test_zone(self, request):
|
||||||
|
"""Run OCR on a single ad-hoc zone of a document and return what it
|
||||||
|
yields: the raw OCR text, the transformed value, and whether the
|
||||||
|
validation regex matches. Non-destructive - writes nothing. Used by the
|
||||||
|
editor's per-zone test so a user can tune the zone/regex before saving.
|
||||||
|
|
||||||
|
Accepts: {"document": <id>, "zone": {x, y, width, height, page,
|
||||||
|
ocr_language, transform, validation_regex, zone_source_width,
|
||||||
|
zone_source_height}}.
|
||||||
|
"""
|
||||||
|
from documents.models import OcrTemplateZone
|
||||||
|
from documents.zone_ocr import extract_zone_preview
|
||||||
|
|
||||||
|
zone_data = request.data.get("zone") or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = Document.objects.get(pk=request.data.get("document"))
|
||||||
|
except (Document.DoesNotExist, ValueError, TypeError):
|
||||||
|
return Response(
|
||||||
|
{"error": "Document not found"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc_path = document.archive_path or document.source_path
|
||||||
|
if not doc_path or not Path(doc_path).is_file():
|
||||||
|
return Response(
|
||||||
|
{"error": "Document file not found"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
zone = OcrTemplateZone(
|
||||||
|
name=zone_data.get("name") or "test",
|
||||||
|
x=int(zone_data.get("x", 0)),
|
||||||
|
y=int(zone_data.get("y", 0)),
|
||||||
|
width=int(zone_data.get("width", 0)),
|
||||||
|
height=int(zone_data.get("height", 0)),
|
||||||
|
page=zone_data.get("page"),
|
||||||
|
ocr_language=zone_data.get("ocr_language") or "eng",
|
||||||
|
transform=zone_data.get("transform") or "strip",
|
||||||
|
date_format=zone_data.get("date_format") or "",
|
||||||
|
validation_regex=zone_data.get("validation_regex") or "",
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return Response(
|
||||||
|
{"error": "Invalid zone definition"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if zone.width < 2 or zone.height < 2:
|
||||||
|
return Response(
|
||||||
|
{"error": "Zone is too small to test"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = extract_zone_preview(
|
||||||
|
Path(doc_path),
|
||||||
|
zone,
|
||||||
|
int(zone_data.get("zone_source_width") or 0),
|
||||||
|
int(zone_data.get("zone_source_height") or 0),
|
||||||
|
document.page_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
regex_match = None
|
||||||
|
if zone.validation_regex and result.get("value") is not None:
|
||||||
|
try:
|
||||||
|
regex_match = (
|
||||||
|
re.fullmatch(zone.validation_regex, result["value"]) is not None
|
||||||
|
)
|
||||||
|
except re.error:
|
||||||
|
regex_match = None
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"raw_text": result.get("raw_text"),
|
||||||
|
"value": result.get("value"),
|
||||||
|
"regex": zone.validation_regex,
|
||||||
|
"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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,757 @@
|
|||||||
|
"""
|
||||||
|
Zone-based OCR extraction engine.
|
||||||
|
|
||||||
|
After a document is consumed, this module checks if the document's type has
|
||||||
|
an active OCR template. If so, it renders the relevant pages as images,
|
||||||
|
crops each zone, runs Tesseract OCR on the crop, applies transforms,
|
||||||
|
and writes the results to the mapped custom fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import string
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import OcrTemplate
|
||||||
|
from documents.models import OcrTemplateZone
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperless.zone_ocr")
|
||||||
|
|
||||||
|
|
||||||
|
def run_zone_extraction(
|
||||||
|
document: Document,
|
||||||
|
original_file: Path | None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Run zone-based OCR extraction for a document if its type has an active template.
|
||||||
|
Called from the document_consumption_finished signal handler.
|
||||||
|
"""
|
||||||
|
if not document.document_type_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
templates = OcrTemplate.objects.filter(
|
||||||
|
document_type_id=document.document_type_id,
|
||||||
|
enabled=True,
|
||||||
|
).prefetch_related("zones", "zones__custom_field")
|
||||||
|
|
||||||
|
if not templates.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve the document file: prefer archive (PDF/A), then source, then signal arg
|
||||||
|
doc_path = _resolve_doc_path(document, original_file)
|
||||||
|
if doc_path is None:
|
||||||
|
logger.warning(
|
||||||
|
"Zone OCR: no accessible file for document %d",
|
||||||
|
document.pk,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for template in templates:
|
||||||
|
zones = list(template.zones.all())
|
||||||
|
if not zones:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Zone OCR: processing template '%s' for document %d (%d zones)",
|
||||||
|
template.name,
|
||||||
|
document.pk,
|
||||||
|
len(zones),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_process_template(document, doc_path, template, zones)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Zone OCR: error processing template '%s' for document %d",
|
||||||
|
template.name,
|
||||||
|
document.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_doc_path(
|
||||||
|
document: Document,
|
||||||
|
original_file: Path | None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Find an accessible file for the document."""
|
||||||
|
candidates = []
|
||||||
|
if document.has_archive_version:
|
||||||
|
candidates.append(document.archive_path)
|
||||||
|
candidates.append(document.source_path)
|
||||||
|
if original_file is not None:
|
||||||
|
candidates.append(original_file)
|
||||||
|
|
||||||
|
for path in candidates:
|
||||||
|
if path is not None and Path(path).is_file():
|
||||||
|
return Path(path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_page_idx(page_value, page_count) -> int:
|
||||||
|
"""Resolve a 1-indexed page (1 = first, -1 = last) to a 0-indexed image
|
||||||
|
index. A blank page_value defaults to the first page."""
|
||||||
|
if page_value is None:
|
||||||
|
return 0
|
||||||
|
if page_value == -1:
|
||||||
|
return (page_count - 1) if page_count else 0
|
||||||
|
if page_value >= 1:
|
||||||
|
return page_value - 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _process_template(
|
||||||
|
document: Document,
|
||||||
|
doc_path: Path,
|
||||||
|
template: OcrTemplate,
|
||||||
|
zones: list[OcrTemplateZone],
|
||||||
|
) -> None:
|
||||||
|
"""Process all zones in a template against a document.
|
||||||
|
|
||||||
|
Each zone is OCR'd independently, then zones are grouped by their target
|
||||||
|
field and each field is written exactly once. When several zones share a
|
||||||
|
field, their values are combined via the template's per-field format string
|
||||||
|
(or joined in order if none is set) — this avoids the zones overwriting each
|
||||||
|
other's value.
|
||||||
|
"""
|
||||||
|
pages_needed: set[int] = {
|
||||||
|
_resolve_page_idx(zone.page, document.page_count) for zone in zones
|
||||||
|
}
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(dir=settings.SCRATCH_DIR) as tmp_dir:
|
||||||
|
tmp_path = Path(tmp_dir)
|
||||||
|
|
||||||
|
page_images = _render_pages(
|
||||||
|
doc_path,
|
||||||
|
pages_needed,
|
||||||
|
tmp_path,
|
||||||
|
document.page_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pass 1: OCR every zone into a value (or None if it failed/was rejected).
|
||||||
|
zone_values: dict[int, str | None] = {}
|
||||||
|
for zone in zones:
|
||||||
|
page_idx = _resolve_page_idx(zone.page, document.page_count)
|
||||||
|
|
||||||
|
if page_idx not in page_images:
|
||||||
|
logger.warning(
|
||||||
|
"Zone OCR: page %d not available for zone '%s'",
|
||||||
|
page_idx,
|
||||||
|
zone.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
src_w = zone.zone_source_width or template.source_width
|
||||||
|
src_h = zone.zone_source_height or template.source_height
|
||||||
|
|
||||||
|
extracted = _extract_zone(
|
||||||
|
page_images[page_idx],
|
||||||
|
zone,
|
||||||
|
src_w,
|
||||||
|
src_h,
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
extracted is not None
|
||||||
|
and zone.validation_regex
|
||||||
|
and not re.fullmatch(zone.validation_regex, extracted)
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Zone OCR: '%s' value %r rejected by regex '%s'",
|
||||||
|
zone.name,
|
||||||
|
extracted[:100],
|
||||||
|
zone.validation_regex,
|
||||||
|
)
|
||||||
|
extracted = None
|
||||||
|
|
||||||
|
zone_values[id(zone)] = extracted
|
||||||
|
|
||||||
|
# Pass 2: group zones by target field and write each field once.
|
||||||
|
grouped: dict[str, list[OcrTemplateZone]] = {}
|
||||||
|
for zone in zones:
|
||||||
|
grouped.setdefault(_field_key(zone), []).append(zone)
|
||||||
|
|
||||||
|
combine_formats = template.combine_formats or {}
|
||||||
|
for key, field_zones in grouped.items():
|
||||||
|
value = _combine_field_value(
|
||||||
|
combine_formats.get(key, ""),
|
||||||
|
field_zones,
|
||||||
|
zone_values,
|
||||||
|
)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_zone = field_zones[0]
|
||||||
|
_write_zone_value(document, target_zone, value)
|
||||||
|
logger.info(
|
||||||
|
"Zone OCR: %s = %r (from %d zone(s))",
|
||||||
|
_zone_target_label(target_zone),
|
||||||
|
value[:100] if len(value) > 100 else value,
|
||||||
|
len(field_zones),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _field_key(zone: OcrTemplateZone) -> str:
|
||||||
|
"""Identify a zone's target field. Custom fields key by id, built-in targets
|
||||||
|
by their name. Matches the key used in OcrTemplate.combine_formats and on the
|
||||||
|
frontend field select."""
|
||||||
|
target = getattr(zone, "target", None) or "custom_field"
|
||||||
|
if target == "custom_field" and zone.custom_field_id:
|
||||||
|
return str(zone.custom_field_id)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _combine_field_value(
|
||||||
|
fmt: str,
|
||||||
|
field_zones: list[OcrTemplateZone],
|
||||||
|
zone_values: dict[int, str | None],
|
||||||
|
) -> str:
|
||||||
|
"""Combine the OCR values of all zones targeting one field.
|
||||||
|
|
||||||
|
With a format string, `{Zone Name}` tokens are replaced by that zone's value
|
||||||
|
and literal text is kept; separators left dangling by an empty token are
|
||||||
|
cleaned up. Without a format, the zone values are joined in order by a space.
|
||||||
|
"""
|
||||||
|
values = {z.name: (zone_values.get(id(z)) or "") for z in field_zones}
|
||||||
|
|
||||||
|
if not fmt:
|
||||||
|
parts = [zone_values.get(id(z)) or "" for z in field_zones]
|
||||||
|
return " ".join(p for p in parts if p).strip()
|
||||||
|
|
||||||
|
def _replace(match: re.Match) -> str:
|
||||||
|
return values.get(match.group(1).strip(), "")
|
||||||
|
|
||||||
|
combined = re.sub(r"\{([^{}]+)\}", _replace, fmt)
|
||||||
|
# Tidy up separators an empty token may have left behind.
|
||||||
|
combined = re.sub(r"\s{2,}", " ", combined)
|
||||||
|
combined = re.sub(r"([^\w\s])\s*\1+", r"\1", combined)
|
||||||
|
return combined.strip().strip("-/.,;:| \t")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_pages(
|
||||||
|
doc_path: Path,
|
||||||
|
pages: set[int],
|
||||||
|
tmp_dir: Path,
|
||||||
|
page_count: int | None,
|
||||||
|
) -> dict[int, Path]:
|
||||||
|
"""Render specific PDF pages as PNG images using pdftoppm (poppler-utils)."""
|
||||||
|
result: dict[int, Path] = {}
|
||||||
|
mime = _detect_mime(doc_path)
|
||||||
|
|
||||||
|
if mime and mime.startswith("image/"):
|
||||||
|
# Single-image document — use it directly as page 0.
|
||||||
|
result[0] = doc_path
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Callers pass already-resolved 0-indexed page numbers (see _resolve_page_idx).
|
||||||
|
for actual_page in pages:
|
||||||
|
if actual_page < 0:
|
||||||
|
logger.warning("Zone OCR: invalid page index %d", actual_page)
|
||||||
|
continue
|
||||||
|
|
||||||
|
output_prefix = tmp_dir / f"page_{actual_page}"
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"pdftoppm",
|
||||||
|
"-png",
|
||||||
|
"-r",
|
||||||
|
"300",
|
||||||
|
"-f",
|
||||||
|
str(actual_page + 1), # pdftoppm is 1-indexed
|
||||||
|
"-l",
|
||||||
|
str(actual_page + 1),
|
||||||
|
str(doc_path),
|
||||||
|
str(output_prefix),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Zone OCR: pdftoppm timed out for page %d", actual_page)
|
||||||
|
continue
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(
|
||||||
|
"Zone OCR: pdftoppm failed for page %d: %s",
|
||||||
|
actual_page,
|
||||||
|
e.stderr.decode(errors="replace") if e.stderr else str(e),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("Zone OCR: pdftoppm not found — is poppler-utils installed?")
|
||||||
|
return result # No point trying other pages
|
||||||
|
|
||||||
|
# pdftoppm names output as prefix-NNNN.png
|
||||||
|
rendered = sorted(tmp_dir.glob(f"page_{actual_page}-*.png"))
|
||||||
|
if rendered:
|
||||||
|
result[actual_page] = rendered[0]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _crop_zone(
|
||||||
|
page_img: Path,
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
source_width: int,
|
||||||
|
source_height: int,
|
||||||
|
tmp_dir: Path,
|
||||||
|
) -> Image.Image | None:
|
||||||
|
"""Crop a zone from the page image and return the PIL Image."""
|
||||||
|
try:
|
||||||
|
with Image.open(page_img) as img:
|
||||||
|
img_width, img_height = img.size
|
||||||
|
|
||||||
|
scale_x = img_width / source_width
|
||||||
|
scale_y = img_height / source_height
|
||||||
|
|
||||||
|
crop_left = int(zone.x * scale_x)
|
||||||
|
crop_top = int(zone.y * scale_y)
|
||||||
|
crop_right = int((zone.x + zone.width) * scale_x)
|
||||||
|
crop_bottom = int((zone.y + zone.height) * scale_y)
|
||||||
|
|
||||||
|
# Clamp to the image so an oversized zone can't crop out of bounds.
|
||||||
|
crop_left = max(0, min(crop_left, img_width))
|
||||||
|
crop_top = max(0, min(crop_top, img_height))
|
||||||
|
crop_right = max(crop_left + 1, min(crop_right, img_width))
|
||||||
|
crop_bottom = max(crop_top + 1, min(crop_bottom, img_height))
|
||||||
|
|
||||||
|
if crop_right - crop_left < 2 or crop_bottom - crop_top < 2:
|
||||||
|
logger.warning("Zone OCR: crop too small for zone '%s'", zone.name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return img.crop((crop_left, crop_top, crop_right, crop_bottom)).copy()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Zone OCR: crop failed for zone '%s'", zone.name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_barcode(cropped: Image.Image, zone_name: str) -> str | None:
|
||||||
|
"""Read QR/barcode from a cropped image using zxingcpp."""
|
||||||
|
try:
|
||||||
|
import zxingcpp
|
||||||
|
|
||||||
|
results = zxingcpp.read_barcodes(cropped)
|
||||||
|
if results:
|
||||||
|
text = results[0].text
|
||||||
|
logger.debug(
|
||||||
|
"Zone OCR: barcode found in zone '%s': %s",
|
||||||
|
zone_name,
|
||||||
|
text[:100],
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
logger.debug("Zone OCR: no barcode found in zone '%s'", zone_name)
|
||||||
|
return None
|
||||||
|
except ImportError:
|
||||||
|
logger.error("Zone OCR: zxingcpp not available — install zxing-cpp")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Zone OCR: barcode read failed for zone '%s'", zone_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ocr_text(cropped: Image.Image, zone: OcrTemplateZone, tmp_dir: Path) -> str | None:
|
||||||
|
"""OCR a cropped image with Tesseract."""
|
||||||
|
crop_path = tmp_dir / f"zone_{zone.pk}.png"
|
||||||
|
cropped.save(crop_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"tesseract",
|
||||||
|
str(crop_path),
|
||||||
|
"stdout",
|
||||||
|
"-l",
|
||||||
|
zone.ocr_language,
|
||||||
|
"--psm",
|
||||||
|
"6", # Assume uniform block of text
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return proc.stdout.strip() or None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Zone OCR: Tesseract timed out for zone '%s'", zone.name)
|
||||||
|
return None
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(
|
||||||
|
"Zone OCR: Tesseract failed for zone '%s': %s",
|
||||||
|
zone.name,
|
||||||
|
e.stderr[:200] if e.stderr else str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("Zone OCR: Tesseract not found — is tesseract-ocr installed?")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_zone(
|
||||||
|
page_img: Path,
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
source_width: int,
|
||||||
|
source_height: int,
|
||||||
|
tmp_dir: Path,
|
||||||
|
) -> str | None:
|
||||||
|
"""Crop a zone from the page image and extract text via OCR or barcode reader."""
|
||||||
|
cropped = _crop_zone(page_img, zone, source_width, source_height, tmp_dir)
|
||||||
|
if cropped is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# QR/barcode zones skip Tesseract entirely
|
||||||
|
if zone.transform == "qr_code":
|
||||||
|
text = _read_barcode(cropped, zone.name)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return _apply_transform(
|
||||||
|
text,
|
||||||
|
zone.transform,
|
||||||
|
getattr(zone, "date_format", "") or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
text = _ocr_text(cropped, zone, tmp_dir)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _apply_transform(
|
||||||
|
text,
|
||||||
|
zone.transform,
|
||||||
|
getattr(zone, "date_format", "") or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_zone_preview(
|
||||||
|
doc_path: Path,
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
source_width: int,
|
||||||
|
source_height: int,
|
||||||
|
page_count: int | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Non-destructive single-zone extraction for the editor's per-zone test.
|
||||||
|
|
||||||
|
Renders the zone's page, crops it, runs OCR (or the barcode reader) and
|
||||||
|
applies the transform — WITHOUT writing any custom field. Returns the raw
|
||||||
|
OCR text and the transformed value so the user can see what the zone yields
|
||||||
|
(and tune the validation regex) before saving.
|
||||||
|
"""
|
||||||
|
# zone.page is 1-indexed (1 = first, -1 = last); resolve to a 0-indexed
|
||||||
|
# image index exactly like the production extraction path does.
|
||||||
|
page_idx = _resolve_page_idx(zone.page, page_count)
|
||||||
|
with tempfile.TemporaryDirectory(dir=settings.SCRATCH_DIR) as tmp_dir:
|
||||||
|
tmp_path = Path(tmp_dir)
|
||||||
|
page_images = _render_pages(doc_path, {page_idx}, tmp_path, page_count)
|
||||||
|
if page_idx not in page_images:
|
||||||
|
return {"raw_text": None, "value": None}
|
||||||
|
|
||||||
|
if not source_width or not source_height:
|
||||||
|
with Image.open(page_images[page_idx]) as im:
|
||||||
|
source_width, source_height = im.size
|
||||||
|
|
||||||
|
cropped = _crop_zone(
|
||||||
|
page_images[page_idx],
|
||||||
|
zone,
|
||||||
|
source_width,
|
||||||
|
source_height,
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
if cropped is None:
|
||||||
|
return {"raw_text": None, "value": None}
|
||||||
|
|
||||||
|
if zone.transform == "qr_code":
|
||||||
|
raw_text = _read_barcode(cropped, zone.name)
|
||||||
|
else:
|
||||||
|
raw_text = _ocr_text(cropped, zone, tmp_path)
|
||||||
|
|
||||||
|
value = (
|
||||||
|
_apply_transform(
|
||||||
|
raw_text,
|
||||||
|
zone.transform,
|
||||||
|
getattr(zone, "date_format", "") or "",
|
||||||
|
)
|
||||||
|
if raw_text
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {"raw_text": raw_text, "value": value}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(text: str, fmt: str) -> str:
|
||||||
|
"""Parse a date from OCR text. With a Python strptime `fmt`, try that first;
|
||||||
|
otherwise (or on failure) fall back to dateparser auto-detection. Returns an
|
||||||
|
ISO date string, or the original text if nothing parses."""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
if fmt:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, fmt).date().isoformat()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import dateparser
|
||||||
|
|
||||||
|
parsed = dateparser.parse(
|
||||||
|
text,
|
||||||
|
settings={
|
||||||
|
"PREFER_DAY_OF_MONTH": "first",
|
||||||
|
"RETURN_AS_TIMEZONE_AWARE": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if parsed:
|
||||||
|
return parsed.date().isoformat()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Zone OCR: dateparser failed for %r", text[:50])
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_transform(text: str, transform: str, date_format: str = "") -> str:
|
||||||
|
"""Apply post-processing transform to extracted text."""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
if transform in ("strip", "none"):
|
||||||
|
return text
|
||||||
|
elif transform == "date":
|
||||||
|
return _parse_date(text, date_format)
|
||||||
|
elif transform == "uppercase":
|
||||||
|
return text.upper()
|
||||||
|
elif transform == "lowercase":
|
||||||
|
return text.lower()
|
||||||
|
elif transform == "numeric":
|
||||||
|
result = re.sub(r"[^\d.,\-]", "", text)
|
||||||
|
return result if result else text
|
||||||
|
elif transform == "strip_punctuation":
|
||||||
|
return text.strip(string.punctuation + " \t\r\n")
|
||||||
|
elif transform == "qr_code":
|
||||||
|
# Barcode/QR content as read by _read_barcode.
|
||||||
|
return text
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _zone_target_label(zone: OcrTemplateZone) -> str:
|
||||||
|
"""Human label of a zone's write target (for logging)."""
|
||||||
|
target = getattr(zone, "target", None) or "custom_field"
|
||||||
|
if target == "custom_field":
|
||||||
|
return zone.custom_field.name if zone.custom_field_id else "(no field)"
|
||||||
|
return {"title": "Title", "asn": "ASN", "created": "Created"}.get(target, target)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_created_datetime(value: str):
|
||||||
|
"""Parse an extracted value into a tz-aware datetime for document.created.
|
||||||
|
|
||||||
|
Prefers an ISO date (the zone should use a date transform); falls back to
|
||||||
|
dateparser. Returns None if no date can be parsed.
|
||||||
|
"""
|
||||||
|
from django.utils import timezone as djtz
|
||||||
|
|
||||||
|
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", value)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
dt = datetime(int(m[1]), int(m[2]), int(m[3]))
|
||||||
|
return djtz.make_aware(dt) if djtz.is_naive(dt) else dt
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import dateparser
|
||||||
|
|
||||||
|
parsed = dateparser.parse(
|
||||||
|
value,
|
||||||
|
settings={"RETURN_AS_TIMEZONE_AWARE": False},
|
||||||
|
)
|
||||||
|
if parsed:
|
||||||
|
return djtz.make_aware(parsed) if djtz.is_naive(parsed) else parsed
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Zone OCR: dateparser failed for created value %r", value[:50])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_zone_value(
|
||||||
|
document: Document,
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
value: str,
|
||||||
|
) -> None:
|
||||||
|
"""Write an extracted value to the zone's target — a custom field, or a
|
||||||
|
built-in document field (title / archive_serial_number / created)."""
|
||||||
|
target = getattr(zone, "target", None) or "custom_field"
|
||||||
|
|
||||||
|
if target == "custom_field":
|
||||||
|
if zone.custom_field_id:
|
||||||
|
_write_custom_field(document, zone.custom_field, value)
|
||||||
|
else:
|
||||||
|
logger.debug("Zone OCR: zone '%s' has no custom field set", zone.name)
|
||||||
|
return
|
||||||
|
|
||||||
|
if target == "title":
|
||||||
|
document.title = value[:128]
|
||||||
|
document.save(update_fields=["title"])
|
||||||
|
elif target == "asn":
|
||||||
|
digits = re.sub(r"[^\d]", "", value)
|
||||||
|
if not digits:
|
||||||
|
logger.debug(
|
||||||
|
"Zone OCR: ASN zone '%s' produced no digits (%r)",
|
||||||
|
zone.name,
|
||||||
|
value[:50],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
document.archive_serial_number = int(digits)
|
||||||
|
document.save(update_fields=["archive_serial_number"])
|
||||||
|
elif target == "created":
|
||||||
|
parsed = _parse_created_datetime(value)
|
||||||
|
if parsed is None:
|
||||||
|
logger.debug(
|
||||||
|
"Zone OCR: created zone '%s' could not parse a date (%r)",
|
||||||
|
zone.name,
|
||||||
|
value[:50],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
document.created = parsed
|
||||||
|
document.save(update_fields=["created"])
|
||||||
|
|
||||||
|
|
||||||
|
def _write_custom_field(
|
||||||
|
document: Document,
|
||||||
|
custom_field: CustomField,
|
||||||
|
value: str,
|
||||||
|
) -> None:
|
||||||
|
"""Write an extracted value to a document's custom field."""
|
||||||
|
typed_value = _convert_value(value, custom_field.data_type)
|
||||||
|
if typed_value is None:
|
||||||
|
logger.debug(
|
||||||
|
"Zone OCR: skipping custom field '%s' — value conversion returned None",
|
||||||
|
custom_field.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
value_field_name = CustomFieldInstance.get_value_field_name(custom_field.data_type)
|
||||||
|
|
||||||
|
CustomFieldInstance.objects.update_or_create(
|
||||||
|
document=document,
|
||||||
|
field=custom_field,
|
||||||
|
defaults={value_field_name: typed_value},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_value(value: str, data_type: str) -> object | None:
|
||||||
|
"""Convert an extracted OCR string to the appropriate type for the custom field."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if data_type in (
|
||||||
|
CustomField.FieldDataType.STRING,
|
||||||
|
CustomField.FieldDataType.URL,
|
||||||
|
):
|
||||||
|
return value[:128]
|
||||||
|
|
||||||
|
elif data_type == CustomField.FieldDataType.LONG_TEXT:
|
||||||
|
return value
|
||||||
|
|
||||||
|
elif data_type == CustomField.FieldDataType.INT:
|
||||||
|
digits = re.sub(r"[^\d\-]", "", value)
|
||||||
|
# Handle edge case: only dashes or empty
|
||||||
|
digits = digits.lstrip("-") or ""
|
||||||
|
if not digits:
|
||||||
|
return None
|
||||||
|
# Restore leading minus if original had one
|
||||||
|
if value.strip().startswith("-"):
|
||||||
|
digits = "-" + digits
|
||||||
|
return int(digits)
|
||||||
|
|
||||||
|
elif data_type == CustomField.FieldDataType.FLOAT:
|
||||||
|
# Handle European format: 1.234,56 → 1234.56
|
||||||
|
cleaned = re.sub(r"[^\d.,\-]", "", value)
|
||||||
|
if not cleaned or cleaned in (".", ",", "-"):
|
||||||
|
return None
|
||||||
|
# If both . and , present, the last one is the decimal separator
|
||||||
|
if "," in cleaned and "." in cleaned:
|
||||||
|
if cleaned.rindex(",") > cleaned.rindex("."):
|
||||||
|
# European: 1.234,56
|
||||||
|
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||||
|
else:
|
||||||
|
# US: 1,234.56
|
||||||
|
cleaned = cleaned.replace(",", "")
|
||||||
|
elif "," in cleaned:
|
||||||
|
# Only comma — treat as decimal separator
|
||||||
|
cleaned = cleaned.replace(",", ".")
|
||||||
|
return float(cleaned)
|
||||||
|
|
||||||
|
elif data_type == CustomField.FieldDataType.DATE:
|
||||||
|
match = re.search(r"(\d{4})-(\d{2})-(\d{2})", value)
|
||||||
|
if match:
|
||||||
|
y, m, d = match.groups()
|
||||||
|
# Validate the date
|
||||||
|
date(int(y), int(m), int(d))
|
||||||
|
return f"{y}-{m}-{d}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif data_type == CustomField.FieldDataType.MONETARY:
|
||||||
|
cleaned = re.sub(r"[^\d.,\-]", "", value)
|
||||||
|
if not cleaned or cleaned in (".", ",", "-"):
|
||||||
|
return None
|
||||||
|
if "," in cleaned and "." in cleaned:
|
||||||
|
if cleaned.rindex(",") > cleaned.rindex("."):
|
||||||
|
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||||
|
else:
|
||||||
|
cleaned = cleaned.replace(",", "")
|
||||||
|
elif "," in cleaned:
|
||||||
|
cleaned = cleaned.replace(",", ".")
|
||||||
|
# Validate it parses as a number
|
||||||
|
float(cleaned)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
elif data_type == CustomField.FieldDataType.BOOL:
|
||||||
|
lower = value.lower().strip()
|
||||||
|
if lower in ("true", "yes", "1", "ja", "oui", "si", "x"):
|
||||||
|
return True
|
||||||
|
elif lower in ("false", "no", "0", "nein", "non"):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Unsupported types (DOCUMENTLINK, SELECT) — can't OCR into these
|
||||||
|
logger.debug(
|
||||||
|
"Zone OCR: unsupported custom field type %s for OCR extraction",
|
||||||
|
data_type,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning("Zone OCR: could not convert %r to %s: %s", value, data_type, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_mime(path: Path) -> str | None:
|
||||||
|
"""Detect MIME type of a file."""
|
||||||
|
try:
|
||||||
|
import magic
|
||||||
|
|
||||||
|
return magic.from_file(str(path), mime=True)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Zone OCR: magic failed for %s, falling back to extension", path)
|
||||||
|
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
return {
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".tiff": "image/tiff",
|
||||||
|
".tif": "image/tiff",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
}.get(suffix)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,8 @@
|
|||||||
from compression_middleware.middleware import CompressionMiddleware
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from paperless import version
|
from paperless import version
|
||||||
|
|
||||||
|
|
||||||
class StreamAwareCompressionMiddleware(CompressionMiddleware):
|
|
||||||
"""
|
|
||||||
Bypasses compression for server-sent streams (text/event-stream).
|
|
||||||
|
|
||||||
See https://github.com/friedelwolff/django-compression-middleware/pull/7
|
|
||||||
"""
|
|
||||||
|
|
||||||
def process_response(self, request, response):
|
|
||||||
content_type = response.headers.get("Content-Type", "")
|
|
||||||
if content_type.startswith("text/event-stream"):
|
|
||||||
return response
|
|
||||||
return super().process_response(request, response)
|
|
||||||
|
|
||||||
|
|
||||||
class ApiVersionMiddleware:
|
class ApiVersionMiddleware:
|
||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from compression_middleware.middleware import CompressionMiddleware
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -95,13 +96,6 @@ MODEL_FILE = get_path_from_env(
|
|||||||
"PAPERLESS_MODEL_FILE",
|
"PAPERLESS_MODEL_FILE",
|
||||||
DATA_DIR / "classification_model.pickle",
|
DATA_DIR / "classification_model.pickle",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Minimum confidence (0.0-1.0) for the ML classifier to assign a correspondent,
|
|
||||||
# document type, or storage path. 0.0 disables the threshold.
|
|
||||||
CLASSIFIER_MATCH_THRESHOLD: Final[float] = get_float_from_env(
|
|
||||||
"PAPERLESS_CLASSIFIER_MATCH_THRESHOLD",
|
|
||||||
0.6,
|
|
||||||
)
|
|
||||||
LLM_INDEX_DIR = DATA_DIR / "llm_index"
|
LLM_INDEX_DIR = DATA_DIR / "llm_index"
|
||||||
LLM_INDEX_LOCK = LLM_INDEX_DIR / "index.lock"
|
LLM_INDEX_LOCK = LLM_INDEX_DIR / "index.lock"
|
||||||
# Cross-process read/write lock guarding the LLM index compaction/migration
|
# Cross-process read/write lock guarding the LLM index compaction/migration
|
||||||
@@ -200,10 +194,22 @@ MIDDLEWARE = [
|
|||||||
"allauth.account.middleware.AccountMiddleware",
|
"allauth.account.middleware.AccountMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Optional to enable compression. The subclass leaves server-sent events
|
# Optional to enable compression
|
||||||
# uncompressed; see paperless.middleware.StreamAwareCompressionMiddleware.
|
|
||||||
if get_bool_from_env("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover
|
if get_bool_from_env("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover
|
||||||
MIDDLEWARE.insert(0, "paperless.middleware.StreamAwareCompressionMiddleware")
|
MIDDLEWARE.insert(0, "compression_middleware.middleware.CompressionMiddleware")
|
||||||
|
|
||||||
|
# Workaround to not compress streaming responses (e.g. chat).
|
||||||
|
# See https://github.com/friedelwolff/django-compression-middleware/pull/7
|
||||||
|
original_process_response = CompressionMiddleware.process_response
|
||||||
|
|
||||||
|
|
||||||
|
def patched_process_response(self, request, response):
|
||||||
|
if getattr(request, "compress_exempt", False):
|
||||||
|
return response
|
||||||
|
return original_process_response(self, request, response)
|
||||||
|
|
||||||
|
|
||||||
|
CompressionMiddleware.process_response = patched_process_response
|
||||||
|
|
||||||
ROOT_URLCONF = "paperless.urls"
|
ROOT_URLCONF = "paperless.urls"
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
from django.http import HttpResponse
|
|
||||||
from django.http import StreamingHttpResponse
|
|
||||||
from django.test import RequestFactory
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from paperless.middleware import StreamAwareCompressionMiddleware
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamAwareCompressionMiddleware(TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super().setUp()
|
|
||||||
self.factory = RequestFactory()
|
|
||||||
self.middleware = StreamAwareCompressionMiddleware(lambda request: None)
|
|
||||||
|
|
||||||
def _request(self):
|
|
||||||
return self.factory.get(
|
|
||||||
"/api/documents/chat/",
|
|
||||||
HTTP_ACCEPT_ENCODING="gzip, deflate, br, zstd",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_event_stream_is_not_compressed(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A server-sent event response produced chunk by chunk
|
|
||||||
WHEN:
|
|
||||||
- The compression middleware processes it
|
|
||||||
THEN:
|
|
||||||
- It is passed through unencoded, one wire chunk per source chunk
|
|
||||||
"""
|
|
||||||
chunks = [f"token{i} ".encode() for i in range(40)]
|
|
||||||
response = StreamingHttpResponse(
|
|
||||||
iter(chunks),
|
|
||||||
content_type="text/event-stream",
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.middleware.process_response(self._request(), response)
|
|
||||||
|
|
||||||
assert not response.has_header("Content-Encoding")
|
|
||||||
assert list(response.streaming_content) == chunks
|
|
||||||
|
|
||||||
def test_regular_response_is_still_compressed(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An ordinary response large enough to be worth compressing
|
|
||||||
WHEN:
|
|
||||||
- The compression middleware processes it
|
|
||||||
THEN:
|
|
||||||
- It is compressed as before
|
|
||||||
"""
|
|
||||||
response = HttpResponse(b"a" * 5000, content_type="application/json")
|
|
||||||
|
|
||||||
response = self.middleware.process_response(self._request(), response)
|
|
||||||
|
|
||||||
assert response.has_header("Content-Encoding")
|
|
||||||
@@ -29,6 +29,7 @@ from documents.views import IndexView
|
|||||||
from documents.views import LogViewSet
|
from documents.views import LogViewSet
|
||||||
from documents.views import MergeDocumentsAsVersionsView
|
from documents.views import MergeDocumentsAsVersionsView
|
||||||
from documents.views import MergeDocumentsView
|
from documents.views import MergeDocumentsView
|
||||||
|
from documents.views import OcrTemplateViewSet
|
||||||
from documents.views import PostDocumentView
|
from documents.views import PostDocumentView
|
||||||
from documents.views import RemoteVersionView
|
from documents.views import RemoteVersionView
|
||||||
from documents.views import RemovePasswordDocumentsView
|
from documents.views import RemovePasswordDocumentsView
|
||||||
@@ -87,6 +88,7 @@ api_router.register(r"workflow_triggers", WorkflowTriggerViewSet)
|
|||||||
api_router.register(r"workflow_actions", WorkflowActionViewSet)
|
api_router.register(r"workflow_actions", WorkflowActionViewSet)
|
||||||
api_router.register(r"workflows", WorkflowViewSet)
|
api_router.register(r"workflows", WorkflowViewSet)
|
||||||
api_router.register(r"custom_fields", CustomFieldViewSet)
|
api_router.register(r"custom_fields", CustomFieldViewSet)
|
||||||
|
api_router.register(r"ocr_templates", OcrTemplateViewSet)
|
||||||
api_router.register(r"config", ApplicationConfigurationViewSet)
|
api_router.register(r"config", ApplicationConfigurationViewSet)
|
||||||
api_router.register(r"processed_mail", ProcessedMailViewSet)
|
api_router.register(r"processed_mail", ProcessedMailViewSet)
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ LLM_SYSTEM_PROMPT = (
|
|||||||
|
|
||||||
# openai-python rejects empty keys since 2.34.0, "fake" is the stand-in from
|
# openai-python rejects empty keys since 2.34.0, "fake" is the stand-in from
|
||||||
# llama-index's own OpenAILike docs https://docs.llamaindex.ai/en/stable/api_reference/llms/openai_like/
|
# llama-index's own OpenAILike docs https://docs.llamaindex.ai/en/stable/api_reference/llms/openai_like/
|
||||||
|
# TODO: remove pending resolution of https://github.com/openai/openai-python/issues/3224
|
||||||
PLACEHOLDER_API_KEY: Final = "fake"
|
PLACEHOLDER_API_KEY: Final = "fake"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user