From 20dcfaaee341b5dc050bea6be95c8f8a15df0330 Mon Sep 17 00:00:00 2001 From: ufedaseyeuconsultant Date: Thu, 3 Sep 2026 18:02:16 +0400 Subject: [PATCH 1/2] feat: support editing an existing tag's external_id on taxonomy re-import --- src/openedx_tagging/import_export/actions.py | 134 ++++++++++- .../import_export/import_plan.py | 3 + src/openedx_tagging/import_export/parsers.py | 18 +- .../import_export/test_actions.py | 227 +++++++++++++++++- .../openedx_tagging/import_export/test_api.py | 80 ++++++ .../import_export/test_import_plan.py | 42 ++++ .../import_export/test_parsers.py | 65 +++++ 7 files changed, 564 insertions(+), 5 deletions(-) diff --git a/src/openedx_tagging/import_export/actions.py b/src/openedx_tagging/import_export/actions.py index a501c9e30..ce32d7c3c 100644 --- a/src/openedx_tagging/import_export/actions.py +++ b/src/openedx_tagging/import_export/actions.py @@ -103,10 +103,13 @@ def _validate_parent(self, indexed_actions) -> ImportActionError | None: # Validates that the parent exists on the taxonomy self.taxonomy.tag_set.get(external_id=self.tag.parent_id) except Tag.DoesNotExist: - # Or if the parent is created on previous actions - if not self._search_action( + # Or if the parent is created or renamed-in on previous actions + found = self._search_action( indexed_actions, CreateTag.name, "id", self.tag.parent_id - ): + ) or self._search_action( + indexed_actions, RenameTagExternalId.name, "id", self.tag.parent_id + ) + if not found: return ImportActionError( action=self, message=_( @@ -157,6 +160,15 @@ def _validate_value(self, indexed_actions) -> ImportActionError | None: self.tag.value, ) + if not action: + # Validates value duplication on rename_external_id actions + action = self._search_action( + indexed_actions, + RenameTagExternalId.name, + "value", + self.tag.value, + ) + if action: return ImportActionConflict( action=self, @@ -197,6 +209,8 @@ def applies_for(cls, taxonomy: Taxonomy, tag) -> bool: """ This action applies whenever the tag does not exist """ + if tag.previous_id and tag.id != tag.previous_id: + return False try: taxonomy.tag_set.get(external_id=tag.id) return False @@ -371,6 +385,119 @@ def execute(self) -> None: taxonomy_tag.save() +class RenameTagExternalId(ImportAction): + """ + Action to rename an existing tag's external_id in place. + + Action created when a row's `previous_id` matches an existing tag's + external_id in the taxonomy, and the row's `id` differs from it. + Preserves the tag's primary key and associations across the + rename, instead of deleting the old tag and creating a new one. + + Validations: + - previous_id must match an existing tag's external_id. + - The new id must not collide with a different existing tag, or with a + prior create/rename action in the same import. + - Value duplicates with tags on the database, if the value is changing. + - Parent validation, if parent_id is set. + """ + + name = "rename_external_id" + + def __str__(self) -> str: + return str( + _( + "Rename external_id of tag with previous_id={previous_id} to " + "'{id}' (value={value}, parent_id={parent_id})." + ).format( + previous_id=self.tag.previous_id, + id=self.tag.id, + value=self.tag.value, + parent_id=self.tag.parent_id, + ) + ) + + @classmethod + def applies_for(cls, taxonomy: Taxonomy, tag) -> bool: + """ + This action applies whenever previous_id is set and differs from id + """ + return bool(tag.previous_id) and tag.id != tag.previous_id + + def _validate_new_id(self, indexed_actions) -> ImportActionError | None: + """ + Check that the new id doesn't collide with a different existing tag, + or with a prior create/rename action in the same import. + """ + if self.taxonomy.tag_set.filter(external_id=self.tag.id).exists(): + return ImportActionError( + action=self, + message=_("A tag with external_id ({id}) already exists.").format(id=self.tag.id), + ) + + action = self._search_action(indexed_actions, CreateTag.name, "id", self.tag.id) + if not action: + action = self._search_action(indexed_actions, self.name, "id", self.tag.id) + + if action: + return ImportActionConflict( + action=self, + conflict_action_index=action.index, + message=_("Duplicated external_id tag."), + ) + + return None + + def validate(self, indexed_actions) -> list[ImportActionError]: + """ + Validates the rename_external_id action + """ + errors = [] + + try: + matched_tag = self.taxonomy.tag_set.get(external_id=self.tag.previous_id) + except Tag.DoesNotExist: + matched_tag = None + errors.append( + ImportActionError( + action=self, + message=_( + "Unknown previous_id ({previous_id}). No tag with that " + "external_id exists in this taxonomy." + ).format(previous_id=self.tag.previous_id), + ) + ) + + error = self._validate_new_id(indexed_actions) + if error: + errors.append(error) + + if matched_tag is not None and matched_tag.value != self.tag.value: + error = self._validate_value(indexed_actions) + if error: + errors.append(error) + + if self.tag.parent_id: + error = self._validate_parent(indexed_actions) + if error: + errors.append(error) + + return errors + + def execute(self) -> None: + """ + Renames a tag's external_id in place, and updates its value and parent + """ + target = self.taxonomy.tag_set.get(external_id=self.tag.previous_id) + target.external_id = self.tag.id + target.value = self.tag.value + target.parent = ( + self.taxonomy.tag_set.get(external_id=self.tag.parent_id) + if self.tag.parent_id else None + ) + target.save() + + class DeleteTag(ImportAction): """ Action for delete a Tag @@ -445,6 +572,7 @@ def execute(self) -> None: available_actions = [ UpdateParentTag, RenameTag, + RenameTagExternalId, CreateTag, DeleteTag, WithoutChanges, diff --git a/src/openedx_tagging/import_export/import_plan.py b/src/openedx_tagging/import_export/import_plan.py index 6502c2c1b..e92e55322 100644 --- a/src/openedx_tagging/import_export/import_plan.py +++ b/src/openedx_tagging/import_export/import_plan.py @@ -21,6 +21,7 @@ class TagItem: value: str index: int | None = 0 parent_id: str | None = None + previous_id: str | None = None def __str__(self): """ @@ -162,6 +163,8 @@ def generate_actions( for tag in tags: if tag.id in tags_for_delete: tags_for_delete.pop(tag.id) + if tag.previous_id: + tags_for_delete.pop(tag.previous_id, None) # Delete all not readed tags self._build_delete_actions(tags_for_delete) diff --git a/src/openedx_tagging/import_export/parsers.py b/src/openedx_tagging/import_export/parsers.py index 38e8fb337..656062c90 100644 --- a/src/openedx_tagging/import_export/parsers.py +++ b/src/openedx_tagging/import_export/parsers.py @@ -43,13 +43,16 @@ class Parser: It can convert in both directions, for use during import or export. If you want to add a new field, you can add it to - `required_fields` or `optional_fields` depending on the field type + `required_fields` or `optional_fields` depending on the field type. + `import_only_fields` holds fields that are parsed but never required or + optional for header validation, and are never exported. To create a new Parser you need to implement `_load_data` and `_export_data` """ required_fields = ["id", "value"] optional_fields = ["parent_id"] + import_only_fields = ["previous_id"] # Set the format associated to the parser format: ParserFormat @@ -180,6 +183,19 @@ def _parse_tags(cls, tags_data: list[dict]) -> tuple[list[TagItem], list[TagPars errors.append(cls.invalid_field_error(tag, field=req_field, row=row)) has_error = True + # import_only_fields are parsed but never required/optional for header + # validation, and never appear in _load_tags_for_export. + for io_field in cls.import_only_fields: + value = tag.get(io_field) or None + if isinstance(value, int): + value = str(value) # Technically int is invalid but we coerce to str to be more resilient + + if isinstance(value, str) or value is None: + tag_data[io_field] = value + else: + errors.append(cls.invalid_field_error(tag, field=io_field, row=row)) + has_error = True + tags.append(TagItem(**tag_data)) return tags, errors diff --git a/tests/openedx_tagging/import_export/test_actions.py b/tests/openedx_tagging/import_export/test_actions.py index 71e76a48c..046fac961 100644 --- a/tests/openedx_tagging/import_export/test_actions.py +++ b/tests/openedx_tagging/import_export/test_actions.py @@ -12,6 +12,7 @@ DeleteTag, ImportAction, RenameTag, + RenameTagExternalId, UpdateParentTag, WithoutChanges, ) @@ -52,7 +53,8 @@ def setUp(self) -> None: # Note: we must specify '-> None' to opt in to type ch ), index=1, ) - ] + ], + 'rename_external_id': [], } @@ -133,6 +135,33 @@ def test_validate_parent(self, parent_id: str, expected: bool): ) ) + def test_validate_parent_with_rename_external_id_action(self) -> None: + """ + Regression: a parent referenced by external_id that doesn't exist in + the DB yet, but is being renamed-in via a `RenameTagExternalId` + action earlier in the same import, must validate as a known parent. + """ + indexed_actions = dict(self.indexed_actions) + indexed_actions['rename_external_id'] = [ + RenameTagExternalId( + taxonomy=self.taxonomy, + tag=TagItem(id='tag_60', value='Tag 60', previous_id='tag_3', index=1), + index=1, + ) + ] + action = ImportAction( + self.taxonomy, + TagItem( + id='tag_110', + value='_', + parent_id='tag_60', + index=100, + ), + index=100, + ) + error = action._validate_parent(indexed_actions) # pylint: disable=protected-access + self.assertIsNone(error) + @ddt.data( ( 'Tag 1', @@ -174,6 +203,35 @@ def test_validate_value(self, value: str, expected: str | None): else: self.assertEqual(str(error), expected) + def test_validate_value_with_rename_external_id_action(self) -> None: + """ + Regression: a value collision with a `RenameTagExternalId` action + already queued in the same import must be caught, not only + collisions with `create`/`rename` actions. + """ + indexed_actions = dict(self.indexed_actions) + indexed_actions['rename_external_id'] = [ + RenameTagExternalId( + taxonomy=self.taxonomy, + tag=TagItem(id='tag_60', value='Shared', previous_id='tag_3', index=1), + index=1, + ) + ] + action = ImportAction( + self.taxonomy, + TagItem( + id='tag_110', + value='Shared', + index=100, + ), + index=100, + ) + error = action._validate_value(indexed_actions) # pylint: disable=protected-access + self.assertEqual( + str(error), + "Conflict with 'import_action' (#100) and action #1: Duplicated tag value." + ) + @ddt.ddt class TestCreateTag(TestImportActionMixin, TestCase): @@ -197,6 +255,23 @@ def test_applies_for(self, tag_id: str, expected: bool): ) self.assertEqual(result, expected) + def test_applies_for_previous_id_guard(self) -> None: + """ + A row with a `previous_id` that differs from `id` is a rename + candidate, not a create: `RenameTagExternalId` should handle it + even though no tag exists yet with the new id. + """ + result = CreateTag.applies_for( + self.taxonomy, + TagItem( + id='tag_100', + value='_', + previous_id='tag_99', + index=100, + ) + ) + self.assertFalse(result) + @ddt.data( ('tag_10', False), ('tag_100', True), @@ -496,6 +571,156 @@ def test_execute(self) -> None: assert tag.value == value +@ddt.ddt +class TestRenameTagExternalId(TestImportActionMixin, TestCase): + """ + Test for 'rename_external_id' action + """ + + @ddt.data( + (None, 'tag_50', False), # No previous_id + ('tag_1', 'tag_1', False), # previous_id == id + ('tag_1', 'tag_50', True), # Valid rename + ) + @ddt.unpack + def test_applies_for(self, previous_id: str | None, tag_id: str, expected: bool): + result = RenameTagExternalId.applies_for( + taxonomy=self.taxonomy, + tag=TagItem( + id=tag_id, + value='_', + previous_id=previous_id, + index=100, + ) + ) + self.assertEqual(result, expected) + + def test_validate_unmatched_previous_id(self) -> None: + action = RenameTagExternalId( + taxonomy=self.taxonomy, + tag=TagItem( + id='tag_50', + value='Tag 50', + previous_id='tag_100', + index=100, + ), + index=100, + ) + errors = action.validate(self.indexed_actions) + self.assertEqual(len(errors), 1) + self.assertIn("Unknown previous_id (tag_100)", str(errors[0])) + + def test_validate_new_id_collides_with_db_tag(self) -> None: + # previous_id matches tag_1, but the new id (tag_2) already belongs + # to a different tag in the same taxonomy. + action = RenameTagExternalId( + taxonomy=self.taxonomy, + tag=TagItem( + id='tag_2', + value='Tag 1', + previous_id='tag_1', + index=100, + ), + index=100, + ) + errors = action.validate(self.indexed_actions) + self.assertEqual(len(errors), 1) + self.assertIn("already exists", str(errors[0])) + + def test_validate_new_id_collides_with_create_action(self) -> None: + # The new id (tag_10) matches a pending 'create' action from + # self.indexed_actions (see TestImportActionMixin.setUp). + action = RenameTagExternalId( + taxonomy=self.taxonomy, + tag=TagItem( + id='tag_10', + value='Tag 1', + previous_id='tag_1', + index=100, + ), + index=100, + ) + errors = action.validate(self.indexed_actions) + self.assertEqual(len(errors), 1) + self.assertIn("Duplicated external_id tag", str(errors[0])) + + def test_validate_new_id_collides_with_prior_rename_external_id_action(self) -> None: + indexed_actions = dict(self.indexed_actions) + indexed_actions['rename_external_id'] = [ + RenameTagExternalId( + taxonomy=self.taxonomy, + tag=TagItem(id='tag_60', value='Tag 60', previous_id='tag_3', index=1), + index=1, + ) + ] + action = RenameTagExternalId( + taxonomy=self.taxonomy, + tag=TagItem( + id='tag_60', + value='Tag 1', + previous_id='tag_1', + index=100, + ), + index=100, + ) + errors = action.validate(indexed_actions) + self.assertEqual(len(errors), 1) + self.assertIn("Duplicated external_id tag", str(errors[0])) + + def test_validate_no_error_when_value_unchanged(self) -> None: + # The row's value matches tag_1's current value, so _validate_value's + # duplicate check is skipped, and nothing else is wrong. + action = RenameTagExternalId( + taxonomy=self.taxonomy, + tag=TagItem( + id='tag_50', + value='Tag 1', + previous_id='tag_1', + index=100, + ), + index=100, + ) + errors = action.validate(self.indexed_actions) + self.assertEqual(errors, []) + + def test_validate_parent(self) -> None: + action = RenameTagExternalId( + taxonomy=self.taxonomy, + tag=TagItem( + id='tag_50', + value='Tag 1', + previous_id='tag_1', + parent_id='tag_100', + index=100, + ), + index=100, + ) + errors = action.validate(self.indexed_actions) + self.assertEqual(len(errors), 1) + self.assertIn("Unknown parent tag (tag_100)", str(errors[0])) + + def test_execute(self) -> None: + tag = self.taxonomy.tag_set.get(external_id='tag_1') + pk = tag.pk + tag_item = TagItem( + id='tag_50', + value='Tag 50', + previous_id='tag_1', + parent_id='tag_3', + ) + action = RenameTagExternalId( + taxonomy=self.taxonomy, + tag=tag_item, + index=100, + ) + action.execute() + tag.refresh_from_db() + self.assertEqual(tag.pk, pk) + self.assertEqual(tag.external_id, 'tag_50') + self.assertEqual(tag.value, 'Tag 50') + self.assertEqual(tag.parent.external_id, 'tag_3') + + class TestDeleteTag(TestImportActionMixin, TestCase): """ Test for 'delete' action diff --git a/tests/openedx_tagging/import_export/test_api.py b/tests/openedx_tagging/import_export/test_api.py index bdb04a86a..8e57a80b2 100644 --- a/tests/openedx_tagging/import_export/test_api.py +++ b/tests/openedx_tagging/import_export/test_api.py @@ -324,6 +324,86 @@ def test_import_removing_with_childs_no_external_id(self) -> None: ) assert result + def test_import_rename_external_id_preserves_pk(self) -> None: + """ + Importing a row with a matching `previous_id` renames the tag's + external_id in place, preserving its primary key (see ADR 0010). + """ + old_pk = self.taxonomy.tag_set.get(external_id="tag_1").pk + + importFile = BytesIO(json.dumps({"tags": [ + {"id": "tag_50", "value": "Tag 1 Renamed", "previous_id": "tag_1"}, + ]}).encode()) + result, _task, _plan = import_export_api.import_tags( + self.taxonomy, + importFile, + self.parser_format, + ) + assert result + + renamed_tag = Tag.objects.get(pk=old_pk) + assert renamed_tag.external_id == "tag_50" + assert renamed_tag.value == "Tag 1 Renamed" + assert not self.taxonomy.tag_set.filter(external_id="tag_1").exists() + + def test_import_rename_external_id_then_export(self) -> None: + """ + A follow-up export after a rename contains the new id, and neither + the old id nor a `previous_id` field, since `previous_id` is + import-only and never persisted (see ADR 0010). + """ + importFile = BytesIO(json.dumps({"tags": [ + {"id": "tag_50", "value": "Tag 1 Renamed", "previous_id": "tag_1"}, + ]}).encode()) + result, _task, _plan = import_export_api.import_tags( + self.taxonomy, + importFile, + self.parser_format, + ) + assert result + + output = import_export_api.export_tags(self.taxonomy, self.parser_format) + exported_tags = json.loads(output).get("tags") + exported_ids = [tag.get("id") for tag in exported_tags] + assert "tag_50" in exported_ids + assert "tag_1" not in exported_ids + for tag in exported_tags: + assert "previous_id" not in tag + + def test_import_rename_external_id_unmatched_previous_id_rejected(self) -> None: + importFile = BytesIO(json.dumps({"tags": [ + {"id": "tag_50", "value": "Tag 50", "previous_id": "tag_999"}, + ]}).encode()) + result, task, _plan = import_export_api.import_tags( + self.taxonomy, + importFile, + self.parser_format, + ) + assert not result + log = import_export_api.get_last_import_log(self.taxonomy) + assert log == task.log + assert "Unknown previous_id" in log + assert not self.taxonomy.tag_set.filter(external_id="tag_50").exists() + + def test_import_rename_external_id_colliding_new_id_rejected(self) -> None: + tag_before = self.taxonomy.tag_set.get(external_id="tag_1") + importFile = BytesIO(json.dumps({"tags": [ + {"id": "tag_2", "value": "Tag 1", "previous_id": "tag_1"}, + ]}).encode()) + result, task, _plan = import_export_api.import_tags( + self.taxonomy, + importFile, + self.parser_format, + ) + assert not result + log = import_export_api.get_last_import_log(self.taxonomy) + assert log == task.log + assert "already exists" in log + + tag_after = self.taxonomy.tag_set.get(external_id="tag_1") + assert tag_after.pk == tag_before.pk + assert tag_after.value == tag_before.value + def test_import_same_value_without_external_id(self) -> None: new_taxonomy = Taxonomy(name="New taxonomy") new_taxonomy.save() diff --git a/tests/openedx_tagging/import_export/test_import_plan.py b/tests/openedx_tagging/import_export/test_import_plan.py index 88f24a8b2..cc7e79d51 100644 --- a/tests/openedx_tagging/import_export/test_import_plan.py +++ b/tests/openedx_tagging/import_export/test_import_plan.py @@ -407,6 +407,48 @@ def test_execute(self, tags, replace): external_ids = list(self.taxonomy.tag_set.values_list("external_id", flat=True)) assert tag_external_ids == external_ids + def test_generate_actions_rename_external_id(self) -> None: + tags = [ + TagItem(id='tag_50', value='Tag 1', previous_id='tag_1'), + ] + self.import_plan.generate_actions(tags=tags, replace=False) + self.assertEqual(len(self.import_plan.errors), 0) + self.assertEqual(len(self.import_plan.actions), 1) + self.assertEqual(self.import_plan.actions[0].name, 'rename_external_id') + self.assertEqual(self.import_plan.actions[0].tag.id, 'tag_50') + + def test_generate_actions_rename_external_id_replace_skips_delete(self) -> None: + # tag_1 is renamed to tag_50 (previous_id='tag_1'); under replace=True + # its old id must not be swept up in the delete pass, since it is the + # same underlying tag, not a removed one. + tags = [ + TagItem(id='tag_50', value='Tag 1', previous_id='tag_1'), + TagItem(id='tag_2', value='Tag 2'), + TagItem(id='tag_3', value='Tag 3'), + TagItem(id='tag_4', value='Tag 4', parent_id='tag_3'), + ] + self.import_plan.generate_actions(tags=tags, replace=True) + self.assertEqual(len(self.import_plan.errors), 0) + delete_targets = [ + action.tag.id for action in self.import_plan.actions if action.name == 'delete' + ] + self.assertNotIn('tag_1', delete_targets) + + def test_generate_actions_rename_external_id_value_collision_with_create(self) -> None: + """ + Regression: a value collision between a `RenameTagExternalId` action + and a later `CreateTag` action in the same import must be caught at + validate time, not silently pass through to `execute()` and hit the + database's `unique_together(taxonomy, value)` constraint. + """ + tags = [ + TagItem(id='tag_50', value='Shared', previous_id='tag_1'), + TagItem(id='tag_60', value='Shared'), + ] + self.import_plan.generate_actions(tags=tags, replace=False) + self.assertEqual(len(self.import_plan.errors), 1) + self.assertIn("Duplicated tag value", str(self.import_plan.errors[0])) + def test_error_in_execute(self): created_tag = 'tag_31' tags = [ diff --git a/tests/openedx_tagging/import_export/test_parsers.py b/tests/openedx_tagging/import_export/test_parsers.py index 5cfda2137..a7c55f106 100644 --- a/tests/openedx_tagging/import_export/test_parsers.py +++ b/tests/openedx_tagging/import_export/test_parsers.py @@ -238,6 +238,53 @@ def test_import_with_export_output(self) -> None: if output_tag.get("parent_id"): assert output_tag.get("parent_id") == tag.parent_id + @ddt.data( + ( + {"tags": [ + {"id": "tag_2", "value": "Tag 2", "previous_id": "tag_1"}, + ]}, + "tag_1", + ), + ( + {"tags": [ + {"id": "tag_2", "value": "Tag 2"}, + ]}, + None, + ), + ( + {"tags": [ + {"id": "tag_2", "value": "Tag 2", "previous_id": ""}, + ]}, + None, + ), + ( + {"tags": [ + {"id": "tag_2", "value": "Tag 2", "previous_id": None}, + ]}, + None, + ), + ( + {"tags": [ + {"id": "tag_2", "value": "Tag 2", "previous_id": 123}, + ]}, + "123", + ), + ) + @ddt.unpack + def test_parse_previous_id(self, json_data: dict, expected_previous_id: str | None) -> None: + json_file = BytesIO(json.dumps(json_data).encode()) + tags, errors = JSONParser.parse_import(json_file) + self.assertEqual(len(errors), 0) + self.assertEqual(len(tags), 1) + self.assertEqual(tags[0].previous_id, expected_previous_id) + + def test_export_does_not_include_previous_id(self) -> None: + result = JSONParser.export(self.taxonomy) + tags = json.loads(result).get("tags") + assert len(tags) > 0 + for tag in tags: + assert "previous_id" not in tag + @ddt.ddt class TestCSVParser(TestImportExportMixin, TestCase): @@ -363,3 +410,21 @@ def test_import_with_export_output(self) -> None: assert tag.value == taxonomy_tag.value if tag.parent_id: assert tag.parent_id == taxonomy_tag.parent.external_id + + @ddt.data( + ("id,value,previous_id\ntag_2,Tag 2,tag_1\n", "tag_1"), + ("id,value,previous_id\ntag_2,Tag 2,\n", None), + ("id,value\ntag_2,Tag 2\n", None), + ) + @ddt.unpack + def test_parse_previous_id(self, csv_data: str, expected_previous_id: str | None) -> None: + csv_file = BytesIO(csv_data.encode()) + tags, errors = CSVParser.parse_import(csv_file) + self.assertEqual(len(errors), 0) + self.assertEqual(len(tags), 1) + self.assertEqual(tags[0].previous_id, expected_previous_id) + + def test_export_does_not_include_previous_id(self) -> None: + output = CSVParser.export(self.taxonomy) + header = output.splitlines()[0] + assert "previous_id" not in header.split(",") From cf9ff0680488e76769c3f37e3ea058512ce4118b Mon Sep 17 00:00:00 2001 From: ufedaseyeuconsultant Date: Thu, 3 Sep 2026 18:41:01 +0400 Subject: [PATCH 2/2] fix: removed whitespace --- src/openedx_tagging/import_export/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openedx_tagging/import_export/actions.py b/src/openedx_tagging/import_export/actions.py index ce32d7c3c..28837f515 100644 --- a/src/openedx_tagging/import_export/actions.py +++ b/src/openedx_tagging/import_export/actions.py @@ -390,7 +390,7 @@ class RenameTagExternalId(ImportAction): Action to rename an existing tag's external_id in place. Action created when a row's `previous_id` matches an existing tag's - external_id in the taxonomy, and the row's `id` differs from it. + external_id in the taxonomy, and the row's `id` differs from it. Preserves the tag's primary key and associations across the rename, instead of deleting the old tag and creating a new one.