You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Tag import: parent_id isn't re-validated when it matches the pre-import parent #858
UpdateParentTag.applies_for (src/openedx_tagging/import_export/actions.py:342-355) decides whether a row's parent needs updating by comparing the row's parent_id against the tag's pre-import parent. When they match textually, it concludes "no change" and skips the row entirely, so _validate_parent (the method that actually resolves parent_id against renames happening in the same file) never runs. This produces two distinct, confirmed-wrong outcomes, both tracing back to the same root cause.
Problem A: a stale parent_id is silently accepted for an existing child (violates #673's own AC)
Issue #673's acceptance criteria include this scenario:
Scenario: a child row referencing its parent's pre-import external_id is rejected
Given a taxonomy contains parent competency P (external_id "COMP-P") and child competency C beneath it
When the admin imports a file with a row previous_id "COMP-P" / id "COMP-P2"
And the same file has a row for C with parent_id "COMP-P"
Then the wizard shows a validation error identifying the child row
And the error states that the parent_id names an id the same file renames away, and names the new id to use instead
"C beneath it" means C already exists in the taxonomy before the import, not that it's being created by the same file. Confirmed against the current implementation: renaming tag_1 to tag_50 while an existing tag_2 (already a child of tag_1) keeps parent_id="tag_1" in its own row produces no error. The plan reads #2: No changes needed for <TagItem> (tag_2 / Tag 2), and the import succeeds.
The existing regression tests for this AC scenario (test_generate_actions_parent_id_stale_after_plain_rename_rejected, test_import_rename_referencing_stale_old_id_rejected) both use a brand-new tag as the child row, which goes through CreateTag, a different code path that always calls _validate_parent regardless. Neither test exercises an already-existing child going through UpdateParentTag, which is the literal case the AC describes and the one that's broken.
Problem B: a valid end-state parent_id is wrongly rejected in a swap
Not covered by #673's AC at all (no scenario combines the swap scenario with children referencing the swapped tags), found during review of a first attempt at fixing Problem A.
A first fix (commit aa9ea33, reverted in a8e9d67) added a pre-pass that rejected any parent_id naming an id that is both vacated by one rename row and claimed by a different rename row in the same file (a swap or cycle). That overshoots: in a swap, both ids are simultaneously vacated and claimed, so the check fired on every parent_id naming either id, including a child row whose parent_id already names the correct end-state id. Reproduction:
With aa9ea33 in place, both tag_2's and tag_4's rows are rejected, even though tag_2.parent_id="tag_3" correctly names the tag that will hold tag_3 after the swap. There is no value of parent_id that passes for a full-replace swap of two parents with children, so this case can't be imported through the Studio wizard at all with that fix in place.
Root cause and proposed fix
Both problems come from keying the "does this need validation" decision on the wrong condition. The correct condition is: does this row's ownparent_id match its own current (pre-import) parent? That's the one reading that's genuinely ambiguous (it's exactly the condition UpdateParentTag.applies_for uses to skip validation), regardless of whether the id involved happens to be contended by another row:
current=self.taxonomy.tag_set.filter(
external_id=tag.previous_idortag.id
).select_related("parent").first()
if (
currentisNoneorcurrent.parentisNoneorcurrent.parent.external_id.casefold() !=tag.parent_id.casefold()
):
continue
Traced through both problem cases:
Problem A (tag_2, no previous_id, parent_id="tag_1"): current resolves to tag_2 itself, current.parent.external_id is "tag_1" (pre-import), which matches tag.parent_id exactly, so the row is flagged and rejected, correctly.
Problem B (tag_2, parent_id="tag_3", a forward reference to a tag that doesn't exist under that id yet pre-import): current is still tag_2 itself, but current.parent.external_id is "tag_1", which does not match tag.parent_id="tag_3", so the row is not flagged here; it proceeds to its own UpdateParentTag/_validate_parent, which already resolves the swap's end-state id correctly via the existing execution order (renames run before parent updates).
The accepted forward-reference case for a brand-new tag (test_generate_actions_parent_id_new_id_after_earlier_rename_accepted) also still works: current resolves to None (the new tag doesn't exist pre-import), so the row is never flagged here and falls through to CreateTag's own _validate_parent.
This single condition replaces the need for any separate "is this id contended" check, so there's one rule instead of two.
Also fix, in the same pass: the current comment/docstring near this logic claims the plain-rename case is already safe because of _validate_parent. That's not accurate: a row with no other change becomes WithoutChanges and never calls _validate_parent at all. It only comes out correct today because nothing physically moves for a plain rename; Problem A shows that "coming out correct" and "being validated" are different things once an AC scenario demands an explicit rejection regardless of outcome.
Problem B's repro (swap with two children, each referencing the opposite swap partner's correct end-state id) succeeds and leaves both children on the correct physical parent.
A genuinely ambiguous swap reference (a child's parent_id naming the pre-import/stale id of a tag involved in a swap) is still rejected.
test_generate_actions_parent_id_new_id_after_earlier_rename_accepted (forward reference to a plain rename's new id) and test_generate_actions_rename_external_id_replace_skips_delete* (replace-mode survival) continue to pass unchanged.
Starting point
Branch ufedaseyeu/parent-id-swap-ambiguity-wip (based on the reverted aa9ea33) holds the first, incorrect ("contended"-keyed) implementation and its tests. Useful as a reference for what doesn't work and for the method/exception skeleton, not as logic to reuse as-is.
Summary
UpdateParentTag.applies_for(src/openedx_tagging/import_export/actions.py:342-355) decides whether a row's parent needs updating by comparing the row'sparent_idagainst the tag's pre-import parent. When they match textually, it concludes "no change" and skips the row entirely, so_validate_parent(the method that actually resolvesparent_idagainst renames happening in the same file) never runs. This produces two distinct, confirmed-wrong outcomes, both tracing back to the same root cause.Problem A: a stale parent_id is silently accepted for an existing child (violates #673's own AC)
Issue #673's acceptance criteria include this scenario:
"C beneath it" means C already exists in the taxonomy before the import, not that it's being created by the same file. Confirmed against the current implementation: renaming
tag_1totag_50while an existingtag_2(already a child oftag_1) keepsparent_id="tag_1"in its own row produces no error. The plan reads#2: No changes needed for <TagItem> (tag_2 / Tag 2), and the import succeeds.The existing regression tests for this AC scenario (
test_generate_actions_parent_id_stale_after_plain_rename_rejected,test_import_rename_referencing_stale_old_id_rejected) both use a brand-new tag as the child row, which goes throughCreateTag, a different code path that always calls_validate_parentregardless. Neither test exercises an already-existing child going throughUpdateParentTag, which is the literal case the AC describes and the one that's broken.Problem B: a valid end-state parent_id is wrongly rejected in a swap
Not covered by #673's AC at all (no scenario combines the swap scenario with children referencing the swapped tags), found during review of a first attempt at fixing Problem A.
A first fix (commit
aa9ea33, reverted ina8e9d67) added a pre-pass that rejected anyparent_idnaming an id that is both vacated by one rename row and claimed by a different rename row in the same file (a swap or cycle). That overshoots: in a swap, both ids are simultaneously vacated and claimed, so the check fired on everyparent_idnaming either id, including a child row whoseparent_idalready names the correct end-state id. Reproduction:{"tags": [ {"id": "tag_3", "value": "Tag 1", "previous_id": "tag_1"}, {"id": "tag_1", "value": "Tag 3", "previous_id": "tag_3"}, {"id": "tag_2", "value": "Tag 2", "parent_id": "tag_3"}, {"id": "tag_4", "value": "Tag 4", "parent_id": "tag_1"} ]}With
aa9ea33in place, bothtag_2's andtag_4's rows are rejected, even thoughtag_2.parent_id="tag_3"correctly names the tag that will holdtag_3after the swap. There is no value ofparent_idthat passes for a full-replace swap of two parents with children, so this case can't be imported through the Studio wizard at all with that fix in place.Root cause and proposed fix
Both problems come from keying the "does this need validation" decision on the wrong condition. The correct condition is: does this row's own
parent_idmatch its own current (pre-import) parent? That's the one reading that's genuinely ambiguous (it's exactly the conditionUpdateParentTag.applies_foruses to skip validation), regardless of whether the id involved happens to be contended by another row:Traced through both problem cases:
tag_2, noprevious_id,parent_id="tag_1"):currentresolves totag_2itself,current.parent.external_idis"tag_1"(pre-import), which matchestag.parent_idexactly, so the row is flagged and rejected, correctly.tag_2,parent_id="tag_3", a forward reference to a tag that doesn't exist under that id yet pre-import):currentis stilltag_2itself, butcurrent.parent.external_idis"tag_1", which does not matchtag.parent_id="tag_3", so the row is not flagged here; it proceeds to its ownUpdateParentTag/_validate_parent, which already resolves the swap's end-state id correctly via the existing execution order (renames run before parent updates).test_generate_actions_parent_id_new_id_after_earlier_rename_accepted) also still works:currentresolves toNone(the new tag doesn't exist pre-import), so the row is never flagged here and falls through toCreateTag's own_validate_parent.This single condition replaces the need for any separate "is this id contended" check, so there's one rule instead of two.
Also fix, in the same pass: the current comment/docstring near this logic claims the plain-rename case is already safe because of
_validate_parent. That's not accurate: a row with no other change becomesWithoutChangesand never calls_validate_parentat all. It only comes out correct today because nothing physically moves for a plain rename; Problem A shows that "coming out correct" and "being validated" are different things once an AC scenario demands an explicit rejection regardless of outcome.Acceptance criteria for the fix
test_generate_actions_parent_id_new_id_after_earlier_rename_accepted(forward reference to a plain rename's new id) andtest_generate_actions_rename_external_id_replace_skips_delete*(replace-mode survival) continue to pass unchanged.Starting point
Branch
ufedaseyeu/parent-id-swap-ambiguity-wip(based on the revertedaa9ea33) holds the first, incorrect ("contended"-keyed) implementation and its tests. Useful as a reference for what doesn't work and for the method/exception skeleton, not as logic to reuse as-is.References
feat: support editing an existing tag's external_id on taxonomy re-im… #804 (comment)
feat: support editing an existing tag's external_id on taxonomy re-im… #804 (comment)
)
docs/openedx_tagging/decisions/0010-mutable-tag-external-id.rst(the design this import mechanism implements).