From 5d5e9b6db4d9773312ee01c838e131a09bdb7a32 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:39:14 -0700 Subject: [PATCH] Backend changes and migration for remote OCR Config --- 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 +++++--- 5 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 src/paperless/migrations/0014_applicationconfiguration_remote_ocr_api_key_and_more.py 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 963a7e9f0..94ab4f297 100644 --- a/src/paperless/parsers/remote.py +++ b/src/paperless/parsers/remote.py @@ -57,6 +57,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 ( @@ -138,11 +150,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: @@ -228,11 +236,7 @@ class RemoteDocumentParser: Ignored — the remote engine always returns a searchable PDF, which is stored as the archive copy regardless of this flag. """ - 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):