-
Notifications
You must be signed in to change notification settings - Fork 290
Fix exercise extra_fields migration for non-m_of_n mastery models #5714
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
Merged
rtibbles
merged 6 commits into
learningequality:hotfixes
from
rtibbles:exercise_your_rights
Feb 20, 2026
+516
−1
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
a5129f9
Fix migrate_extra_fields to null m/n for non-m_of_n mastery models
rtibbles 7cf6353
Migrate old-style exercise extra_fields on ricecooker import
rtibbles 3be47f3
Cleanup management command and add more logging.
rtibbles 40ba158
Target dry_run more focally for better info, cleanup redundant save.
rtibbles 946fa2b
Lazy loading, lazy coding.
rtibbles ddd9a71
Mark_complete returns an empty list if complete.
rtibbles File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
140 changes: 140 additions & 0 deletions
140
contentcuration/contentcuration/management/commands/fix_exercise_extra_fields.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import json | ||
| import logging as logmodule | ||
| import time | ||
|
|
||
| from django.core.management.base import BaseCommand | ||
| from le_utils.constants import content_kinds | ||
| from le_utils.constants import exercises | ||
|
|
||
| from contentcuration.models import ContentNode | ||
| from contentcuration.utils.nodes import migrate_extra_fields | ||
|
|
||
| logging = logmodule.getLogger("command") | ||
|
|
||
| CHUNKSIZE = 5000 | ||
|
|
||
|
|
||
| def _needs_m_n_fix(extra_fields): | ||
| """ | ||
| Check if already-migrated extra_fields have non-null m/n | ||
| on a non-m_of_n mastery model. | ||
| """ | ||
| try: | ||
| threshold = extra_fields["options"]["completion_criteria"]["threshold"] | ||
| except (KeyError, TypeError): | ||
| return False | ||
| mastery_model = threshold.get("mastery_model") | ||
| if mastery_model is None or mastery_model == exercises.M_OF_N: | ||
| return False | ||
| return threshold.get("m") is not None or threshold.get("n") is not None | ||
|
|
||
|
|
||
| def _needs_old_style_migration(extra_fields): | ||
| """ | ||
| Check if extra_fields still has old-style top-level mastery_model. | ||
| """ | ||
| return isinstance(extra_fields, dict) and "mastery_model" in extra_fields | ||
|
|
||
|
|
||
| class Command(BaseCommand): | ||
| help = ( | ||
| "Fix exercise extra_fields that were migrated with invalid m/n values " | ||
| "in their completion criteria threshold. Non-m_of_n mastery models " | ||
| "require m and n to be null, but old data may have had non-null values " | ||
| "that were carried over during migration. Also migrates any remaining " | ||
| "old-style extra_fields to the new format." | ||
| ) | ||
|
|
||
| def add_arguments(self, parser): | ||
| parser.add_argument( | ||
| "--dry-run", | ||
| action="store_true", | ||
| help="Report what would be changed without modifying the database.", | ||
| ) | ||
|
|
||
| def handle(self, *args, **options): | ||
| dry_run = options.get("dry_run", False) | ||
| start = time.time() | ||
|
|
||
| # Single pass over all exercises, filtering in Python to avoid | ||
| # expensive nested JSON field queries in the database. | ||
| queryset = ContentNode.objects.filter(kind_id=content_kinds.EXERCISE) | ||
|
|
||
| total = ContentNode.objects.filter(kind_id="exercise").count() | ||
| migrated_fixed = 0 | ||
| migrated_complete = 0 | ||
| old_style_fixed = 0 | ||
| old_style_complete = 0 | ||
| exercises_checked = 0 | ||
|
|
||
| for node in queryset.iterator(chunk_size=CHUNKSIZE): | ||
bjester marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| fix_type, complete = self._process_node(node, dry_run) | ||
| if fix_type == "old_style": | ||
| old_style_fixed += 1 | ||
| if complete: | ||
| old_style_complete += 1 | ||
| elif fix_type == "m_n_fix": | ||
| migrated_fixed += 1 | ||
| if complete: | ||
| migrated_complete += 1 | ||
| exercises_checked += 1 | ||
| if exercises_checked % CHUNKSIZE == 0: | ||
| logging.info( | ||
| "{} / {} exercises checked".format(exercises_checked, total) | ||
| ) | ||
| logging.info( | ||
| "{} marked complete out of {} old style fixed".format( | ||
| old_style_complete, old_style_fixed | ||
| ) | ||
| ) | ||
| logging.info( | ||
| "{} marked complete out of {} migrated fixed".format( | ||
| migrated_complete, migrated_fixed | ||
| ) | ||
| ) | ||
|
|
||
| logging.info("{} / {} exercises checked".format(exercises_checked, total)) | ||
| logging.info( | ||
| "{} marked complete out of {} old style fixed".format( | ||
| old_style_complete, old_style_fixed | ||
| ) | ||
| ) | ||
| logging.info( | ||
| "{} marked complete out of {} migrated fixed".format( | ||
| migrated_complete, migrated_fixed | ||
| ) | ||
| ) | ||
| logging.info( | ||
| "Done in {:.1f}s. Fixed {} migrated exercises, " | ||
| "migrated {} old-style exercises.{}".format( | ||
| time.time() - start, | ||
| migrated_fixed, | ||
| old_style_fixed, | ||
| " (dry run)" if dry_run else "", | ||
| ) | ||
| ) | ||
|
|
||
| def _process_node(self, node, dry_run): | ||
| ef = node.extra_fields | ||
| if isinstance(ef, str): | ||
| try: | ||
| ef = json.loads(ef) | ||
| except (json.JSONDecodeError, ValueError): | ||
| return None, None | ||
| if not isinstance(ef, dict): | ||
| return None, None | ||
|
|
||
| if _needs_old_style_migration(ef): | ||
| ef = migrate_extra_fields(ef) | ||
| fix_type = "old_style" | ||
| elif _needs_m_n_fix(ef): | ||
| ef["options"]["completion_criteria"]["threshold"]["m"] = None | ||
| ef["options"]["completion_criteria"]["threshold"]["n"] = None | ||
| fix_type = "m_n_fix" | ||
| else: | ||
| return None, None | ||
| node.extra_fields = ef | ||
| complete = not node.mark_complete() | ||
| if not dry_run: | ||
| node.save(update_fields=["extra_fields", "complete"]) | ||
| return fix_type, complete | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.