Skip to content

Commit 0d3365b

Browse files
authored
Merge pull request #5753 from learningequality/hotfixes
Hotfixes release 2026.03.17
2 parents e315609 + 050e85b commit 0d3365b

File tree

9 files changed

+564
-4
lines changed

9 files changed

+564
-4
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ migrate:
3838
# 4) Remove the management command from this `deploy-migrate` recipe
3939
# 5) Repeat!
4040
deploy-migrate:
41-
echo "Nothing to do here!"
41+
python contentcuration/manage.py fix_exercise_extra_fields
4242

4343
contentnodegc:
4444
python contentcuration/manage.py garbage_collect

contentcuration/contentcuration/frontend/shared/views/GlobalSnackbar.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
:key="key"
66
:timeout="snackbarOptions.duration"
77
left
8+
multi-line
89
:value="snackbarIsVisible"
910
@input="visibilityToggled"
1011
>

contentcuration/contentcuration/frontend/shared/views/policies/TermsOfServiceModal.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@
185185
<p>
186186
<ActionLink
187187
:text="$tr('dmcaLink')"
188-
href="https://docs.google.com/forms/d/e/1FAIpQLSd7qWORCOOczCnOlDzaftIjBsaUtl3DKH3hbxlO1arRc1_IQg/viewform?usp=sf_link"
188+
href="https://forms.gle/oviMu2YPuFSrW7S26"
189189
target="_blank"
190190
/>
191191
</p>
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import json
2+
import logging as logmodule
3+
import time
4+
5+
from django.core.management.base import BaseCommand
6+
from le_utils.constants import content_kinds
7+
from le_utils.constants import exercises
8+
9+
from contentcuration.models import ContentNode
10+
from contentcuration.utils.nodes import migrate_extra_fields
11+
12+
logging = logmodule.getLogger("command")
13+
14+
CHUNKSIZE = 5000
15+
16+
17+
def _needs_m_n_fix(extra_fields):
18+
"""
19+
Check if already-migrated extra_fields have non-null m/n
20+
on a non-m_of_n mastery model.
21+
"""
22+
try:
23+
threshold = extra_fields["options"]["completion_criteria"]["threshold"]
24+
except (KeyError, TypeError):
25+
return False
26+
mastery_model = threshold.get("mastery_model")
27+
if mastery_model is None or mastery_model == exercises.M_OF_N:
28+
return False
29+
return threshold.get("m") is not None or threshold.get("n") is not None
30+
31+
32+
def _needs_old_style_migration(extra_fields):
33+
"""
34+
Check if extra_fields still has old-style top-level mastery_model.
35+
"""
36+
return isinstance(extra_fields, dict) and "mastery_model" in extra_fields
37+
38+
39+
class Command(BaseCommand):
40+
help = (
41+
"Fix exercise extra_fields that were migrated with invalid m/n values "
42+
"in their completion criteria threshold. Non-m_of_n mastery models "
43+
"require m and n to be null, but old data may have had non-null values "
44+
"that were carried over during migration. Also migrates any remaining "
45+
"old-style extra_fields to the new format."
46+
)
47+
48+
def add_arguments(self, parser):
49+
parser.add_argument(
50+
"--dry-run",
51+
action="store_true",
52+
help="Report what would be changed without modifying the database.",
53+
)
54+
55+
def handle(self, *args, **options):
56+
dry_run = options.get("dry_run", False)
57+
start = time.time()
58+
59+
# Single pass over all exercises, filtering in Python to avoid
60+
# expensive nested JSON field queries in the database.
61+
queryset = ContentNode.objects.filter(kind_id=content_kinds.EXERCISE)
62+
63+
total = ContentNode.objects.filter(kind_id="exercise").count()
64+
migrated_fixed = 0
65+
migrated_complete = 0
66+
old_style_fixed = 0
67+
old_style_complete = 0
68+
incomplete_fixed = 0
69+
exercises_checked = 0
70+
71+
for node in queryset.iterator(chunk_size=CHUNKSIZE):
72+
fix_type, complete = self._process_node(node, dry_run)
73+
if fix_type == "old_style":
74+
old_style_fixed += 1
75+
if complete:
76+
old_style_complete += 1
77+
elif fix_type == "m_n_fix":
78+
migrated_fixed += 1
79+
if complete:
80+
migrated_complete += 1
81+
elif fix_type == "incomplete" and complete:
82+
incomplete_fixed += 1
83+
exercises_checked += 1
84+
if exercises_checked % CHUNKSIZE == 0:
85+
logging.info(
86+
"{} / {} exercises checked".format(exercises_checked, total)
87+
)
88+
logging.info(
89+
"{} marked complete out of {} old style fixed".format(
90+
old_style_complete, old_style_fixed
91+
)
92+
)
93+
logging.info(
94+
"{} marked complete out of {} migrated fixed".format(
95+
migrated_complete, migrated_fixed
96+
)
97+
)
98+
logging.info(
99+
"{} marked complete that were previously incomplete".format(
100+
incomplete_fixed
101+
)
102+
)
103+
104+
logging.info("{} / {} exercises checked".format(exercises_checked, total))
105+
logging.info(
106+
"{} marked complete out of {} old style fixed".format(
107+
old_style_complete, old_style_fixed
108+
)
109+
)
110+
logging.info(
111+
"{} marked complete out of {} migrated fixed".format(
112+
migrated_complete, migrated_fixed
113+
)
114+
)
115+
logging.info(
116+
"{} marked complete that were previously incomplete".format(
117+
incomplete_fixed
118+
)
119+
)
120+
logging.info(
121+
"Done in {:.1f}s. Fixed {} migrated exercises, "
122+
"migrated {} old-style exercises."
123+
"marked {} previously incomplete exercises complete. {}".format(
124+
time.time() - start,
125+
migrated_fixed,
126+
old_style_fixed,
127+
incomplete_fixed,
128+
" (dry run)" if dry_run else "",
129+
)
130+
)
131+
132+
def _process_node(self, node, dry_run):
133+
ef = node.extra_fields
134+
was_complete = node.complete
135+
if isinstance(ef, str):
136+
try:
137+
ef = json.loads(ef)
138+
except (json.JSONDecodeError, ValueError):
139+
return None, None
140+
if not isinstance(ef, dict):
141+
return None, None
142+
143+
if _needs_old_style_migration(ef):
144+
ef = migrate_extra_fields(ef)
145+
fix_type = "old_style"
146+
elif _needs_m_n_fix(ef):
147+
ef["options"]["completion_criteria"]["threshold"]["m"] = None
148+
ef["options"]["completion_criteria"]["threshold"]["n"] = None
149+
fix_type = "m_n_fix"
150+
elif not was_complete:
151+
fix_type = "incomplete"
152+
else:
153+
return None, None
154+
node.extra_fields = ef
155+
complete = not node.mark_complete()
156+
if not dry_run:
157+
node.save(update_fields=["extra_fields", "complete"])
158+
return fix_type, complete

0 commit comments

Comments
 (0)