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"],