Skip to content

Commit e315609

Browse files
authored
Merge pull request #5710 from learningequality/hotfixes
Studio Release v2026.02.18
2 parents b888505 + bf03d5f commit e315609

File tree

14 files changed

+293
-75
lines changed

14 files changed

+293
-75
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,14 @@ var/
2525

2626
# Ignore editor / IDE related data
2727
.vscode/
28+
.gemini/
2829

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

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/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
},
10598
actions: {
10699
saveSession(context, currentUser) {

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: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import csv
2+
import logging
3+
import time
4+
5+
from django.core.management.base import BaseCommand
6+
from django.db.models import Exists
7+
from django.db.models import FilteredRelation
8+
from django.db.models import OuterRef
9+
from django.db.models import Q
10+
from django.db.models.expressions import F
11+
from django_cte import With
12+
13+
from contentcuration.models import Channel
14+
from contentcuration.models import ContentNode
15+
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class Command(BaseCommand):
21+
"""
22+
Audits nodes that have imported content from public channels and whether the imported content
23+
has a missing source node.
24+
25+
TODO: this does not yet FIX them
26+
"""
27+
28+
def handle(self, *args, **options):
29+
start = time.time()
30+
31+
public_cte = self.get_public_cte()
32+
33+
# preliminary filter on channels to those private and non-deleted, which have content
34+
# lft=1 is always true for root nodes, so rght>2 means it actually has children
35+
private_channels_cte = With(
36+
Channel.objects.filter(
37+
public=False,
38+
deleted=False,
39+
)
40+
.annotate(
41+
non_empty_main_tree=FilteredRelation(
42+
"main_tree", condition=Q(main_tree__rght__gt=2)
43+
),
44+
)
45+
.annotate(
46+
tree_id=F("non_empty_main_tree__tree_id"),
47+
)
48+
.values("id", "name", "tree_id"),
49+
name="dest_channel_cte",
50+
)
51+
52+
# reduce the list of private channels to those that have an imported node
53+
# from a public channel
54+
destination_channels = (
55+
private_channels_cte.queryset()
56+
.with_cte(public_cte)
57+
.with_cte(private_channels_cte)
58+
.filter(
59+
Exists(
60+
public_cte.join(
61+
ContentNode.objects.filter(
62+
tree_id=OuterRef("tree_id"),
63+
),
64+
original_channel_id=public_cte.col.id,
65+
)
66+
)
67+
)
68+
.values("id", "name", "tree_id")
69+
.order_by("id")
70+
)
71+
72+
logger.info("=== Iterating over private destination channels. ===")
73+
channel_count = 0
74+
total_node_count = 0
75+
76+
with open("fix_missing_import_sources.csv", "w", newline="") as csv_file:
77+
csv_writer = csv.DictWriter(
78+
csv_file,
79+
fieldnames=[
80+
"channel_id",
81+
"channel_name",
82+
"contentnode_id",
83+
"contentnode_title",
84+
"public_channel_id",
85+
"public_channel_name",
86+
"public_channel_deleted",
87+
],
88+
)
89+
csv_writer.writeheader()
90+
91+
for channel in destination_channels.iterator():
92+
node_count = self.handle_channel(csv_writer, channel)
93+
94+
if node_count > 0:
95+
total_node_count += node_count
96+
channel_count += 1
97+
98+
logger.info("=== Done iterating over private destination channels. ===")
99+
logger.info(f"Found {total_node_count} nodes across {channel_count} channels.")
100+
logger.info(f"Finished in {time.time() - start}")
101+
102+
def get_public_cte(self) -> With:
103+
# This CTE gets all public channels with their main tree info
104+
return With(
105+
Channel.objects.filter(public=True)
106+
.annotate(
107+
tree_id=F("main_tree__tree_id"),
108+
)
109+
.values("id", "name", "deleted", "tree_id"),
110+
name="public_cte",
111+
)
112+
113+
def handle_channel(self, csv_writer: csv.DictWriter, channel: dict) -> int:
114+
public_cte = self.get_public_cte()
115+
channel_id = channel["id"]
116+
channel_name = channel["name"]
117+
tree_id = channel["tree_id"]
118+
119+
missing_source_nodes = (
120+
public_cte.join(
121+
ContentNode.objects.filter(tree_id=tree_id),
122+
original_channel_id=public_cte.col.id,
123+
)
124+
.with_cte(public_cte)
125+
.annotate(
126+
public_channel_id=public_cte.col.id,
127+
public_channel_name=public_cte.col.name,
128+
public_channel_deleted=public_cte.col.deleted,
129+
)
130+
.filter(
131+
Q(public_channel_deleted=True)
132+
| ~Exists(
133+
ContentNode.objects.filter(
134+
tree_id=public_cte.col.tree_id,
135+
node_id=OuterRef("original_source_node_id"),
136+
)
137+
)
138+
)
139+
.values(
140+
"public_channel_id",
141+
"public_channel_name",
142+
"public_channel_deleted",
143+
contentnode_id=F("id"),
144+
contentnode_title=F("title"),
145+
)
146+
)
147+
148+
# Count and log results
149+
node_count = missing_source_nodes.count()
150+
151+
# TODO: this will be replaced with logic to correct the missing source nodes
152+
if node_count > 0:
153+
logger.info(
154+
f"{channel_id}:{channel_name}\t{node_count} node(s) with missing source nodes."
155+
)
156+
row_dict = {
157+
"channel_id": channel_id,
158+
"channel_name": channel_name,
159+
}
160+
for node_dict in missing_source_nodes.iterator():
161+
row_dict.update(node_dict)
162+
csv_writer.writerow(row_dict)
163+
164+
return node_count

contentcuration/contentcuration/models.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from django.utils import timezone
4848
from django.utils.translation import gettext as _
4949
from django_cte import CTEManager
50+
from django_cte import CTEQuerySet
5051
from django_cte import With
5152
from le_utils import proquint
5253
from le_utils.constants import content_kinds
@@ -837,7 +838,7 @@ def exists(self, *filters):
837838
return Exists(self.queryset().filter(*filters).values("user_id"))
838839

839840

840-
class ChannelModelQuerySet(models.QuerySet):
841+
class ChannelModelQuerySet(CTEQuerySet):
841842
def create(self, **kwargs):
842843
"""
843844
Create a new object with the given kwargs, saving it to the database
@@ -863,6 +864,12 @@ def update_or_create(self, defaults=None, **kwargs):
863864
return super().update_or_create(defaults, **kwargs)
864865

865866

867+
class ChannelModelManager(models.Manager.from_queryset(ChannelModelQuerySet)):
868+
"""Custom Channel models manager with CTE support"""
869+
870+
pass
871+
872+
866873
class Channel(models.Model):
867874
""" Permissions come from association with organizations """
868875

@@ -994,7 +1001,7 @@ class Channel(models.Model):
9941001
]
9951002
)
9961003

997-
objects = ChannelModelQuerySet.as_manager()
1004+
objects = ChannelModelManager()
9981005

9991006
@classmethod
10001007
def get_editable(cls, user, channel_id):

contentcuration/contentcuration/static/feature_flags.json

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@
99
"description": "This no-op feature flag is excluded from non-dev environments",
1010
"$env": "development"
1111
},
12-
"ai_feature":{
13-
"type": "boolean",
14-
"title":"Test AI feature",
15-
"description": "Allow user access to AI features"
16-
},
1712
"survey":{
1813
"type": "boolean",
1914
"title":"Test Survey feature",

contentcuration/contentcuration/tests/management/__init__.py

Whitespace-only changes.

contentcuration/contentcuration/tests/management/commands/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)