Fix: change async handling of select custom field updates (#11490)

This commit is contained in:
shamoon
2025-11-29 19:54:15 -08:00
committed by GitHub
parent 67d079fe14
commit 0c43b50f01
3 changed files with 77 additions and 21 deletions

View File

@@ -396,7 +396,6 @@ class CannotMoveFilesException(Exception):
@receiver(models.signals.post_save, sender=CustomFieldInstance, weak=False) @receiver(models.signals.post_save, sender=CustomFieldInstance, weak=False)
@receiver(models.signals.m2m_changed, sender=Document.tags.through, weak=False) @receiver(models.signals.m2m_changed, sender=Document.tags.through, weak=False)
@receiver(models.signals.post_save, sender=Document, weak=False) @receiver(models.signals.post_save, sender=Document, weak=False)
@shared_task
def update_filename_and_move_files( def update_filename_and_move_files(
sender, sender,
instance: Document | CustomFieldInstance, 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 @shared_task
@receiver(models.signals.post_save, sender=CustomField) def process_cf_select_update(custom_field: CustomField):
def check_paths_and_prune_custom_fields(sender, instance: CustomField, **kwargs):
""" """
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, 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 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. 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. 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 ( if (
instance.data_type == CustomField.FieldDataType.SELECT instance.data_type == CustomField.FieldDataType.SELECT
and instance.fields.count() > 0 and instance.fields.count() > 0
and instance.extra_data and instance.extra_data
): # Only select fields, for now ): # Only select fields, for now
select_options = { process_cf_select_update.delay(instance)
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)
@receiver(models.signals.post_delete, sender=CustomField) @receiver(models.signals.post_delete, sender=CustomField)

View File

@@ -1,5 +1,6 @@
import json import json
from datetime import date from datetime import date
from unittest import mock
from unittest.mock import ANY from unittest.mock import ANY
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@@ -276,6 +277,52 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
doc.refresh_from_db() doc.refresh_from_db()
self.assertEqual(doc.custom_fields.first().value, None) 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): def test_custom_field_select_old_version(self):
""" """
GIVEN: GIVEN:

View File

@@ -530,6 +530,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
@override_settings( @override_settings(
FILENAME_FORMAT="{{title}}_{{custom_fields|get_cf_value('test')}}", FILENAME_FORMAT="{{title}}_{{custom_fields|get_cf_value('test')}}",
CELERY_TASK_ALWAYS_EAGER=True,
) )
@mock.patch("documents.signals.handlers.update_filename_and_move_files") @mock.patch("documents.signals.handlers.update_filename_and_move_files")
def test_select_cf_updated(self, m): 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")) self.assertEqual(generate_filename(doc), Path("document_apple.pdf"))
# handler should not have been called # handler should not have been called
self.assertEqual(m.delay.call_count, 0) self.assertEqual(m.call_count, 0)
cf.extra_data = { cf.extra_data = {
"select_options": [ "select_options": [
{"label": "aubergine", "id": "abc123"}, {"label": "aubergine", "id": "abc123"},
@@ -579,8 +580,8 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
} }
cf.save() cf.save()
self.assertEqual(generate_filename(doc), Path("document_aubergine.pdf")) self.assertEqual(generate_filename(doc), Path("document_aubergine.pdf"))
# handler should have been called via delay # handler should have been called once via the async task
self.assertEqual(m.delay.call_count, 1) self.assertEqual(m.call_count, 1)
class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, TestCase): class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, TestCase):