Repository navigation
fix: reject stale parent ids on tag import and make parent changes order-independent #871
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -105,51 +105,54 @@ def _search_action( | |
|
|
||
| def _validate_parent(self, indexed_actions) -> ImportActionError | None: | ||
| """ | ||
| Validate that `parent_id` names a tag that will exist once this import finishes. | ||
|
|
||
| A match against a tag being renamed away in this same import (see | ||
| `_vacated_pks`) doesn't count as the real parent: it falls through to the | ||
| same "created or renamed-in this import" check as a brand-new parent. If | ||
| nothing lands on that vacated id either, the error names the id it was | ||
| renamed to, instead of the generic "unknown parent" message for a parent | ||
| never mentioned at all. | ||
| Validate that `parent_id` names the tag that will hold that id once this import finishes. | ||
|
|
||
| That is the tag currently holding `parent_id`, unless this import renames it | ||
| away (see `_vacated_pks`). A renamed-away id, like a brand-new one, must be | ||
| given to a tag by a row of this file: a rename onto it (`_vacated_pks` maps | ||
| every renamed tag to its new id, whatever the row order) or a create action | ||
| built before this one (TagImportPlan builds a new parent before its | ||
| children). Even then it must not be this row's own current parent: a row | ||
| that names its parent's pre-import id while another row takes that id (a | ||
| swap) could mean "stay with my parent" or "move under the tag now called | ||
| `parent_id`", so it is rejected. The error for a renamed-away id names the | ||
| id it was renamed to, instead of the generic "unknown parent" message for | ||
| an id never mentioned at all. | ||
| """ | ||
| vacated_pks: dict[int, str] = indexed_actions.get("_vacated_pks", {}) | ||
| parent_tag = None | ||
| is_vacated = False | ||
| try: | ||
| # Validates that the parent exists on the taxonomy | ||
| parent_tag = self.taxonomy.tag_set.get(external_id=self.tag.parent_id) | ||
| if parent_tag.pk in vacated_pks: | ||
| is_vacated = True | ||
| raise Tag.DoesNotExist | ||
| except Tag.DoesNotExist: | ||
| # 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, casefold=True | ||
| ) or self._search_action( | ||
| indexed_actions, RenameTagExternalId.name, "id", self.tag.parent_id, casefold=True | ||
| parent_tag = self.taxonomy.tag_set.filter(external_id=self.tag.parent_id).first() | ||
| new_id = vacated_pks.get(parent_tag.pk) if parent_tag is not None else None | ||
| if parent_tag is not None and new_id is None: | ||
| return None # The parent exists and keeps its id. | ||
|
|
||
| found = (self.tag.parent_id or "").casefold() in { | ||
| renamed_to.casefold() for renamed_to in vacated_pks.values() | ||
| } or self._search_action(indexed_actions, CreateTag.name, "id", self.tag.parent_id, casefold=True) | ||
| if found and not (parent_tag is not None and self._is_current_parent(parent_tag)): | ||
| return None | ||
| if new_id is not None: | ||
| return ImportActionError( | ||
| action=self, | ||
| message=_( | ||
| "Parent tag {parent_id} is renamed to {new_id} in this file; " | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion, within the current requirements: for the ambiguous swap case, the error tells the admin to use the parent's new id, which assumes they meant "stay with my parent". An admin who meant "move under the tag now called tag_1" gets steered the wrong way. Could the message also say how to do that, for example by doing the swap and the move in two separate imports? |
||
| "use {new_id} as the parent_id." | ||
| ).format(parent_id=self.tag.parent_id, new_id=new_id), | ||
| ) | ||
| if not found: | ||
| if is_vacated: | ||
| # is_vacated is only set after parent_tag was assigned above. | ||
| assert parent_tag is not None | ||
| new_id = vacated_pks[parent_tag.pk] | ||
| return ImportActionError( | ||
| action=self, | ||
| message=_( | ||
| "Parent tag {parent_id} is renamed to {new_id} in this file; " | ||
| "use {new_id} as the parent_id." | ||
| ).format(parent_id=self.tag.parent_id, new_id=new_id), | ||
| ) | ||
| return ImportActionError( | ||
| action=self, | ||
| message=_( | ||
| "Unknown parent tag ({parent_id}). " | ||
| "You need to add parent before the child in your file." | ||
| ).format(parent_id=self.tag.parent_id), | ||
| ) | ||
| return None | ||
| return ImportActionError( | ||
| action=self, | ||
| message=_( | ||
| "Unknown parent tag ({parent_id}). " | ||
| "Add a row for the parent to your file." | ||
| ).format(parent_id=self.tag.parent_id), | ||
| ) | ||
|
|
||
| def _is_current_parent(self, parent_tag: Tag) -> bool: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: I found the name and docstring of |
||
| """ | ||
| Whether `parent_tag` is the parent that this row's tag has before the import. | ||
| """ | ||
| return self.taxonomy.tag_set.filter( | ||
| external_id=self.tag.previous_id or self.tag.id, parent=parent_tag, | ||
| ).exists() | ||
|
|
||
| def _validate_value(self, indexed_actions) -> ImportActionError | None: | ||
| """ | ||
|
|
@@ -326,33 +329,49 @@ class UpdateParentTag(ImportAction): | |
| """ | ||
|
|
||
| name = "update_parent" | ||
| _description: str | None = None | ||
|
|
||
| def __str__(self) -> str: | ||
| taxonomy_tag = self._get_tag() | ||
|
|
||
| description_str = _("Update the parent of {tag} from parent {old_parent} to {new_parent}").format( | ||
| tag=taxonomy_tag.display_str(), | ||
| old_parent=taxonomy_tag.parent.display_str() if taxonomy_tag.parent else None, | ||
| new_parent=self.tag.parent_id, | ||
| ) | ||
|
|
||
| return str(description_str) | ||
| # Describe the change as of the plan: the plan is logged before it runs, and the | ||
| # execution detaches every moved tag first, which would then read as "from None". | ||
| if self._description is None: | ||
| taxonomy_tag = self._get_tag() | ||
| self._description = str( | ||
| _("Update the parent of {tag} from parent {old_parent} to {new_parent}").format( | ||
| tag=taxonomy_tag.display_str(), | ||
| old_parent=taxonomy_tag.parent.display_str() if taxonomy_tag.parent else None, | ||
| new_parent=self.tag.parent_id, | ||
| ) | ||
| ) | ||
| return self._description | ||
|
Comment on lines
334
to
+346
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion from Claude: the cached description only reads correctly if |
||
|
|
||
| @classmethod | ||
| def applies_for(cls, taxonomy: Taxonomy, tag: TagItem, indexed_actions=None) -> bool: | ||
| """ | ||
| This action applies whenever there is a change on the parent. | ||
|
|
||
| A row that names its parent's current id is still a parent change when | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: I had trouble telling whether "current id" meant the id before or after the import. In this PR "current" always means before the import, but the new docstrings and comments use several terms for the same two ideas: "current id" here, "currently holding" (line 110), "own current parent" (line 115) and "pre-import id" (line 116) for the id before the import, and "final id" and "end state" in |
||
| this import gives that id to another tag: by renaming the parent away | ||
| from it, or by renaming another tag onto it once the parent is deleted. | ||
| Such a row used to count as "no change", so a stale id got through | ||
| `_validate_parent` and a reclaimed id lost the child to the delete | ||
| phase's promotion. | ||
|
Comment on lines
+356
to
+358
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: these lines describe how the code used to behave, which belongs in the commit history rather than the docstring. I'd delete them, or keep only the reason in present tense, for example "Otherwise a stale id would skip parent validation, and a child of a reclaimed id would be lost to the delete phase's promotion." |
||
|
|
||
| See ImportAction._resolve_update_target for when this doesn't apply | ||
| regardless of the parent change (a rename_external_id row). | ||
| """ | ||
| taxonomy_tag = cls._resolve_update_target(taxonomy, tag) | ||
| if taxonomy_tag is None: | ||
| return False | ||
| return ( | ||
| taxonomy_tag.parent is not None | ||
| and taxonomy_tag.parent.external_id != tag.parent_id | ||
| ) or (taxonomy_tag.parent is None and tag.parent_id is not None) | ||
| parent = taxonomy_tag.parent | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||
| if parent is None: | ||
| return tag.parent_id is not None | ||
| if parent.external_id != tag.parent_id: | ||
| return True | ||
| vacated_pks: dict[int, str] = indexed_actions.get("_vacated_pks", {}) if indexed_actions else {} | ||
| return parent.pk in vacated_pks or parent.external_id.casefold() in { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion from Claude: this line, and line 128 in |
||
| new_id.casefold() for new_id in vacated_pks.values() | ||
| } | ||
|
|
||
| def validate(self, indexed_actions) -> list[ImportActionError]: | ||
| """ | ||
|
|
@@ -641,6 +660,56 @@ def execute(self) -> None: | |
| self.taxonomy.tag_set.filter(pk=self.target_pk).update(external_id=placeholder) | ||
|
|
||
|
|
||
| class DetachTag(ImportAction): | ||
| """ | ||
| Action to move a tag to the root before the actions that change parents run. | ||
|
|
||
| Synthesized by TagImportPlan._build_detach_actions for every existing tag | ||
| whose parent changes, not read from a file row. Tag.save checks the depth | ||
| of each step, and a cycle corrupts the tree silently, so a restructure that | ||
| is valid as a whole can still go wrong part-way through, depending on the | ||
| order of the actions. Once every moved tag is at the root, each tag is at | ||
| or above its final depth and none is an ancestor of its new parent, so the | ||
| actions that follow can attach tags in any order. | ||
| """ | ||
|
|
||
| name = "detach" | ||
|
|
||
| def __str__(self) -> str: | ||
| return str( | ||
| _( | ||
| "Move tag {tag} to the root first, so that the parent changes " | ||
| "in this file can run in any order." | ||
| ).format(tag=self.tag) | ||
| ) | ||
|
|
||
| @classmethod | ||
| def applies_for(cls, taxonomy: Taxonomy, tag: TagItem, indexed_actions=None) -> bool: | ||
| """ | ||
| This action is an exception: synthesized in TagImportPlan.generate_actions. | ||
| """ | ||
| return False | ||
|
|
||
| def validate(self, indexed_actions) -> list[ImportActionError]: | ||
| """ | ||
| No validations necessary | ||
| """ | ||
| return [] | ||
|
|
||
| def execute(self) -> None: | ||
| """ | ||
| Moves the tag to the root, with its descendants. | ||
|
|
||
| Looks the tag up by pk: a StageTagExternalIdForSwap action may have | ||
| moved it onto a placeholder id by now. | ||
| """ | ||
| # _build_detach_actions only builds this action with a target_pk. | ||
| assert self.target_pk is not None | ||
| tag = self.taxonomy.tag_set.get(pk=self.target_pk) | ||
| tag.parent = None | ||
| tag.save() | ||
|
|
||
|
|
||
| class DeleteTag(ImportAction): | ||
| """ | ||
| Action for delete a Tag | ||
|
|
@@ -718,6 +787,7 @@ def execute(self) -> None: | |
| RenameTagExternalId, | ||
| CreateTag, | ||
| StageTagExternalIdForSwap, | ||
| DetachTag, | ||
| DeleteTag, | ||
| WithoutChanges, | ||
| ] | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: these four lines took me a few reads. As far as I can tell,
foundmeans "some row of this file gives the idparent_idto a tag, by a rename onto it or by a create action built earlier", and line 131 means "accept that, unless the id currently belongs to this row's own parent (the ambiguous swap)". Naming the pieces would make both lines say that directly, something like: