Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions ckanext/multilang/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}')
Expand All @@ -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}')

Expand Down
150 changes: 150 additions & 0 deletions ckanext/multilang/tests/test_resource_update_fix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""
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_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 = {}

with patch('ckanext.multilang.plugin.helpers.getLanguage', return_value='de'), \
patch('ckanext.multilang.plugin.after_update_dataset') as mock_update, \
patch('ckanext.multilang.plugin.after_update_resource'):

Comment on lines +99 to +102
plugin.before_resource_update(context, {}, {})
self.assertTrue(context.get(plugin._RESOURCE_OP_FLAG))

plugin.after_dataset_update(
context, {'id': 'pkg-1', 'title': 'Italian Title'}
)
mock_update.assert_not_called()

plugin.after_resource_update(context, {'id': 'res-1'})

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'):
Comment on lines +121 to +124

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_dataset_update 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_dataset_update should not be called during resource delete")
self.assertNotIn(plugin._RESOURCE_OP_FLAG, context)
Loading