From a0f00c000b86f851ba9e61baf649e78170c3cf5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 11:07:15 +0000 Subject: [PATCH 1/4] Initial plan From f7b5ac3f9f9d213dc86fd323332f1dff4db1f963 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 11:14:39 +0000 Subject: [PATCH 2/4] Fix resource updates causing package-level translation corruption Agent-Logs-Url: https://github.com/geosolutions-it/ckanext-multilang/sessions/fb2d3167-a4a9-47d1-b9fb-820ecf6c7cee Co-authored-by: etj <717359+etj@users.noreply.github.com> --- ckanext/multilang/plugin.py | 22 +++ .../tests/test_resource_update_fix.py | 173 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 ckanext/multilang/tests/test_resource_update_fix.py diff --git a/ckanext/multilang/plugin.py b/ckanext/multilang/plugin.py index 641c597..264a8a6 100644 --- a/ckanext/multilang/plugin.py +++ b/ckanext/multilang/plugin.py @@ -258,18 +258,39 @@ def before_resource_show(self, resource_dict): return resource_dict + # Flag key used to mark that a resource-level operation is in progress so + # that after_dataset_update can avoid overwriting package multilang entries + # with stale core-table data. + _RESOURCE_OP_FLAG = '__multilang_skip_pkg_update' + + def before_resource_create(self, context, resource): + context[self._RESOURCE_OP_FLAG] = True + + def before_resource_update(self, context, current, resource): + context[self._RESOURCE_OP_FLAG] = True + + def before_resource_delete(self, context, resource, resources): + context[self._RESOURCE_OP_FLAG] = True + def after_dataset_update(self, context, obj_dict): + if context.get(self._RESOURCE_OP_FLAG): + log.debug('Skipping package multilang update: triggered by a resource operation') + return lang = helpers.getLanguage() log.debug(f'Dispatching after_dataset_update for LANG:{lang}') if lang: after_update_dataset(context, obj_dict, lang) def after_resource_update(self, context, obj_dict): + context.pop(self._RESOURCE_OP_FLAG, None) lang = helpers.getLanguage() log.debug(f'Dispatching after_resource_update for LANG:{lang}') if lang: after_update_resource(context, obj_dict, lang) + def after_resource_delete(self, context, resources): + context.pop(self._RESOURCE_OP_FLAG, None) + def after_dataset_create(self, context, obj_dict): lang = helpers.getLanguage() log.debug(f'Dispatching after_dataset_create for LANG:{lang}') @@ -278,6 +299,7 @@ def after_dataset_create(self, context, obj_dict): after_create_dataset(context, obj_dict, lang) def after_resource_create(self, context, obj_dict): + context.pop(self._RESOURCE_OP_FLAG, None) lang = helpers.getLanguage() log.debug(f'Dispatching after_resource_create for LANG:{lang}') diff --git a/ckanext/multilang/tests/test_resource_update_fix.py b/ckanext/multilang/tests/test_resource_update_fix.py new file mode 100644 index 0000000..ac6acbf --- /dev/null +++ b/ckanext/multilang/tests/test_resource_update_fix.py @@ -0,0 +1,173 @@ +""" +Tests for the fix that prevents resource updates from corrupting package-level +translations in the multilang extension. + +When a resource is created/updated/deleted, CKAN internally calls package_update, +which triggers after_dataset_update. Without the fix, the package multilang entries +for the current session language would be overwritten with stale core-table data. + +The fix uses a context flag set by before_resource_create/update/delete to signal +to after_dataset_update that it should skip the package multilang update. +""" +import unittest +from unittest.mock import patch + + +class TestResourceUpdateFix(unittest.TestCase): + """Tests that resource operations do not corrupt package multilang entries.""" + + def _make_plugin(self): + from ckanext.multilang.plugin import MultilangPlugin + return MultilangPlugin() + + def test_before_resource_create_sets_flag(self): + """before_resource_create should set the skip flag in context.""" + plugin = self._make_plugin() + context = {} + plugin.before_resource_create(context, {}) + self.assertTrue(context.get(plugin._RESOURCE_OP_FLAG)) + + def test_before_resource_update_sets_flag(self): + """before_resource_update should set the skip flag in context.""" + plugin = self._make_plugin() + context = {} + plugin.before_resource_update(context, {}, {}) + self.assertTrue(context.get(plugin._RESOURCE_OP_FLAG)) + + def test_before_resource_delete_sets_flag(self): + """before_resource_delete should set the skip flag in context.""" + plugin = self._make_plugin() + context = {} + plugin.before_resource_delete(context, {}, []) + self.assertTrue(context.get(plugin._RESOURCE_OP_FLAG)) + + def test_after_resource_create_clears_flag(self): + """after_resource_create should remove the skip flag from context.""" + plugin = self._make_plugin() + context = {plugin._RESOURCE_OP_FLAG: True} + with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value=None): + plugin.after_resource_create(context, {}) + self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) + + def test_after_resource_update_clears_flag(self): + """after_resource_update should remove the skip flag from context.""" + plugin = self._make_plugin() + context = {plugin._RESOURCE_OP_FLAG: True} + with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value=None): + plugin.after_resource_update(context, {}) + self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) + + def test_after_resource_delete_clears_flag(self): + """after_resource_delete should remove the skip flag from context.""" + plugin = self._make_plugin() + context = {plugin._RESOURCE_OP_FLAG: True} + plugin.after_resource_delete(context, []) + self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) + + def test_after_dataset_update_skips_when_flag_set(self): + """after_dataset_update must not update package multilang when the resource + operation flag is present, preventing stale-data corruption.""" + plugin = self._make_plugin() + context = {plugin._RESOURCE_OP_FLAG: True} + with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value='de'), \ + patch('ckanext.multilang.plugin.after_update_dataset') as mock_update: + plugin.after_dataset_update(context, {'id': 'pkg-1', 'title': 'Italian Title'}) + mock_update.assert_not_called() + + def test_after_dataset_update_runs_normally_without_flag(self): + """after_dataset_update must proceed normally when no resource operation flag + is present (i.e. a direct package update from the user).""" + plugin = self._make_plugin() + context = {} + pkg_dict = {'id': 'pkg-1', 'title': 'Deutscher Titel'} + with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value='de'), \ + patch('ckanext.multilang.plugin.after_update_dataset') as mock_update: + plugin.after_dataset_update(context, pkg_dict) + mock_update.assert_called_once_with(context, pkg_dict, 'de') + + def test_flag_lifecycle_during_resource_update(self): + """Simulate the full CKAN resource_update hook sequence and verify the flag + is set before package_update (after_dataset_update) and cleared after.""" + plugin = self._make_plugin() + context = {} + flag_during_pkg_update = [] + + def fake_after_dataset_update(ctx, obj_dict, lang): + # Capture the flag value at the time package_update runs + flag_during_pkg_update.append(ctx.get(plugin._RESOURCE_OP_FLAG)) + + with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value='de'), \ + patch('ckanext.multilang.plugin.after_update_dataset', + side_effect=fake_after_dataset_update), \ + patch('ckanext.multilang.plugin.after_update_resource'): + + # 1. before_resource_update (CKAN calls this before package_update) + plugin.before_resource_update(context, {}, {}) + + # 2. Simulate package_update triggering after_dataset_update + # (should be skipped because flag is set) + plugin.after_dataset_update(context, {'id': 'pkg-1', 'title': 'Italian Title'}) + + # 3. after_resource_update (CKAN calls this after package_update) + plugin.after_resource_update(context, {'id': 'res-1'}) + + # after_dataset_update should have been skipped (not called through) + # because the flag was set; fake_after_dataset_update captures calls that + # pass through the guard, so it should not have been invoked + self.assertEqual(flag_during_pkg_update, [], + "after_update_dataset should not be called during resource update") + # Flag should be cleared by after_resource_update + self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) + + def test_flag_lifecycle_during_resource_create(self): + """Simulate the full CKAN resource_create hook sequence.""" + plugin = self._make_plugin() + context = {} + pkg_update_calls = [] + + with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value='de'), \ + patch('ckanext.multilang.plugin.after_update_dataset', + side_effect=lambda *a: pkg_update_calls.append(a)), \ + patch('ckanext.multilang.plugin.after_create_resource'): + + plugin.before_resource_create(context, {}) + plugin.after_dataset_update(context, {'id': 'pkg-1'}) + plugin.after_resource_create(context, {'id': 'res-1'}) + + self.assertEqual(pkg_update_calls, [], + "after_update_dataset should not be called during resource create") + self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) + + def test_flag_lifecycle_during_resource_delete(self): + """Simulate the full CKAN resource_delete hook sequence.""" + plugin = self._make_plugin() + context = {} + pkg_update_calls = [] + + with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value='de'), \ + patch('ckanext.multilang.plugin.after_update_dataset', + side_effect=lambda *a: pkg_update_calls.append(a)): + + plugin.before_resource_delete(context, {}, []) + plugin.after_dataset_update(context, {'id': 'pkg-1'}) + plugin.after_resource_delete(context, []) + + self.assertEqual(pkg_update_calls, [], + "after_update_dataset should not be called during resource delete") + self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) + + def test_after_resource_create_clears_flag_even_when_no_lang(self): + """after_resource_create must clear the flag regardless of language.""" + plugin = self._make_plugin() + context = {plugin._RESOURCE_OP_FLAG: True} + with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value=None): + plugin.after_resource_create(context, {}) + self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) + + def test_after_resource_update_clears_flag_even_when_no_lang(self): + """after_resource_update must clear the flag regardless of language.""" + plugin = self._make_plugin() + context = {plugin._RESOURCE_OP_FLAG: True} + with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value=None): + plugin.after_resource_update(context, {}) + self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) From 2c04d21223332ecc7a1a58e279aeb7ff8899bd49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 11:15:58 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Fix=20test=20assertion=20message=20spelling?= =?UTF-8?q?=20(after=5Fupdate=5Fdataset=20=E2=86=92=20after=5Fdataset=5Fup?= =?UTF-8?q?date)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/geosolutions-it/ckanext-multilang/sessions/fb2d3167-a4a9-47d1-b9fb-820ecf6c7cee Co-authored-by: etj <717359+etj@users.noreply.github.com> --- ckanext/multilang/tests/test_resource_update_fix.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ckanext/multilang/tests/test_resource_update_fix.py b/ckanext/multilang/tests/test_resource_update_fix.py index ac6acbf..5438467 100644 --- a/ckanext/multilang/tests/test_resource_update_fix.py +++ b/ckanext/multilang/tests/test_resource_update_fix.py @@ -115,7 +115,7 @@ def fake_after_dataset_update(ctx, obj_dict, lang): # because the flag was set; fake_after_dataset_update captures calls that # pass through the guard, so it should not have been invoked self.assertEqual(flag_during_pkg_update, [], - "after_update_dataset should not be called during resource update") + "after_dataset_update should not be called during resource update") # Flag should be cleared by after_resource_update self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) @@ -135,7 +135,7 @@ def test_flag_lifecycle_during_resource_create(self): plugin.after_resource_create(context, {'id': 'res-1'}) self.assertEqual(pkg_update_calls, [], - "after_update_dataset should not be called during resource create") + "after_dataset_update should not be called during resource create") self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) def test_flag_lifecycle_during_resource_delete(self): @@ -153,7 +153,7 @@ def test_flag_lifecycle_during_resource_delete(self): plugin.after_resource_delete(context, []) self.assertEqual(pkg_update_calls, [], - "after_update_dataset should not be called during resource delete") + "after_dataset_update should not be called during resource delete") self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) def test_after_resource_create_clears_flag_even_when_no_lang(self): From 3087eb4e8a0be02ab3350e4df5e8a0c252131d3f Mon Sep 17 00:00:00 2001 From: Emanuele Tajariol Date: Mon, 4 May 2026 18:39:44 +0200 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../tests/test_resource_update_fix.py | 51 +++++-------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/ckanext/multilang/tests/test_resource_update_fix.py b/ckanext/multilang/tests/test_resource_update_fix.py index 5438467..948cba3 100644 --- a/ckanext/multilang/tests/test_resource_update_fix.py +++ b/ckanext/multilang/tests/test_resource_update_fix.py @@ -85,38 +85,31 @@ def test_after_dataset_update_runs_normally_without_flag(self): plugin.after_dataset_update(context, pkg_dict) mock_update.assert_called_once_with(context, pkg_dict, 'de') - def test_flag_lifecycle_during_resource_update(self): - """Simulate the full CKAN resource_update hook sequence and verify the flag - is set before package_update (after_dataset_update) and cleared after.""" + def test_resource_update_hooks_manage_flag_on_reused_context(self): + """Unit-test the plugin hook contract when the same context dict is reused. + + This does not exercise CKAN's real ``resource_update`` action stack; it + only verifies that the plugin sets the skip flag before the dataset hook, + that ``after_dataset_update`` respects that flag, and that the resource + post-hook clears it afterwards. + """ plugin = self._make_plugin() context = {} - flag_during_pkg_update = [] - - def fake_after_dataset_update(ctx, obj_dict, lang): - # Capture the flag value at the time package_update runs - flag_during_pkg_update.append(ctx.get(plugin._RESOURCE_OP_FLAG)) with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value='de'), \ - patch('ckanext.multilang.plugin.after_update_dataset', - side_effect=fake_after_dataset_update), \ + patch('ckanext.multilang.plugin.after_update_dataset') as mock_update, \ patch('ckanext.multilang.plugin.after_update_resource'): - # 1. before_resource_update (CKAN calls this before package_update) plugin.before_resource_update(context, {}, {}) + self.assertTrue(context.get(plugin._RESOURCE_OP_FLAG)) - # 2. Simulate package_update triggering after_dataset_update - # (should be skipped because flag is set) - plugin.after_dataset_update(context, {'id': 'pkg-1', 'title': 'Italian Title'}) + plugin.after_dataset_update( + context, {'id': 'pkg-1', 'title': 'Italian Title'} + ) + mock_update.assert_not_called() - # 3. after_resource_update (CKAN calls this after package_update) plugin.after_resource_update(context, {'id': 'res-1'}) - # after_dataset_update should have been skipped (not called through) - # because the flag was set; fake_after_dataset_update captures calls that - # pass through the guard, so it should not have been invoked - self.assertEqual(flag_during_pkg_update, [], - "after_dataset_update should not be called during resource update") - # Flag should be cleared by after_resource_update self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) def test_flag_lifecycle_during_resource_create(self): @@ -155,19 +148,3 @@ def test_flag_lifecycle_during_resource_delete(self): self.assertEqual(pkg_update_calls, [], "after_dataset_update should not be called during resource delete") self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) - - def test_after_resource_create_clears_flag_even_when_no_lang(self): - """after_resource_create must clear the flag regardless of language.""" - plugin = self._make_plugin() - context = {plugin._RESOURCE_OP_FLAG: True} - with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value=None): - plugin.after_resource_create(context, {}) - self.assertNotIn(plugin._RESOURCE_OP_FLAG, context) - - def test_after_resource_update_clears_flag_even_when_no_lang(self): - """after_resource_update must clear the flag regardless of language.""" - plugin = self._make_plugin() - context = {plugin._RESOURCE_OP_FLAG: True} - with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value=None): - plugin.after_resource_update(context, {}) - self.assertNotIn(plugin._RESOURCE_OP_FLAG, context)