Repository navigation
Conversation
…l_id Closes openedx#858. Alternative to openedx#867 with a much smaller footprint. UpdateParentTag.applies_for compared the row's parent_id with the parent's pre-import external_id, so a row that kept the old id was "no change" and _validate_parent never ran for it. When the same file renamed that parent away (tag_1 -> tag_50), the stale reference was accepted silently. Now such a row applies as a parent change whenever its parent is renamed away (`_vacated_pks`), and _validate_parent rejects it with the existing "renamed to X; use X" error. _validate_parent also rejects a row whose parent_id is its own parent's pre-import id when another row takes that id (a swap): that reference is ambiguous, while a child naming the end-state id of a swapped parent is still accepted. A case-only rename no longer counts as vacating the id, since external_id is case-insensitive. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…trees Covers the remaining bugs openedx#867 found around parent_id handling, with one mechanism instead of a dry-run simulator and a conditional rebuild: - The plan computes the tree as it will be after the import (_end_state), rejects a parent cycle or a tree deeper than the limit there (Tag.save only validates the saved tag's depth, and silently corrupts lineage on a cycle), and records which existing tags change parent. - Rows are built after the row that creates or renames-in their parent, so a child may precede its new parent in the file. _validate_parent recognises a renamed-in parent from _vacated_pks, whatever the row order. - execute() detaches every moved tag before running the actions. After that each tag is at or above its final depth and none is an ancestor of its new parent, so no action can overflow or form a cycle part-way. - Kept children of deleted tags are promoted before the first delete, so the cascade cannot remove a kept grandchild. - A child whose deleted parent's id is reclaimed by a rename follows the renamed tag instead of being left at the root. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The detach of every tag whose parent changes used to run as a hidden phase at the start of execute(). It is now a synthesized DetachTag action per tag, like StageTagExternalIdForSwap, built after staging and before the row actions, so the plan lists everything that runs. A root tag, a kept child of a deleted tag (already promoted) and a row with an unknown parent get no detach action. The tag is looked up by pk, since staging may have moved it onto a placeholder id. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
I had Opus do an analysis of this PR to compare it with #867 and it claims that it does cover the same ground with a small caveat and less testing. Claude drafted the following comment to share its analysis: To compare the two PRs, I ran #867's tests against this branch's code, because #867's tests map one-to-one to the acceptance criteria in #858, which Seva updated on 10-14 to cover all the extra fixes. I ran them on SQLite only. Coverage. Behaviorally, this PR covers nearly all of #858. Out of 199 tests, 156 pass, including the swap, row-order, delete-cascade, depth-limit, intermediate-step and case-variant scenarios. Most of the 43 failures are only wording differences: this PR keeps the existing "Parent tag X is renamed to Y in this file" message, and its depth error names the tag rather than using the ticket's quoted text. Both of those are fine with me. One mixed cycle (a rename row plus a new tag) gets the cycle error here instead of "Unknown parent tag", which #858 doesn't specify either way. Two gaps remain:
Whether the gaps matter. I don't think either one is a correctness problem. The row number is a small usability fix that I'd like to see in whichever PR we go with. The extra move-to-root lines are the price of the always-detach approach, and they're what lets this PR drop the in-memory simulation and the second plan build. I think that's a reasonable trade, and that the two ACs it breaks describe #867's mechanism more than an outcome an admin needs. If we go this way, I'd rather amend those two ACs in #858 than add the simulation back. The bigger difference between the PRs is test depth, not behavior. This PR adds about 360 lines of tests against about 1,900 in #867. Whichever implementation we keep, I'd suggest bringing over #867's |
Preamble
@ufedaseyeuconsultant, @mgwozdz-unicon: I did this as an experiment, to see if a more capable model (Fable 5.1) could make something equivalent to #867 without as much code and complexity. I was particularly worried by
_ParentForestand whether we really needed that many tests to run through our edge cases. I have not actually looked through this code in detail—what follows is entirely Claude-generated. I mostly wanted to see how well it could do for a first pass. Please take a look at this and see if it seems like a reasonable approach to you. Thank you.Description
Alternative to #867 with the same fixes in about a third of the code. Closes #858.
Problem
UpdateParentTag.applies_forcompared a row'sparent_idwith the parent's pre-importexternal_id. A row that kept the old id was "no change", so_validate_parentnever ran for it, and a file that renamedtag_1totag_50while a child row still saidparent_id: tag_1was accepted silently (#858).Fixing that exposed the neighbouring problems #867 found: parent references only worked when the parent's row came first; a kept grandchild of a deleted chain was removed by the delete cascade; parent cycles and imports deeper than the limit passed the Plan step (
Tag.savevalidates only the saved tag's depth, cascades descendants unchecked, and silently corrupts lineage on a cycle); and some valid restructures failed part-way through because an intermediate step was too deep or cyclic.Approach
#867 searches for an action order that is valid at every intermediate step, which needs a dependency sort with subtree walks, an in-memory forest simulator, and a second plan build when the simulation fails. This PR always detaches first instead, so no simulation is needed:
TagImportPlan._end_statecomputes the tree as it will be after the import from one query._validate_hierarchyrejects a parent cycle (once, on its first row) and a tree deeper than the limit there.DetachTagaction (likeStageTagExternalIdForSwap) moves every existing tag whose parent changes to the root, after staging and before the row actions. After that every tag is at or above its final depth and none is an ancestor of its new parent, so the row actions can run in any order without exceeding the depth limit or building a cycle.For #858 itself,
UpdateParentTag.applies_fornow treats "same id as my current parent" as a parent change when this import gives that id to another tag, and_validate_parentrejects a row that names its own parent's pre-import id while a swap row takes that id, since it could mean "stay with my parent" or "move under the tag now called that". A child naming the end-state id of a swapped parent is accepted._validate_parentrecognises a renamed-in parent from_vacated_pks, so that check no longer depends on row order either.What changed
All in
src/openedx_tagging/import_export/:actions.py:_validate_parentrewritten without exception-as-control-flow, plus the own-parent check;UpdateParentTag.applies_foras above, and its description cached at plan time so the execution log doesn't read "from parent None" after the detach; newDetachTagaction; "Unknown parent" message no longer tells the admin to reorder the file.import_plan.py:_existing_tags,_end_state,_validate_hierarchy,_build_detach_actions,_order_rows_parent_first; kept children are promoted before the first delete; a case-only rename no longer counts as vacating its id.exceptions.py:ParentCycleError,TagTooDeepError.User-visible changes
Row N's parent_id (X) creates a parent cycle.andThis import would leave tag X more than 6 levels deep."Row N" is the N-th data row, as in the existingDuplicateFinalIdError.Parent tag tag_1 is renamed to tag_50 in this file; use tag_50 as the parent_id.Unknown parent tag (X). Add a row for the parent to your file.replaces the old "add parent before the child" wording.Move tag X to the root first, so that the parent changes in this file can run in any order.before the row actions. Withreplace=True, promotions of kept children now precede the deletes.Supporting information
Testing instructions
Automated:
pytest tests/openedx_tagging/import_export/ -o addopts=""(214 tests) andpytest tests/openedx_tagging -o addopts="". Quality:tox -e quality.Manual, through the taxonomy import endpoint or the Studio import wizard, on a taxonomy with
tag_1(root),tag_2undertag_1,tag_3(root),tag_4undertag_3:tagsrows)tag_50(previous_idtag_1),tag_2(parent_idtag_1),tag_3,tag_4(parent_idtag_3)Parent tag tag_1 is renamed to tag_50 in this file; use tag_50 as the parent_id.tag_3(previous_idtag_1),tag_1(previous_idtag_3),tag_2(parent_idtag_3),tag_4(parent_idtag_1), in any row ordertag_2undertag_3andtag_4undertag_1.tag_1(parent_idtag_2), plus the other three rows unchangedRow 1's parent_id (tag_2) creates a parent cycle.Other information
openedx-platformneeds to change.replace=Trueplan expectations (promotion before deletes, order only); two plans that move an existing tag gain the detach line; the "Unknown parent" wording; one_validate_parentunit test now sets_vacated_pksinstead of a built rename action.claude-fable-5-1).🤖 Generated with Claude Code