mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-14 01:21:14 -06:00
Merge branch 'dev' into feature-remote-ocr-2
This commit is contained in:
@@ -396,7 +396,6 @@ class CannotMoveFilesException(Exception):
|
||||
@receiver(models.signals.post_save, sender=CustomFieldInstance, weak=False)
|
||||
@receiver(models.signals.m2m_changed, sender=Document.tags.through, weak=False)
|
||||
@receiver(models.signals.post_save, sender=Document, weak=False)
|
||||
@shared_task
|
||||
def update_filename_and_move_files(
|
||||
sender,
|
||||
instance: Document | CustomFieldInstance,
|
||||
@@ -533,34 +532,43 @@ def update_filename_and_move_files(
|
||||
)
|
||||
|
||||
|
||||
# should be disabled in /src/documents/management/commands/document_importer.py handle
|
||||
@receiver(models.signals.post_save, sender=CustomField)
|
||||
def check_paths_and_prune_custom_fields(sender, instance: CustomField, **kwargs):
|
||||
@shared_task
|
||||
def process_cf_select_update(custom_field: CustomField):
|
||||
"""
|
||||
When a custom field is updated:
|
||||
Update documents tied to a select custom field:
|
||||
|
||||
1. 'Select' custom field instances get their end-user value (e.g. in file names) from the select_options in extra_data,
|
||||
which is contained in the custom field itself. So when the field is changed, we (may) need to update the file names
|
||||
of all documents that have this custom field.
|
||||
2. If a 'Select' field option was removed, we need to nullify the custom field instances that have the option.
|
||||
"""
|
||||
select_options = {
|
||||
option["id"]: option["label"]
|
||||
for option in custom_field.extra_data.get("select_options", [])
|
||||
}
|
||||
|
||||
# Clear select values that no longer exist
|
||||
custom_field.fields.exclude(
|
||||
value_select__in=select_options.keys(),
|
||||
).update(value_select=None)
|
||||
|
||||
for cf_instance in custom_field.fields.select_related("document").iterator():
|
||||
# Update the filename and move files if necessary
|
||||
update_filename_and_move_files(CustomFieldInstance, cf_instance)
|
||||
|
||||
|
||||
# should be disabled in /src/documents/management/commands/document_importer.py handle
|
||||
@receiver(models.signals.post_save, sender=CustomField)
|
||||
def check_paths_and_prune_custom_fields(sender, instance: CustomField, **kwargs):
|
||||
"""
|
||||
When a custom field is updated, check if we need to update any documents. Done async to avoid slowing down the save operation.
|
||||
"""
|
||||
if (
|
||||
instance.data_type == CustomField.FieldDataType.SELECT
|
||||
and instance.fields.count() > 0
|
||||
and instance.extra_data
|
||||
): # Only select fields, for now
|
||||
select_options = {
|
||||
option["id"]: option["label"]
|
||||
for option in instance.extra_data.get("select_options", [])
|
||||
}
|
||||
|
||||
for cf_instance in instance.fields.all():
|
||||
# Check if the current value is still a valid option
|
||||
if cf_instance.value not in select_options:
|
||||
cf_instance.value_select = None
|
||||
cf_instance.save(update_fields=["value_select"])
|
||||
|
||||
# Update the filename and move files if necessary
|
||||
update_filename_and_move_files.delay(sender, cf_instance)
|
||||
process_cf_select_update.delay(instance)
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=CustomField)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
from datetime import date
|
||||
from unittest import mock
|
||||
from unittest.mock import ANY
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
@@ -276,6 +277,52 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
||||
doc.refresh_from_db()
|
||||
self.assertEqual(doc.custom_fields.first().value, None)
|
||||
|
||||
@mock.patch("documents.signals.handlers.process_cf_select_update.delay")
|
||||
def test_custom_field_update_offloaded_once(self, mock_delay):
|
||||
"""
|
||||
GIVEN:
|
||||
- A select custom field attached to multiple documents
|
||||
WHEN:
|
||||
- The select options are updated
|
||||
THEN:
|
||||
- The async update task is enqueued once
|
||||
"""
|
||||
cf_select = CustomField.objects.create(
|
||||
name="Select Field",
|
||||
data_type=CustomField.FieldDataType.SELECT,
|
||||
extra_data={
|
||||
"select_options": [
|
||||
{"label": "Option 1", "id": "abc-123"},
|
||||
{"label": "Option 2", "id": "def-456"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
documents = [
|
||||
Document.objects.create(
|
||||
title="WOW",
|
||||
content="the content",
|
||||
checksum=f"{i}",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
for document in documents:
|
||||
CustomFieldInstance.objects.create(
|
||||
document=document,
|
||||
field=cf_select,
|
||||
value_select="def-456",
|
||||
)
|
||||
|
||||
cf_select.extra_data = {
|
||||
"select_options": [
|
||||
{"label": "Option 1", "id": "abc-123"},
|
||||
],
|
||||
}
|
||||
cf_select.save()
|
||||
|
||||
mock_delay.assert_called_once_with(cf_select)
|
||||
|
||||
def test_custom_field_select_old_version(self):
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -530,6 +530,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
@override_settings(
|
||||
FILENAME_FORMAT="{{title}}_{{custom_fields|get_cf_value('test')}}",
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
)
|
||||
@mock.patch("documents.signals.handlers.update_filename_and_move_files")
|
||||
def test_select_cf_updated(self, m):
|
||||
@@ -569,7 +570,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(generate_filename(doc), Path("document_apple.pdf"))
|
||||
|
||||
# handler should not have been called
|
||||
self.assertEqual(m.delay.call_count, 0)
|
||||
self.assertEqual(m.call_count, 0)
|
||||
cf.extra_data = {
|
||||
"select_options": [
|
||||
{"label": "aubergine", "id": "abc123"},
|
||||
@@ -579,8 +580,8 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
}
|
||||
cf.save()
|
||||
self.assertEqual(generate_filename(doc), Path("document_aubergine.pdf"))
|
||||
# handler should have been called via delay
|
||||
self.assertEqual(m.delay.call_count, 1)
|
||||
# handler should have been called once via the async task
|
||||
self.assertEqual(m.call_count, 1)
|
||||
|
||||
|
||||
class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-14 16:09+0000\n"
|
||||
"PO-Revision-Date: 2025-11-20 00:35\n"
|
||||
"PO-Revision-Date: 2025-11-24 12:15\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Catalan\n"
|
||||
"Language: ca_ES\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-14 16:09+0000\n"
|
||||
"PO-Revision-Date: 2025-11-14 16:11\n"
|
||||
"PO-Revision-Date: 2025-11-25 00:35\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Korean\n"
|
||||
"Language: ko_KR\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-14 16:09+0000\n"
|
||||
"PO-Revision-Date: 2025-11-14 16:11\n"
|
||||
"PO-Revision-Date: 2025-11-27 00:26\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Polish\n"
|
||||
"Language: pl_PL\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-14 16:09+0000\n"
|
||||
"PO-Revision-Date: 2025-11-14 16:11\n"
|
||||
"PO-Revision-Date: 2025-11-28 12:15\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Language: sl_SI\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-14 16:09+0000\n"
|
||||
"PO-Revision-Date: 2025-11-21 23:54\n"
|
||||
"PO-Revision-Date: 2025-11-25 12:16\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Language: tr_TR\n"
|
||||
|
||||
@@ -137,3 +137,25 @@ class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
user.save()
|
||||
handle_social_account_updated(None, request, sociallogin)
|
||||
return user
|
||||
|
||||
def on_authentication_error(
|
||||
self,
|
||||
request,
|
||||
provider,
|
||||
error=None,
|
||||
exception=None,
|
||||
extra_context=None,
|
||||
):
|
||||
"""
|
||||
Just log errors and pass them along.
|
||||
"""
|
||||
logger.warning(
|
||||
f"Social authentication error for provider `{provider!s}`: {error!s} ({exception!s})",
|
||||
)
|
||||
return super().on_authentication_error(
|
||||
request,
|
||||
provider,
|
||||
error,
|
||||
exception,
|
||||
extra_context,
|
||||
)
|
||||
|
||||
@@ -38,10 +38,19 @@ def handle_social_account_updated(sender, request, sociallogin, **kwargs):
|
||||
"""
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
social_account_groups = sociallogin.account.extra_data.get(
|
||||
extra_data = sociallogin.account.extra_data or {}
|
||||
social_account_groups = extra_data.get(
|
||||
"groups",
|
||||
[],
|
||||
) # None if not found
|
||||
) # pre-allauth 65.11.0 structure
|
||||
|
||||
if not social_account_groups:
|
||||
# allauth 65.11.0+ nests claims under `userinfo`/`id_token`
|
||||
social_account_groups = (
|
||||
extra_data.get("userinfo", {}).get("groups")
|
||||
or extra_data.get("id_token", {}).get("groups")
|
||||
or []
|
||||
)
|
||||
if settings.SOCIAL_ACCOUNT_SYNC_GROUPS and social_account_groups is not None:
|
||||
groups = Group.objects.filter(name__in=social_account_groups)
|
||||
logger.debug(
|
||||
|
||||
@@ -167,3 +167,17 @@ class TestCustomSocialAccountAdapter(TestCase):
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertTrue(user.groups.filter(name="group1").exists())
|
||||
self.assertFalse(user.groups.filter(name="group2").exists())
|
||||
|
||||
def test_error_logged_on_authentication_error(self):
|
||||
adapter = get_social_adapter()
|
||||
request = HttpRequest()
|
||||
with self.assertLogs("paperless.auth", level="INFO") as log_cm:
|
||||
adapter.on_authentication_error(
|
||||
request,
|
||||
provider="test-provider",
|
||||
error="Error",
|
||||
exception="Test authentication error",
|
||||
)
|
||||
self.assertTrue(
|
||||
any("Test authentication error" in message for message in log_cm.output),
|
||||
)
|
||||
|
||||
@@ -192,6 +192,68 @@ class TestSyncSocialLoginGroups(TestCase):
|
||||
)
|
||||
self.assertEqual(list(user.groups.all()), [])
|
||||
|
||||
@override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=True)
|
||||
def test_userinfo_groups(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Enabled group syncing, and `groups` nested under `userinfo`
|
||||
WHEN:
|
||||
- The social login is updated via signal after login
|
||||
THEN:
|
||||
- The user's groups are updated using `userinfo.groups`
|
||||
"""
|
||||
group = Group.objects.create(name="group1")
|
||||
user = User.objects.create_user(username="testuser")
|
||||
sociallogin = Mock(
|
||||
user=user,
|
||||
account=Mock(
|
||||
extra_data={
|
||||
"userinfo": {
|
||||
"groups": ["group1"],
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
handle_social_account_updated(
|
||||
sender=None,
|
||||
request=HttpRequest(),
|
||||
sociallogin=sociallogin,
|
||||
)
|
||||
|
||||
self.assertEqual(list(user.groups.all()), [group])
|
||||
|
||||
@override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=True)
|
||||
def test_id_token_groups_fallback(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Enabled group syncing, and `groups` only under `id_token`
|
||||
WHEN:
|
||||
- The social login is updated via signal after login
|
||||
THEN:
|
||||
- The user's groups are updated using `id_token.groups`
|
||||
"""
|
||||
group = Group.objects.create(name="group1")
|
||||
user = User.objects.create_user(username="testuser")
|
||||
sociallogin = Mock(
|
||||
user=user,
|
||||
account=Mock(
|
||||
extra_data={
|
||||
"id_token": {
|
||||
"groups": ["group1"],
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
handle_social_account_updated(
|
||||
sender=None,
|
||||
request=HttpRequest(),
|
||||
sociallogin=sociallogin,
|
||||
)
|
||||
|
||||
self.assertEqual(list(user.groups.all()), [group])
|
||||
|
||||
|
||||
class TestUserGroupDeletionCleanup(TestCase):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[tuple[int, int, int]] = (2, 19, 6)
|
||||
__version__: Final[tuple[int, int, int]] = (2, 20, 1)
|
||||
# Version string like X.Y.Z
|
||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||
# Version string like X.Y
|
||||
|
||||
Reference in New Issue
Block a user