From 7927e5c4366ed3fb4e8d83b2317ab03fd93261b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:01:52 -0800 Subject: [PATCH 1/6] Changelog v2.19.5 - GHA (#11305) --- docs/changelog.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 7f51cb04e..064e37adb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,19 @@ # Changelog +## paperless-ngx 2.19.5 + +### Bug Fixes + +- Fix: ensure custom field query propagation, change detection [@shamoon](https://github.com/shamoon) ([#11291](https://github.com/paperless-ngx/paperless-ngx/pull/11291)) + +### Dependencies + +- docker(deps): Bump astral-sh/uv from 0.9.4-python3.12-bookworm-slim to 0.9.7-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11283](https://github.com/paperless-ngx/paperless-ngx/pull/11283)) + +### All App Changes + +- Fix: ensure custom field query propagation, change detection [@shamoon](https://github.com/shamoon) ([#11291](https://github.com/paperless-ngx/paperless-ngx/pull/11291)) + ## paperless-ngx 2.19.4 ### Bug Fixes From 2a9d1fce0d99d7e4b001185e7fa456f6a57a201c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:20:27 -0800 Subject: [PATCH 2/6] Chore: include password validation on user edit (#11308) --- .../profile-edit-dialog.component.ts | 2 + src/documents/tests/test_api_permissions.py | 4 +- src/documents/tests/test_api_profile.py | 59 +++++++++++++++++++ src/paperless/serialisers.py | 41 ++++++++----- src/paperless/views.py | 6 +- 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts index dfa5c56a8..c4a103397 100644 --- a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts @@ -183,6 +183,7 @@ export class ProfileEditDialogComponent this.newPassword && this.currentPassword !== this.newPassword const profile = Object.assign({}, this.form.value) delete profile.totp_code + this.error = null this.networkActive = true this.profileService .update(profile) @@ -204,6 +205,7 @@ export class ProfileEditDialogComponent }, error: (error) => { this.toastService.showError($localize`Error saving profile`, error) + this.error = error?.error this.networkActive = false }, }) diff --git a/src/documents/tests/test_api_permissions.py b/src/documents/tests/test_api_permissions.py index 8ffce1f95..bc81dabe9 100644 --- a/src/documents/tests/test_api_permissions.py +++ b/src/documents/tests/test_api_permissions.py @@ -648,7 +648,7 @@ class TestApiUser(DirectoriesMixin, APITestCase): user1 = { "username": "testuser", - "password": "test", + "password": "areallysupersecretpassword235", "first_name": "Test", "last_name": "User", } @@ -730,7 +730,7 @@ class TestApiUser(DirectoriesMixin, APITestCase): f"{self.ENDPOINT}{user1.pk}/", data={ "first_name": "Updated Name 2", - "password": "123xyz", + "password": "newreallystrongpassword456", }, ) diff --git a/src/documents/tests/test_api_profile.py b/src/documents/tests/test_api_profile.py index 8475459a2..2eedf3297 100644 --- a/src/documents/tests/test_api_profile.py +++ b/src/documents/tests/test_api_profile.py @@ -192,6 +192,65 @@ class TestApiProfile(DirectoriesMixin, APITestCase): self.assertEqual(user.first_name, user_data["first_name"]) self.assertEqual(user.last_name, user_data["last_name"]) + def test_update_profile_invalid_password_returns_field_error(self): + """ + GIVEN: + - Configured user + WHEN: + - API call is made to update profile with weak password + THEN: + - Profile update fails with password field error + """ + + user_data = { + "email": "new@email.com", + "password": "short", # shorter than default validator threshold + "first_name": "new first name", + "last_name": "new last name", + } + + response = self.client.patch(self.ENDPOINT, user_data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("password", response.data) + self.assertIsInstance(response.data["password"], list) + self.assertTrue( + any( + "too short" in message.lower() for message in response.data["password"] + ), + ) + + def test_update_profile_placeholder_password_skips_validation(self): + """ + GIVEN: + - Configured user with existing password + WHEN: + - API call is made with the obfuscated placeholder password value + THEN: + - Profile is updated without changing the password or running validators + """ + + original_password = "orig-pass-12345" + self.user.set_password(original_password) + self.user.save() + + user_data = { + "email": "new@email.com", + "password": "*" * 12, # matches obfuscated value from serializer + "first_name": "new first name", + "last_name": "new last name", + } + + response = self.client.patch(self.ENDPOINT, user_data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + user = User.objects.get(username=self.user.username) + self.assertTrue(user.check_password(original_password)) + self.assertEqual(user.email, user_data["email"]) + self.assertEqual(user.first_name, user_data["first_name"]) + self.assertEqual(user.last_name, user_data["last_name"]) + def test_update_auth_token(self): """ GIVEN: diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 754a3c594..97b84fd14 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -9,6 +9,7 @@ from allauth.socialaccount.models import SocialApp from django.contrib.auth.models import Group from django.contrib.auth.models import Permission from django.contrib.auth.models import User +from django.contrib.auth.password_validation import validate_password from rest_framework import serializers from rest_framework.authtoken.serializers import AuthTokenSerializer @@ -19,6 +20,23 @@ from paperless_mail.serialisers import ObfuscatedPasswordField logger = logging.getLogger("paperless.settings") +class PasswordValidationMixin: + def _has_real_password(self, value: str | None) -> bool: + return bool(value) and value.replace("*", "") != "" + + def validate_password(self, value: str) -> str: + if not self._has_real_password(value): + return value + + request = self.context.get("request") if hasattr(self, "context") else None + user = self.instance or ( + request.user if request and hasattr(request, "user") else None + ) + validate_password(value, user) # raise ValidationError if invalid + + return value + + class PaperlessAuthTokenSerializer(AuthTokenSerializer): code = serializers.CharField( label="MFA Code", @@ -49,7 +67,7 @@ class PaperlessAuthTokenSerializer(AuthTokenSerializer): return attrs -class UserSerializer(serializers.ModelSerializer): +class UserSerializer(PasswordValidationMixin, serializers.ModelSerializer): password = ObfuscatedPasswordField(required=False) user_permissions = serializers.SlugRelatedField( many=True, @@ -87,11 +105,11 @@ class UserSerializer(serializers.ModelSerializer): return obj.get_group_permissions() def update(self, instance, validated_data): - if "password" in validated_data: - if len(validated_data.get("password").replace("*", "")) > 0: - instance.set_password(validated_data.get("password")) - instance.save() - validated_data.pop("password") + password = validated_data.pop("password", None) + if self._has_real_password(password): + instance.set_password(password) + instance.save() + super().update(instance, validated_data) return instance @@ -102,12 +120,7 @@ class UserSerializer(serializers.ModelSerializer): user_permissions = None if "user_permissions" in validated_data: user_permissions = validated_data.pop("user_permissions") - password = None - if ( - "password" in validated_data - and len(validated_data.get("password").replace("*", "")) > 0 - ): - password = validated_data.pop("password") + password = validated_data.pop("password", None) user = User.objects.create(**validated_data) # set groups if groups: @@ -116,7 +129,7 @@ class UserSerializer(serializers.ModelSerializer): if user_permissions: user.user_permissions.set(user_permissions) # set password - if password: + if self._has_real_password(password): user.set_password(password) user.save() return user @@ -156,7 +169,7 @@ class SocialAccountSerializer(serializers.ModelSerializer): return "Unknown App" -class ProfileSerializer(serializers.ModelSerializer): +class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer): email = serializers.EmailField(allow_blank=True, required=False) password = ObfuscatedPasswordField(required=False, allow_null=False) auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key") diff --git a/src/paperless/views.py b/src/paperless/views.py index 69375e1bc..e79c0e668 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -197,10 +197,10 @@ class ProfileView(GenericAPIView): serializer.is_valid(raise_exception=True) user = self.request.user if hasattr(self.request, "user") else None - if len(serializer.validated_data.get("password").replace("*", "")) > 0: - user.set_password(serializer.validated_data.get("password")) + password = serializer.validated_data.pop("password", None) + if password and password.replace("*", ""): + user.set_password(password) user.save() - serializer.validated_data.pop("password") for key, value in serializer.validated_data.items(): setattr(user, key, value) From 2049497b76be79cc835d752e7c7972a6944e5ed5 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Nov 2025 19:23:35 +0000 Subject: [PATCH 3/6] Auto translate strings --- src-ui/messages.xlf | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 63360c991..13deb8a89 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -2557,7 +2557,7 @@ src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 195 + 196 @@ -6044,71 +6044,71 @@ Profile updated successfully src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 192 + 193 Error saving profile src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 206 + 207 Error generating auth token src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 223 + 225 Error disconnecting social account src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 248 + 250 Error fetching TOTP settings src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 267 + 269 TOTP activated successfully src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 288 + 290 Error activating TOTP src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 290 + 292 src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 296 + 298 TOTP deactivated successfully src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 312 + 314 Error deactivating TOTP src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 314 + 316 src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts - 319 + 321 From e9f846ca24f5e7915869e03ab95498f2369e6965 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 8 Nov 2025 13:31:57 -0800 Subject: [PATCH 4/6] Fix: include replace none logic in storage path preview, improve jinja conditionals for empty metadata (#11315) --- src/documents/file_handling.py | 45 ++++++++++++----------- src/documents/templating/filepath.py | 31 +++++++++++++++- src/documents/tests/test_api_objects.py | 40 ++++++++++++++++++++ src/documents/tests/test_file_handling.py | 41 +++++++++++++++++++++ src/documents/views.py | 4 +- 5 files changed, 135 insertions(+), 26 deletions(-) diff --git a/src/documents/file_handling.py b/src/documents/file_handling.py index 3a0ffd9fb..48cd57311 100644 --- a/src/documents/file_handling.py +++ b/src/documents/file_handling.py @@ -99,6 +99,29 @@ def generate_unique_filename(doc, *, archive_filename=False) -> Path: return new_filename +def format_filename(document: Document, template_str: str) -> str | None: + rendered_filename = validate_filepath_template_and_render( + template_str, + document, + ) + if rendered_filename is None: + return None + + # Apply this setting. It could become a filter in the future (or users could use |default) + if settings.FILENAME_FORMAT_REMOVE_NONE: + rendered_filename = rendered_filename.replace("/-none-/", "/") + rendered_filename = rendered_filename.replace(" -none-", "") + rendered_filename = rendered_filename.replace("-none-", "") + rendered_filename = rendered_filename.strip(os.sep) + + rendered_filename = rendered_filename.replace( + "-none-", + "none", + ) # backward compatibility + + return rendered_filename + + def generate_filename( doc: Document, *, @@ -108,28 +131,6 @@ def generate_filename( ) -> Path: base_path: Path | None = None - def format_filename(document: Document, template_str: str) -> str | None: - rendered_filename = validate_filepath_template_and_render( - template_str, - document, - ) - if rendered_filename is None: - return None - - # Apply this setting. It could become a filter in the future (or users could use |default) - if settings.FILENAME_FORMAT_REMOVE_NONE: - rendered_filename = rendered_filename.replace("/-none-/", "/") - rendered_filename = rendered_filename.replace(" -none-", "") - rendered_filename = rendered_filename.replace("-none-", "") - rendered_filename = rendered_filename.strip(os.sep) - - rendered_filename = rendered_filename.replace( - "-none-", - "none", - ) # backward compatibility - - return rendered_filename - # Determine the source of the format string if doc.storage_path is not None: filename_format = doc.storage_path.path diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py index 00de8de2c..7d76e7f31 100644 --- a/src/documents/templating/filepath.py +++ b/src/documents/templating/filepath.py @@ -52,6 +52,33 @@ class FilePathTemplate(Template): return clean_filepath(original_render) +class PlaceholderString(str): + """ + String subclass used as a sentinel for empty metadata values inside templates. + + - Renders as \"-none-\" to preserve existing filename cleaning logic. + - Compares equal to either \"-none-\" or \"none\" so templates can check for either. + - Evaluates to False so {% if correspondent %} behaves intuitively. + """ + + def __new__(cls, value: str = "-none-"): + return super().__new__(cls, value) + + def __bool__(self) -> bool: + return False + + def __eq__(self, other) -> bool: + if isinstance(other, str) and other == "none": + other = "-none-" + return super().__eq__(other) + + def __ne__(self, other) -> bool: + return not self.__eq__(other) + + +NO_VALUE_PLACEHOLDER = PlaceholderString("-none-") + + _template_environment.undefined = _LogStrictUndefined _template_environment.filters["get_cf_value"] = get_cf_value @@ -128,7 +155,7 @@ def get_added_date_context(document: Document) -> dict[str, str]: def get_basic_metadata_context( document: Document, *, - no_value_default: str, + no_value_default: str = NO_VALUE_PLACEHOLDER, ) -> dict[str, str]: """ Given a Document, constructs some basic information about it. If certain values are not set, @@ -266,7 +293,7 @@ def validate_filepath_template_and_render( # Build the context dictionary context = ( {"document": document} - | get_basic_metadata_context(document, no_value_default="-none-") + | get_basic_metadata_context(document, no_value_default=NO_VALUE_PLACEHOLDER) | get_creation_date_context(document) | get_added_date_context(document) | get_tags_context(tags_list) diff --git a/src/documents/tests/test_api_objects.py b/src/documents/tests/test_api_objects.py index bba9031db..014dd3c2a 100644 --- a/src/documents/tests/test_api_objects.py +++ b/src/documents/tests/test_api_objects.py @@ -4,6 +4,7 @@ from unittest import mock from django.contrib.auth.models import Permission from django.contrib.auth.models import User +from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase @@ -334,6 +335,45 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, "path/Something") + def test_test_storage_path_respects_none_placeholder_setting(self): + """ + GIVEN: + - A storage path template referencing an empty field + WHEN: + - Testing the template before and after enabling remove-none + THEN: + - The preview shows "none" by default and drops the placeholder when configured + """ + document = Document.objects.create( + mime_type="application/pdf", + storage_path=self.sp1, + title="Something", + checksum="123", + ) + payload = json.dumps( + { + "document": document.id, + "path": "folder/{{ correspondent }}/{{ title }}", + }, + ) + + response = self.client.post( + f"{self.ENDPOINT}test/", + payload, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "folder/none/Something") + + with override_settings(FILENAME_FORMAT_REMOVE_NONE=True): + response = self.client.post( + f"{self.ENDPOINT}test/", + payload, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "folder/Something") + class TestBulkEditObjects(APITestCase): # See test_api_permissions.py for bulk tests on permissions diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py index 62ca52d71..c0070aa81 100644 --- a/src/documents/tests/test_file_handling.py +++ b/src/documents/tests/test_file_handling.py @@ -1078,6 +1078,47 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): Path("SomeImportantNone/2020-07-25.pdf"), ) + @override_settings( + FILENAME_FORMAT=( + "{% if correspondent == 'none' %}none/{% endif %}" + "{% if correspondent == '-none-' %}dash/{% endif %}" + "{% if not correspondent %}false/{% endif %}" + "{% if correspondent != 'none' %}notnoneyes/{% else %}notnoneno/{% endif %}" + "{{ correspondent or 'missing' }}/{{ title }}" + ), + ) + def test_placeholder_matches_none_variants_and_false(self): + """ + GIVEN: + - Templates that compare against 'none', '-none-' and rely on truthiness + WHEN: + - A document has or lacks a correspondent + THEN: + - Empty placeholders behave like both strings and evaluate False + """ + doc_without_correspondent = Document.objects.create( + title="does not matter", + mime_type="application/pdf", + checksum="abc", + ) + doc_with_correspondent = Document.objects.create( + title="does not matter", + mime_type="application/pdf", + checksum="def", + correspondent=Correspondent.objects.create(name="Acme"), + ) + + self.assertEqual( + generate_filename(doc_without_correspondent), + Path( + "none/dash/false/notnoneno/missing/does not matter.pdf", + ), + ) + self.assertEqual( + generate_filename(doc_with_correspondent), + Path("notnoneyes/Acme/does not matter.pdf"), + ) + @override_settings( FILENAME_FORMAT="{created_year_short}/{created_month_name_short}/{created_month_name}/{title}", ) diff --git a/src/documents/views.py b/src/documents/views.py index ec347a553..c007a4cee 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -108,6 +108,7 @@ from documents.conditionals import thumbnail_last_modified from documents.data_models import ConsumableDocument from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentSource +from documents.file_handling import format_filename from documents.filters import CorrespondentFilterSet from documents.filters import CustomFieldFilterSet from documents.filters import DocumentFilterSet @@ -183,7 +184,6 @@ from documents.tasks import index_optimize from documents.tasks import sanity_check from documents.tasks import train_classifier from documents.tasks import update_document_parent_tags -from documents.templating.filepath import validate_filepath_template_and_render from documents.utils import get_boolean from paperless import version from paperless.celery import app as celery_app @@ -2336,7 +2336,7 @@ class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): document = serializer.validated_data.get("document") path = serializer.validated_data.get("path") - result = validate_filepath_template_and_render(path, document) + result = format_filename(document, path) return Response(result) From 44f0191bfbaba378852528ffe99e4b64acdaba28 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 8 Nov 2025 16:34:46 -0800 Subject: [PATCH 5/6] Fix: only cache remote version for version checking (#11320) --- src/documents/views.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/documents/views.py b/src/documents/views.py index c007a4cee..822647fdb 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -23,6 +23,7 @@ from django.conf import settings from django.contrib.auth.models import Group from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.db import connections from django.db.migrations.loader import MigrationLoader from django.db.migrations.recorder import MigrationRecorder @@ -51,7 +52,6 @@ from django.utils.timezone import make_aware from django.utils.translation import get_language from django.views import View from django.views.decorators.cache import cache_control -from django.views.decorators.cache import cache_page from django.views.decorators.http import condition from django.views.decorators.http import last_modified from django.views.generic import TemplateView @@ -2426,7 +2426,6 @@ class UiSettingsView(GenericAPIView): ) -@method_decorator(cache_page(60 * 15), name="dispatch") @extend_schema_view( get=extend_schema( description="Get the current version of the Paperless-NGX server", @@ -2436,31 +2435,34 @@ class UiSettingsView(GenericAPIView): ), ) class RemoteVersionView(GenericAPIView): + cache_key = "remote_version_view_latest_release" + def get(self, request, format=None): - remote_version = "0.0.0" - is_greater_than_current = False current_version = packaging_version.parse(version.__full_version_str__) - try: - resp = httpx.get( - "https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest", - headers={"Accept": "application/json"}, - ) - resp.raise_for_status() + remote_version = cache.get(self.cache_key) + if remote_version is None: try: + resp = httpx.get( + "https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest", + headers={"Accept": "application/json"}, + ) + resp.raise_for_status() data = resp.json() remote_version = data["tag_name"] # Some early tags used ngx-x.y.z remote_version = remote_version.removeprefix("ngx-") except ValueError as e: logger.debug(f"An error occurred parsing remote version json: {e}") - except httpx.HTTPError as e: - logger.debug(f"An error occurred checking for available updates: {e}") + except httpx.HTTPError as e: + logger.debug(f"An error occurred checking for available updates: {e}") + + if remote_version: + cache.set(self.cache_key, remote_version, 60 * 15) + else: + remote_version = "0.0.0" is_greater_than_current = ( - packaging_version.parse( - remote_version, - ) - > current_version + packaging_version.parse(remote_version) > current_version ) return Response( From 005ef4fce68cce4458aed2f644b78bb38d9c3eb2 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 11 Nov 2025 08:27:24 -0800 Subject: [PATCH 6/6] Fix: update Outlook refresh token when refreshed (#11341) --- src/paperless_mail/oauth.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/paperless_mail/oauth.py b/src/paperless_mail/oauth.py index f2050451b..08b5d9647 100644 --- a/src/paperless_mail/oauth.py +++ b/src/paperless_mail/oauth.py @@ -103,6 +103,9 @@ class PaperlessMailOAuth2Manager: refresh_token=account.refresh_token, ), ) + if "refresh_token" in result: + # Outlook returns a new refresh token on refresh, Gmail does not + account.refresh_token = result["refresh_token"] account.password = result["access_token"] account.expiration = timezone.now() + timedelta( seconds=result["expires_in"],