diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue index 3fe8ae61ec..6bc99d0df0 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue @@ -60,6 +60,7 @@ id="learning_activities" ref="learning_activities" v-model="contentLearningActivities" + :disabled="isTopic" @focus="trackClick('Learning activities')" /> @@ -127,7 +128,7 @@ - +

{{ $tr('completionLabel') }} @@ -513,7 +514,9 @@ return this.firstNode.original_channel_name; }, requiresAccessibility() { - return this.nodes.every(node => node.kind !== ContentKindsNames.AUDIO); + return this.nodes.every( + node => node.kind !== ContentKindsNames.AUDIO && node.kind !== ContentKindsNames.TOPIC + ); }, audioAccessibility() { return this.oneSelected && this.firstNode.kind === 'audio'; @@ -658,7 +661,7 @@ } }, videoSelected() { - return this.oneSelected && this.firstNode.kind === 'video'; + return this.oneSelected && this.firstNode.kind === ContentKindsNames.VIDEO; }, newContent() { return !this.nodes.some(n => n[NEW_OBJECT]); @@ -667,7 +670,10 @@ return this.$store.getters.hasFeatureEnabled(FeatureFlagKeys.channel_quizzes); }, isDocument() { - return this.firstNode.kind === 'document'; + return this.firstNode.kind === ContentKindsNames.DOCUMENT; + }, + isTopic() { + return this.firstNode.kind === ContentKindsNames.TOPIC; }, }, watch: { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/LearningActivityOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/LearningActivityOptions.vue index 457b799e29..23b9834e24 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/LearningActivityOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/LearningActivityOptions.vue @@ -4,6 +4,7 @@ [], }, + disabled: { + type: Boolean, + default: false, + }, }, computed: { learningActivity: { get() { - return this.value; + if (!this.disabled) { + return this.value; + } + return null; }, set(value) { - this.$emit('input', value); + if (!this.disabled) { + this.$emit('input', value); + } }, }, learningActivities() { @@ -50,7 +60,7 @@ })); }, learningActivityRules() { - return getLearningActivityValidators().map(translateValidator); + return this.disabled ? [] : getLearningActivityValidators().map(translateValidator); }, }, }; diff --git a/contentcuration/contentcuration/tests/test_exportchannel.py b/contentcuration/contentcuration/tests/test_exportchannel.py index f3c4a0e905..066792e220 100644 --- a/contentcuration/contentcuration/tests/test_exportchannel.py +++ b/contentcuration/contentcuration/tests/test_exportchannel.py @@ -12,6 +12,12 @@ from kolibri_content import models as kolibri_models from kolibri_content.router import get_active_content_database from kolibri_content.router import set_active_content_database +from le_utils.constants.labels import accessibility_categories +from le_utils.constants.labels import learning_activities +from le_utils.constants.labels import levels +from le_utils.constants.labels import needs +from le_utils.constants.labels import resource_type +from le_utils.constants.labels import subjects from mock import patch from .base import StudioTestCase @@ -21,7 +27,6 @@ from .testdata import slideshow from contentcuration import models as cc from contentcuration.utils.publish import convert_channel_thumbnail -from contentcuration.utils.publish import create_bare_contentnode from contentcuration.utils.publish import create_content_database from contentcuration.utils.publish import create_slideshow_manifest from contentcuration.utils.publish import fill_published_fields @@ -91,6 +96,42 @@ def setUp(self): new_video.parent = self.content_channel.main_tree new_video.save() + first_topic = self.content_channel.main_tree.get_descendants().first() + first_topic.accessibility_labels = { + accessibility_categories.AUDIO_DESCRIPTION: True, + } + first_topic.learning_activities = { + learning_activities.WATCH: True, + } + first_topic.grade_levels = { + levels.LOWER_SECONDARY: True, + } + first_topic.learner_needs = { + needs.PRIOR_KNOWLEDGE: True, + } + first_topic.resource_types = { + resource_type.LESSON_PLAN: True, + } + first_topic.categories = { + subjects.MATHEMATICS: True, + } + first_topic.save() + + first_topic_first_child = first_topic.children.first() + first_topic_first_child.accessibility_labels = { + accessibility_categories.CAPTIONS_SUBTITLES: True, + } + first_topic_first_child.categories = { + subjects.ALGEBRA: True, + } + first_topic_first_child.learner_needs = { + needs.FOR_BEGINNERS: True, + } + first_topic_first_child.learning_activities = { + learning_activities.LISTEN: True, + } + first_topic_first_child.save() + set_channel_icon_encoding(self.content_channel) self.tempdb = create_content_database(self.content_channel, True, self.admin_user.id, True) @@ -189,6 +230,58 @@ def test_assessment_metadata(self): self.assertTrue(isinstance(json.loads(asm.assessment_item_ids), list)) self.assertTrue(isinstance(json.loads(asm.mastery_model), dict)) + def test_inherited_category(self): + first_topic_node_id = self.content_channel.main_tree.get_descendants().first().node_id + for child in kolibri_models.ContentNode.objects.filter(parent_id=first_topic_node_id)[1:]: + self.assertEqual(child.categories, subjects.MATHEMATICS) + + def test_inherited_category_no_overwrite(self): + first_topic_node_id = self.content_channel.main_tree.get_descendants().first().node_id + first_child = kolibri_models.ContentNode.objects.filter(parent_id=first_topic_node_id).first() + self.assertEqual(first_child.categories, subjects.ALGEBRA) + + def test_inherited_needs(self): + first_topic_node_id = self.content_channel.main_tree.get_descendants().first().node_id + for child in kolibri_models.ContentNode.objects.filter(parent_id=first_topic_node_id)[1:]: + self.assertEqual(child.learner_needs, needs.PRIOR_KNOWLEDGE) + + def test_inherited_needs_no_overwrite(self): + first_topic_node_id = self.content_channel.main_tree.get_descendants().first().node_id + first_child = kolibri_models.ContentNode.objects.filter(parent_id=first_topic_node_id).first() + self.assertEqual(first_child.learner_needs, needs.FOR_BEGINNERS) + + def test_topics_no_accessibility_label(self): + first_topic_node_id = self.content_channel.main_tree.get_descendants().first().node_id + topic = kolibri_models.ContentNode.objects.get(id=first_topic_node_id) + self.assertIsNone(topic.accessibility_labels) + + def test_child_no_inherit_accessibility_label(self): + first_topic_node_id = self.content_channel.main_tree.get_descendants().first().node_id + first_child = kolibri_models.ContentNode.objects.filter(parent_id=first_topic_node_id).first() + # Should only be the learning activities we set on the child directly, not any parent ones. + self.assertEqual(first_child.accessibility_labels, accessibility_categories.CAPTIONS_SUBTITLES) + + def test_inherited_grade_levels(self): + first_topic_node_id = self.content_channel.main_tree.get_descendants().first().node_id + for child in kolibri_models.ContentNode.objects.filter(parent_id=first_topic_node_id): + self.assertEqual(child.grade_levels, levels.LOWER_SECONDARY) + + def test_inherited_resource_types(self): + first_topic_node_id = self.content_channel.main_tree.get_descendants().first().node_id + for child in kolibri_models.ContentNode.objects.filter(parent_id=first_topic_node_id): + self.assertEqual(child.resource_types, resource_type.LESSON_PLAN) + + def test_topics_no_learning_activity(self): + first_topic_node_id = self.content_channel.main_tree.get_descendants().first().node_id + topic = kolibri_models.ContentNode.objects.get(id=first_topic_node_id) + self.assertIsNone(topic.learning_activities) + + def test_child_no_inherit_learning_activity(self): + first_topic_node_id = self.content_channel.main_tree.get_descendants().first().node_id + first_child = kolibri_models.ContentNode.objects.filter(parent_id=first_topic_node_id).first() + # Should only be the learning activities we set on the child directly, not any parent ones. + self.assertEqual(first_child.learning_activities, learning_activities.LISTEN) + class ChannelExportUtilityFunctionTestCase(StudioTestCase): @classmethod @@ -240,10 +333,8 @@ def test_convert_channel_thumbnail_encoding_invalid(self): self.assertEqual("this is a test", convert_channel_thumbnail(channel)) def test_create_slideshow_manifest(self): - content_channel = cc.Channel.objects.create() ccnode = cc.ContentNode.objects.create(kind_id=slideshow(), extra_fields={}, complete=True) - kolibrinode = create_bare_contentnode(ccnode, ccnode.language, content_channel.id, content_channel.name) - create_slideshow_manifest(ccnode, kolibrinode) + create_slideshow_manifest(ccnode) manifest_collection = cc.File.objects.filter(contentnode=ccnode, preset_id=u"slideshow_manifest") assert len(manifest_collection) == 1 diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 644bd77795..b9362c08c6 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -1,6 +1,5 @@ from __future__ import division -import collections import itertools import json import logging as logmodule @@ -118,7 +117,7 @@ def create_content_database(channel, force, user_id, force_exercises, progress_t no_input=True) if progress_tracker: progress_tracker.track(10) - map_content_nodes( + tree_mapper = TreeMapper( channel.main_tree, channel.language, channel.id, @@ -127,6 +126,7 @@ def create_content_database(channel, force, user_id, force_exercises, progress_t force_exercises=force_exercises, progress_tracker=progress_tracker, ) + tree_mapper.map_nodes() map_channel_to_kolibri_channel(channel) # It should be at this percent already, but just in case. if progress_tracker: @@ -154,63 +154,83 @@ def assign_license_to_contentcuration_nodes(channel, license): channel.main_tree.get_family().update(license_id=license.pk) -def map_content_nodes( # noqa: C901 - root_node, - default_language, - channel_id, - channel_name, - user_id=None, - force_exercises=False, - progress_tracker=None, -): - """ - :type progress_tracker: contentcuration.utils.celery.ProgressTracker|None - """ - # make sure we process nodes higher up in the tree first, or else when we - # make mappings the parent nodes might not be there - - if not root_node.complete: - raise ValueError("Attempted to publish a channel with an incomplete root node") - - node_queue = collections.deque() - node_queue.append(root_node) - - task_percent_total = 80.0 - total_nodes = root_node.get_descendant_count() + 1 # make sure we include root_node - percent_per_node = old_div(task_percent_total, total_nodes) - - def queue_get_return_none_when_empty(): - try: - return node_queue.popleft() - except IndexError: - return None - - with ccmodels.ContentNode.objects.delay_mptt_updates(), kolibrimodels.ContentNode.objects.delay_mptt_updates(): - for node in iter(queue_get_return_none_when_empty, None): - logging.debug("Mapping node with id {id}".format( - id=node.pk)) - - if node.get_descendants(include_self=True).exclude(kind_id=content_kinds.TOPIC).exists() and node.complete: - children = (node.children.all()) - node_queue.extend(children) - - kolibrinode = create_bare_contentnode(node, default_language, channel_id, channel_name) - - if node.kind.kind == content_kinds.EXERCISE: - exercise_data = process_assessment_metadata(node, kolibrinode) - if force_exercises or node.changed or not \ - node.files.filter(preset_id=format_presets.EXERCISE).exists(): - create_perseus_exercise(node, kolibrinode, exercise_data, user_id=user_id) - elif node.kind.kind == content_kinds.SLIDESHOW: - create_slideshow_manifest(node, kolibrinode, user_id=user_id) - create_associated_file_objects(kolibrinode, node) - map_tags_to_node(kolibrinode, node) - - if progress_tracker: - progress_tracker.increment(increment=percent_per_node) - - -def create_slideshow_manifest(ccnode, kolibrinode, user_id=None): +inheritable_fields = [ + "grade_levels", + "resource_types", + "categories", + "learner_needs", +] + + +class TreeMapper: + def __init__( + self, + root_node, + default_language, + channel_id, + channel_name, + user_id=None, + force_exercises=False, + progress_tracker=None, + ): + if not root_node.complete: + raise ValueError("Attempted to publish a channel with an incomplete root node") + + self.root_node = root_node + task_percent_total = 80.0 + total_nodes = root_node.get_descendant_count() + 1 # make sure we include root_node + self.percent_per_node = old_div(task_percent_total, total_nodes) + self.progress_tracker = progress_tracker + self.default_language = default_language + self.channel_id = channel_id + self.channel_name = channel_name + self.user_id = user_id + self.force_exercises = force_exercises + + def _node_completed(self): + if self.progress_tracker: + self.progress_tracker.increment(increment=self.percent_per_node) + + def map_nodes(self): + self.recurse_nodes(self.root_node, {}) + + def recurse_nodes(self, node, inherited_fields): + logging.debug("Mapping node with id {id}".format(id=node.pk)) + + # Only process nodes that are either non-topics or have non-topic descendants + if node.get_descendants(include_self=True).exclude(kind_id=content_kinds.TOPIC).exists() and node.complete: + + metadata = {} + + for field in inheritable_fields: + metadata[field] = {} + inherited_keys = (inherited_fields.get(field) or {}).keys() + own_keys = (getattr(node, field) or {}).keys() + # Get a list of all keys in reverse order of length so we can remove any less specific values + all_keys = sorted(set(inherited_keys).union(set(own_keys)), key=len, reverse=True) + for key in all_keys: + if not any(k != key and k.startswith(key) for k in all_keys): + metadata[field][key] = True + + kolibrinode = create_bare_contentnode(node, self.default_language, self.channel_id, self.channel_name, metadata) + + if node.kind.kind == content_kinds.EXERCISE: + exercise_data = process_assessment_metadata(node, kolibrinode) + if self.force_exercises or node.changed or not \ + node.files.filter(preset_id=format_presets.EXERCISE).exists(): + create_perseus_exercise(node, kolibrinode, exercise_data, user_id=self.user_id) + elif node.kind.kind == content_kinds.SLIDESHOW: + create_slideshow_manifest(node, user_id=self.user_id) + elif node.kind_id == content_kinds.TOPIC: + for child in node.children.all(): + self.recurse_nodes(child, metadata) + create_associated_file_objects(kolibrinode, node) + map_tags_to_node(kolibrinode, node) + + self._node_completed() + + +def create_slideshow_manifest(ccnode, user_id=None): print("Creating slideshow manifest...") preset = ccmodels.FormatPreset.objects.filter(pk="slideshow_manifest")[0] @@ -240,7 +260,7 @@ def create_slideshow_manifest(ccnode, kolibrinode, user_id=None): temp_manifest.close() -def create_bare_contentnode(ccnode, default_language, channel_id, channel_name): +def create_bare_contentnode(ccnode, default_language, channel_id, channel_name, metadata): # noqa: C901 logging.debug("Creating a Kolibri contentnode for instance id {}".format( ccnode.node_id)) @@ -270,6 +290,14 @@ def create_bare_contentnode(ccnode, default_language, channel_id, channel_name): # aggregate duration from associated files, choosing maximum if there are multiple, like hi and lo res videos. duration = ccnode.files.aggregate(duration=Max("duration")).get("duration") + learning_activities = None + accessibility_labels = None + if ccnode.kind_id != content_kinds.TOPIC: + if ccnode.learning_activities: + learning_activities = ",".join(ccnode.learning_activities.keys()) + if ccnode.accessibility_labels: + accessibility_labels = ",".join(ccnode.accessibility_labels.keys()) + kolibrinode, is_new = kolibrimodels.ContentNode.objects.update_or_create( pk=ccnode.node_id, defaults={ @@ -291,12 +319,12 @@ def create_bare_contentnode(ccnode, default_language, channel_id, channel_name): 'duration': duration, 'options': json.dumps(options), # Fields for metadata labels - "grade_levels": ",".join(ccnode.grade_levels.keys()) if ccnode.grade_levels else None, - "resource_types": ",".join(ccnode.resource_types.keys()) if ccnode.resource_types else None, - "learning_activities": ",".join(ccnode.learning_activities.keys()) if ccnode.learning_activities else None, - "accessibility_labels": ",".join(ccnode.accessibility_labels.keys()) if ccnode.accessibility_labels else None, - "categories": ",".join(ccnode.categories.keys()) if ccnode.categories else None, - "learner_needs": ",".join(ccnode.learner_needs.keys()) if ccnode.learner_needs else None, + "grade_levels": ",".join(metadata["grade_levels"].keys()) if metadata["grade_levels"] else None, + "resource_types": ",".join(metadata["resource_types"].keys()) if metadata["resource_types"] else None, + "learning_activities": learning_activities, + "accessibility_labels": accessibility_labels, + "categories": ",".join(metadata["categories"].keys()) if metadata["categories"] else None, + "learner_needs": ",".join(metadata["learner_needs"].keys()) if metadata["learner_needs"] else None, } )