- @for (option of getCategoryOptions(category); track option.key) {
-
-
-
-
-
- {{option.title}}
-
-
-
-
- @if (isSet(option.key)) {
-
- Reset
-
+ @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)) {
+
+ Reset
+
+ }
+
+
+ @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