From 4fd1c6073166405dba2083d4f2114be518812f33 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:17:52 -0700 Subject: [PATCH] Enhancement: support using remote OCR engines selectively (#13633) * Backend changes and migration for remote OCR Config * Backend tests * Frontend stuff, with sections * Docs * Update test_tesseract_parser.py * Actually we cant use this any more, in case settings are in app config * Dont mark entire test file for db, use a mock for empty engine settings --- docs/usage.md | 3 +- .../admin/config/config.component.html | 71 ++++++++------- .../admin/config/config.component.spec.ts | 25 +++++- .../admin/config/config.component.ts | 16 +++- src-ui/src/app/data/paperless-config.ts | 39 ++++++++ src/documents/tests/test_api_app_config.py | 46 ++++++++++ src/paperless/checks.py | 14 --- src/paperless/config.py | 24 +++++ ...nfiguration_remote_ocr_api_key_and_more.py | 44 ++++++++++ src/paperless/models.py | 37 ++++++++ src/paperless/parsers/remote.py | 24 ++--- src/paperless/serialisers.py | 19 ++-- src/paperless/tests/parsers/conftest.py | 26 +++++- .../tests/parsers/test_remote_parser.py | 35 ++++++-- .../tests/parsers/test_tesseract_parser.py | 2 + src/paperless/tests/test_checks.py | 26 ------ src/paperless/tests/test_remote_ocr_config.py | 88 +++++++++++++++++++ 17 files changed, 437 insertions(+), 102 deletions(-) create mode 100644 src/paperless/migrations/0014_applicationconfiguration_remote_ocr_api_key_and_more.py create mode 100644 src/paperless/tests/test_remote_ocr_config.py diff --git a/docs/usage.md b/docs/usage.md index 8cc921b05..b25dd8d15 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1090,7 +1090,8 @@ Paperless-ngx supports performing OCR on documents using remote services. At the [Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence). This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing -the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. +the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These +settings can be supplied as environment variables or via **Application Configuration**. Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size or page limitations (e.g. with a free tier). diff --git a/src-ui/src/app/components/admin/config/config.component.html b/src-ui/src/app/components/admin/config/config.component.html index 7c8a65628..af0fe9e6f 100644 --- a/src-ui/src/app/components/admin/config/config.component.html +++ b/src-ui/src/app/components/admin/config/config.component.html @@ -14,43 +14,48 @@ {{category}}
-
- @for (option of getCategoryOptions(category); track option.key) { -
-
-
-
-
- {{option.title}} -
- - - - @if (isSet(option.key)) { - + @for (section of getCategorySections(category); track section) { + @if (section) { +
{{section}}
+ } +
+ @for (option of getCategoryOptions(category, section); track option.key) { +
+
+
+
+
+ {{option.title}} +
+ + + + @if (isSet(option.key)) { + + } +
+
+ @switch (option.type) { + @case (ConfigOptionType.Select) { } + @case (ConfigOptionType.Number) { } + @case (ConfigOptionType.Boolean) { } + @case (ConfigOptionType.String) { } + @case (ConfigOptionType.JSON) { } + @case (ConfigOptionType.File) { } + @case (ConfigOptionType.Password) { } + } +
+ @if (option.note) { +
{{option.note}}
}
-
- @switch (option.type) { - @case (ConfigOptionType.Select) { } - @case (ConfigOptionType.Number) { } - @case (ConfigOptionType.Boolean) { } - @case (ConfigOptionType.String) { } - @case (ConfigOptionType.JSON) { } - @case (ConfigOptionType.File) { } - @case (ConfigOptionType.Password) { } - } -
- @if (option.note) { -
{{option.note}}
- }
-
- } -
+ } +
+ }
diff --git a/src-ui/src/app/components/admin/config/config.component.spec.ts b/src-ui/src/app/components/admin/config/config.component.spec.ts index f4f4799a6..7925b6ad1 100644 --- a/src-ui/src/app/components/admin/config/config.component.spec.ts +++ b/src-ui/src/app/components/admin/config/config.component.spec.ts @@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgSelectModule } from '@ng-select/ng-select' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { of, throwError } from 'rxjs' -import { OutputTypeConfig } from 'src/app/data/paperless-config' +import { + ConfigCategory, + ConfigSection, + OutputTypeConfig, +} from 'src/app/data/paperless-config' import { ConfigService } from 'src/app/services/config.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' @@ -158,4 +162,23 @@ describe('ConfigComponent', () => { component.resetOption('barcodes_enabled') expect(component.configForm.get('barcodes_enabled').value).toBeNull() }) + + it('should group options into sections within a category, or not', () => { + const sections = component.getCategorySections(ConfigCategory.OCR) + expect(sections).toEqual([null, ConfigSection.RemoteOCR]) + expect( + component + .getCategoryOptions(ConfigCategory.OCR) + .map((option) => option.key) + ).toContain('output_type') + expect( + component + .getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR) + .map((option) => option.key) + ).toEqual([ + 'remote_ocr_engine', + 'remote_ocr_api_key', + 'remote_ocr_endpoint', + ]) + }) }) diff --git a/src-ui/src/app/components/admin/config/config.component.ts b/src-ui/src/app/components/admin/config/config.component.ts index 9c2946438..0e3f432e2 100644 --- a/src-ui/src/app/components/admin/config/config.component.ts +++ b/src-ui/src/app/components/admin/config/config.component.ts @@ -74,8 +74,20 @@ export class ConfigComponent return Object.values(ConfigCategory) } - getCategoryOptions(category: string): ConfigOption[] { - return PaperlessConfigOptions.filter((o) => o.category === category) + getCategorySections(category: string): string[] { + return [ + ...new Set( + PaperlessConfigOptions.filter((o) => o.category === category).map( + (o) => o.section ?? null // null means no section + ) + ), + ] + } + + getCategoryOptions(category: string, section: string = null): ConfigOption[] { + return PaperlessConfigOptions.filter( + (o) => o.category === category && (o.section ?? null) === section + ) } initialConfig: PaperlessConfig diff --git a/src-ui/src/app/data/paperless-config.ts b/src-ui/src/app/data/paperless-config.ts index ad3f4c0c9..575525f1b 100644 --- a/src-ui/src/app/data/paperless-config.ts +++ b/src-ui/src/app/data/paperless-config.ts @@ -54,6 +54,10 @@ export const ConfigCategory = { AI: $localize`AI Settings`, } +export const ConfigSection = { + RemoteOCR: $localize`Remote OCR`, +} + export const LLMEmbeddingBackendConfig = { OPENAI_LIKE: 'openai-like', HUGGINGFACE: 'huggingface', @@ -65,6 +69,10 @@ export const LLMBackendConfig = { OLLAMA: 'ollama', } +export const RemoteOCREngineConfig = { + AZURE_AI: 'azureai', +} + export interface ConfigOption { key: string title: string @@ -72,6 +80,7 @@ export interface ConfigOption { choices?: Array<{ id: string; name: string }> config_key?: string category: string + section?: string note?: string } @@ -181,6 +190,33 @@ export const PaperlessConfigOptions: ConfigOption[] = [ config_key: 'PAPERLESS_OCR_USER_ARGS', category: ConfigCategory.OCR, }, + { + key: 'remote_ocr_engine', + title: $localize`Remote OCR Engine`, + type: ConfigOptionType.Select, + choices: mapToItems(RemoteOCREngineConfig), + config_key: 'PAPERLESS_REMOTE_OCR_ENGINE', + category: ConfigCategory.OCR, + section: ConfigSection.RemoteOCR, + note: $localize`Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.`, + }, + { + key: 'remote_ocr_api_key', + title: $localize`Remote OCR API Key`, + type: ConfigOptionType.Password, + config_key: 'PAPERLESS_REMOTE_OCR_API_KEY', + category: ConfigCategory.OCR, + section: ConfigSection.RemoteOCR, + }, + { + key: 'remote_ocr_endpoint', + title: $localize`Remote OCR Endpoint`, + type: ConfigOptionType.String, + config_key: 'PAPERLESS_REMOTE_OCR_ENDPOINT', + category: ConfigCategory.OCR, + section: ConfigSection.RemoteOCR, + note: $localize`Required when using the Azure AI engine.`, + }, { key: 'app_logo', title: $localize`Application Logo`, @@ -398,6 +434,9 @@ export interface PaperlessConfig extends ObjectWithId { barcode_enable_tag: boolean barcode_tag_mapping: object barcode_tag_split: boolean + remote_ocr_engine: string + remote_ocr_api_key: string + remote_ocr_endpoint: string ai_enabled: boolean llm_embedding_backend: string llm_embedding_model: string diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index 63db4b766..d4cc4b6d2 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -72,6 +72,9 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): "barcode_enable_tag": None, "barcode_tag_mapping": None, "barcode_tag_split": None, + "remote_ocr_engine": None, + "remote_ocr_api_key": None, + "remote_ocr_endpoint": None, "ai_enabled": False, "llm_embedding_backend": None, "llm_embedding_model": None, @@ -870,6 +873,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): config.refresh_from_db() self.assertEqual(config.llm_api_key, None) + def test_update_remote_ocr_api_key(self) -> None: + """ + GIVEN: + - Existing config with remote_ocr_api_key specified + WHEN: + - API to update remote_ocr_api_key is called with all *s + - API to update remote_ocr_api_key is called with empty string + THEN: + - remote_ocr_api_key is unchanged + - remote_ocr_api_key is set to None + """ + config = ApplicationConfiguration.objects.first() + assert config is not None + config.remote_ocr_api_key = "1234567890" + config.save() + + # Test with all * + response = self.client.patch( + f"{self.ENDPOINT}1/", + json.dumps( + { + "remote_ocr_api_key": "*" * 32, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + config.refresh_from_db() + self.assertEqual(config.remote_ocr_api_key, "1234567890") + # Test with empty string + response = self.client.patch( + f"{self.ENDPOINT}1/", + json.dumps( + { + "remote_ocr_api_key": "", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + config.refresh_from_db() + self.assertEqual(config.remote_ocr_api_key, None) + def test_enable_ai_index_triggers_update(self) -> None: """ GIVEN: diff --git a/src/paperless/checks.py b/src/paperless/checks.py index aea335343..5d9da91c4 100644 --- a/src/paperless/checks.py +++ b/src/paperless/checks.py @@ -337,20 +337,6 @@ def check_deprecated_v2_ocr_env_vars( return warnings -@register() -def check_remote_parser_configured(app_configs: Any, **kwargs: Any) -> list[Error]: - if settings.REMOTE_OCR_ENGINE == "azureai" and not ( - settings.REMOTE_OCR_ENDPOINT and settings.REMOTE_OCR_API_KEY - ): - return [ - Error( - "Azure AI remote parser requires endpoint and API key to be configured.", - ), - ] - - return [] - - def get_tesseract_langs(): proc = subprocess.run( [shutil.which("tesseract"), "--list-langs"], diff --git a/src/paperless/config.py b/src/paperless/config.py index 0a1984de7..c3dcc3f15 100644 --- a/src/paperless/config.py +++ b/src/paperless/config.py @@ -185,6 +185,30 @@ class GeneralConfig(BaseConfig): self.app_logo = app_config.app_logo.url if app_config.app_logo else None +@dataclasses.dataclass +class RemoteOCRConfig(BaseConfig): + """ + Settings for the remote (cloud) OCR parser + """ + + remote_ocr_engine: str | None = dataclasses.field(init=False) + remote_ocr_api_key: str | None = dataclasses.field(init=False) + remote_ocr_endpoint: str | None = dataclasses.field(init=False) + + def __post_init__(self) -> None: + app_config = self._get_config_instance() + + self.remote_ocr_engine = ( + app_config.remote_ocr_engine or settings.REMOTE_OCR_ENGINE + ) + self.remote_ocr_api_key = ( + app_config.remote_ocr_api_key or settings.REMOTE_OCR_API_KEY + ) + self.remote_ocr_endpoint = ( + app_config.remote_ocr_endpoint or settings.REMOTE_OCR_ENDPOINT + ) + + @dataclasses.dataclass class AIConfig(BaseConfig): """ diff --git a/src/paperless/migrations/0014_applicationconfiguration_remote_ocr_api_key_and_more.py b/src/paperless/migrations/0014_applicationconfiguration_remote_ocr_api_key_and_more.py new file mode 100644 index 000000000..3673ede08 --- /dev/null +++ b/src/paperless/migrations/0014_applicationconfiguration_remote_ocr_api_key_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.16 on 2026-08-10 14:37 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("paperless", "0013_applicationconfiguration_llm_request_timeout"), + ] + + operations = [ + migrations.AddField( + model_name="applicationconfiguration", + name="remote_ocr_api_key", + field=models.CharField( + blank=True, + max_length=1024, + null=True, + verbose_name="Sets the remote OCR API key", + ), + ), + migrations.AddField( + model_name="applicationconfiguration", + name="remote_ocr_endpoint", + field=models.CharField( + blank=True, + max_length=256, + null=True, + verbose_name="Sets the remote OCR endpoint", + ), + ), + migrations.AddField( + model_name="applicationconfiguration", + name="remote_ocr_engine", + field=models.CharField( + blank=True, + choices=[("azureai", "Azure AI Document Intelligence")], + max_length=32, + null=True, + verbose_name="Sets the remote OCR engine", + ), + ), + ] diff --git a/src/paperless/models.py b/src/paperless/models.py index 71920cb0b..7c230daba 100644 --- a/src/paperless/models.py +++ b/src/paperless/models.py @@ -74,6 +74,14 @@ class ColorConvertChoices(models.TextChoices): CMYK = ("CMYK", _("CMYK")) +class RemoteOCREngine(models.TextChoices): + """ + Matches to PAPERLESS_REMOTE_OCR_ENGINE + """ + + AZURE_AI = ("azureai", _("Azure AI Document Intelligence")) + + class LLMEmbeddingBackend(models.TextChoices): OPENAI_LIKE = ("openai-like", _("OpenAI-compatible")) HUGGINGFACE = ("huggingface", _("Huggingface")) @@ -286,6 +294,35 @@ class ApplicationConfiguration(AbstractSingletonModel): null=True, ) + """ + Settings for the remote OCR parser + """ + + # PAPERLESS_REMOTE_OCR_ENGINE + remote_ocr_engine = models.CharField( + verbose_name=_("Sets the remote OCR engine"), + blank=True, + null=True, + max_length=32, + choices=RemoteOCREngine.choices, + ) + + # PAPERLESS_REMOTE_OCR_API_KEY + remote_ocr_api_key = models.CharField( + verbose_name=_("Sets the remote OCR API key"), + blank=True, + null=True, + max_length=1024, + ) + + # PAPERLESS_REMOTE_OCR_ENDPOINT + remote_ocr_endpoint = models.CharField( + verbose_name=_("Sets the remote OCR endpoint"), + blank=True, + null=True, + max_length=256, + ) + """ AI related settings """ diff --git a/src/paperless/parsers/remote.py b/src/paperless/parsers/remote.py index d575902f4..f76528807 100644 --- a/src/paperless/parsers/remote.py +++ b/src/paperless/parsers/remote.py @@ -61,6 +61,18 @@ class RemoteEngineConfig: self.api_key = api_key self.endpoint = endpoint + @classmethod + def from_app_config(cls) -> Self: + """Build the config from the app config, falling back to the env.""" + from paperless.config import RemoteOCRConfig + + app_config = RemoteOCRConfig() + return cls( + engine=app_config.remote_ocr_engine, + api_key=app_config.remote_ocr_api_key, + endpoint=app_config.remote_ocr_endpoint, + ) + def engine_is_valid(self) -> bool: """Return True when the engine is known and fully configured.""" return ( @@ -145,11 +157,7 @@ class RemoteDocumentParser: 20 when the remote engine is configured and the MIME type is supported, otherwise None. """ - config = RemoteEngineConfig( - engine=settings.REMOTE_OCR_ENGINE, - api_key=settings.REMOTE_OCR_API_KEY, - endpoint=settings.REMOTE_OCR_ENDPOINT, - ) + config = RemoteEngineConfig.from_app_config() if not config.engine_is_valid(): return None if mime_type not in _SUPPORTED_MIME_TYPES: @@ -244,11 +252,7 @@ class RemoteDocumentParser: Whether an archive copy is wanted. For PDFs, False skips the remote engine and uses locally-extracted text instead. """ - config = RemoteEngineConfig( - engine=settings.REMOTE_OCR_ENGINE, - api_key=settings.REMOTE_OCR_API_KEY, - endpoint=settings.REMOTE_OCR_ENDPOINT, - ) + config = RemoteEngineConfig.from_app_config() if not config.engine_is_valid(): logger.warning( diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 173de8153..39b0a58aa 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -219,6 +219,13 @@ class ApplicationConfigurationSerializer( allow_null=True, max_length=1024, ) + remote_ocr_api_key = ObfuscatedPasswordField( + required=False, + allow_null=True, + max_length=1024, + ) + + OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key") def run_validation(self, data): # Empty strings treated as None to avoid unexpected behavior @@ -230,11 +237,13 @@ class ApplicationConfigurationSerializer( data["language"] = None if "llm_output_language" in data and data["llm_output_language"] == "": data["llm_output_language"] = None - if "llm_api_key" in data and data["llm_api_key"] is not None: - if data["llm_api_key"] == "": - data["llm_api_key"] = None - elif len(data["llm_api_key"].replace("*", "")) == 0: - del data["llm_api_key"] + for field in self.OBFUSCATED_FIELDS: + if field in data and data[field] is not None: + if data[field] == "": + data[field] = None + # Not a real value, don't overwrite the stored one + elif len(data[field].replace("*", "")) == 0: + del data[field] return super().run_validation(data) def update(self, instance, validated_data): diff --git a/src/paperless/tests/parsers/conftest.py b/src/paperless/tests/parsers/conftest.py index 843ffdb88..0ae8c3865 100644 --- a/src/paperless/tests/parsers/conftest.py +++ b/src/paperless/tests/parsers/conftest.py @@ -114,7 +114,26 @@ def remote_parser() -> Generator[RemoteDocumentParser, None, None]: @pytest.fixture() -def azure_settings(settings: SettingsWrapper) -> SettingsWrapper: +def empty_remote_ocr_app_config(mocker: MockerFixture) -> MagicMock: + # empty app config without accessing db + app_config = mocker.MagicMock( + remote_ocr_engine=None, + remote_ocr_api_key=None, + remote_ocr_endpoint=None, + remote_ocr_mode=None, + ) + mocker.patch( + "paperless.config.BaseConfig._get_config_instance", + return_value=app_config, + ) + return app_config + + +@pytest.fixture() +def azure_settings( + settings: SettingsWrapper, + empty_remote_ocr_app_config: MagicMock, +) -> SettingsWrapper: """Configure Django settings for a valid Azure AI OCR engine. Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and @@ -133,7 +152,10 @@ def azure_settings(settings: SettingsWrapper) -> SettingsWrapper: @pytest.fixture() -def no_engine_settings(settings: SettingsWrapper) -> SettingsWrapper: +def no_engine_settings( + settings: SettingsWrapper, + empty_remote_ocr_app_config: MagicMock, +) -> SettingsWrapper: """Configure Django settings with no remote engine configured. Returns diff --git a/src/paperless/tests/parsers/test_remote_parser.py b/src/paperless/tests/parsers/test_remote_parser.py index d210e7716..e55bc5de7 100644 --- a/src/paperless/tests/parsers/test_remote_parser.py +++ b/src/paperless/tests/parsers/test_remote_parser.py @@ -21,6 +21,7 @@ from unittest.mock import Mock import pytest from documents.parsers import ParseError +from paperless.models import ApplicationConfiguration from paperless.parsers import ParserContext from paperless.parsers import ParserProtocol from paperless.parsers.remote import RemoteDocumentParser @@ -198,21 +199,21 @@ class TestRemoteParserScore: def test_score_returns_none_when_api_key_missing( self, - settings: SettingsWrapper, + no_engine_settings: SettingsWrapper, ) -> None: - settings.REMOTE_OCR_ENGINE = "azureai" - settings.REMOTE_OCR_API_KEY = None - settings.REMOTE_OCR_ENDPOINT = "https://test.cognitiveservices.azure.com" + no_engine_settings.REMOTE_OCR_ENGINE = "azureai" + no_engine_settings.REMOTE_OCR_ENDPOINT = ( + "https://test.cognitiveservices.azure.com" + ) result = RemoteDocumentParser.score("application/pdf", "doc.pdf") assert result is None def test_score_returns_none_when_endpoint_missing( self, - settings: SettingsWrapper, + no_engine_settings: SettingsWrapper, ) -> None: - settings.REMOTE_OCR_ENGINE = "azureai" - settings.REMOTE_OCR_API_KEY = "key" - settings.REMOTE_OCR_ENDPOINT = None + no_engine_settings.REMOTE_OCR_ENGINE = "azureai" + no_engine_settings.REMOTE_OCR_API_KEY = "key" result = RemoteDocumentParser.score("application/pdf", "doc.pdf") assert result is None @@ -227,6 +228,24 @@ class TestRemoteParserScore: score = RemoteDocumentParser.score("application/pdf", "doc.pdf") assert score is not None and score > 10 + @pytest.mark.django_db + def test_score_uses_app_config_when_env_unset( + self, + settings: SettingsWrapper, + ) -> None: + """The app config alone is enough to activate the parser.""" + settings.REMOTE_OCR_ENGINE = None + settings.REMOTE_OCR_API_KEY = None + settings.REMOTE_OCR_ENDPOINT = None + config = ApplicationConfiguration.objects.first() + assert config is not None + config.remote_ocr_engine = "azureai" + config.remote_ocr_api_key = "app-config-key" + config.remote_ocr_endpoint = "https://config.cognitiveservices.azure.com" + config.save() + + assert RemoteDocumentParser.score("application/pdf", "doc.pdf") == 20 + # --------------------------------------------------------------------------- # Properties diff --git a/src/paperless/tests/parsers/test_tesseract_parser.py b/src/paperless/tests/parsers/test_tesseract_parser.py index f25efccc7..bb6a85031 100644 --- a/src/paperless/tests/parsers/test_tesseract_parser.py +++ b/src/paperless/tests/parsers/test_tesseract_parser.py @@ -1277,6 +1277,8 @@ class TestParserFileTypes: # --------------------------------------------------------------------------- +# Remote ocr config from ApplicationConfiguration needs DB access +@pytest.mark.django_db class TestRasterisedDocumentParserRegistry: def test_registered_in_defaults(self) -> None: from paperless.parsers.registry import ParserRegistry diff --git a/src/paperless/tests/test_checks.py b/src/paperless/tests/test_checks.py index e5fef4629..b85618a21 100644 --- a/src/paperless/tests/test_checks.py +++ b/src/paperless/tests/test_checks.py @@ -15,7 +15,6 @@ from paperless.checks import audit_log_check from paperless.checks import binaries_check from paperless.checks import check_default_language_available from paperless.checks import check_deprecated_db_settings -from paperless.checks import check_remote_parser_configured from paperless.checks import check_v3_minimum_upgrade_version from paperless.checks import debug_mode_check from paperless.checks import paths_check @@ -631,31 +630,6 @@ class TestV3MinimumUpgradeVersionCheck: assert check_v3_minimum_upgrade_version(None) == [] -class TestRemoteParserChecks: - def test_no_engine(self, settings: SettingsWrapper) -> None: - settings.REMOTE_OCR_ENGINE = None - msgs = check_remote_parser_configured(None) - - assert len(msgs) == 0 - - def test_azure_no_endpoint(self, settings: SettingsWrapper) -> None: - - settings.REMOTE_OCR_ENGINE = "azureai" - settings.REMOTE_OCR_API_KEY = "somekey" - settings.REMOTE_OCR_ENDPOINT = None - - msgs = check_remote_parser_configured(None) - - assert len(msgs) == 1 - - msg = msgs[0] - - assert ( - "Azure AI remote parser requires endpoint and API key to be configured." - in msg.msg - ) - - class TestTesseractChecks: def test_default_language(self) -> None: check_default_language_available(None) diff --git a/src/paperless/tests/test_remote_ocr_config.py b/src/paperless/tests/test_remote_ocr_config.py new file mode 100644 index 000000000..b331d41a9 --- /dev/null +++ b/src/paperless/tests/test_remote_ocr_config.py @@ -0,0 +1,88 @@ +"""Tests for RemoteOCRConfig precedence between app config and Django settings.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from django.test import override_settings + +from paperless.config import RemoteOCRConfig + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + +@pytest.fixture() +def null_app_config(mocker) -> MagicMock: + """Mock ApplicationConfiguration with all fields None → falls back to Django settings.""" + return mocker.MagicMock( + remote_ocr_engine=None, + remote_ocr_api_key=None, + remote_ocr_endpoint=None, + ) + + +@pytest.fixture() +def make_remote_ocr_config(mocker): + def _make(app_config, **django_settings_overrides): + mocker.patch( + "paperless.config.BaseConfig._get_config_instance", + return_value=app_config, + ) + with override_settings(**django_settings_overrides): + return RemoteOCRConfig() + + return _make + + +class TestRemoteOCRConfig: + def test_falls_back_to_settings( + self, + make_remote_ocr_config, + null_app_config, + ) -> None: + cfg = make_remote_ocr_config( + null_app_config, + REMOTE_OCR_ENGINE="azureai", + REMOTE_OCR_API_KEY="env-key", + REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com", + ) + assert cfg.remote_ocr_engine == "azureai" + assert cfg.remote_ocr_api_key == "env-key" + assert cfg.remote_ocr_endpoint == "https://env.cognitiveservices.azure.com" + + def test_app_config_takes_precedence( + self, + make_remote_ocr_config, + mocker, + ) -> None: + app_config = mocker.MagicMock( + remote_ocr_engine="azureai", + remote_ocr_api_key="db-key", + remote_ocr_endpoint="https://db.cognitiveservices.azure.com", + ) + cfg = make_remote_ocr_config( + app_config, + REMOTE_OCR_ENGINE=None, + REMOTE_OCR_API_KEY="env-key", + REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com", + ) + assert cfg.remote_ocr_engine == "azureai" + assert cfg.remote_ocr_api_key == "db-key" + assert cfg.remote_ocr_endpoint == "https://db.cognitiveservices.azure.com" + + def test_unset_everywhere( + self, + make_remote_ocr_config, + null_app_config, + ) -> None: + cfg = make_remote_ocr_config( + null_app_config, + REMOTE_OCR_ENGINE=None, + REMOTE_OCR_API_KEY=None, + REMOTE_OCR_ENDPOINT=None, + ) + assert cfg.remote_ocr_engine is None + assert cfg.remote_ocr_api_key is None + assert cfg.remote_ocr_endpoint is None