Skip to content

Commit e6c4f53

Browse files
authored
Merge pull request learningequality#5771 from rtibbles/hotfixesintounstable
Hotfixes into unstable
2 parents bbeeea9 + 9353ee4 commit e6c4f53

32 files changed

Lines changed: 1384 additions & 107 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,14 @@ var/
2626
# Ignore editor / IDE related data
2727
.vscode/
2828
.claude/
29+
.gemini/
2930

3031
# IntelliJ IDE, except project config
3132
.idea/
3233
/*.iml
34+
.junie/
35+
.aiassistant/
36+
.aiignore
3337
# ignore future updates to run configuration
3438
.run/devserver.run.xml
3539

contentcuration/contentcuration/constants/completion_criteria.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
from jsonschema.validators import validator_for
44
from le_utils.constants import completion_criteria
55
from le_utils.constants import content_kinds
6+
from le_utils.constants import exercises
67
from le_utils.constants import mastery_criteria
8+
from le_utils.constants import modalities
79

810

911
def _build_validator():
@@ -52,10 +54,11 @@ def _build_validator():
5254
completion_criteria.APPROX_TIME,
5355
completion_criteria.REFERENCE,
5456
},
57+
content_kinds.TOPIC: {completion_criteria.MASTERY},
5558
}
5659

5760

58-
def check_model_for_kind(data, kind):
61+
def check_model_for_kind(data, kind, modality=None):
5962
model = data.get("model")
6063
if kind is None or model is None or kind not in ALLOWED_MODELS_PER_KIND:
6164
return
@@ -68,11 +71,37 @@ def check_model_for_kind(data, kind):
6871
)
6972
)
7073

74+
if kind == content_kinds.TOPIC:
75+
check_topic_completion_criteria(data, modality)
7176

72-
def validate(data, kind=None):
77+
78+
def check_topic_completion_criteria(data, modality):
79+
"""
80+
Validates topic-specific completion criteria rules:
81+
- Topics can only have completion criteria if modality is UNIT
82+
- Topics can only use PRE_POST_TEST mastery model
83+
"""
84+
# Topics can only have completion criteria with UNIT modality
85+
if modality != modalities.UNIT:
86+
raise ValidationError(
87+
"Topics can only have completion criteria with UNIT modality"
88+
)
89+
90+
# Topics can only use PRE_POST_TEST mastery model
91+
threshold = data.get("threshold", {})
92+
mastery_model = threshold.get("mastery_model")
93+
if mastery_model is not None and mastery_model != exercises.PRE_POST_TEST:
94+
raise ValidationError(
95+
"mastery_model '{}' is invalid for topic content kind; "
96+
"only '{}' is allowed".format(mastery_model, exercises.PRE_POST_TEST)
97+
)
98+
99+
100+
def validate(data, kind=None, modality=None):
73101
"""
74102
:param data: Dictionary of data to validate
75103
:param kind: A str of the node content kind
104+
:param modality: A str of the node modality (required for topics with completion criteria)
76105
:raises: ValidationError: When invalid
77106
"""
78107
# empty dicts are okay
@@ -104,4 +133,4 @@ def validate(data, kind=None):
104133
e.error_list.extend(error_descriptions)
105134
raise e
106135

107-
check_model_for_kind(data, kind)
136+
check_model_for_kind(data, kind, modality)

contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@
227227
</VBtn>
228228

229229
<VBtn
230+
v-if="false"
230231
color="primary"
231232
data-test="display-publish-draft-dialog-btn"
232233
@click="displayPublishDraftDialog = true"

contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/SearchOrBrowseWindow.vue

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,6 @@
400400
...mapGetters('contentNode', ['getContentNodeAncestors']),
401401
...mapGetters('currentChannel', ['currentChannel']),
402402
...mapGetters('importFromChannels', ['savedSearchesExist']),
403-
...mapGetters(['isAIFeatureEnabled']),
404403
...mapState('importFromChannels', ['selected']),
405404
isBrowsing() {
406405
return this.$route.name === RouteNames.IMPORT_FROM_CHANNELS_BROWSE;
@@ -432,10 +431,6 @@
432431
};
433432
},
434433
shouldShowRecommendations() {
435-
if (!this.isAIFeatureEnabled) {
436-
return false;
437-
}
438-
439434
if (this.embedTopicRequest === null) {
440435
return false;
441436
}

contentcuration/contentcuration/frontend/channelEdit/views/ImportFromChannels/__tests__/SearchOrBrowseWindow.spec.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ describe('SearchOrBrowseWindow', () => {
7777
getters = {
7878
'currentChannel/currentChannel': () => ({ language: 'en' }),
7979
'importFromChannels/savedSearchesExist': () => true,
80-
isAIFeatureEnabled: () => true,
8180
'contentNode/getContentNodeAncestors': () => () => [{ id: 'node-1', title: 'Test folder' }],
8281
};
8382

@@ -132,9 +131,7 @@ describe('SearchOrBrowseWindow', () => {
132131
actions: {
133132
showSnackbar: actions.showSnackbar,
134133
},
135-
getters: {
136-
isAIFeatureEnabled: getters.isAIFeatureEnabled,
137-
},
134+
getters: {},
138135
});
139136

140137
const routes = [

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
<KExternalLink
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
:openInNewTab="true"
190190
/>
191191
</p>

contentcuration/contentcuration/frontend/shared/vuex/session/index.js

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { Session, User } from 'shared/data/resources';
66
import { forceServerSync } from 'shared/data/serverSync';
77
import translator from 'shared/translator';
88
import { applyMods } from 'shared/data/applyRemoteChanges';
9-
import { FeatureFlagKeys } from 'shared/constants';
109

1110
function langCode(language) {
1211
// Turns a Django language name (en-gb) into an ISO language code (en-GB)
@@ -95,12 +94,6 @@ export default {
9594
return getters.isAdmin || Boolean(getters.featureFlags[flag]);
9695
};
9796
},
98-
isAIFeatureEnabled(state, getters) {
99-
if (getters.loggedIn) {
100-
return getters.hasFeatureEnabled(FeatureFlagKeys.ai_feature);
101-
}
102-
return false;
103-
},
10497
hasNewNotifications(state) {
10598
const {
10699
newest_notification_date: newestNotificationDate,

contentcuration/contentcuration/frontend/shared/vuex/session/index.spec.js

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import vuexSessionModule from './index.js';
2-
import { FeatureFlagKeys } from 'shared/constants';
32

43
describe('session module feature flag related getters', () => {
54
let state;
@@ -12,7 +11,6 @@ describe('session module feature flag related getters', () => {
1211
},
1312
},
1413
};
15-
state.currentUser.feature_flags[FeatureFlagKeys.ai_feature] = true;
1614
});
1715

1816
describe('featureFlags', () => {
@@ -54,31 +52,4 @@ describe('session module feature flag related getters', () => {
5452
expect(getters.hasFeatureEnabled(state, getters)('false_flag')).toBe(false);
5553
});
5654
});
57-
58-
describe('isAIFeatureEnabled', () => {
59-
let getters;
60-
beforeEach(() => {
61-
getters = {
62-
loggedIn: true,
63-
hasFeatureEnabled: vuexSessionModule.getters.hasFeatureEnabled(state, {
64-
featureFlags: vuexSessionModule.getters.featureFlags(state),
65-
isAdmin: false,
66-
}),
67-
isAIFeatureEnabled: vuexSessionModule.getters.isAIFeatureEnabled,
68-
};
69-
});
70-
it('should return false if not logged in', () => {
71-
getters.loggedIn = false;
72-
expect(getters.isAIFeatureEnabled(state, getters)).toBe(false);
73-
});
74-
75-
it('should return true if logged in and ai feature flag is true', () => {
76-
expect(getters.isAIFeatureEnabled(state, getters)).toBe(true);
77-
});
78-
79-
it('should return false if logged in and ai feature flag is false', () => {
80-
state.currentUser.feature_flags[FeatureFlagKeys.ai_feature] = false;
81-
expect(getters.isAIFeatureEnabled(state, getters)).toBe(false);
82-
});
83-
});
8455
});
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)