Skip to content

fix/814: treat an archived tag as no longer part of its taxonomy on create, import, and export - #927

Open
AShatsila wants to merge 11 commits into
openedx:mainfrom
AShatsila:ashatsila/814-archived-tag-treatment
Open

AShatsila wants to merge 11 commits into
openedx:mainfrom
AShatsila:ashatsila/814-archived-tag-treatment

Conversation

@AShatsila

@AShatsila AShatsila commented Nov 4, 2026 •

Copy link
Copy Markdown

Description

Closes #814.

An archived tag still holds its value and external_id, because the per-taxonomy unique constraints include archived rows. Before this change, creating or importing a tag with such a value failed with a plain "already exists" message that pointed at a tag the administrator cannot see, and an import could update or revive the archived tag. This PR makes create and import refuse those collisions with a message that names the cause, and pins that export and re-import leave archived tags out.

Stacked PR. This PR is stacked on #856 (#776, adds Tag.archived) and tbain#1 (#778, hides archived records from read paths and export). Until those two merge, the diff also shows their seven commits. The #814 change is only the last four commits, 2c8b34d through 78e19ac: compare view. #778 keeps its own PR and is reviewed there.

What changes

Create a tag (Taxonomy.add_tag, and the REST serializer's validate_tag_value):

  • A value held by an archived tag is refused with Tag value '<value>' belongs to an archived tag in this taxonomy. Use a different value. The serializer returns the same text in its own quoting style.
  • An external_id held by an archived tag is refused with Tag external_id '<id>' belongs to an archived tag in this taxonomy. Use a different external_id.
  • A value or external_id held by an unarchived tag keeps its existing message byte for byte.
  • Adding a tag under an archived tag, or under a tag below one, is refused with Cannot add a tag under the archived tag '<value>'.

Import (TagImportPlan.generate_actions):

  • A new plan-level validator checks every row's value, id, previous_id and parent_id. A row that points at an archived tag, or at a tag under one, produces an ArchivedTagCollisionError, for example Row 2's value (Photosynthesis) belongs to an archived tag in this taxonomy. Use a different value. When any row fails, the plan builds no actions, so nothing is imported.
  • The match runs in the database, not in Python. The import actions look rows up through the column collation, so the guard must use the same comparison. MySQL's utf8mb4_unicode_ci also ignores accents and trailing spaces, which Python's casefold() does not. A parity test asserts that the plan's verdict equals the database's for Straße/STRASSE and Café/Cafe.
  • A replace import (replace=True) never deletes an archived tag or any tag under it. The export leaves that subtree out, so its absence from the file is no reason to delete it. An archived tag whose unarchived parent is deleted is moved to the root, as any kept child already is.

Export: no code change. Tests pin that JSON and CSV exports omit archived tags (#778's behavior), that an export imports cleanly into an empty taxonomy, and that a replace re-import of a taxonomy's own export keeps its archived subtrees.

Decisions beyond the ticket

The ticket's acceptance criteria cover archived tags themselves. Three rules were added during implementation and review. Please push back on any of them:

  1. A tag under an archived tag is treated as outside the taxonomy, just like the archived tag. The export and the replace sweep already treated the subtree that way, so the guard and add_tag now agree with them.
  2. A replace import keeps archived subtrees instead of deleting them.
  3. A parent_id or previous_id that points at an archived tag is a plan error.

Open questions for reviewers

Known limits, not addressed here

  • When a row points at an archived tag, the plan stops before other plan-level checks, so errors such as cycles show up only after the archived rows are fixed.
  • One row that matches an archived tag on both value and id gets two errors.
  • Renaming a tag to an archived value through the Python method Taxonomy.update_tag raises an IntegrityError. This predates the change and is outside [BE] Treat an archived tag as no longer part of its taxonomy on create, import, and export #814. The REST rename path is covered by the serializer.

Testing instructions

pytest tests/openedx_tagging --no-cov -q

Locally on SQLite: 705 passed. mypy, pylint, pycodestyle, isort and lint-imports are clean, and makemigrations --check reports no changes. MySQL was not run locally, so the MySQL collation cases rely on CI.

Manual check through the API, with a taxonomy that has an archived tag Photosynthesis:

  1. POST /tagging/rest_api/v1/taxonomies/<id>/tags/ with {"tag": "Photosynthesis"} returns 400 with the archived-tag message.
  2. Importing a JSON file that contains a tag with value Photosynthesis fails, the import log lists the archived-tag error, and the taxonomy is unchanged.
  3. Exporting the taxonomy does not list Photosynthesis.

AI usage

The implementation, tests, and this description were written with Claude Code (Claude Opus 5.5), and reviewed and directed by the author. The change was reviewed by separate AI review passes before opening.

🤖 Generated with Claude Code

tbain and others added 11 commits October 26, 2026 21:00
An archived tag still holds its value and external_id, so adding a tag
with either one failed with the generic "already exists" message, even
though no such tag is visible anywhere. Taxonomy.add_tag and the REST
serializer's value check now say the value or external_id belongs to an
archived tag and that a different one should be used. The message for an
unarchived duplicate is unchanged.

Part of openedx#814.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An import resolved an incoming row to an existing tag by value or
external_id even when that tag was archived, so it updated, renamed, or
nested under an archived tag and revived it. The import plan now reports
each row whose value, id, previous_id or parent_id belongs to an archived
tag, and builds no actions, so nothing is imported.

A replace import no longer deletes an archived tag or anything under it:
that subtree is out of the taxonomy and out of its export, so its absence
from the file does not mean it should go. An archived tag whose parent is
deleted is moved to the root, as any kept child is.

Part of openedx#814.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Export a taxonomy containing archived tags, then import the result into
an empty taxonomy and, with replace, back into the same one. The export
leaves out archived tags and their descendants, the import into an empty
taxonomy succeeds, and the replace import leaves every archived subtree
in place.

Part of openedx#814.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The import plan compared rows with archived tags using Python casefold,
while the actions resolve rows through the database collation. On MySQL,
which also ignores accents, a row id like "Cafe" passed the check and then
renamed the archived tag "Café". The check now asks the database, so it
agrees with the unique constraints and with the actions' own lookups.

A tag under an archived tag is out of the taxonomy too: export leaves it
out and a replace import keeps it. Import now refuses rows that target
such a tag, and add_tag refuses a parent with an archived ancestor. The
replace import uses the same definition of the archived subtree.

Also pin the unchanged message for a taken external_id, test the archived
external_id through REST, and correct docstrings and comments that
overstated how hidden archived tags are or went stale.

Part of openedx#814.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Nov 4, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @AShatsila!

This repository is currently maintained by @axim-engineering.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

@github-project-automation github-project-automation Bot moved this to Needs Triage in Contributions Nov 4, 2026
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Ready for Review in Contributions Nov 4, 2026
@mgwozdz-unicon

Copy link
Copy Markdown
Contributor

Claude flagged this while I was looking into why #814 was inccorectly tagged as a frontend ticket. FYI @tbain for your review:

The PR description looks like it was carried over from the #778 work and not updated for the #814 commits on top of it. Three places disagree with the code in this branch:

@AShatsila Could you update the description so it matches what the branch delivers? Either "Closes #778 and closes #814" with the #814 behavior described alongside the #778 behavior, or, if you'd rather keep the two tickets reviewable on their own, split the #814 commits into a second PR stacked on this one. I lean toward the single PR since the #814 import changes build directly on the unarchived() queryset and import plan changes here, but either works.

@mgwozdz-unicon
mgwozdz-unicon requested a review from tbain November 4, 2026 16:26
@AShatsila AShatsila changed the title feat/814 :Treat an archived tag as no longer part of its taxonomy on create, import, and export fix/814: treat an archived tag as no longer part of its taxonomy on create, import, and export Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Ready for Review

Development

Successfully merging this pull request may close these issues.

[BE] Treat an archived tag as no longer part of its taxonomy on create, import, and export

4 participants