From 7813375123b3a161c7929c1c3176646f583d3f06 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:39:22 -0700 Subject: [PATCH] Enhancement (QoL): surface externally-set options in Config UI (#13989) --- .../admin/config/config.component.html | 21 +++++++++++--- .../admin/config/config.component.spec.ts | 13 +++++++++ .../admin/config/config.component.ts | 12 +++++++- src-ui/src/app/data/paperless-config.ts | 1 + src/documents/tests/test_api_app_config.py | 29 ++++++++++++++++++- src/paperless/serialisers.py | 8 +++++ 6 files changed, 78 insertions(+), 6 deletions(-) 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 e516ba941..31e9aecfa 100644 --- a/src-ui/src/app/components/admin/config/config.component.html +++ b/src-ui/src/app/components/admin/config/config.component.html @@ -23,17 +23,30 @@
-
+
{{option.title}}
+ @if (isExternallyConfigured(option.config_key)) { + @if (isSet(option.key)) { + Overrides external + } @else { + Set externally + } + } @if (isSet(option.key)) { - + @if (isExternallyConfigured(option.config_key)) { + + } @else { + + } }
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 a99171f93..650761c7d 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 @@ -163,6 +163,19 @@ describe('ConfigComponent', () => { expect(component.configForm.get('barcodes_enabled').value).toBeNull() }) + it('should identify externally configured options', () => { + component.externallyConfiguredVariables = new Set([ + 'PAPERLESS_OCR_LANGUAGE', + ]) + + expect( + component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE') + ).toBeTruthy() + expect( + component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE') + ).toBeFalsy() + }) + it('should group options into sections within a category, or not', () => { const sections = component.getCategorySections(ConfigCategory.OCR) expect(sections).toEqual([null, ConfigSection.RemoteOCR]) 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 0e3f432e2..570eb7d55 100644 --- a/src-ui/src/app/components/admin/config/config.component.ts +++ b/src-ui/src/app/components/admin/config/config.component.ts @@ -69,6 +69,7 @@ export class ConfigComponent public configForm = new FormGroup({}) public errors = {} + public externallyConfiguredVariables = new Set() get optionCategories(): string[] { return Object.values(ConfigCategory) @@ -152,6 +153,9 @@ export class ConfigComponent } private initialize(config: PaperlessConfig) { + this.externallyConfiguredVariables = new Set( + config.externally_configured_variables ?? [] + ) if (!this.store) { this.store = new BehaviorSubject(config) @@ -162,7 +166,9 @@ export class ConfigComponent this.configForm.patchValue(state, { emitEvent: false }) }) - this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable()) + this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), { + excludeKeys: ['externally_configured_variables'], + }) } this.configForm.patchValue(config) @@ -227,6 +233,10 @@ export class ConfigComponent return this.configForm.get(key).value != null } + public isExternallyConfigured(configKey: string): boolean { + return this.externallyConfiguredVariables.has(configKey) + } + public resetOption(key: string) { this.configForm.get(key).setValue(null) } diff --git a/src-ui/src/app/data/paperless-config.ts b/src-ui/src/app/data/paperless-config.ts index 92af2cec5..690d7aa4e 100644 --- a/src-ui/src/app/data/paperless-config.ts +++ b/src-ui/src/app/data/paperless-config.ts @@ -422,6 +422,7 @@ export const PaperlessConfigOptions: ConfigOption[] = [ ] export interface PaperlessConfig extends ObjectWithId { + externally_configured_variables: string[] output_type: OutputTypeConfig pages: number language: string diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index b98c0d7da..0ca6f4412 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -35,7 +35,8 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): THEN: - Existing config """ - response = self.client.get(self.ENDPOINT, format="json") + with patch.dict("os.environ", {}, clear=True): + response = self.client.get(self.ENDPOINT, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -45,6 +46,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): response.data[0], { "id": 1, + "externally_configured_variables": [], "output_type": None, "pages": None, "language": None, @@ -91,6 +93,31 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): }, ) + def test_api_get_config_reports_external_configuration_without_values(self) -> None: + with patch.dict( + "os.environ", + { + "PAPERLESS_OCR_LANGUAGE": "eng", + "PAPERLESS_REMOTE_OCR_API_KEY": "secret-value", + "PAPERLESS_FUTURE_SETTING": "future-value", + "UNRELATED_SETTING": "unrelated-value", + }, + clear=True, + ): + response = self.client.get(self.ENDPOINT, format="json") + + self.assertCountEqual( + response.data[0]["externally_configured_variables"], + [ + "PAPERLESS_FUTURE_SETTING", + "PAPERLESS_OCR_LANGUAGE", + "PAPERLESS_REMOTE_OCR_API_KEY", + ], + ) + self.assertNotContains(response, "secret-value") + self.assertNotContains(response, "future-value") + self.assertNotContains(response, "UNRELATED_SETTING") + def test_api_get_ui_settings_with_config(self) -> None: """ GIVEN: diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 38baf1be5..f7cfc3d45 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -1,4 +1,5 @@ import logging +import os from io import BytesIO import magic @@ -212,6 +213,7 @@ class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer[Use class ApplicationConfigurationSerializer( serializers.ModelSerializer[ApplicationConfiguration], ): + externally_configured_variables = serializers.SerializerMethodField() user_args = serializers.JSONField(binary=True, allow_null=True) barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True) llm_api_key = ObfuscatedPasswordField( @@ -227,6 +229,12 @@ class ApplicationConfigurationSerializer( OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key") + def get_externally_configured_variables( + self, + instance: ApplicationConfiguration, + ) -> list[str]: + return sorted(name for name in os.environ if name.startswith("PAPERLESS_")) + def run_validation(self, data): # Empty strings treated as None to avoid unexpected behavior if "user_args" in data and data["user_args"] == "":