Skip to content

feat: make Tag.external_id non-nullable, auto-generated when omitted - #825

Merged
ormsbee merged 1 commit into
openedx:mainfrom
ufedaseyeuconsultant:feat--758-not-nullable-external_id
Oct 1, 2026
Merged

ormsbee merged 1 commit into
openedx:mainfrom
ufedaseyeuconsultant:feat--758-not-nullable-external_id

Conversation

@ufedaseyeuconsultant

Copy link
Copy Markdown
Contributor

Summary

  • Every Tag now always carries an external identifier (the Competency ID shown on the Competency Management page), whether an institution assigns one or the system generates it from the tag's name. Previously a tag only got an external_id via file import; one created through the taxonomy editor or the tagging API had none at all.
  • Tag.save() generates a candidate from the tag's value when external_id is empty, resolving collisions with a casefold-based per-taxonomy uniqueness check and a numeric suffix (-2, -3, ...). Taxonomy.add_tag() rejects a caller-supplied duplicate (exact or case-different) with ValueError instead of letting it reach the database as an IntegrityError.
  • New migration (0022_tag_external_id_not_null) backfills every existing null external_id per taxonomy without overwriting institution-assigned ones, then makes the column NOT NULL. Rollback only loosens that constraint; generated data is intentionally left in place.
  • Removes the four identifier-less special cases that are now unreachable: the str(tag.id) fallback in TagImportPlan._get_tag_id(), the external_id=None fallback in ImportAction._get_tag(), the no-identifier branch in Tag.display_str(), and the stale field comment explaining the old nullable rationale.

Implements ADR docs/openedx_tagging/decisions/0012-non-nullable-tag-external-id.rst (left in Proposed status; accepting it is a separate step this PR doesn't take).

Closes #758.

Test plan

  • pytest --no-cov tests/openedx_tagging/ — 494 passed under SQLite
  • Same suite against real MySQL 8 (DJANGO_SETTINGS_MODULE=mysql_test_settings) — 494 passed, exercising the NOT NULL column rebuild and the utf8mb4_unicode_ci case-insensitive uniqueness
  • New tests/openedx_tagging/test_migrations.py using django-test-migrations, covering the backfill (plain tag, >255-char name, institution-assigned-vs-colliding-value) and that rollback keeps generated data
  • Direct coverage of the collision-resolution branch in Tag.save() outside the migration, a read-only taxonomy getting generated identifiers while staying read-only, and a pre-upgrade export file failing safely (no side effects) when re-imported in add mode
  • pylint, mypy, pycodestyle, isort --check-only --diff clean on touched files
  • lint-imports — layering contract intact (2 kept, 0 broken)
  • Not tested: backfill performance against a Lightcast-framework-scale taxonomy (tens of thousands of tags). The migration is bounded-query by design (.iterator(), per-taxonomy bulk_update(batch_size=500)), but this wasn't load-tested.
  • Manual testing via UI for all of ticket AC

🤖 Generated with Claude Code

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Sep 21, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @ufedaseyeuconsultant!

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 Sep 21, 2026
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Ready for Review in Contributions Sep 22, 2026

@tbain tbain left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible Bug: actions.py:75; The AI review of this bit of code is concerned that the logic is not properly handling the case of a pre-existing tag with an external ID colliding with an old tag that might have the same ID during import. Do we need to do some de-conflicting logic here, like we do for the backfill() function in the migration, or do you feel like the use case for this logic properly handles that issue?

Possible Bug: 0022_tag_external_id_not_null.py:42; The AI review points out that this logic works for null, but does not take into account empty strings or other 'nullish' values. I think this is an interesting thing to point out; the AC states "tags without an external identifier receive one" but it doesn't specify null only. We might need to ask if we need to handle 'nullish' values instead of just null values for this backfill logic?

Bug: 0022_tag_external_id_not_null.py:50-60; The AI review points out that while the batching logic handles concerns with SQL size concerns, it does not handle in-memory batching so the python memory usage can become bloated. I think this is kind of a minimal concern considering the data we're dealing with and realistic sizes of a given real-world taxonomy, but I think it's valid. A simple way to (perhaps) handle it according to the AI is to clear the batch after it's processed a batch, e.g. something like:

batch.append(tag)

if len(batch) >= 500:
    Tag.objects.bulk_update(batch, ["external_id"], batch_size=500)
    batch.clear()

if batch:
    Tag.objects.bulk_update(batch, ["external_id"], batch_size=500)

Bonus points if you use a new BATCH_SIZE constant :D

Suggestion: test_models.py:1271-1287; Can we make this test more consistent with similar tests, and also clearer to future developers by changing the numbers to be based on the TAG_EXTERNAL_ID_MAX_LENGTH constant? e.g.:

        value_one = "X" * (TAG_EXTERNAL_ID_MAX_LENGTH + 45)
        value_two = ("X" * TAG_EXTERNAL_ID_MAX_LENGTH) + ("Y" * 50)

        first = Tag.objects.create(taxonomy=self.taxonomy, value=value_one)
        second = Tag.objects.create(taxonomy=self.taxonomy, value=value_two)

        assert first.external_id == "X" * TAG_EXTERNAL_ID_MAX_LENGTH

AI (Codex) Raw output:

Details Verdict: I found two correctness issues and one scalability issue; I would not merge this PR as-is. - Bug. [actions.py:75](https://github.com/openedx/openedx-core/blob/d4064f02ac4bf9b9bfb912e2f225b65e96e1811e/src/openedx_tagging/import_export/actions.py#L75) treats a pre-upgrade numeric database ID as a valid external ID. If it matches another tag’s external ID, importing an old export can rename or re-parent the wrong tag, violating the issue’s “fails safely with no side effects” criterion. The tests only cover the non-collision case. - Bug. [0022_tag_external_id_not_null.py:42](https://github.com/openedx/openedx-core/blob/d4064f02ac4bf9b9bfb912e2f225b65e96e1811e/src/openedx_tagging/migrations/0022_tag_external_id_not_null.py#L42) backfills only NULL values. The field remains blank=True, and empty-string identifiers are therefore not repaired; direct model writes can also generate an empty identifier from an empty tag value. - Bug. [0022_tag_external_id_not_null.py:50-60](https://github.com/openedx/openedx-core/blob/d4064f02ac4bf9b9bfb912e2f225b65e96e1811e/src/openedx_tagging/migrations/0022_tag_external_id_not_null.py#L50) accumulates every missing tag in one taxonomy before calling bulk_update. batch_size=500 limits SQL statement size, not Python memory usage, so a large taxonomy can consume excessive memory during the upgrade despite the stated scale requirement. Suggestion: the PR’s “Manual testing via UI for all of ticket AC” entry is not reproducible manual-testing documentation; it should list concrete steps and expected results.

@ufedaseyeuconsultant

ufedaseyeuconsultant commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Possible Bug: actions.py:75; The AI review of this bit of code is concerned that the logic is not properly handling the case of a pre-existing tag with an external ID colliding with an old tag that might have the same ID during import. Do we need to do some de-conflicting logic here, like we do for the backfill() function in the migration, or do you feel like the use case for this logic properly handles that issue?

@tbain According to AC and code this one is already covered as needed

Additional Claude review Investigated this instead of adding a de-conflicting check in actions.py. Short version: the concrete failure mode the review describes (a wrong tag getting renamed or re-parented) doesn't reproduce for the scenario the ticket's AC actually requires, because _get_tag() doesn't decide anything in isolation — every row still runs through the full action-generation pipeline, and an existing check in that pipeline already closes the gap.

The scenario. A pre-#758 export references a tag by its old database id, because that tag had no external_id at export time. If that old id happens to equal a different tag's real, current external_id, _get_tag() resolves the row to that other tag instead of failing to find one.

Why it still fails safely. generate_actions() checks every action class against a row, not just the first one that matches. For a row like this, RenameTag.applies_for() also evaluates, and it's true here: the wrongly-matched tag's value can't equal the row's value, because Tag.value is unique per taxonomy and the tag the row is actually about still holds that value (the re-import is unchanged, so nothing renamed it away). That mismatch means a RenameTag action gets built for the wrongly-matched tag too, and its own validation (_validate_value) finds the value already taken and raises an error. TagImportPlan.execute() is all-or-nothing (if self.errors: return), so that one error blocks the entire plan, including whatever UpdateParentTag action might otherwise have re-parented the wrong tag.

Verification. Confirmed this empirically before trusting the trace: wrote a probe reproducing the exact collision (a tag whose real external_id equals another tag's old PK, re-imported unchanged) in both a flat and a parent/child taxonomy shape. In both cases the plan reported "Duplicated tag value" and neither tag's value nor parent changed. Kept the flat-taxonomy case as a permanent regression test: test_reimport_pre_upgrade_export_file_fails_safely_on_id_collision in tests/openedx_tagging/import_export/test_api.py.

What's genuinely still open. The protection depends on the row's value still matching an existing tag. If a re-imported file also carries a value nothing else in the taxonomy currently has — meaning it's not just "re-import unchanged" but a deliberate rename layered on top of a stale, colliding id — that combination isn't guarded against. That's a narrower scenario than "re-import an old export unchanged," which is what the ticket's acceptance criteria describes, so I've documented it as a known limitation in ADR 0012 rather than adding matching logic for it. Closing it fully would mean weighing it against rejecting legitimate imports that use a plain numeric external_id (a common style for CBE competency identifiers) that happens to equal an unrelated tag's database id, since the import format has no way to tell those two cases apart.

Conclusion: no change to actions.py. Added the regression test above and the "Known limitations" section in docs/openedx_tagging/decisions/0012-non-nullable-tag-external-id.rst to make this reasoning and its boundary explicit for future readers.

@ormsbee ormsbee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit. Please squash, adjust the commit message as needed, and I'll merge. Thank you.

if attempt == 1:
return value.strip()[:TAG_EXTERNAL_ID_MAX_LENGTH].strip()
suffix = f"-{attempt}"
return value.strip()[: TAG_EXTERNAL_ID_MAX_LENGTH - len(suffix)].strip() + suffix

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain in plain English or by example what the sequence of candidate external IDs looks like.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Squashed commits and added comment

Every tag now always carries an external identifier (the Competency ID shown
on the Competency Management page), whether an institution assigns one or the
system generates it from the tag's name. Previously a tag only got an
external_id via file import; one created through the taxonomy editor or the
tagging API had none at all.

Tag.save() generates a candidate from the tag's value when external_id is
empty, resolving collisions with a casefold-based per-taxonomy uniqueness
check and a numeric suffix. Taxonomy.add_tag() rejects a caller-supplied
duplicate (exact or case-different) with ValueError instead of letting it
reach the database as an IntegrityError.

A new migration backfills every existing null or empty-string external_id, in
capped in-memory and SQL batches, without overwriting institution-assigned
ones, then makes the column NOT NULL; rollback only loosens that constraint
and leaves generated data in place. A stale pre-upgrade export whose id
collides with another tag's real external_id still fails safely on
re-import, tripping a co-triggered rename action's existing duplicate-value
check; this behavior and its narrower residual limitation are documented in
ADR 0012.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ufedaseyeuconsultant
ufedaseyeuconsultant force-pushed the feat--758-not-nullable-external_id branch from c3cd75c to e95a9bf Compare October 1, 2026 16:34
@ormsbee
ormsbee merged commit 4f73aaa into openedx:main Oct 1, 2026
8 checks passed
@github-project-automation github-project-automation Bot moved this from Ready for Review to Done in Contributions Oct 1, 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: Done

Development

Successfully merging this pull request may close these issues.

[BE] Implement the external_id not nullable constraint as described in openedx_tagging ADR 12

5 participants