Skip to content

feat: lock tag associations when a learner competency status is written - #4

Open
tbain wants to merge 3 commits into
tbain/779_archive_locked_object_tag_deletesfrom
tbain/782_lock_object_tags_on_status_write
Open

tbain wants to merge 3 commits into
tbain/779_archive_locked_object_tag_deletesfrom
tbain/782_lock_object_tags_on_status_write

Conversation

@tbain

@tbain tbain commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Description

record_graded_object_statuses() now locks the tag association (ObjectTag) behind every criterion whose learner status it records, so that association is archived rather than deleted later and the learner's recorded mastery keeps a row to point at. This is the first caller of lock_object_tags_for_deletion() from openedx#777, so it is also the first time a deletion_locked=True row exists outside the tests.

What changed

One function, record_graded_object_statuses() in src/openedx_learning/applets/cbe/api.py, and one new test module.

  • Where the lock is set. The function already loops over every CompetencyCriterion attached to the graded objects and writes a leaf status for each one whose rule applies. It now also collects criterion.object_tag_id for each of those criteria, and after the loop makes a single lock_object_tags_for_deletion() call with the collected ids. A criterion whose rule cannot be applied (the existing ValidationError skip path) writes no status, so it is not collected and its association stays unlocked.
  • Same transaction as the status. The function already requires the caller's atomic block and opens none of its own, so the lock commits or rolls back with the grade and the statuses. If the lock raises (an association deleted between the criteria query and the lock), the exception propagates and the caller's transaction rolls the statuses back with it. The docstring now lists ObjectTag.DoesNotExist under :raises.
  • One bulk call. The lock is one call per invocation however many criteria were evaluated, and the if locked_object_tag_ids: guard means a call that evaluated no criterion, which is the common case for ungraded or untagged content, issues no lock query at all.
  • Already-locked rows and existing statuses. The lock function skips rows already locked, so a second learner's status against a locked association simply succeeds. A criterion whose status row already exists at the same or a higher level (the write returns 0) is still locked; this repairs associations whose statuses predate the lock.
  • Only openedx_tagging.api is imported. The setter joins the existing from openedx_tagging.api import ... line. Nothing is called for the tag or the taxonomy; [BE] Add deletion-lock functions to openedx_tagging openedx/openedx-core#777's reader functions derive their protection from the locked association.
  • An archived association records no new mastery. The criteria-resolution query gains object_tag__archived=False next to the existing archived=False on the criterion. This is one line outside the ticket's stated scope, added because this PR is what makes the state reachable: once the lock exists, removing a tag from content through tag_object() or delete_object_tags() archives the association ([BE] Branch tag association deletes between archiving and hard delete openedx/openedx-core#779) instead of deleting it, and without this condition later learners kept earning statuses through the archived row. The condition rides on the join the query already makes, so it adds no query. The learner who earned a status before the archive still resolves to the archived ObjectTag through the status row's foreign keys, which this query does not touch. [BE] Exclude archived competency criteria and criteria groups from evaluation when a grade is recorded openedx/openedx-core#816's Context section states that [BE] Evaluate learner’s criterion status for grade openedx/openedx-core#699 already excluded archived associations; the merged [BE] Evaluate learner’s criterion status for grade openedx/openedx-core#699 query filters only the criterion's own archived flag, so this PR is where the association condition lands.

Choices that differ from the ticket

The ticket says This PR Why
Call lock_object_tag_for_deletion() Calls lock_object_tags_for_deletion() The singular name does not exist; the plural is openedx#777's only setter.
"Do not add a read to avoid a write" No pre-read added here Honored. Note that openedx#777's setter itself reads before it writes (added during that PR's review), so a first lock costs a read and an update, and locking an already-locked association costs the read alone. The query-count test asserts the first case for one criterion and for three.
AC scenario 3: deleting the competency (the tag) archives it Not exercised here Tag and taxonomy delete branches are openedx#780, which the ticket's own Out of scope section assigns elsewhere and which is not written yet. On this branch deleting the tag raises ProtectedError through CompetencyCriteriaGroup.tag. The end-to-end test instead deletes the locked association through openedx#779's delete_object_tags(), asserts the tag and taxonomy report as protected through openedx#777's readers, and asserts the learner's status still resolves to the archived association.
Out of scope: "any change to how or when a learner status is written" The criteria query excludes archived associations See "An archived association records no new mastery" above. Agreed with the technical architect while this PR was in review, since the lock this PR adds is what makes an archived association reachable.

