From c93c996edf32b959d74806dbf097de0917155fd3 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:43:05 -0700 Subject: [PATCH] Remote ocr reprocess (#13636) * Backend stuff for remote ocr reprocess, add to bulk edit pass in from ui settings * Ok, frontend reprocess remote option * Docs --- docs/api.md | 3 +- docs/usage.md | 2 + .../reprocess-confirm-dialog.component.html | 28 ++++++++ ...reprocess-confirm-dialog.component.spec.ts | 72 +++++++++++++++++++ .../reprocess-confirm-dialog.component.ts | 20 ++++++ .../document-detail.component.spec.ts | 14 +++- .../document-detail.component.ts | 8 ++- .../bulk-editor/bulk-editor.component.spec.ts | 1 + .../bulk-editor/bulk-editor.component.ts | 8 ++- src-ui/src/app/data/ui-settings.ts | 13 ++++ .../services/rest/document.service.spec.ts | 15 ++++ .../src/app/services/rest/document.service.ts | 6 +- .../src/app/services/settings.service.spec.ts | 23 ++++++ src-ui/src/app/services/settings.service.ts | 12 ++++ src/documents/bulk_edit.py | 10 ++- src/documents/serialisers.py | 11 ++- src/documents/tests/test_api_bulk_edit.py | 47 +++++++++++- src/documents/tests/test_api_uisettings.py | 48 +++++++++++++ src/documents/tests/test_bulk_edit.py | 53 ++++++++++++++ src/documents/views.py | 7 ++ 20 files changed, 390 insertions(+), 11 deletions(-) create mode 100644 src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html create mode 100644 src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.ts diff --git a/docs/api.md b/docs/api.md index 58927229d..91e8492ed 100644 --- a/docs/api.md +++ b/docs/api.md @@ -302,7 +302,8 @@ The following methods are supported: - `delete` - No `parameters` required - `reprocess` - - No `parameters` required + - Optional `parameters`: `{ "remote_ocr": true }` to send the documents to the + remote OCR engine, see [Remote OCR](usage.md#remote-ocr). Defaults to false. - `set_permissions` - Requires `parameters`: - `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or diff --git a/docs/usage.md b/docs/usage.md index 42e2716ba..0dc13b07a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1100,6 +1100,8 @@ By default, every document of a supported file type is sent to the remote engine [remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally unless a workflow explicitly enables remote OCR for them, so you can limit the remote engine to particular documents. +Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents. + ## Architecture Paperless-ngx consists of the following components: diff --git a/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html new file mode 100644 index 000000000..eccd39c3b --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html @@ -0,0 +1,28 @@ + + + diff --git a/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.spec.ts new file mode 100644 index 000000000..f6e2d9eac --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.spec.ts @@ -0,0 +1,72 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { provideHttpClientTesting } from '@angular/common/http/testing' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { RemoteOCRModeConfig } from 'src/app/data/paperless-config' +import { SETTINGS_KEYS } from 'src/app/data/ui-settings' +import { SettingsService } from 'src/app/services/settings.service' +import { ReprocessConfirmDialogComponent } from './reprocess-confirm-dialog.component' + +describe('ReprocessConfirmDialogComponent', () => { + let component: ReprocessConfirmDialogComponent + let fixture: ComponentFixture + let settingsService: SettingsService + + const createComponent = (configured: boolean, mode: string) => { + settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, configured) + settingsService.set(SETTINGS_KEYS.REMOTE_OCR_MODE, mode) + + fixture = TestBed.createComponent(ReprocessConfirmDialogComponent) + component = fixture.componentInstance + fixture.detectChanges() + } + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + NgbActiveModal, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + imports: [ReprocessConfirmDialogComponent], + }).compileComponents() + + settingsService = TestBed.inject(SettingsService) + }) + + it('should not request remote OCR by default', () => { + createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY) + + expect(component.remoteOcr).toBeFalsy() + }) + + it('should not offer remote OCR when no engine is configured', () => { + createComponent(false, RemoteOCRModeConfig.WORKFLOW_ONLY) + + expect(component.showRemoteOcr).toBeFalsy() + expect( + fixture.nativeElement.querySelector('#reprocessRemoteOcr') + ).toBeNull() + }) + + it('should not offer remote OCR when it already handles every document', () => { + createComponent(true, RemoteOCRModeConfig.ALWAYS) + + expect(component.showRemoteOcr).toBeFalsy() + expect( + fixture.nativeElement.querySelector('#reprocessRemoteOcr') + ).toBeNull() + }) + + it('should offer remote OCR when configured and selective', () => { + createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY) + + expect(component.showRemoteOcr).toBeTruthy() + const checkbox = fixture.nativeElement.querySelector('#reprocessRemoteOcr') + expect(checkbox).not.toBeNull() + + checkbox.click() + fixture.detectChanges() + expect(component.remoteOcr).toBeTruthy() + }) +}) diff --git a/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.ts new file mode 100644 index 000000000..48f370944 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.ts @@ -0,0 +1,20 @@ +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { SettingsService } from 'src/app/services/settings.service' +import { ConfirmDialogComponent } from '../confirm-dialog.component' + +@Component({ + selector: 'pngx-reprocess-confirm-dialog', + templateUrl: './reprocess-confirm-dialog.component.html', + imports: [FormsModule], +}) +export class ReprocessConfirmDialogComponent extends ConfirmDialogComponent { + private settings = inject(SettingsService) + + remoteOcr: boolean = false + + public get showRemoteOcr(): boolean { + // Hidden when it is not configured, or when it already handles every document anyway. + return this.settings.remoteOCRIsSelectable + } +} diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index c0a12b3ee..30e7a4bdf 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -963,12 +963,24 @@ describe('DocumentDetailComponent', () => { component.reprocess() const modalCloseSpy = jest.spyOn(openModal, 'close') openModal.componentInstance.confirmClicked.next() - expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }) + expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false) expect(modalSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled() }) + it('should pass remote OCR choice when reprocessing', () => { + initNormally() + const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') + reprocessSpy.mockReturnValue(of(true)) + let openModal: NgbModalRef + modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) + component.reprocess() + openModal.componentInstance.remoteOcr = true + openModal.componentInstance.confirmClicked.next() + expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, true) + }) + it('should show error if redo ocr call fails', () => { initNormally() const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index cffc91109..bb6898a48 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -97,6 +97,7 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import * as UTIF from 'utif' import { DocumentDetailFieldID } from '../admin/settings/settings.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' +import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' @@ -1398,7 +1399,7 @@ export class DocumentDetailComponent } reprocess() { - let modal = this.modalService.open(ConfirmDialogComponent, { + let modal = this.modalService.open(ReprocessConfirmDialogComponent, { backdrop: 'static', }) modal.componentInstance.title = $localize`Reprocess confirm` @@ -1409,7 +1410,10 @@ export class DocumentDetailComponent modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.buttonsEnabled.set(false) this.documentsService - .reprocessDocuments({ documents: [this.document().id] }) + .reprocessDocuments( + { documents: [this.document().id] }, + modal.componentInstance.remoteOcr + ) .subscribe({ next: () => { this.toastService.showInfo( diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index e8a80acb1..a8d99b656 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -1122,6 +1122,7 @@ describe('BulkEditorComponent', () => { req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], + remote_ocr: false, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 044c44163..3145e3959 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -52,6 +52,7 @@ import { flattenTags } from 'src/app/utils/flatten-tags' import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' +import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' @@ -916,7 +917,7 @@ export class BulkEditorComponent } reprocessSelected() { - let modal = this.modalService.open(ConfirmDialogComponent, { + let modal = this.modalService.open(ReprocessConfirmDialogComponent, { backdrop: 'static', }) modal.componentInstance.title = $localize`Reprocess confirm` @@ -930,7 +931,10 @@ export class BulkEditorComponent modal.componentInstance.buttonsEnabled.set(false) this.executeDocumentAction( modal, - this.documentService.reprocessDocuments(this.getSelectionQuery()) + this.documentService.reprocessDocuments( + this.getSelectionQuery(), + modal.componentInstance.remoteOcr + ) ) }) } diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index cec804f99..4739051fa 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -1,5 +1,6 @@ import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode' import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types' +import { RemoteOCRModeConfig } from './paperless-config' import { User } from './user' export interface UiSettings { @@ -94,6 +95,8 @@ export const SETTINGS_KEYS = { OUTLOOK_OAUTH_URL: 'outlook_oauth_url', EMAIL_ENABLED: 'email_enabled', AI_ENABLED: 'ai_enabled', + REMOTE_OCR_CONFIGURED: 'remote_ocr:configured', + REMOTE_OCR_MODE: 'remote_ocr:mode', } export const SETTINGS: UiSetting[] = [ @@ -347,4 +350,14 @@ export const SETTINGS: UiSetting[] = [ type: 'string', default: PdfEditorEditMode.Create, }, + { + key: SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, + type: 'boolean', + default: false, + }, + { + key: SETTINGS_KEYS.REMOTE_OCR_MODE, + type: 'string', + default: RemoteOCRModeConfig.ALWAYS, + }, ] diff --git a/src-ui/src/app/services/rest/document.service.spec.ts b/src-ui/src/app/services/rest/document.service.spec.ts index e491e0b45..fbb763c46 100644 --- a/src-ui/src/app/services/rest/document.service.spec.ts +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -284,6 +284,21 @@ describe(`DocumentService`, () => { expect(req.request.method).toEqual('POST') expect(req.request.body).toEqual({ documents: ids, + remote_ocr: false, + }) + }) + + it('should request remote OCR when reprocessing with it enabled', () => { + const ids = [1, 2, 3] + subscription = service + .reprocessDocuments({ documents: ids }, true) + .subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/reprocess/` + ) + expect(req.request.body).toEqual({ + documents: ids, + remote_ocr: true, }) }) diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index d201a546a..92ba21d33 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -349,9 +349,13 @@ export class DocumentService extends AbstractPaperlessService { }) } - reprocessDocuments(selection: DocumentSelectionQuery) { + reprocessDocuments( + selection: DocumentSelectionQuery, + remoteOcr: boolean = false + ) { return this.http.post(this.getResourceUrl(null, 'reprocess'), { ...selection, + remote_ocr: remoteOcr, }) } diff --git a/src-ui/src/app/services/settings.service.spec.ts b/src-ui/src/app/services/settings.service.spec.ts index c91296537..510f1857f 100644 --- a/src-ui/src/app/services/settings.service.spec.ts +++ b/src-ui/src/app/services/settings.service.spec.ts @@ -13,6 +13,7 @@ import { environment } from 'src/environments/environment' import { CustomFieldDataType } from '../data/custom-field' import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' import { SavedView } from '../data/saved-view' +import { RemoteOCRModeConfig } from '../data/paperless-config' import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings' import { PermissionsService } from './permissions.service' import { CustomFieldsService } from './rest/custom-fields.service' @@ -434,4 +435,26 @@ describe('SettingsService', () => { ).name ).toEqual(customFields[0].name) }) + it('should offer remote OCR only when configured and selective', () => { + settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, false) + settingsService.set( + SETTINGS_KEYS.REMOTE_OCR_MODE, + RemoteOCRModeConfig.WORKFLOW_ONLY + ) + expect(settingsService.remoteOCRIsSelectable).toBeFalsy() + + // configured, but already handling every document + settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, true) + settingsService.set( + SETTINGS_KEYS.REMOTE_OCR_MODE, + RemoteOCRModeConfig.ALWAYS + ) + expect(settingsService.remoteOCRIsSelectable).toBeFalsy() + + settingsService.set( + SETTINGS_KEYS.REMOTE_OCR_MODE, + RemoteOCRModeConfig.WORKFLOW_ONLY + ) + expect(settingsService.remoteOCRIsSelectable).toBeTruthy() + }) }) diff --git a/src-ui/src/app/services/settings.service.ts b/src-ui/src/app/services/settings.service.ts index 415b99a8f..850b4d4e8 100644 --- a/src-ui/src/app/services/settings.service.ts +++ b/src-ui/src/app/services/settings.service.ts @@ -19,6 +19,7 @@ import { } from 'src/app/utils/color' import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment' import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' +import { RemoteOCRModeConfig } from '../data/paperless-config' import { SavedView } from '../data/saved-view' import { PAPERLESS_GREEN_HEX, @@ -687,6 +688,17 @@ export class SettingsService { return this.settingIsSet(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED) } + /** + * Offering remote OCR as a choice only makes sense when an engine + * is configured but is not already handling every document. + */ + get remoteOCRIsSelectable(): boolean { + return ( + this.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) && + this.get(SETTINGS_KEYS.REMOTE_OCR_MODE) !== RemoteOCRModeConfig.ALWAYS + ) + } + offerTour(): boolean { return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE) } diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index a6a310632..4053ee8cb 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -399,10 +399,16 @@ def delete(doc_ids: list[int]) -> Literal["OK"]: return "OK" -def reprocess(doc_ids: list[int]) -> Literal["OK"]: +def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]: + """ + Re-run parsing for the given documents. + + Consumption workflows do not run here, so ``remote_ocr`` is how the user + asks for the remote engine when it is not configured to handle everything. + """ for document_id in doc_ids: update_document_content_maybe_archive_file.apply_async( - kwargs={"document_id": document_id}, + kwargs={"document_id": document_id, "remote_ocr": remote_ocr}, headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL}, ) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index b2f14b505..2c653b40d 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1795,7 +1795,7 @@ class DeleteDocumentsSerializer(DocumentSelectionSerializer): class ReprocessDocumentsSerializer(DocumentSelectionSerializer): - pass + remote_ocr = serializers.BooleanField(required=False, default=False) class BulkEditSerializer( @@ -2137,6 +2137,13 @@ class BulkEditSerializer( f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.", ) + def _validate_parameters_reprocess(self, parameters) -> None: + if "remote_ocr" in parameters: + if not isinstance(parameters["remote_ocr"], bool): + raise serializers.ValidationError("remote_ocr must be a boolean") + else: + parameters["remote_ocr"] = False + def validate_parameters_remove_password(self, parameters): if "password" not in parameters: raise serializers.ValidationError("password not specified") @@ -2201,6 +2208,8 @@ class BulkEditSerializer( self._validate_parameters_edit_pdf(parameters, attrs["documents"][0]) elif method == bulk_edit.remove_password: self.validate_parameters_remove_password(parameters) + elif method == bulk_edit.reprocess: + self._validate_parameters_reprocess(parameters) return attrs diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index fd202a04a..cf447501c 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -532,7 +532,29 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): m.assert_called_once() args, kwargs = m.call_args self.assertEqual(args[0], [self.doc1.id]) - self.assertEqual(len(kwargs), 0) + self.assertEqual(kwargs, {"remote_ocr": False}) + + @mock.patch("documents.views.bulk_edit.reprocess") + def test_reprocess_documents_endpoint_remote_ocr(self, m) -> None: + """ + GIVEN: + - API data to reprocess a document with remote OCR requested + WHEN: + - API is called + THEN: + - reprocess is called with remote_ocr=True + """ + self.setup_mock(m, "reprocess") + response = self.client.post( + "/api/documents/reprocess/", + json.dumps({"documents": [self.doc1.id], "remote_ocr": True}), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertEqual(args[0], [self.doc1.id]) + self.assertEqual(kwargs, {"remote_ocr": True}) @mock.patch("documents.serialisers.bulk_edit.set_storage_path") def test_api_set_storage_path(self, m) -> None: @@ -1553,6 +1575,29 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): ), ) + def test_legacy_bulk_edit_reprocess_invalid_remote_ocr(self) -> None: + """ + GIVEN: + - The deprecated bulk_edit endpoint with a non-boolean remote_ocr + WHEN: + - API is called + THEN: + - The request is rejected rather than passed through to the task + """ + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id], + "method": "reprocess", + "parameters": {"remote_ocr": "yes please"}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + @mock.patch("documents.views.bulk_edit.edit_pdf") def test_edit_pdf(self, m) -> None: self.setup_mock(m, "edit_pdf") diff --git a/src/documents/tests/test_api_uisettings.py b/src/documents/tests/test_api_uisettings.py index 811b77286..82ee5d0fa 100644 --- a/src/documents/tests/test_api_uisettings.py +++ b/src/documents/tests/test_api_uisettings.py @@ -60,6 +60,10 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase): }, "email_enabled": False, "ai_enabled": False, + "remote_ocr": { + "configured": False, + "mode": "always", + }, }, ) @@ -154,6 +158,50 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase): str(response.data["settings"]), ) + @override_settings( + REMOTE_OCR_ENGINE="azureai", + REMOTE_OCR_API_KEY="somekey", + REMOTE_OCR_ENDPOINT="https://example.cognitiveservices.azure.com", + REMOTE_OCR_MODE="workflow_only", + ) + def test_settings_reports_remote_ocr_when_configured(self) -> None: + """ + GIVEN: + - A fully configured remote OCR engine in workflow_only mode + WHEN: + - The ui_settings endpoint is called + THEN: + - The UI is told remote OCR is available and selective, so it can + offer it where it would actually change something + """ + response = self.client.get(self.ENDPOINT, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["settings"]["remote_ocr"], + {"configured": True, "mode": "workflow_only"}, + ) + + @override_settings( + REMOTE_OCR_ENGINE="azureai", + REMOTE_OCR_API_KEY=None, + REMOTE_OCR_ENDPOINT=None, + ) + def test_settings_reports_remote_ocr_incompletely_configured(self) -> None: + """ + GIVEN: + - An engine named but missing its endpoint and API key + WHEN: + - The ui_settings endpoint is called + THEN: + - It is reported as not configured, matching what the parser + registry will actually do + """ + response = self.client.get(self.ENDPOINT, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.data["settings"]["remote_ocr"]["configured"]) + @override_settings( OAUTH_CALLBACK_BASE_URL="http://localhost:8000", GMAIL_OAUTH_CLIENT_ID="abc123", diff --git a/src/documents/tests/test_bulk_edit.py b/src/documents/tests/test_bulk_edit.py index 010744af1..6063a9bbc 100644 --- a/src/documents/tests/test_bulk_edit.py +++ b/src/documents/tests/test_bulk_edit.py @@ -1782,3 +1782,56 @@ class TestPDFActions(DirectoriesMixin, TestCase): self.assertIn("wrong password", str(exc.exception)) self.assertIn("Error removing password from document", cm.output[0]) + + +class TestBulkEditReprocess(DirectoriesMixin, TestCase): + def setUp(self) -> None: + super().setUp() + + self.doc = Document.objects.create( + title="test", + checksum="A", + mime_type="application/pdf", + ) + + @mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file") + def test_reprocess_defaults_to_local(self, mock_task: mock.Mock) -> None: + """ + GIVEN: + - A reprocess request that says nothing about remote OCR + WHEN: + - reprocess is called + THEN: + - The task is queued without asking for the remote engine + """ + result = bulk_edit.reprocess([self.doc.id]) + + self.assertEqual(result, "OK") + mock_task.apply_async.assert_called_once() + _, kwargs = mock_task.apply_async.call_args + self.assertEqual( + kwargs["kwargs"], + {"document_id": self.doc.id, "remote_ocr": False}, + ) + + @mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file") + def test_reprocess_passes_remote_ocr(self, mock_task: mock.Mock) -> None: + """ + GIVEN: + - A reprocess request that explicitly asks for remote OCR + WHEN: + - reprocess is called + THEN: + - The request is forwarded to the task for every document + """ + other = Document.objects.create( + title="test2", + checksum="B", + mime_type="application/pdf", + ) + + bulk_edit.reprocess([self.doc.id, other.id], remote_ocr=True) + + self.assertEqual(mock_task.apply_async.call_count, 2) + for call in mock_task.apply_async.call_args_list: + self.assertTrue(call.kwargs["kwargs"]["remote_ocr"]) diff --git a/src/documents/views.py b/src/documents/views.py index e1b9f194c..0d3f9de99 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -239,8 +239,10 @@ from paperless import version from paperless.celery import app as celery_app from paperless.config import AIConfig from paperless.config import GeneralConfig +from paperless.config import RemoteOCRConfig from paperless.models import ApplicationConfiguration from paperless.parsers.registry import get_parser_registry +from paperless.parsers.remote import RemoteEngineConfig from paperless.serialisers import GroupSerializer from paperless.serialisers import UserSerializer from paperless.views import StandardPagination @@ -4100,6 +4102,11 @@ class UiSettingsView(GenericAPIView[Any]): ui_settings["auditlog_enabled"] = settings.AUDIT_LOG_ENABLED + ui_settings["remote_ocr"] = { + "configured": RemoteEngineConfig.from_app_config().engine_is_valid(), + "mode": RemoteOCRConfig().remote_ocr_mode, + } + if settings.GMAIL_OAUTH_ENABLED or settings.OUTLOOK_OAUTH_ENABLED: manager = PaperlessMailOAuth2Manager() if settings.GMAIL_OAUTH_ENABLED: