Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ var/

# Ignore editor / IDE related data
.vscode/
.gemini/

# IntelliJ IDE, except project config
.idea/
/*.iml
.junie/
.aiassistant/
.aiignore
# ignore future updates to run configuration
.run/devserver.run.xml

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,6 @@
...mapGetters('contentNode', ['getContentNodeAncestors']),
...mapGetters('currentChannel', ['currentChannel']),
...mapGetters('importFromChannels', ['savedSearchesExist']),
...mapGetters(['isAIFeatureEnabled']),
...mapState('importFromChannels', ['selected']),
isBrowsing() {
return this.$route.name === RouteNames.IMPORT_FROM_CHANNELS_BROWSE;
Expand Down Expand Up @@ -432,10 +431,6 @@
};
},
shouldShowRecommendations() {
if (!this.isAIFeatureEnabled) {
return false;
}

if (this.embedTopicRequest === null) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ describe('SearchOrBrowseWindow', () => {
getters = {
'currentChannel/currentChannel': () => ({ language: 'en' }),
'importFromChannels/savedSearchesExist': () => true,
isAIFeatureEnabled: () => true,
'contentNode/getContentNodeAncestors': () => () => [{ id: 'node-1', title: 'Test folder' }],
};

Expand Down Expand Up @@ -132,9 +131,7 @@ describe('SearchOrBrowseWindow', () => {
actions: {
showSnackbar: actions.showSnackbar,
},
getters: {
isAIFeatureEnabled: getters.isAIFeatureEnabled,
},
getters: {},
});

const routes = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { Session, User } from 'shared/data/resources';
import { forceServerSync } from 'shared/data/serverSync';
import translator from 'shared/translator';
import { applyMods } from 'shared/data/applyRemoteChanges';
import { FeatureFlagKeys } from 'shared/constants';

function langCode(language) {
// Turns a Django language name (en-gb) into an ISO language code (en-GB)
Expand Down Expand Up @@ -95,12 +94,6 @@ export default {
return getters.isAdmin || Boolean(getters.featureFlags[flag]);
};
},
isAIFeatureEnabled(state, getters) {
if (getters.loggedIn) {
return getters.hasFeatureEnabled(FeatureFlagKeys.ai_feature);
}
return false;
},
},
actions: {
saveSession(context, currentUser) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import vuexSessionModule from './index.js';
import { FeatureFlagKeys } from 'shared/constants';

describe('session module feature flag related getters', () => {
let state;
Expand All @@ -12,7 +11,6 @@ describe('session module feature flag related getters', () => {
},
},
};
state.currentUser.feature_flags[FeatureFlagKeys.ai_feature] = true;
});

describe('featureFlags', () => {
Expand Down Expand Up @@ -54,31 +52,4 @@ describe('session module feature flag related getters', () => {
expect(getters.hasFeatureEnabled(state, getters)('false_flag')).toBe(false);
});
});

describe('isAIFeatureEnabled', () => {
let getters;
beforeEach(() => {
getters = {
loggedIn: true,
hasFeatureEnabled: vuexSessionModule.getters.hasFeatureEnabled(state, {
featureFlags: vuexSessionModule.getters.featureFlags(state),
isAdmin: false,
}),
isAIFeatureEnabled: vuexSessionModule.getters.isAIFeatureEnabled,
};
});
it('should return false if not logged in', () => {
getters.loggedIn = false;
expect(getters.isAIFeatureEnabled(state, getters)).toBe(false);
});

it('should return true if logged in and ai feature flag is true', () => {
expect(getters.isAIFeatureEnabled(state, getters)).toBe(true);
});

it('should return false if logged in and ai feature flag is false', () => {
state.currentUser.feature_flags[FeatureFlagKeys.ai_feature] = false;
expect(getters.isAIFeatureEnabled(state, getters)).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import csv
import logging
import time

from django.core.management.base import BaseCommand
from django.db.models import Exists
from django.db.models import FilteredRelation
from django.db.models import OuterRef
from django.db.models import Q
from django.db.models.expressions import F
from django_cte import With

from contentcuration.models import Channel
from contentcuration.models import ContentNode


logger = logging.getLogger(__name__)


class Command(BaseCommand):
"""
Audits nodes that have imported content from public channels and whether the imported content
has a missing source node.

TODO: this does not yet FIX them
"""

def handle(self, *args, **options):
start = time.time()

public_cte = self.get_public_cte()

# preliminary filter on channels to those private and non-deleted, which have content
# lft=1 is always true for root nodes, so rght>2 means it actually has children
private_channels_cte = With(
Channel.objects.filter(
public=False,
deleted=False,
)
.annotate(
non_empty_main_tree=FilteredRelation(
"main_tree", condition=Q(main_tree__rght__gt=2)
),
)
.annotate(
tree_id=F("non_empty_main_tree__tree_id"),
)
.values("id", "name", "tree_id"),
name="dest_channel_cte",
)

# reduce the list of private channels to those that have an imported node
# from a public channel
destination_channels = (
private_channels_cte.queryset()
.with_cte(public_cte)
.with_cte(private_channels_cte)
.filter(
Exists(
public_cte.join(
ContentNode.objects.filter(
tree_id=OuterRef("tree_id"),
),
original_channel_id=public_cte.col.id,
)
)
)
.values("id", "name", "tree_id")
.order_by("id")
)

logger.info("=== Iterating over private destination channels. ===")
channel_count = 0
total_node_count = 0

with open("fix_missing_import_sources.csv", "w", newline="") as csv_file:
csv_writer = csv.DictWriter(
csv_file,
fieldnames=[
"channel_id",
"channel_name",
"contentnode_id",
"contentnode_title",
"public_channel_id",
"public_channel_name",
"public_channel_deleted",
],
)
csv_writer.writeheader()

for channel in destination_channels.iterator():
node_count = self.handle_channel(csv_writer, channel)

if node_count > 0:
total_node_count += node_count
channel_count += 1

logger.info("=== Done iterating over private destination channels. ===")
logger.info(f"Found {total_node_count} nodes across {channel_count} channels.")
logger.info(f"Finished in {time.time() - start}")

def get_public_cte(self) -> With:
# This CTE gets all public channels with their main tree info
return With(
Channel.objects.filter(public=True)
.annotate(
tree_id=F("main_tree__tree_id"),
)
.values("id", "name", "deleted", "tree_id"),
name="public_cte",
)

def handle_channel(self, csv_writer: csv.DictWriter, channel: dict) -> int:
public_cte = self.get_public_cte()
channel_id = channel["id"]
channel_name = channel["name"]
tree_id = channel["tree_id"]

missing_source_nodes = (
public_cte.join(
ContentNode.objects.filter(tree_id=tree_id),
original_channel_id=public_cte.col.id,
)
.with_cte(public_cte)
.annotate(
public_channel_id=public_cte.col.id,
public_channel_name=public_cte.col.name,
public_channel_deleted=public_cte.col.deleted,
)
.filter(
Q(public_channel_deleted=True)
| ~Exists(
ContentNode.objects.filter(
tree_id=public_cte.col.tree_id,
node_id=OuterRef("original_source_node_id"),
)
)
)
.values(
"public_channel_id",
"public_channel_name",
"public_channel_deleted",
contentnode_id=F("id"),
contentnode_title=F("title"),
)
)

# Count and log results
node_count = missing_source_nodes.count()

# TODO: this will be replaced with logic to correct the missing source nodes
if node_count > 0:
logger.info(
f"{channel_id}:{channel_name}\t{node_count} node(s) with missing source nodes."
)
row_dict = {
"channel_id": channel_id,
"channel_name": channel_name,
}
for node_dict in missing_source_nodes.iterator():
row_dict.update(node_dict)
csv_writer.writerow(row_dict)

return node_count
11 changes: 9 additions & 2 deletions contentcuration/contentcuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from django.utils import timezone
from django.utils.translation import gettext as _
from django_cte import CTEManager
from django_cte import CTEQuerySet
from django_cte import With
from le_utils import proquint
from le_utils.constants import content_kinds
Expand Down Expand Up @@ -837,7 +838,7 @@ def exists(self, *filters):
return Exists(self.queryset().filter(*filters).values("user_id"))


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


class ChannelModelManager(models.Manager.from_queryset(ChannelModelQuerySet)):
"""Custom Channel models manager with CTE support"""

pass


class Channel(models.Model):
""" Permissions come from association with organizations """

Expand Down Expand Up @@ -994,7 +1001,7 @@ class Channel(models.Model):
]
)

objects = ChannelModelQuerySet.as_manager()
objects = ChannelModelManager()

@classmethod
def get_editable(cls, user, channel_id):
Expand Down
5 changes: 0 additions & 5 deletions contentcuration/contentcuration/static/feature_flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@
"description": "This no-op feature flag is excluded from non-dev environments",
"$env": "development"
},
"ai_feature":{
"type": "boolean",
"title":"Test AI feature",
"description": "Allow user access to AI features"
},
"survey":{
"type": "boolean",
"title":"Test Survey feature",
Expand Down
Empty file.
Empty file.
Loading