Who is affected

Supporting information

Testing instructions

Automated:

  1. The new tests (10 tests, 12 cases):

    pytest --no-cov tests/openedx_learning/applets/cbe/test_record_graded_object_statuses_locking.py

    They cover: a status write locks the association; a second learner on a locked association succeeds and it stays locked; content with no criteria never reaches the lock call; a criterion whose rule cannot be applied leaves its association unlocked; the lock is exactly one UPDATE and a constant three oel_tagging_objecttag queries for one criterion and for three; a rolled-back write is locked inside the transaction and unlocked after; a lock that raises rolls the status write back; an existing status row still gets its association locked; deleting the locked association archives it while get_tags_locked_for_deletion() and is_taxonomy_locked_for_deletion() report the tag and taxonomy protected before and after, neither model carries a deletion_locked field, and a freshly loaded status for the learner dereferences to the archived row; and an association archived after one learner earned a status through it records nothing for a later learner while the first learner's status still dereferences to the archived row.

  2. The full suite: pytest --no-cov tests (1437 tests: 1436 pass, 1 is skipped).

  3. pylint, mypy, pycodestyle, isort --check-only, and lint-imports.

Manual, in a Django shell. This creates rows in your dev database.

from decimal import Decimal
from django.contrib.auth import get_user_model
from django.db import transaction
from openedx_learning.api import GradedObjectScore, record_graded_object_statuses
from openedx_learning.models import CompetencyCriteriaGroup, CompetencyCriterion, CompetencyRuleProfile, CompetencyTaxonomy
from openedx_tagging import api
from openedx_tagging.models import ObjectTag, Tag

learner = get_user_model().objects.create(username="demo-learner")
taxonomy = CompetencyTaxonomy.objects.create(name="Lock demo", export_id="lock-demo")
tag = Tag.objects.create(taxonomy=taxonomy, value="Writing Poetry")
object_tag = ObjectTag.objects.create(object_id="demo-block", taxonomy=taxonomy, tag=tag)
default_profile = CompetencyRuleProfile.objects.get(
    organization__isnull=True, course__isnull=True, competency_taxonomy__isnull=True,
)
CompetencyCriterion.objects.create(
    group=CompetencyCriteriaGroup.objects.create(tag=tag), object_tag=object_tag, rule_profile=default_profile,
)

object_tag.deletion_locked   # False

with transaction.atomic():
    record_graded_object_statuses(
        user_id=learner.id, scores=[GradedObjectScore(object_id="demo-block", fraction=Decimal("0.9"))],
    )   # 1
object_tag.refresh_from_db()
object_tag.deletion_locked   # True

api.get_tags_locked_for_deletion([tag.id]) == {tag.id}   # True
api.is_taxonomy_locked_for_deletion(taxonomy.id)          # True

api.delete_object_tags("demo-block")                      # the #779 path
object_tag.refresh_from_db()
(object_tag.archived, object_tag.deletion_locked)         # (True, True): archived, not deleted

Deadline

None.

Other information

  • No model changes, no migrations, no new dependencies. Existing tests are unchanged.
  • Known gaps:
  • Suggested reading order: the diff of record_graded_object_statuses(), then lock_object_tags_for_deletion() in src/openedx_tagging/api.py for the query pattern the count test asserts, then the test module top to bottom.
  • AI usage: this PR was developed with Claude (Anthropic) in Cowork, with Sonnet sub-agents doing the implementation and an independent review pass, and the main session verifying. A preliminary review by Codex using the backend-code-review skill found the archived-association gap. I reviewed the change and ran the tests and checks locally.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant