diff --git a/kolibri/core/assets/src/api-resources/contentNode.js b/kolibri/core/assets/src/api-resources/contentNode.js index b9490e5c42..11690ae3dc 100644 --- a/kolibri/core/assets/src/api-resources/contentNode.js +++ b/kolibri/core/assets/src/api-resources/contentNode.js @@ -89,9 +89,6 @@ export default new Resource({ fetchDescendantsAssessments(ids) { return this.getListEndpoint('descendants_assessments', { ids }); }, - fetchNodeAssessments(ids) { - return this.getListEndpoint('node_assessments', { ids }); - }, fetchRecommendationsFor(id, getParams) { return this.fetchDetailCollection('recommendations_for', id, getParams); }, diff --git a/kolibri/core/assets/src/constants.js b/kolibri/core/assets/src/constants.js index d6da17ff4d..0724341583 100644 --- a/kolibri/core/assets/src/constants.js +++ b/kolibri/core/assets/src/constants.js @@ -194,3 +194,7 @@ export const Presets = Object.freeze({ FORMAL: 'formal', NONFORMAL: 'nonformal', }); + +// This should be kept in sync with the value in +// kolibri/core/exams/constants.py +export const MAX_QUESTIONS_PER_QUIZ_SECTION = 50; diff --git a/kolibri/core/assets/src/exams/utils.js b/kolibri/core/assets/src/exams/utils.js index 5b48f57dac..0a164939e0 100644 --- a/kolibri/core/assets/src/exams/utils.js +++ b/kolibri/core/assets/src/exams/utils.js @@ -1,6 +1,6 @@ import uniq from 'lodash/uniq'; import some from 'lodash/some'; -import { v4 as uuidv4 } from 'uuid'; +import { MAX_QUESTIONS_PER_QUIZ_SECTION } from 'kolibri.coreVue.vuex.constants'; import { ExamResource, ContentNodeResource } from 'kolibri.resources'; /* @@ -96,17 +96,26 @@ function annotateQuestionsWithItem(questions) { export function convertExamQuestionSourcesV2toV3({ question_sources, learners_see_fixed_order }) { // In V2, question_sources are questions so we add them // to the newly created section's `questions` property - const questions = question_sources; - return [ - { - section_id: uuidv4(), + const questions = question_sources.map(item => { + return { + ...item, + // Overwrite the exercise title as the question title + // is user editable in the V3 schema, so we set it to + // blank to indicate it has not been set by an editor. + title: '', + }; + }); + const sections = []; + + while (questions.length > 0) { + sections.push({ section_title: '', description: '', - questions, + questions: questions.splice(0, MAX_QUESTIONS_PER_QUIZ_SECTION), learners_see_fixed_order, - question_count: questions.length, - }, - ]; + }); + } + return sections; } /** diff --git a/kolibri/core/assets/src/objectSpecs.js b/kolibri/core/assets/src/objectSpecs.js index 1bfb6a764b..95df4dfaaf 100644 --- a/kolibri/core/assets/src/objectSpecs.js +++ b/kolibri/core/assets/src/objectSpecs.js @@ -186,8 +186,7 @@ export function objectWithDefaults(object, spec) { // set defaults if necessary if (isUndefined(cloned[dataKey]) && !isUndefined(options.default)) { // arrays and objects need to use a function to return defaults - const needsFunction = options.type === Array || options.type === Object; - if (needsFunction && options.default !== null) { + if (options.default instanceof Function) { cloned[dataKey] = options.default(); } // all other types can be assigned directly diff --git a/kolibri/core/assets/test/exams/utils.spec.js b/kolibri/core/assets/test/exams/utils.spec.js index 57c4c1ad26..d261ff80ab 100644 --- a/kolibri/core/assets/test/exams/utils.spec.js +++ b/kolibri/core/assets/test/exams/utils.spec.js @@ -1,5 +1,6 @@ import map from 'lodash/map'; import { ContentNodeResource } from 'kolibri.resources'; +import { MAX_QUESTIONS_PER_QUIZ_SECTION } from 'kolibri.coreVue.vuex.constants'; import { convertExamQuestionSources } from '../../src/exams/utils'; // map of content IDs to lists of question IDs @@ -116,21 +117,21 @@ describe('exam utils', () => { { exercise_id: 'E1', question_id: 'Q1', - title: 'Question 1', + title: '', counter_in_exercise: 1, item: 'E1:Q1', }, { exercise_id: 'E1', question_id: 'Q2', - title: 'Question 2', + title: '', counter_in_exercise: 2, item: 'E1:Q2', }, { exercise_id: 'E2', question_id: 'Q1', - title: 'Question 1', + title: '', counter_in_exercise: 1, item: 'E2:Q1', }, @@ -139,16 +140,12 @@ describe('exam utils', () => { it('returns an array of newly structured objects with old question sources in questions', async () => { const converted = await convertExamQuestionSources(exam); - // The section id is randomly generated so just test that it is there and is set on the object - expect(converted.question_sources[0].section_id).toBeTruthy(); expect(converted.question_sources).toEqual([ { - section_id: converted.question_sources[0].section_id, section_title: '', description: '', questions: expectedSources.sort(), learners_see_fixed_order: true, - question_count: 3, }, ]); }); @@ -163,17 +160,15 @@ describe('exam utils', () => { const converted = await convertExamQuestionSources(exam); expect(converted.question_sources).toEqual([ { - section_id: converted.question_sources[0].section_id, section_title: '', description: '', questions: expectedSources.sort(), learners_see_fixed_order: false, - question_count: 3, // There are only 3 questions in the question_sources }, ]); }); }); - describe('convertExamQuestionSources converting from V1 to V2', () => { + describe('convertExamQuestionSources converting from V1 to V3', () => { it('returns a question_sources array with a counter_in_exercise field', async () => { const exam = { data_model_version: 1, @@ -200,21 +195,21 @@ describe('exam utils', () => { { exercise_id: 'E1', question_id: 'Q1', - title: 'Question 1', + title: '', counter_in_exercise: 1, item: 'E1:Q1', }, { exercise_id: 'E1', question_id: 'Q2', - title: 'Question 2', + title: '', counter_in_exercise: 2, item: 'E1:Q2', }, { exercise_id: 'E2', question_id: 'Q1', - title: 'Question 1', + title: '', counter_in_exercise: 1, item: 'E2:Q1', }, @@ -244,22 +239,43 @@ describe('exam utils', () => { { question_id: 'Q1', exercise_id: 'E1', - title: 'Question 1', + title: '', counter_in_exercise: 4000, item: 'E1:Q1', }, { question_id: 'Q1', exercise_id: 'E2', - title: 'Question 2', + title: '', counter_in_exercise: 1, item: 'E2:Q1', }, ]); }); + it('renames creates multiple sections if the questions are longer than MAX_QUESTIONS_PER_QUIZ_SECTION', async () => { + const question_sources = []; + for (let i = 0; i < MAX_QUESTIONS_PER_QUIZ_SECTION + 1; i++) { + question_sources.push({ + exercise_id: 'E1', + question_id: `Q${i}`, + title: `Question ${i + 1}`, + counter_in_exercise: i + 1, + }); + } + const exam = { + data_model_version: 1, + question_sources, + }; + const converted = await convertExamQuestionSources(exam); + expect(converted.question_sources.length).toEqual(2); + expect(converted.question_sources[0].questions.length).toEqual( + MAX_QUESTIONS_PER_QUIZ_SECTION + ); + expect(converted.question_sources[1].questions.length).toEqual(1); + }); }); - describe('convertExamQuestionSources converting from V0 to V2', () => { + describe('convertExamQuestionSources converting from V0 to V3', () => { it('should return 10 specific ordered questions from 3 exercises', async () => { const exam = { data_model_version: 0, @@ -303,61 +319,61 @@ describe('exam utils', () => { counter_in_exercise: 16, question_id: 'fc60ecb9f83f505fa31e734e517e6523', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 13, question_id: 'd4623921a2ef5ddaa39048c0f7a6fe06', exercise_id: 'b186a2a3ae8e51dd867614db03eb3783', - title: 'Find 1 more or 1 less than a number', + title: '', }, { counter_in_exercise: 6, question_id: '2f5fdbc49ce35310abf49971867ac94e', exercise_id: 'b186a2a3ae8e51dd867614db03eb3783', - title: 'Find 1 more or 1 less than a number', + title: '', }, { counter_in_exercise: 15, question_id: '19421254d90d520d981bd07fd6ede9b2', exercise_id: 'b9444e7d11395946b2e14edb5dc4670f', - title: 'Count in order', + title: '', }, { counter_in_exercise: 1, question_id: '1e0ce47a58465b2cb298acd3b893dce5', exercise_id: 'b9444e7d11395946b2e14edb5dc4670f', - title: 'Count in order', + title: '', }, { counter_in_exercise: 10, question_id: 'd3ac055fa4ad599bbd30a00eaeb93e5e', exercise_id: 'b186a2a3ae8e51dd867614db03eb3783', - title: 'Find 1 more or 1 less than a number', + title: '', }, { counter_in_exercise: 1, question_id: 'a5f508eb2ba05d429812dc43b577ef03', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 17, question_id: '8358fbbd0a285e9d99da558094eabd4c', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 2, question_id: '3aeb023925e35001865091de1fb4d3ae', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 3, question_id: 'a654dec351af5bdf937566e46b7c2fc3', exercise_id: 'b9444e7d11395946b2e14edb5dc4670f', - title: 'Count in order', + title: '', }, ]; @@ -386,61 +402,61 @@ describe('exam utils', () => { counter_in_exercise: 20, question_id: 'fc5958e2a67d5cd2bd48962e9e1c35c3', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 3, question_id: 'beb5eae9491c564fb6bc5b9c1421d085', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 2, question_id: '3aeb023925e35001865091de1fb4d3ae', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 17, question_id: '8358fbbd0a285e9d99da558094eabd4c', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 6, question_id: '4dd2526065ee572998a06b1fdae9cb4b', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 5, question_id: '10e6239f9cf35b75b0cf75ca7f7e6a14', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 12, question_id: 'e92a277052cf56e2aaae44338fa0bfec', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 9, question_id: '6ef7996754de54ad92b660d06436976c', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 4, question_id: 'b19341ebca9a5bdb817cdc31b8d62993', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 8, question_id: 'e226724765085214acfb807942918fed', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, ]; expect(converted.question_sources[0].questions).toEqual( @@ -478,19 +494,19 @@ describe('exam utils', () => { counter_in_exercise: 9, question_id: '6ef7996754de54ad92b660d06436976c', exercise_id: '69e5e6abf479581483d441b83d7d76f4', - title: 'Count with small numbers', + title: '', }, { counter_in_exercise: 2, question_id: '952857e446b95c5da36226f59237ffcc', exercise_id: 'b9444e7d11395946b2e14edb5dc4670f', - title: 'Count in order', + title: '', }, { counter_in_exercise: 7, question_id: '5a56a46b261d5ff7b3f870cf09c6952f', exercise_id: 'b186a2a3ae8e51dd867614db03eb3783', - title: 'Find 1 more or 1 less than a number', + title: '', }, ]; expect(converted.question_sources[0].questions).toEqual( diff --git a/kolibri/core/content/api.py b/kolibri/core/content/api.py index b6685c7187..7f44c67b7e 100644 --- a/kolibri/core/content/api.py +++ b/kolibri/core/content/api.py @@ -12,7 +12,6 @@ from django.db.models import OuterRef from django.db.models import Q from django.db.models import Subquery -from django.db.models import Sum from django.db.models.aggregates import Count from django.http import Http404 from django.utils.cache import add_never_cache_headers @@ -253,15 +252,18 @@ def list(self, request, *args, **kwargs): class ChannelMetadataFilter(FilterSet): available = BooleanFilter(method="filter_available", label="Available") - has_exercise = BooleanFilter(method="filter_has_exercise", label="Has exercises") + contains_exercise = BooleanFilter( + method="filter_contains_exercise", label="Has exercises" + ) + contains_quiz = BooleanFilter(method="filter_contains_quiz", label="Has quizzes") class Meta: model = models.ChannelMetadata - fields = ("available", "has_exercise") + fields = ("available", "contains_exercise", "contains_quiz") - def filter_has_exercise(self, queryset, name, value): + def filter_contains_exercise(self, queryset, name, value): queryset = queryset.annotate( - has_exercise=Exists( + contains_exercise=Exists( models.ContentNode.objects.filter( kind=content_kinds.EXERCISE, available=True, @@ -270,7 +272,20 @@ def filter_has_exercise(self, queryset, name, value): ) ) - return queryset.filter(has_exercise=True) + return queryset.filter(contains_exercise=True) + + def filter_contains_quiz(self, queryset, name, value): + queryset = queryset.annotate( + contains_quiz=Exists( + models.ContentNode.objects.filter( + options__contains='"modality": "QUIZ"', + available=True, + channel_id=OuterRef("id"), + ) + ) + ) + + return queryset.filter(contains_quiz=True) def filter_available(self, queryset, name, value): return queryset.filter(root__available=value) @@ -363,16 +378,6 @@ class ChannelMetadataViewSet(BaseChannelMetadataMixin, RemoteViewSet): pass -class IdFilter(FilterSet): - ids = CharFilter(method="filter_ids") - - def filter_ids(self, queryset, name, value): - return queryset.filter_by_uuids(value.split(",")) - - class Meta: - fields = ["ids"] - - MODALITIES = set(["QUIZ"]) @@ -413,7 +418,8 @@ class CharInFilter(BaseInFilter, CharFilter): ] -class ContentNodeFilter(IdFilter): +class ContentNodeFilter(FilterSet): + ids = UUIDInFilter(method="filter_ids") kind = ChoiceFilter( method="filter_kind", choices=(content_kinds.choices + (("content", _("Resource")),)), @@ -444,6 +450,9 @@ class Meta: model = models.ContentNode fields = contentnode_filter_fields + def filter_ids(self, queryset, name, value): + return queryset.filter_by_uuids(value) + def filter_by_authors(self, queryset, name, value): """ Show content filtered by author @@ -863,9 +872,8 @@ def descendants(self, request): ids = self.request.query_params.get("ids", None) if not ids: return Response([]) - ids = ids.split(",") kind = self.request.query_params.get("descendant_kind", None) - nodes = models.ContentNode.objects.filter_by_uuids(ids).filter(available=True) + nodes = self.filter_queryset(self.get_queryset()) data = [] for node in nodes: @@ -887,10 +895,7 @@ def descendants_assessments(self, request): ids = self.request.query_params.get("ids", None) if not ids: return Response([]) - ids = ids.split(",") - queryset = models.ContentNode.objects.filter_by_uuids(ids).filter( - available=True - ) + queryset = self.filter_queryset(self.get_queryset()) data = list( queryset.annotate( num_assessments=SQSum( @@ -909,24 +914,6 @@ def descendants_assessments(self, request): ) return Response(data) - @action(detail=False) - def node_assessments(self, request): - ids = self.request.query_params.get("ids", "").split(",") - data = 0 - if ids and ids[0]: - nodes = ( - models.ContentNode.objects.filter_by_uuids(ids) - .filter(available=True) - .prefetch_related("assessmentmetadata") - ) - data = ( - nodes.aggregate(Sum("assessmentmetadata__number_of_assessments"))[ - "assessmentmetadata__number_of_assessments__sum" - ] - or 0 - ) - return Response(data) - @action(detail=True) def recommendations_for(self, request, **kwargs): """ diff --git a/kolibri/core/content/test/test_content_app.py b/kolibri/core/content/test/test_content_app.py index 21ffc5711b..f17e078f42 100644 --- a/kolibri/core/content/test/test_content_app.py +++ b/kolibri/core/content/test/test_content_app.py @@ -1142,34 +1142,6 @@ def test_contentnode_descendants_availability(self): ) self.assertEqual(len(response.data), 0) - def test_contentnode_node_assessments_available(self): - content.ContentNode.objects.all().update(available=True) - root = content.ContentNode.objects.get(parent__isnull=True) - exercise_ids = ( - root.get_descendants() - .filter(kind=content_kinds.EXERCISE) - .values_list("id", flat=True) - ) - response = self.client.get( - reverse("kolibri:core:contentnode-node-assessments"), - data={"ids": ",".join(exercise_ids)}, - ) - self.assertEqual(response.data, 1) - - def test_contentnode_node_assessments_not_available(self): - content.ContentNode.objects.all().update(available=False) - root = content.ContentNode.objects.get(parent__isnull=True) - exercise_ids = ( - root.get_descendants() - .filter(kind=content_kinds.EXERCISE) - .values_list("id", flat=True) - ) - response = self.client.get( - reverse("kolibri:core:contentnode-node-assessments"), - data={"ids": ",".join(exercise_ids)}, - ) - self.assertEqual(response.data, 0) - def test_contentnode_recommendations(self): node_id = content.ContentNode.objects.get(title="c2c2").id response = self.client.get( @@ -1302,7 +1274,7 @@ def test_channelmetadata_content_available_field_false(self): response = self.client.get(reverse("kolibri:core:channel-list")) self.assertEqual(response.data[0]["available"], False) - def test_channelmetadata_has_exercises_filter(self): + def test_channelmetadata_contains_exercise_filter(self): # Has nothing else for that matter... no_exercise_channel = content.ContentNode.objects.create( pk="6a406ac66b224106aa2e93f73a94333d", @@ -1319,7 +1291,7 @@ def test_channelmetadata_has_exercises_filter(self): no_filter_response = self.client.get(reverse("kolibri:core:channel-list")) self.assertEqual(len(no_filter_response.data), 2) with_filter_response = self.client.get( - reverse("kolibri:core:channel-list"), {"has_exercise": True} + reverse("kolibri:core:channel-list"), {"contains_exercise": True} ) self.assertEqual(len(with_filter_response.data), 1) self.assertEqual(with_filter_response.data[0]["name"], "testing") diff --git a/kolibri/core/content/test/utils/test_assignment.py b/kolibri/core/content/test/utils/test_assignment.py index 60b0d74c4e..b16c32bc5a 100644 --- a/kolibri/core/content/test/utils/test_assignment.py +++ b/kolibri/core/content/test/utils/test_assignment.py @@ -488,7 +488,6 @@ def test_on_downloadable_assignment__exam(self): ] resources = [ { - "section_id": uuid.uuid4().hex, "section_title": "Test Section Title", "description": "Test descripton for Section", "questions": questions, @@ -539,7 +538,6 @@ def test_on_downloadable_assignment__individual_syncable_exam(self): ] resources = [ { - "section_id": uuid.uuid4().hex, "section_title": "Test Section Title", "description": "Test descripton for Section", "questions": questions, diff --git a/kolibri/core/exams/constants.py b/kolibri/core/exams/constants.py new file mode 100644 index 0000000000..9797b77eb9 --- /dev/null +++ b/kolibri/core/exams/constants.py @@ -0,0 +1,3 @@ +# This should be kept in sync with the value in +# kolibri/core/assets/src/constants.js +MAX_QUESTIONS_PER_QUIZ_SECTION = 50 diff --git a/kolibri/core/exams/models.py b/kolibri/core/exams/models.py index 567049165e..1c72bc9e89 100644 --- a/kolibri/core/exams/models.py +++ b/kolibri/core/exams/models.py @@ -23,13 +23,13 @@ def exam_assignment_lookup(question_sources): :return: a tuple of contentnode_id and metadata """ for question_source in question_sources: - if "section_id" in question_source: + if "exercise_id" in question_source: + yield (question_source["exercise_id"], None) + else: questions = question_source.get("questions") if questions is not None: for question in question_source["questions"]: yield (question["exercise_id"], None) - else: - yield (question_source["exercise_id"], None) class AbstractExam(models.Model): @@ -57,7 +57,6 @@ class Meta: [ # Section 1 { - "section_id": , "section_title":
, "description":
, "question_count": , diff --git a/kolibri/core/exams/serializers.py b/kolibri/core/exams/serializers.py index 873b3a08e8..3e8bfe5c05 100644 --- a/kolibri/core/exams/serializers.py +++ b/kolibri/core/exams/serializers.py @@ -16,6 +16,7 @@ from kolibri.core.auth.models import FacilityUser from kolibri.core.auth.models import Membership from kolibri.core.auth.utils.users import create_adhoc_group_for_learners +from kolibri.core.exams.constants import MAX_QUESTIONS_PER_QUIZ_SECTION from kolibri.core.exams.models import DraftExam from kolibri.core.exams.models import Exam from kolibri.core.exams.models import ExamAssignment @@ -24,17 +25,19 @@ class QuestionSourceSerializer(Serializer): exercise_id = HexUUIDField(format="hex") question_id = HexUUIDField(format="hex") - title = CharField(default="") + title = CharField(default="", allow_blank=True) counter_in_exercise = IntegerField() class QuizSectionSerializer(Serializer): - section_id = HexUUIDField(format="hex") description = CharField(required=False, allow_blank=True) section_title = CharField(allow_blank=True, required=False) - question_count = IntegerField() learners_see_fixed_order = BooleanField(default=False) - questions = ListField(child=QuestionSourceSerializer(), required=False) + questions = ListField( + child=QuestionSourceSerializer(), + required=False, + max_length=MAX_QUESTIONS_PER_QUIZ_SECTION, + ) class ExamSerializer(ModelSerializer): diff --git a/kolibri/core/exams/test/test_exam_api.py b/kolibri/core/exams/test/test_exam_api.py index 0be21878e7..740aaef652 100644 --- a/kolibri/core/exams/test/test_exam_api.py +++ b/kolibri/core/exams/test/test_exam_api.py @@ -14,6 +14,7 @@ from kolibri.core.auth.models import FacilityUser from kolibri.core.auth.models import LearnerGroup from kolibri.core.auth.test.helpers import provision_device +from kolibri.core.exams.constants import MAX_QUESTIONS_PER_QUIZ_SECTION from kolibri.core.logger.models import ContentSummaryLog from kolibri.core.logger.models import MasteryLog @@ -72,7 +73,6 @@ def setUpTestData(cls): creator=cls.admin, question_sources=[ { - "section_id": uuid.uuid4().hex, "section_title": "Test Section Title", "description": "Test descripton for Section", "questions": [ @@ -83,7 +83,6 @@ def setUpTestData(cls): "counter_in_exercise": 0, } ], - "question_count": 1, "learners_see_fixed_order": False, } ], @@ -108,11 +107,9 @@ def make_basic_sections(self, no_of_sec): questions = self.make_basic_questions(3) for i in range(1, no_of_sec + 1): section = { - "section_id": uuid.uuid4().hex, "section_title": "Test Section Title", "description": "Test descripton for Section", "questions": questions, - "question_count": len(questions), "learners_see_fixed_order": False, } sections.append(section) @@ -369,62 +366,6 @@ def test_can_get_quiz_size(self): ) self.assertEqual(response.status_code, 200) - def test_quiz_section_with_no_section_id(self): - self.login_as_admin() - exam = self.make_basic_exam() - title = "no_section_id" - questions = self.make_basic_questions(1) - exam["title"] = title - exam["question_sources"].append( - { - "section_title": "Test Section Title", - "description": "Test descripton for Section", - "questions": questions, - "question_count": 0, - "learners_see_fixed_order": False, - } - ) - response = self.post_new_exam(exam) - self.assertEqual(response.status_code, 400) - self.assertExamNotExist(title=title) - - def test_quiz_section_with_invalid_section_id(self): - self.login_as_admin() - exam = self.make_basic_exam() - title = "invalid_section_sources" - exam["title"] = title - exam["question_sources"].append( - { - "section_id": "evil ID", - "section_title": "Test Section Title", - "description": "Test descripton for Section", - "question_count": 0, - "learners_see_fixed_order": False, - } - ) - response = self.post_new_exam(exam) - self.assertEqual(response.status_code, 400) - self.assertExamNotExist(title=title) - - def test_quiz_section_with_no_question_count(self): - self.login_as_admin() - exam = self.make_basic_exam() - title = "invalid_question_sources" - questions = self.make_basic_questions(1) - exam["title"] = title - exam["question_sources"].append( - { - "section_id": uuid.uuid4().hex, - "section_title": "Test Section Title", - "description": "Test descripton for Section", - "questions": questions, - "learners_see_fixed_order": False, - } - ) - response = self.post_new_exam(exam) - self.assertEqual(response.status_code, 400) - self.assertExamNotExist(title=title) - def test_quiz_section_with_no_question_succeds(self): self.login_as_admin() exam = self.make_basic_exam() @@ -432,10 +373,8 @@ def test_quiz_section_with_no_question_succeds(self): exam["title"] = title exam["question_sources"].append( { - "section_id": uuid.uuid4().hex, "section_title": "Test Section Title", "description": "Test descripton for Section", - "question_count": 0, "learners_see_fixed_order": False, } ) @@ -548,6 +487,14 @@ def test_exam_question_count_calculation(self): exam_model_instance = self.class_object.objects.get(id=exam_id) self.assertEqual(exam_model_instance.question_count, question_count) + def test_exam_question_max_length(self): + self.login_as_admin() + exam = self.make_basic_exam() + questions = self.make_basic_questions(MAX_QUESTIONS_PER_QUIZ_SECTION + 1) + exam["question_sources"][0]["questions"] = questions + response = self.post_new_exam(exam) + self.assertEqual(response.status_code, 400) + class ExamAPITestCase(BaseExamTest, APITestCase): class_object = models.Exam @@ -650,10 +597,8 @@ def test_logged_in_admin_exam_can_create_and_publish_remove_empty_sections(self) exam["draft"] = False exam["question_sources"].append( { - "section_id": uuid.uuid4().hex, "section_title": "Test Section Title", "description": "Test descripton for Section", - "question_count": 0, "questions": [], "learners_see_fixed_order": False, } @@ -678,18 +623,14 @@ def test_logged_in_admin_exam_cant_create_and_publish_empty_sections(self): exam["draft"] = False exam["question_sources"] = [ { - "section_id": uuid.uuid4().hex, "section_title": "Test Section Title", "description": "Test descripton for Section", - "question_count": 0, "questions": [], "learners_see_fixed_order": False, }, { - "section_id": uuid.uuid4().hex, "section_title": "Test Section Title", "description": "Test descripton for Section", - "question_count": 0, "questions": [], "learners_see_fixed_order": False, }, @@ -856,10 +797,8 @@ def test_logged_in_admin_exam_can_update_and_publish_remove_empty_sections(self) self.login_as_admin() self.exam.question_sources = self.make_basic_sections(1) + [ { - "section_id": uuid.uuid4().hex, "section_title": "Test Section Title", "description": "Test descripton for Section", - "question_count": 0, "questions": [], "learners_see_fixed_order": False, } @@ -875,10 +814,8 @@ def test_logged_in_admin_exam_cant_update_and_publish_empty_quiz(self): self.login_as_admin() self.exam.question_sources = [ { - "section_id": uuid.uuid4().hex, "section_title": "Test Section Title", "description": "Test descripton for Section", - "question_count": 0, "questions": [], "learners_see_fixed_order": False, } diff --git a/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js b/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js index f827b4be6e..69081668ba 100644 --- a/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js +++ b/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js @@ -11,7 +11,7 @@ * within the `QuizSeciton.resource_pool` property. * @property {string} id Unique ID for this exercise (aka, `exercise_id` elsewhere) * @property {string} title The resource title - * @property {string} ancestor_id The ID of the parent contentnode + * @property {string} parent The ID of the parent contentnode * @property {string} content_id The ID for the piece of content * @property {bool} is_leaf Whether or not this is a leaf node (i.e. an exercise) * @property {string} kind Exercise or Topic in our case - see: `ContentNodeKinds` @@ -26,7 +26,7 @@ export const QuizExercise = { type: String, default: '', }, - ancestor_id: { + parent: { type: String, default: '', }, @@ -46,17 +46,17 @@ export const QuizExercise = { type: Object, default: () => ({ assessment_item_ids: [] }), }, - contentnode: { - type: String, - default: '', - }, }; +function _exercise_id_validator(value) { + return /^[0-9a-f]{32}$/.test(value); +} + /** * @typedef {Object} QuizQuestion A particular question in a Quiz - aka an assessment item * from an QuizExercise. - * @property {string} id A ** unique ** identifier for this question that is - * a combination of : + * @property {string} item A ** unique ** identifier for this question that is + * a combination of : * @property {string} exercise_id The ID of the resource from which the question originates * @property {string} question_id A *unique* identifier of this particular question within * the quiz -- same as the `assessment_item_id` @@ -65,9 +65,28 @@ export const QuizExercise = { * same exercise title to differentiate them */ export const QuizQuestion = { + item: { + type: String, + required: true, + validator: value => { + const segments = value.split(':'); + if (segments.length !== 2) { + return false; + } + if (segments[0] === '' || segments[1] === '') { + return false; + } + // The exercise_id (segment[0]) should be a 32 digit hex string + if (!_exercise_id_validator(segments[0])) { + return false; + } + return true; + }, + }, exercise_id: { type: String, required: true, + validator: _exercise_id_validator, }, question_id: { type: String, @@ -78,7 +97,7 @@ export const QuizQuestion = { default: '', }, counter_in_exercise: { - type: 'number', + type: Number, default: 0, }, }; @@ -89,20 +108,16 @@ export const QuizQuestion = { * only used on the front-end * @property {string} section_title The title of the quiz section * @property {string} description A text blob associated with the section - * @property {number} question_count The number of questions in the section * @property {QuizQuestion[]} questions The list of QuizQuestion objects in the * section * @property {boolean} learners_see_fixed_order A bool flag indicating whether this * section is shown in the same order, or * randomized, to the learners - * @property {QuizExercise[]} resource_pool An array of QuizExercise objects from - * which the questions in this section_id - * will be drawn */ export const QuizSection = { section_id: { type: String, - required: true, + default: () => String(performance.now()), }, section_title: { type: String, @@ -117,19 +132,10 @@ export const QuizSection = { default: () => [], spec: QuizQuestion, }, - question_count: { - type: Number, - default: 10, - }, learners_see_fixed_order: { type: Boolean, default: false, }, - resource_pool: { - type: Array, - default: () => [], - spec: QuizExercise, - }, }; function getRandomInt() { @@ -178,7 +184,7 @@ export const Quiz = { }, seed: { type: Number, - default: getRandomInt(), + default: getRandomInt, }, // Default to sections being shown in a fixed order learners_see_fixed_order: { diff --git a/kolibri/plugins/coach/assets/src/composables/useFetchTree.js b/kolibri/plugins/coach/assets/src/composables/useFetchTree.js index e61d606af5..5d90ff6164 100644 --- a/kolibri/plugins/coach/assets/src/composables/useFetchTree.js +++ b/kolibri/plugins/coach/assets/src/composables/useFetchTree.js @@ -55,7 +55,7 @@ export default function useFetchTree({ topicId, params = {} } = {}) { async function _fetchNodeTree(params) { set(_loading, true); - return ContentNodeResource.fetchTree({ id: get(topicId), ...params }).then(topicTree => { + return ContentNodeResource.fetchTree({ id: get(topicId), params }).then(topicTree => { // results is the list of all children from this call to the API // more is an object that contains the parameters we need to fetch the next batch of nodes const { results, more } = topicTree.children || { results: [], more: null }; diff --git a/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js b/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js index 8684bf788e..230a25872f 100644 --- a/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js +++ b/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js @@ -1,20 +1,22 @@ -import { v4 } from 'uuid'; import isEqual from 'lodash/isEqual'; import { enhancedQuizManagementStrings } from 'kolibri-common/strings/enhancedQuizManagementStrings'; import uniq from 'lodash/uniq'; -import { ContentNodeKinds } from 'kolibri.coreVue.vuex.constants'; -import { ChannelResource, ExamResource } from 'kolibri.resources'; +import { MAX_QUESTIONS_PER_QUIZ_SECTION } from 'kolibri.coreVue.vuex.constants'; +import { ExamResource } from 'kolibri.resources'; import { validateObject, objectWithDefaults } from 'kolibri.utils.objectSpecs'; import { get, set } from '@vueuse/core'; -import { computed, ref, provide, inject } from 'kolibri.lib.vueCompositionApi'; +import { + computed, + ref, + provide, + inject, + getCurrentInstance, + watch, +} from 'kolibri.lib.vueCompositionApi'; import { fetchExamWithContent } from 'kolibri.utils.exams'; // TODO: Probably move this to this file's local dir -import selectQuestions from '../utils/selectQuestions.js'; -import { Quiz, QuizSection } from './quizCreationSpecs.js'; - -function uuidv4() { - return v4().replace(/-/g, ''); -} +import selectQuestions, { getExerciseQuestionsMap } from '../utils/selectQuestions.js'; +import { Quiz, QuizSection, QuizQuestion } from './quizCreationSpecs.js'; /** Validators **/ /* objectSpecs expects every property to be available -- but we don't want to have to make an @@ -40,6 +42,7 @@ const fieldsToSave = [ * Composable function presenting primary interface for Quiz Creation */ export default function useQuizCreation() { + const store = getCurrentInstance()?.proxy?.$store; // ----------- // Local state // ----------- @@ -53,17 +56,16 @@ export default function useQuizCreation() { /** @type {ref} * The section that is currently selected for editing */ - const _activeSectionId = ref(null); + const activeSectionIndex = computed(() => Number(store?.state?.route?.params?.sectionIndex || 0)); /** @type {ref} - * The QuizQuestion.id's that are currently selected for action in the active section */ + * The QuizQuestion.items that are currently selected for action in the active section */ const _selectedQuestionIds = ref([]); - /** @type {ref} A list of all channels available which have exercises */ - const _channels = ref([]); - - /** @type {ref} A list of all Question objects selected for replacement */ - const replacements = ref([]); + // An internal map for exercises + // used to cache state for exercises so we can avoid fetching them multiple times + // and have them available for quick access in active section resource pools and the like. + const _exerciseMap = {}; // ------------------ // Section Management @@ -72,146 +74,95 @@ export default function useQuizCreation() { /** * @param {QuizSection} section * @returns {QuizSection} - * @affects _quiz - Updates the section with the given section_id with the given param + * @affects _quiz - Updates the section with the given sectionIndex with the given param * @throws {TypeError} if section is not a valid QuizSection **/ - function updateSection({ section_id, ...updates }) { + function updateSection({ sectionIndex, ...updates }) { set(quizHasChanged, true); - const targetSection = get(allSections).find(section => section.section_id === section_id); + const targetSection = get(allSections)[sectionIndex]; if (!targetSection) { - throw new TypeError(`Section with id ${section_id} not found; cannot be updated.`); + throw new TypeError(`Section with id ${sectionIndex} not found; cannot be updated.`); } - // original variables are the original values of the properties we're updating - const { - resource_pool: originalResourcePool, - questions: originalQuestions, - question_count: originalQuestionCount, - } = targetSection; - - const { resource_pool, question_count } = updates; + const { questions, resourcePool } = updates; - if (resource_pool?.length === 0) { - // The user has removed all resources from the section, so we can clear all questions too - updates.questions = []; - } - - if (resource_pool?.length > 0) { - // The resource_pool is being updated - if (originalResourcePool.length === 0) { - // We're adding resources to a section which didn't previously have any - - // TODO This code could be broken out into a separate functions - - // *** - // Note that we're basically assuming that `questions*` properties aren't being updated -- - // meaning we expect that we can only update one or the other of `resource_pool` and - // `questions*` at a time. We can safely assume this because there can't be questions - // if there weren't resources in the originalResourcePool before. - // *** - updates.questions = selectRandomQuestionsFromResources( - question_count || originalQuestionCount || 0, - resource_pool + if (questions) { + if (!Array.isArray(questions)) { + throw new TypeError('Questions must be an array'); + } + if (questions.length > MAX_QUESTIONS_PER_QUIZ_SECTION) { + throw new TypeError( + `Questions array must not exceed ${MAX_QUESTIONS_PER_QUIZ_SECTION} items` ); - } else { - // We're updating the resource_pool of a section that already had resources - if (question_count === 0) { - updates.questions = []; - } else { - // In this case, we already had resources in the section, so we need to handle the - // case where a resource has been removed so that we remove & replace the questions - const removedResourceQuestionIds = originalResourcePool.reduce( - (questionIds, originalResource) => { - if (!resource_pool.map(r => r.id).includes(originalResource.id)) { - // If the resource_pool doesn't have the originalResource, we're removing it - questionIds = [...questionIds, ...originalResource.unique_question_ids]; - return questionIds; - } - return questionIds; - }, - [] - ); - if (removedResourceQuestionIds.length !== 0) { - const questionsToKeep = originalQuestions.filter( - q => !removedResourceQuestionIds.includes(q.item) - ); - const numReplacementsNeeded = - (question_count || originalQuestionCount) - questionsToKeep.length; - updates.questions = [ - ...questionsToKeep, - ...selectRandomQuestionsFromResources(numReplacementsNeeded, resource_pool), - ]; - } - } + } + if (questions.some(q => !validateObject(q, QuizQuestion))) { + throw new TypeError('Questions must be valid QuizQuestion objects'); } } - // The resource pool isn't being updated but the question_count is so we need to update them - if (question_count > originalQuestionCount) { - updates.questions = [ - ...originalQuestions, - ...selectRandomQuestionsFromResources( - question_count - originalQuestionCount, - originalResourcePool - ), - ]; - } else if (question_count < originalQuestionCount) { - updates.questions = originalQuestions.slice(0, question_count); + + if (resourcePool) { + // Update the exercise map with the new resource pool + // Add these resources to our cache + for (const exercise of resourcePool) { + _exerciseMap[exercise.id] = exercise; + } + delete updates.resourcePool; } + const _allSections = get(allSections); + set(_quiz, { ...get(quiz), // Update matching QuizSections with the updates object - question_sources: get(allSections).map(section => { - if (section.section_id === section_id) { - return { ...section, ...updates }; - } - return section; - }), + question_sources: [ + ..._allSections.slice(0, sectionIndex), + { ...targetSection, ...updates }, + ..._allSections.slice(sectionIndex + 1), + ], }); } - function handleReplacement() { + function addQuestionsToSectionFromResources({ sectionIndex, resourcePool, questionCount }) { + const targetSection = get(allSections)[sectionIndex]; + if (!targetSection) { + throw new TypeError(`Section with id ${sectionIndex} not found; cannot be updated.`); + } + + if (!resourcePool || resourcePool.length === 0) { + throw new TypeError('Resource pool must be a non-empty array of resources'); + } + + if (!questionCount || questionCount < 1) { + throw new TypeError('Question count must be a positive integer'); + } + + const newQuestions = selectQuestions( + questionCount, + resourcePool, + // Seed the random number generator with a random number + Math.floor(Math.random() * 1000), + // Exclude the questions that are already in the entire quiz + get(allQuestionsInQuiz).map(q => q.item) + ); + + const questions = [...targetSection.questions, ...newQuestions]; + + updateSection({ sectionIndex, questions, resourcePool }); + } + + function handleReplacement(replacements) { const questions = activeQuestions.value.map(question => { - if (selectedActiveQuestions.value.includes(question.id)) { - return replacements.value.shift(); + if (selectedActiveQuestions.value.includes(question.item)) { + return replacements.shift(); } return question; }); updateSection({ - section_id: activeSection.value.section_id, + sectionIndex: get(activeSectionIndex), questions, }); } - /** - * @description Selects random questions from the active section's `resource_pool` - no side - * effects - * @param {Number} numQuestions - * @param {QuizExercise[]} pool The resource pool to select questions from, will default to - * the activeResourcePool's value if not provided. This is useful if you need to select questions - * from a resource pool that hasn't been committed to the section yet. - * @param {String[]} excludedIds A list of IDs to exclude from random selection - * @returns {QuizQuestion[]} - */ - function selectRandomQuestionsFromResources(numQuestions, pool = [], excludedIds = []) { - pool = pool.length ? pool : get(activeResourcePool); - const exerciseIds = pool.map(r => r.id); - const exerciseTitles = pool.map(r => r.title); - const questionIdArrays = pool.map(r => r.unique_question_ids); - return selectQuestions( - numQuestions, - exerciseIds, - exerciseTitles, - questionIdArrays, - Math.floor(Math.random() * 1000), - [ - ...excludedIds, - // Always exclude the questions that are already in the entire quiz - ...get(allQuestionsInQuiz).map(q => q.item), - ] - ); - } - /** * @param {QuizQuestion[]} newQuestions * @affects _quiz - Updates the active section's `questions` property @@ -227,80 +178,57 @@ export default function useQuizCreation() { /** @returns {QuizSection} * Adds a section to the quiz and returns it */ function addSection() { - const newSection = objectWithDefaults({ section_id: uuidv4() }, QuizSection); + const newSection = objectWithDefaults({}, QuizSection); updateQuiz({ question_sources: [...get(quiz).question_sources, newSection] }); - setActiveSection(newSection.section_id); return newSection; } /** * @throws {Error} if section not found - * Deletes the given section by section_id */ - function removeSection(section_id) { - const updatedSections = get(allSections).filter(section => section.section_id !== section_id); - if (updatedSections.length === get(allSections).length) { - throw new Error(`Section with id ${section_id} not found; cannot be removed.`); - } - if (updatedSections.length === 0) { - const newSection = addSection(); - setActiveSection(newSection.section_id); - updatedSections.push(newSection); - } else { - setActiveSection(get(updatedSections)[0].section_id); + * Deletes the given section by sectionIndex */ + function removeSection(sectionIndex) { + if (!get(allSections)[sectionIndex]) { + throw new Error(`Section with index ${sectionIndex} not found; cannot be removed.`); } + const updatedSections = get(allSections) + .slice(0, sectionIndex) + .concat(get(allSections).slice(sectionIndex + 1)); updateQuiz({ question_sources: updatedSections }); + if (get(allSections).length === 0) { + // Always need to have at least one section + addSection(); + } } - /** - * @param {string} [section_id] - * @affects _activeSectionId - * Sets the given section_id as the active section ID, however, if the ID is not found or is null - * it will set the activeId to the first section in _quiz.question_sources */ - function setActiveSection(section_id = null) { - set(_selectedQuestionIds, []); // Clear the selected questions when changing sections - set(_activeSectionId, section_id); - } - + watch(activeSectionIndex, () => { + // Clear the selected questions when changing sections + set(_selectedQuestionIds, []); + }); // ------------ // Quiz General // ------------ /** @affects _quiz - * @affects _activeSectionId - * @affects _channels - Calls _fetchChannels to bootstrap the list of needed channels + * @affects activeSectionIndex * @param {string} collection - The collection (aka current class ID) to associate the exam with * Adds a new section to the quiz and sets the activeSectionID to it, preparing the module for * use */ async function initializeQuiz(collection, quizId = 'new') { - _fetchChannels(); if (quizId === 'new') { const assignments = [collection]; set(_quiz, objectWithDefaults({ collection, assignments }, Quiz)); - const newSection = addSection(); - setActiveSection(newSection.section_id); + addSection(); } else { const exam = await ExamResource.fetchModel({ id: quizId }); const { exam: quiz, exercises } = await fetchExamWithContent(exam); - const exerciseMap = {}; + // Put the exercises into the local cache for (const exercise of exercises) { - exerciseMap[exercise.id] = exercise; + _exerciseMap[exercise.id] = exercise; } - quiz.question_sources = quiz.question_sources.map(section => { - const resource_pool = uniq(section.questions.map(resource => resource.exercise_id)) - .map(exercise_id => exerciseMap[exercise_id]) - .filter(Boolean); - return { - ...section, - resource_pool, - }; - }); set(_quiz, objectWithDefaults(quiz, Quiz)); if (get(allSections).length === 0) { - const newSection = addSection(); - setActiveSection(newSection.section_id); - } else { - setActiveSection(get(allSections)[0].section_id); + addSection(); } } set(quizHasChanged, false); @@ -325,10 +253,9 @@ export default function useQuizCreation() { } if (finalQuiz.draft) { - // Here we update each section's `resource_pool` to only be the IDs of the resources const questionSourcesWithoutResourcePool = get(allSections).map(section => { const sectionToSave = { ...section }; - delete sectionToSave.resource_pool; + delete sectionToSave.section_id; sectionToSave.questions = section.questions.map(question => { const questionToSave = { ...question }; delete questionToSave.item; @@ -343,6 +270,8 @@ export default function useQuizCreation() { if (id !== exam.id) { updateQuiz({ id: exam.id }); } + // Update quizHasChanged to false once we have saved the quiz + set(quizHasChanged, false); return exam; }); } @@ -404,27 +333,6 @@ export default function useQuizCreation() { } } - /** - * @affects _channels - Fetches all channels with exercises and sets them to _channels */ - function _fetchChannels() { - ChannelResource.fetchCollection({ params: { has_exercises: true, available: true } }).then( - response => { - set( - _channels, - response.map(chnl => { - return { - ...chnl, - id: chnl.root, - title: chnl.name, - kind: ContentNodeKinds.CHANNEL, - is_leaf: false, - }; - }) - ); - } - ); - } - // Utilities // Computed properties @@ -433,53 +341,47 @@ export default function useQuizCreation() { /** @type {ComputedRef} The value of _quiz's `question_sources` */ const allSections = computed(() => get(quiz).question_sources); /** @type {ComputedRef} The active section */ - const activeSection = computed(() => - get(allSections).find(s => s.section_id === get(_activeSectionId)) - ); - const activeSectionIndex = computed(() => - get(allSections).findIndex(s => isEqual(s.section_title === get(activeSection).section_title)) - ); + const activeSection = computed(() => get(allSections)[get(activeSectionIndex)]); /** @type {ComputedRef} The inactive sections */ const inactiveSections = computed(() => - get(allSections).filter(s => s.section_id !== get(_activeSectionId)) - ); - /** @type {ComputedRef} The active section's `resource_pool` */ - const activeResourcePool = computed(() => get(activeSection).resource_pool); - /** @type {ComputedRef} The active section's `resource_pool` */ - const activeResourceMap = computed(() => - get(activeResourcePool).reduce((acc, resource) => { - acc[resource.id] = resource; - return acc; - }, {}) + get(allSections) + .slice(0, get(activeSectionIndex)) + .concat(get(allSections).slice(get(activeSectionIndex) + 1)) ); - /** @type {ComputedRef} All questions in the active section's `resource_pool` - * exercises */ - const activeQuestionsPool = computed(() => { - const pool = get(activeResourcePool); - const exerciseIds = pool.map(r => r.exercise_id); - const exerciseTitles = pool.map(r => r.title); - const questionIdArrays = pool.map(r => r.unique_question_ids); - return selectQuestions( - pool.reduce((acc, r) => acc + r.assessmentmetadata.assessment_item_ids.length, 0), - exerciseIds, - exerciseTitles, - questionIdArrays, - get(_quiz).seed - ); + /** @type {ComputedRef>} + * A map of exercise id to exercise for the currently active section */ + const activeResourceMap = computed(() => { + const map = {}; + for (const question of get(activeSection).questions) { + if (!map[question.exercise_id]) { + map[question.exercise_id] = _exerciseMap[question.exercise_id]; + } + } + return map; + }); + + const allResourceMap = computed(() => { + // Check the quiz value, so that our computed property is reactive to changes in the quiz + // as the _exerciseMap is not reactive, but is only updated when the quiz is updated + return _quiz.value && _exerciseMap; }); + + /** @type {ComputedRef} The active section's exercises */ + const activeResourcePool = computed(() => Object.values(get(activeResourceMap))); + /** @type {ComputedRef} All questions in the active section's `questions` property * those which are currently set to be used in the section */ const activeQuestions = computed(() => get(activeSection).questions); - /** @type {ComputedRef} All QuizQuestion.ids the user selected for the active section */ + /** @type {ComputedRef} + * All QuizQuestion.items the user selected for the active section */ const selectedActiveQuestions = computed(() => get(_selectedQuestionIds)); - /** @type {ComputedRef} Questions in the active section's `resource_pool` that + /** @type {ComputedRef} Questions in the active section's exercises that * are not in `questions` */ const replacementQuestionPool = computed(() => { const excludedQuestions = get(allQuestionsInQuiz).map(q => q.item); - return get(activeQuestionsPool).filter(q => !excludedQuestions.includes(q.item)); + const questionsMap = getExerciseQuestionsMap(get(activeResourcePool), excludedQuestions); + return Object.values(questionsMap).reduce((acc, questions) => [...acc, ...questions], []); }); - /** @type {ComputedRef} A list of all channels available which have exercises */ - const channels = computed(() => get(_channels)); /** @type {ComputedRef>} A list of all questions in the quiz */ const allQuestionsInQuiz = computed(() => { @@ -513,14 +415,13 @@ export default function useQuizCreation() { */ function deleteActiveSelectedQuestions() { - const { section_id, questions: section_questions } = get(activeSection); + const sectionIndex = get(activeSectionIndex); + const { questions: section_questions } = get(activeSection); const selectedIds = get(selectedActiveQuestions); const questions = section_questions.filter(q => !selectedIds.includes(q.item)); - const question_count = questions.length; updateSection({ - section_id, + sectionIndex, questions, - question_count, }); set(_selectedQuestionIds, []); } @@ -545,24 +446,23 @@ export default function useQuizCreation() { provide('allQuestionsInQuiz', allQuestionsInQuiz); provide('updateSection', updateSection); + provide('addQuestionsToSectionFromResources', addQuestionsToSectionFromResources); provide('handleReplacement', handleReplacement); provide('replaceSelectedQuestions', replaceSelectedQuestions); provide('addSection', addSection); provide('removeSection', removeSection); - provide('setActiveSection', setActiveSection); provide('updateQuiz', updateQuiz); provide('addQuestionToSelection', addQuestionToSelection); provide('removeQuestionFromSelection', removeQuestionFromSelection); provide('clearSelectedQuestions', clearSelectedQuestions); - provide('channels', channels); - provide('replacements', replacements); provide('allSections', allSections); + provide('activeSectionIndex', activeSectionIndex); provide('activeSection', activeSection); provide('activeSectionIndex', activeSectionIndex); provide('inactiveSections', inactiveSections); provide('activeResourcePool', activeResourcePool); provide('activeResourceMap', activeResourceMap); - provide('activeQuestionsPool', activeQuestionsPool); + provide('allResourceMap', allResourceMap); provide('activeQuestions', activeQuestions); provide('selectedActiveQuestions', selectedActiveQuestions); provide('allQuestionsSelected', allQuestionsSelected); @@ -576,11 +476,11 @@ export default function useQuizCreation() { // Methods saveQuiz, updateSection, + addQuestionsToSectionFromResources, handleReplacement, replaceSelectedQuestions, addSection, removeSection, - setActiveSection, initializeQuiz, updateQuiz, clearSelectedQuestions, @@ -589,8 +489,6 @@ export default function useQuizCreation() { // Computed quizHasChanged, - channels, - replacements, quiz, allSections, activeSectionIndex, @@ -598,7 +496,6 @@ export default function useQuizCreation() { inactiveSections, activeResourcePool, activeResourceMap, - activeQuestionsPool, activeQuestions, selectedActiveQuestions, replacementQuestionPool, @@ -614,24 +511,22 @@ export default function useQuizCreation() { export function injectQuizCreation() { const allQuestionsInQuiz = inject('allQuestionsInQuiz'); const updateSection = inject('updateSection'); + const addQuestionsToSectionFromResources = inject('addQuestionsToSectionFromResources'); const handleReplacement = inject('handleReplacement'); const replaceSelectedQuestions = inject('replaceSelectedQuestions'); const addSection = inject('addSection'); const removeSection = inject('removeSection'); - const setActiveSection = inject('setActiveSection'); const updateQuiz = inject('updateQuiz'); const addQuestionToSelection = inject('addQuestionToSelection'); const removeQuestionFromSelection = inject('removeQuestionFromSelection'); const clearSelectedQuestions = inject('clearSelectedQuestions'); - const channels = inject('channels'); - const replacements = inject('replacements'); const allSections = inject('allSections'); - const activeSection = inject('activeSection'); const activeSectionIndex = inject('activeSectionIndex'); + const activeSection = inject('activeSection'); const inactiveSections = inject('inactiveSections'); const activeResourcePool = inject('activeResourcePool'); const activeResourceMap = inject('activeResourceMap'); - const activeQuestionsPool = inject('activeQuestionsPool'); + const allResourceMap = inject('allResourceMap'); const activeQuestions = inject('activeQuestions'); const allQuestionsSelected = inject('allQuestionsSelected'); const selectAllIsIndeterminate = inject('selectAllIsIndeterminate'); @@ -646,11 +541,11 @@ export function injectQuizCreation() { deleteActiveSelectedQuestions, selectAllQuestions, updateSection, + addQuestionsToSectionFromResources, handleReplacement, replaceSelectedQuestions, addSection, removeSection, - setActiveSection, updateQuiz, clearSelectedQuestions, addQuestionToSelection, @@ -661,15 +556,13 @@ export function injectQuizCreation() { allQuestionsSelected, allQuestionsInQuiz, selectAllIsIndeterminate, - channels, - replacements, allSections, - activeSection, activeSectionIndex, + activeSection, inactiveSections, activeResourcePool, activeResourceMap, - activeQuestionsPool, + allResourceMap, activeQuestions, selectedActiveQuestions, replacementQuestionPool, diff --git a/kolibri/plugins/coach/assets/src/composables/useQuizResources.js b/kolibri/plugins/coach/assets/src/composables/useQuizResources.js index 65e60128e5..58203edcc5 100644 --- a/kolibri/plugins/coach/assets/src/composables/useQuizResources.js +++ b/kolibri/plugins/coach/assets/src/composables/useQuizResources.js @@ -1,11 +1,9 @@ import { get, set } from '@vueuse/core'; import { computed, ref } from 'kolibri.lib.vueCompositionApi'; -import { validateObject } from 'kolibri.utils.objectSpecs'; import { ContentNodeResource } from 'kolibri.resources'; import { ContentNodeKinds } from 'kolibri.coreVue.vuex.constants'; import logging from 'kolibri.lib.logging'; import useFetchTree from './useFetchTree'; -import { QuizExercise } from './quizCreationSpecs.js'; const logger = logging.getLogger(__filename); const _loadingMore = ref(false); @@ -19,12 +17,16 @@ const _loadingMore = ref(false); * @module useQuizResources * @param {QuizResourcesConfig} config */ -export default function useQuizResources({ topicId } = {}) { +export default function useQuizResources({ topicId, practiceQuiz = false } = {}) { const params = { kind_in: [ContentNodeKinds.EXERCISE, ContentNodeKinds.TOPIC], include_coach_content: true, }; + if (practiceQuiz) { + params.contains_quiz = true; + } + // Initialize useFetchTree methods with the given topicId computed property and params const { topic, fetchTree, fetchMore, hasMore, loading: treeLoading } = useFetchTree({ topicId, @@ -40,51 +42,46 @@ export default function useQuizResources({ topicId } = {}) { /** * Annotates the child TOPIC nodes with the number of assessments that are contained within them - * @param {string[]} topicsIds - The list of topics IDs to fetch descendant counts for - * @affects _resources - The topicIds passed here will have their `num_assessments` property - * added to the corresponding Topic within _resources - * @returns {Promise} - A promise that resolves when the annotations have been made and - * the_resources have been updated, filtering out all topics which do not have assessments + * @param {ContentNode[]} results - The array of results from a content API call + * @returns {Promise} - A promise that resolves when the annotations have been + * made and returns the annotated results */ - async function annotateTopicsWithDescendantCounts(topicIds = []) { - const promises = [ - ContentNodeResource.fetchDescendantsAssessments(topicIds), - ContentNodeResource.fetchDescendants(topicIds, { - descendant__kind: ContentNodeKinds.EXERCISE, - }), - ]; - return Promise.all(promises) - .then(([{ data: topicsWithAssessmentCounts }, { data: exerciseDescendants }]) => { - const childrenWithAnnotatedTopics = get(_resources) - .map(node => { - // We'll map so that the topics are updated in place with the num_assessments, others - // are left as-is - if ([ContentNodeKinds.TOPIC, ContentNodeKinds.CHANNEL].includes(node.kind)) { - const topicWithAssessments = topicsWithAssessmentCounts.find(t => t.id === node.id); - if (topicWithAssessments) { - node.num_assessments = topicWithAssessments.num_assessments; - } - exerciseDescendants.forEach(exercise => { - if (exercise.ancestor_id === node.id) { - node.num_exercises ? (node.num_exercises += 1) : (node.num_exercises = 1); + async function annotateTopicsWithDescendantCounts(results) { + const topicIds = results + .filter(({ kind }) => kind === ContentNodeKinds.TOPIC || kind === ContentNodeKinds.CHANNEL) + .map(topic => topic.id); + return ContentNodeResource.fetchDescendantsAssessments(topicIds) + .then(({ data: topicsWithAssessmentCounts }) => { + const topicsWithAssessmentCountsMap = topicsWithAssessmentCounts.reduce((acc, topic) => { + acc[topic.id] = topic.num_assessments; + return acc; + }, {}); + return ( + results + .map(node => { + // We'll map so that the topics are updated in place with the num_assessments, others + // are left as-is + if ([ContentNodeKinds.TOPIC, ContentNodeKinds.CHANNEL].includes(node.kind)) { + if (!topicsWithAssessmentCountsMap[node.id]) { + // If there are no assessment descendants, + // return null so we can easily filter after + return null; } - }); - - if (!validateObject(node, QuizExercise)) { - logger.error('Topic node was not a valid QuizExercise after annotation:', node); + if (node.kind === ContentNodeKinds.TOPIC && !node.children) { + // If the topic has no children, we can assume it has no assessments + // Only do this check for topics, because CHANNEL kinds are normally + // ChannelMetadata objects masquerading as ContentNode objects + // and so don't have children + return null; + } + node.num_assessments = topicsWithAssessmentCountsMap[node.id]; } - } - return node; - }) - .filter(node => { - // Only keep topics which have assessments in them to begin with - return ( - node.kind === ContentNodeKinds.EXERCISE || - ([ContentNodeKinds.TOPIC, ContentNodeKinds.CHANNEL].includes(node.kind) && - node.num_assessments > 0) - ); - }); - setResources(childrenWithAnnotatedTopics); + return node; + }) + // Filter out any topics that have no assessments + // that we have already flagged as null above + .filter(Boolean) + ); }) .catch(e => { // TODO Work out best UX for this situation -- it may depend on if we're fetching more @@ -101,10 +98,10 @@ export default function useQuizResources({ topicId } = {}) { async function fetchQuizResources() { set(_loading, true); return fetchTree().then(async results => { - setResources(results); - return annotateTopicsWithDescendantCounts( - results.filter(({ kind }) => kind === ContentNodeKinds.TOPIC).map(topic => topic.id) - ).then(() => set(_loading, false)); + return annotateTopicsWithDescendantCounts(results).then(annotatedResults => { + setResources(annotatedResults); + set(_loading, false); + }); }); } @@ -117,10 +114,8 @@ export default function useQuizResources({ topicId } = {}) { set(_loading, true); set(_loadingMore, true); return fetchMore().then(async results => { - set(_resources, [...get(_resources), ...results]); - return annotateTopicsWithDescendantCounts( - results.filter(({ kind }) => kind === ContentNodeKinds.TOPIC).map(topic => topic.id) - ).then(() => { + return annotateTopicsWithDescendantCounts(results).then(annotatedResults => { + set(_resources, [...get(_resources), ...annotatedResults]); set(_loading, false); set(_loadingMore, false); }); @@ -135,7 +130,8 @@ export default function useQuizResources({ topicId } = {}) { return ( node.kind === ContentNodeKinds.EXERCISE || // Has children, no more to load, and no children are topics - (node.children && + (!practiceQuiz && + node.children && !node.children.more && !node.children.results.some(c => c.kind === ContentNodeKinds.TOPIC) && node.children.results.length <= 12) diff --git a/kolibri/plugins/coach/assets/src/constants/examConstants.js b/kolibri/plugins/coach/assets/src/constants/examConstants.js deleted file mode 100644 index a3196c0721..0000000000 --- a/kolibri/plugins/coach/assets/src/constants/examConstants.js +++ /dev/null @@ -1,5 +0,0 @@ -export const Modals = { - PREVIEW_EXAM: 'PREVIEW_EXAM', -}; - -export const MAX_QUESTIONS = 50; diff --git a/kolibri/plugins/coach/assets/src/constants/index.js b/kolibri/plugins/coach/assets/src/constants/index.js index da4723aec6..9a9deee693 100644 --- a/kolibri/plugins/coach/assets/src/constants/index.js +++ b/kolibri/plugins/coach/assets/src/constants/index.js @@ -8,25 +8,14 @@ export const PageNames = { NEW_COACH_PAGES: 'NEW_COACH_PAGES', EXAMS: 'EXAMS', EXAM_CREATION_ROOT: 'EXAM_CREATION_ROOT', - EXAM_CREATION_PRACTICE_QUIZ: 'EXAM_CREATION_PRACTICE_QUIZ', - EXAM_CREATION_SELECT_PRACTICE_QUIZ_TOPIC: 'EXAM_CREATION_SELECT_PRACTICE_QUIZ_TOPIC', - EXAM_CREATION_PRACTICE_QUIZ_PREVIEW: 'EXAM_CREATION_PRACTICE_QUIZ_PREVIEW', /** Newly added routes */ QUIZ_SECTION_EDITOR: 'QUIZ_SECTION_EDITOR', QUIZ_REPLACE_QUESTIONS: 'QUIZ_REPLACE_QUESTIONS', QUIZ_SELECT_RESOURCES: 'QUIZ_SELECT_RESOURCES', + QUIZ_SELECT_PRACTICE_QUIZ: 'QUIZ_SELECT_PRACTICE_QUIZ', BOOK_MARKED_RESOURCES: 'BOOK_MARKED_RESOURCES', - /** TODO Remove unused */ - EXAM_CREATION_TOPIC: 'EXAM_CREATION_TOPIC', - EXAM_CREATION_BOOKMARKS: 'EXAM_CREATION_BOOKMARKS', - EXAM_CREATION_BOOKMARKS_MAIN: 'EXAM_CREATION_BOOKMARKS_MAIN', - EXAM_CREATION_PREVIEW: 'EXAM_CREATION_PREVIEW', - EXAM_CREATION_SEARCH: 'EXAM_CREATION_SEARCH', - EXAM_CREATION_QUESTION_SELECTION: 'EXAM_CREATION_QUESTION_SELECTION', - EXAM_SIDE_PANEL: 'EXAM_SIDE_PANEL', - EXAM_PREVIEW: 'EXAM_PREVIEW', EXAM_REPORT: 'EXAM_REPORT', EXAM_REPORT_DETAIL: 'EXAM_REPORT_DETAIL', EXAM_REPORT_DETAIL_ROOT: 'EXAM_REPORT_DETAIL_ROOT', diff --git a/kolibri/plugins/coach/assets/src/modules/examCreation/actions.js b/kolibri/plugins/coach/assets/src/modules/examCreation/actions.js deleted file mode 100644 index 613c96d910..0000000000 --- a/kolibri/plugins/coach/assets/src/modules/examCreation/actions.js +++ /dev/null @@ -1,248 +0,0 @@ -import pickBy from 'lodash/pickBy'; -import uniq from 'lodash/uniq'; -import unionBy from 'lodash/unionBy'; -import union from 'lodash/union'; -import shuffled from 'kolibri.utils.shuffled'; -import { ContentNodeResource, ContentNodeSearchResource } from 'kolibri.resources'; -import { getContentNodeThumbnail } from 'kolibri.utils.contentNode'; -import router from 'kolibri.coreVue.router'; -import { ContentNodeKinds } from 'kolibri.coreVue.vuex.constants'; -import { PageNames } from '../../constants'; -import { MAX_QUESTIONS } from '../../constants/examConstants'; -import { createExam } from '../examShared/exams'; - -export function resetExamCreationState(store) { - store.commit('RESET_STATE'); -} - -export function addToSelectedExercises(store, exercises) { - store.commit('ADD_TO_SELECTED_EXERCISES', exercises); - return updateAvailableQuestions(store); -} - -export function removeFromSelectedExercises(store, exercises) { - store.commit('REMOVE_FROM_SELECTED_EXERCISES', exercises); - return updateAvailableQuestions(store); -} - -export function updateAvailableQuestions(store) { - const { selectedExercises } = store.state; - // Only bother checking this if there is any doubt that we have sufficient - // questions available. If we have selected more exercises than we allow questions - // then we are sure to have this. - if (Object.keys(selectedExercises).length > 0) { - if (MAX_QUESTIONS > Object.keys(selectedExercises).length) { - return ContentNodeResource.fetchNodeAssessments(Object.keys(selectedExercises)).then(resp => { - store.commit('SET_AVAILABLE_QUESTIONS', resp.data); - }); - } else { - store.commit('SET_AVAILABLE_QUESTIONS', MAX_QUESTIONS); - return Promise.resolve(); - } - } - store.commit('SET_AVAILABLE_QUESTIONS', 0); - return Promise.resolve(); -} - -export function fetchAdditionalSearchResults(store, params) { - let kinds; - if (params.kind) { - kinds = [params.kind]; - } else { - kinds = [ContentNodeKinds.EXERCISE, ContentNodeKinds.TOPIC]; - } - return ContentNodeSearchResource.fetchCollection({ - getParams: { - ...pickBy({ - search: params.searchTerm, - channel_id: params.channelId, - exclude_content_ids: store.state.searchResults.contentIdsFetched, - }), - kind_in: kinds, - }, - }).then(results => { - return filterAndAnnotateContentList(results.results).then(contentList => { - const updatedChannelIds = union(store.state.searchResults.channel_ids, results.channel_ids); - const updatedContentKinds = union( - store.state.searchResults.content_kinds, - results.content_kinds - ).filter(kind => [ContentNodeKinds.TOPIC, ContentNodeKinds.EXERCISE].includes(kind)); - const updatedResults = unionBy([...store.state.searchResults.results, ...contentList], 'id'); - const updatedContentIdsFetched = uniq([ - ...store.state.searchResults.contentIdsFetched, - ...results.results.map(({ content_id }) => content_id), - ]); - const searchResults = { - total_results: store.state.searchResults.total_results, - channel_ids: updatedChannelIds, - content_kinds: updatedContentKinds, - results: updatedResults, - contentIdsFetched: updatedContentIdsFetched, - }; - store.commit('SET_SEARCH_RESULTS', searchResults); - }); - }); -} - -export function createPracticeQuizAndRoute(store, { classId, randomized }) { - // 'randomized' means question order IS random, so fixed order means randomized is false - store.commit('SET_FIXED_ORDER', !randomized); - const exam = { - collection: classId, - title: store.state.title, - seed: store.state.seed, - question_count: store.state.selectedQuestions.length, - question_sources: store.state.selectedQuestions, - assignments: [classId], - learners_see_fixed_order: store.state.learnersSeeFixedOrder, - date_archived: null, - date_activated: null, - }; - return createExam(store, exam).then(() => { - return router.push({ name: PageNames.EXAMS }); - }); -} - -export function createExamAndRoute(store, { classId }) { - const exam = { - collection: classId, - title: store.state.title, - seed: store.state.seed, - question_count: store.state.selectedQuestions.length, - question_sources: store.state.selectedQuestions, - assignments: [classId], - learners_see_fixed_order: store.state.learnersSeeFixedOrder, - date_archived: null, - date_activated: null, - }; - return createExam(store, exam).then(() => { - return router.push({ name: PageNames.EXAMS }); - }); -} - -function _getTopicsWithExerciseDescendants(topicIds = []) { - return new Promise(resolve => { - if (!topicIds.length) { - resolve([]); - return; - } - const topicsNumAssessmentDescendantsPromise = ContentNodeResource.fetchDescendantsAssessments( - topicIds - ); - - topicsNumAssessmentDescendantsPromise.then(response => { - const topicsWithExerciseDescendants = []; - response.data.forEach(descendantAssessments => { - if (descendantAssessments.num_assessments > 0) { - topicsWithExerciseDescendants.push({ - id: descendantAssessments.id, - numAssessments: descendantAssessments.num_assessments, - exercises: [], - }); - } - }); - - ContentNodeResource.fetchDescendants( - topicsWithExerciseDescendants.map(topic => topic.id), - { - descendant_kind: ContentNodeKinds.EXERCISE, - } - ).then(response => { - response.data.forEach(exercise => { - const topic = topicsWithExerciseDescendants.find(t => t.id === exercise.ancestor_id); - topic.exercises.push(exercise); - }); - resolve(topicsWithExerciseDescendants); - }); - }); - }); -} - -export function filterAndAnnotateContentList(childNodes) { - return new Promise(resolve => { - const childTopics = childNodes.filter(({ kind }) => kind === ContentNodeKinds.TOPIC); - const topicIds = childTopics.map(({ id }) => id); - const topicsThatHaveExerciseDescendants = _getTopicsWithExerciseDescendants(topicIds); - - topicsThatHaveExerciseDescendants.then(topics => { - const childNodesWithExerciseDescendants = childNodes - .map(childNode => { - const index = topics.findIndex(topic => topic.id === childNode.id); - if (index !== -1) { - return { ...childNode, ...topics[index] }; - } - return childNode; - }) - .filter(childNode => { - if (childNode.kind === ContentNodeKinds.TOPIC && (childNode.numAssessments || 0) < 1) { - return false; - } - return true; - }); - - const contentList = childNodesWithExerciseDescendants.map(node => ({ - ...node, - thumbnail: getContentNodeThumbnail(node), - })); - resolve(contentList); - }); - }); -} - -export function updateSelectedQuestions(store) { - if (!Object.keys(store.state.selectedExercises).length) { - store.commit('SET_SELECTED_QUESTIONS', []); - return Promise.resolve(); - } - - return new Promise(resolve => { - // If there are more exercises than questions, no need to fetch them all so - // choose N at random where N is the the number of questions. - const exerciseIds = shuffled( - Object.keys(store.state.selectedExercises), - store.state.seed - ).slice(0, store.state.numberOfQuestions); - - store.commit('LOADING_NEW_QUESTIONS', true); - - // The selectedExercises don't have the assessment metadata yet so fetch that - ContentNodeResource.fetchCollection({ - getParams: { ids: exerciseIds }, - }).then(contentNodes => { - store.commit('UPDATE_SELECTED_EXERCISES', contentNodes); // update with full metadata - const exercises = {}; - contentNodes.forEach(exercise => { - exercises[exercise.id] = exercise; - }); - // TODO This file needs to be cleaned up when updates to quiz management are complete -- this - // will be removed altogether so just no-op for now is ok - const doNothing = () => null; - const availableExercises = exerciseIds.filter(id => exercises[id]); - const exerciseTitles = availableExercises.map(id => exercises[id].title); - const questionIdArrays = availableExercises.map(id => - exercises[id].assessmentmetadata ? exercises[id].assessmentmetadata.assessment_item_ids : [] - ); - store.commit( - 'SET_SELECTED_QUESTIONS', - doNothing( - store.state.numberOfQuestions, - availableExercises, - exerciseTitles, - questionIdArrays, - store.state.seed - ) - ); - store.commit('LOADING_NEW_QUESTIONS', false); - resolve(); - }); - }); -} - -export function fetchPracticeQuizzes(parent = null) { - return ContentNodeResource.fetchCollection({ - getParams: { - [parent ? 'parent' : 'parent__isnull']: parent ? parent : true, - contains_quiz: true, - }, - }); -} diff --git a/kolibri/plugins/coach/assets/src/modules/examCreation/handlers.js b/kolibri/plugins/coach/assets/src/modules/examCreation/handlers.js deleted file mode 100644 index a537b35b76..0000000000 --- a/kolibri/plugins/coach/assets/src/modules/examCreation/handlers.js +++ /dev/null @@ -1,335 +0,0 @@ -import pickBy from 'lodash/pickBy'; -import uniq from 'lodash/uniq'; -import { ContentNodeKinds } from 'kolibri.coreVue.vuex.constants'; -import { - ContentNodeResource, - BookmarksResource, - ContentNodeSearchResource, - ChannelResource, -} from 'kolibri.resources'; -import router from 'kolibri.coreVue.router'; -import chunk from 'lodash/chunk'; -import { PageNames } from '../../constants'; -import { filterAndAnnotateContentList, fetchPracticeQuizzes } from './actions'; - -function showExamCreationPage(store, params) { - const { contentList, bookmarksList, pageName, ancestors = [], searchResults = null } = params; - return store.dispatch('loading').then(() => { - store.commit('examCreation/SET_ANCESTORS', ancestors); - store.commit('examCreation/SET_CONTENT_LIST', contentList); - store.commit('examCreation/SET_BOOKMARKS_LIST', bookmarksList); - if (searchResults) { - store.commit('examCreation/SET_SEARCH_RESULTS', searchResults); - } - store.commit('SET_PAGE_NAME', pageName); - store.dispatch('notLoading'); - }); -} - -export function showExamCreationRootPage(store, params) { - return store.dispatch('loading').then(() => { - return ChannelResource.fetchCollection({ - getParams: { available: true, has_exercise: true }, - }).then(channels => { - const channelContentList = channels.map(channel => ({ - ...channel, - id: channel.root, - title: channel.name, - kind: ContentNodeKinds.CHANNEL, - is_leaf: false, - })); - store.commit('SET_TOOLBAR_ROUTE', { - name: PageNames.EXAMS, - }); - return showExamCreationPage(store, { - classId: params.classId, - contentList: channelContentList, - pageName: PageNames.EXAM_CREATION_ROOT, - }); - }); - }); -} -export function showPracticeQuizCreationRootPage(store, params) { - return fetchPracticeQuizzes().then(channels => { - const channelContentList = channels.map(channel => ({ - ...channel, - id: channel.id, - title: channel.title, - kind: ContentNodeKinds.CHANNEL, - is_leaf: false, - })); - store.commit('SET_TOOLBAR_ROUTE', { - name: PageNames.EXAMS, - }); - return showExamCreationPage(store, { - classId: params.classId, - contentList: channelContentList, - pageName: PageNames.EXAM_CREATION_PRACTICE_QUIZ, - }); - }); -} -export function showPracticeQuizCreationTopicPage(store, params) { - return store.dispatch('loading').then(() => { - const { topicId } = params; - const topicNodePromise = ContentNodeResource.fetchModel({ id: topicId }); - const childNodesPromise = ContentNodeResource.fetchCollection({ - getParams: { - parent: topicId, - kind_in: [ContentNodeKinds.TOPIC, ContentNodeKinds.EXERCISE], - contains_quiz: true, - }, - }); - const loadRequirements = [topicNodePromise, childNodesPromise]; - - return Promise.all(loadRequirements).then(([topicNode, childNodes]) => { - return filterAndAnnotateContentList(childNodes).then(contentList => { - store.commit('SET_TOOLBAR_ROUTE', { - name: PageNames.EXAMS, - }); - - return showExamCreationPage(store, { - classId: params.classId, - contentList, - pageName: PageNames.EXAM_CREATION_SELECT_PRACTICE_QUIZ_TOPIC, - ancestors: [...topicNode.ancestors, topicNode], - }); - }); - }); - }); -} -export function showExamCreationTopicPage(store, params) { - return store.dispatch('loading').then(() => { - const { topicId } = params; - const topicNodePromise = ContentNodeResource.fetchModel({ id: topicId }); - const childNodesPromise = ContentNodeResource.fetchCollection({ - getParams: { - parent: topicId, - kind_in: [ContentNodeKinds.TOPIC, ContentNodeKinds.EXERCISE], - }, - }); - const loadRequirements = [topicNodePromise, childNodesPromise]; - - return Promise.all(loadRequirements).then(([topicNode, childNodes]) => { - return filterAndAnnotateContentList(childNodes).then(contentList => { - store.commit('SET_TOOLBAR_ROUTE', { - name: PageNames.EXAMS, - }); - - return showExamCreationPage(store, { - classId: params.classId, - contentList, - pageName: PageNames.EXAM_CREATION_TOPIC, - ancestors: [...topicNode.ancestors, topicNode], - }); - }); - }); - }); -} - -export function showExamCreationBookmarksPage(store, params) { - return store.dispatch('loading').then(() => { - const { topicId } = params; - const topicNodePromise = ContentNodeResource.fetchModel({ id: topicId }); - const childNodesPromise = ContentNodeResource.fetchCollection({ - getParams: { - parent: topicId, - kind_in: [ContentNodeKinds.TOPIC, ContentNodeKinds.VIDEO, ContentNodeKinds.EXERCISE], - }, - }); - const loadRequirements = [topicNodePromise, childNodesPromise]; - - return Promise.all(loadRequirements).then(([topicNode, childNodes]) => { - return filterAndAnnotateContentList(childNodes).then(() => { - store.commit('SET_TOOLBAR_ROUTE', { - name: PageNames.EXAMS, - }); - return showExamCreationPage(store, { - classId: params.classId, - bookmarksList: childNodes, - pageName: PageNames.EXAM_CREATION_BOOKMARKS, - ancestors: [...topicNode.ancestors, topicNode], - }); - }); - }); - }); -} -export function showExamCreationAllBookmarks(store) { - return store.dispatch('loading').then(() => { - getBookmarks().then(bookmarks => { - return showExamCreationPage(store, { - bookmarksList: bookmarks[0], - }); - }); - }); -} -function getBookmarks() { - return BookmarksResource.fetchCollection() - .then(bookmarks => bookmarks.map(bookmark => bookmark.contentnode_id)) - .then(contentNodeIds => { - const chunkedContentNodeIds = chunk(contentNodeIds, 50); // Breaking contentNodeIds into lists no more than 50 in length - // Now we will create an array of promises, each of which queries for the 50-id chunk - const fetchPromises = chunkedContentNodeIds.map(idsChunk => { - return ContentNodeResource.fetchCollection({ - getParams: { - ids: idsChunk, // This filters only the ids we want - }, - }); - }); - return Promise.all(fetchPromises); - }); -} - -export function showExamCreationPreviewPage(store, params, fromRoute, query = {}) { - const { classId, contentId } = params; - return store.dispatch('loading').then(() => { - return Promise.all([_prepExamContentPreview(store, classId, contentId)]) - .then(([contentNode]) => { - const { searchTerm, ...otherQueryParams } = query; - if (searchTerm) { - store.commit('SET_TOOLBAR_ROUTE', { - name: PageNames.EXAM_CREATION_SEARCH, - params: { - searchTerm, - }, - query: otherQueryParams, - }); - } else if (fromRoute && fromRoute.name === PageNames.EXAM_CREATION_TOPIC) { - store.commit('SET_TOOLBAR_ROUTE', { - name: PageNames.EXAM_CREATION_TOPIC, - params: { - topicId: contentNode.parent, - }, - }); - } else { - store.commit('SET_TOOLBAR_ROUTE', { - name: PageNames.EXAM_CREATION_ROOT, - }); - } - store.dispatch('notLoading'); - }) - .catch(error => { - store.dispatch('notLoading'); - return store.dispatch('handleApiError', { error, reloadOnReconnect: true }); - }); - }); -} -export function showPracticeQuizCreationPreviewPage(store, params) { - const { classId, contentId } = params; - return store.dispatch('loading').then(() => { - return Promise.all([_prepPracticeQuizContentPreview(store, classId, contentId)]) - .then(([contentNode]) => { - store.commit('SET_TOOLBAR_ROUTE', { - name: PageNames.EXAM_CREATION_SELECT_PRACTICE_QUIZ_TOPIC, - params: { - topicId: contentNode.parent, - }, - }); - store.dispatch('notLoading'); - }) - .catch(error => { - store.dispatch('notLoading'); - return store.dispatch('handleApiError', { error, reloadOnReconnect: true }); - }); - }); -} - -function _prepPracticeQuizContentPreview(store, classId, contentId) { - return ContentNodeResource.fetchModel({ id: contentId }).then( - contentNode => { - const assessmentMetadata = contentNode.assessmentmetadata || {}; - store.commit('SET_TOOLBAR_ROUTE', {}); - store.commit('examCreation/SET_CURRENT_CONTENT_NODE', { ...contentNode }); - store.commit('examCreation/SET_PREVIEW_STATE', { - questions: assessmentMetadata.assessment_item_ids, - completionData: assessmentMetadata.mastery_model, - }); - store.commit('SET_PAGE_NAME', PageNames.EXAM_CREATION_PRACTICE_QUIZ_PREVIEW); - return contentNode; - }, - error => { - return store.dispatch('handleApiError', { error, reloadOnReconnect: true }); - } - ); -} -function _prepExamContentPreview(store, classId, contentId) { - return ContentNodeResource.fetchModel({ id: contentId }).then( - contentNode => { - const assessmentMetadata = contentNode.assessmentmetadata || {}; - store.commit('SET_TOOLBAR_ROUTE', {}); - store.commit('examCreation/SET_CURRENT_CONTENT_NODE', { ...contentNode }); - store.commit('examCreation/SET_PREVIEW_STATE', { - questions: assessmentMetadata.assessment_item_ids, - completionData: assessmentMetadata.mastery_model, - }); - store.commit('SET_PAGE_NAME', PageNames.EXAM_CREATION_PREVIEW); - return contentNode; - }, - error => { - return store.dispatch('handleApiError', { error, reloadOnReconnect: true }); - } - ); -} - -export function showExamCreationSearchPage(store, params, query = {}) { - return store.dispatch('loading').then(() => { - let kinds; - if (query.kind) { - kinds = [query.kind]; - } else { - kinds = [ContentNodeKinds.EXERCISE, ContentNodeKinds.TOPIC]; - } - - store.commit('SET_TOOLBAR_ROUTE', { - name: PageNames.EXAM_CREATION_ROOT, - params: {}, - }); - - return ContentNodeSearchResource.fetchCollection({ - getParams: { - search: params.searchTerm, - kind_in: kinds, - ...pickBy({ channel_id: query.channel }), - }, - }).then(results => { - return filterAndAnnotateContentList(results.results).then(contentList => { - const searchResults = { - ...results, - results: contentList, - content_kinds: results.content_kinds.filter(kind => - [ContentNodeKinds.TOPIC, ContentNodeKinds.EXERCISE].includes(kind) - ), - contentIdsFetched: uniq(results.results.map(({ content_id }) => content_id)), - }; - return showExamCreationPage(store, { - classId: params.classId, - contentList: contentList, - pageName: PageNames.EXAM_CREATION_SEARCH, - searchResults, - }); - }); - }); - }); -} - -const creationPages = [ - PageNames.EXAM_CREATION_ROOT, - PageNames.EXAM_CREATION_TOPIC, - PageNames.EXAM_CREATION_PREVIEW, - PageNames.EXAM_CREATION_SEARCH, - PageNames.EXAM_CREATION_BOOKMARKS, - PageNames.EXAM_CREATION_BOOKMARKS_MAIN, -]; - -export function showExamCreationQuestionSelectionPage(store, toRoute, fromRoute) { - // if we got here from somewhere else, start over - if (!creationPages.includes(fromRoute.name)) { - router.replace({ - name: PageNames.EXAM_CREATION_ROOT, - params: toRoute.params, - }); - } - store.commit('SET_PAGE_NAME', 'EXAM_CREATION_QUESTION_SELECTION'); - store.commit('SET_TOOLBAR_ROUTE', { name: fromRoute.name, params: fromRoute.params }); - store.dispatch('examCreation/updateSelectedQuestions'); - store.dispatch('notLoading'); -} diff --git a/kolibri/plugins/coach/assets/src/modules/examCreation/index.js b/kolibri/plugins/coach/assets/src/modules/examCreation/index.js deleted file mode 100644 index 2e829077c1..0000000000 --- a/kolibri/plugins/coach/assets/src/modules/examCreation/index.js +++ /dev/null @@ -1,113 +0,0 @@ -import Vue from 'kolibri.lib.vue'; -import * as actions from './actions'; - -function getRandomInt() { - return Math.floor(Math.random() * 1000); -} - -function defaultState() { - return { - title: '', - numberOfQuestions: 10, - seed: getRandomInt(), // consistent seed is used for question selection - contentList: [], - bookmarksList: [], - selectedExercises: {}, - availableQuestions: 0, - searchResults: { - channel_ids: [], - content_kinds: [], - results: [], - total_results: 0, - contentIdsFetched: [], // to account for topics without exercises that are filtered out - }, - ancestors: [], - examsModalSet: false, - currentContentNode: {}, - preview: { - completionData: null, - questions: null, - }, - selectedQuestions: [], - learnersSeeFixedOrder: false, - loadingNewQuestions: false, - }; -} - -export default { - namespaced: true, - state: defaultState(), - actions, - getters: { - numRemainingSearchResults(state) { - return state.searchResults.total_results - state.searchResults.contentIdsFetched.length; - }, - }, - mutations: { - SET_STATE(state, payload) { - Object.assign(state, payload); - }, - RESET_STATE(state) { - Object.assign(state, defaultState()); - }, - SET_TITLE(state, title) { - state.title = title; - }, - LOADING_NEW_QUESTIONS(state, value) { - state.loadingNewQuestions = value; - }, - SET_NUMBER_OF_QUESTIONS(state, numberOfQuestions) { - state.numberOfQuestions = numberOfQuestions; - }, - RANDOMIZE_SEED(state) { - state.seed = getRandomInt(); - }, - SET_FIXED_ORDER(state, value) { - state.learnersSeeFixedOrder = value; - }, - SET_SELECTED_QUESTIONS(state, questions) { - state.selectedQuestions = questions; - }, - SET_CONTENT_LIST(state, contentList) { - state.contentList = contentList; - }, - SET_BOOKMARKS_LIST(state, bookmarksList) { - state.bookmarksList = bookmarksList; - }, - ADD_TO_SELECTED_EXERCISES(state, exercises) { - state.selectedExercises = Object.assign( - {}, - state.selectedExercises, - ...exercises.map(exercise => ({ [exercise.id]: exercise })) - ); - }, - REMOVE_FROM_SELECTED_EXERCISES(state, exercises) { - exercises.forEach(exercise => { - Vue.delete(state.selectedExercises, exercise.id); - }); - }, - UPDATE_SELECTED_EXERCISES(state, exercises) { - exercises.forEach(newExercise => { - Vue.set(state.selectedExercises, newExercise.id, newExercise); - }); - }, - SET_AVAILABLE_QUESTIONS(state, availableQuestions) { - state.availableQuestions = availableQuestions; - }, - SET_ANCESTORS(state, ancestors) { - state.ancestors = [...ancestors]; - }, - SET_SEARCH_RESULTS(state, searchResults) { - state.searchResults = searchResults; - }, - SET_EXAMS_MODAL(state, modalName) { - state.examsModalSet = modalName; - }, - SET_CURRENT_CONTENT_NODE(state, contentNode) { - state.currentContentNode = contentNode; - }, - SET_PREVIEW_STATE(state, previewState) { - state.preview = previewState; - }, - }, -}; diff --git a/kolibri/plugins/coach/assets/src/modules/pluginModule.js b/kolibri/plugins/coach/assets/src/modules/pluginModule.js index 135dbac826..318c7576c2 100644 --- a/kolibri/plugins/coach/assets/src/modules/pluginModule.js +++ b/kolibri/plugins/coach/assets/src/modules/pluginModule.js @@ -1,8 +1,7 @@ import { ClassroomResource } from 'kolibri.resources'; import logger from 'kolibri.lib.logging'; -import { pageNameToModuleMap, PageNames } from '../constants'; +import { pageNameToModuleMap } from '../constants'; import { LessonsPageNames } from '../constants/lessonsConstants'; -import examCreation from './examCreation'; import examReportDetail from './examReportDetail'; import exerciseDetail from './exerciseDetail'; import groups from './groups'; @@ -102,9 +101,6 @@ export default { ) { return store.dispatch('lessonSummary/resetLessonSummaryState'); } - if (toRoute.name === PageNames.EXAMS) { - return store.dispatch('examCreation/resetExamCreationState'); - } const moduleName = pageNameToModuleMap[fromRoute.name]; if (moduleName) { store.commit(`${moduleName}/RESET_STATE`); @@ -136,7 +132,6 @@ export default { modules: { classSummary, coachNotifications, - examCreation, examReportDetail, exerciseDetail, groups, diff --git a/kolibri/plugins/coach/assets/src/routes/planExamRoutes.js b/kolibri/plugins/coach/assets/src/routes/planExamRoutes.js index e296a5a494..365c6be98f 100644 --- a/kolibri/plugins/coach/assets/src/routes/planExamRoutes.js +++ b/kolibri/plugins/coach/assets/src/routes/planExamRoutes.js @@ -1,27 +1,10 @@ -import store from 'kolibri.coreVue.vuex.store'; import { PageNames } from '../constants'; -import { - showExamCreationTopicPage, - showExamCreationBookmarksPage, - showExamCreationAllBookmarks, - showExamCreationSearchPage, - showExamCreationQuestionSelectionPage, - showExamCreationPreviewPage, - showPracticeQuizCreationRootPage, - showPracticeQuizCreationTopicPage, - showPracticeQuizCreationPreviewPage, -} from '../modules/examCreation/handlers'; -import CreatePracticeQuizPage from '../views/plan/CreateExamPage/CreatePracticeQuizPage.vue'; import CreateExamPage from '../views/plan/CreateExamPage'; -import CreateExamPreview from '../views/plan/CreateExamPage/CreateExamPreview.vue'; -import SectionSidePanel from '../views/plan/CreateExamPage/SectionSidePanel.vue'; import SectionEditor from '../views/plan/CreateExamPage/SectionEditor.vue'; import ResourceSelection from '../views/plan/CreateExamPage/ResourceSelection.vue'; import ReplaceQuestions from '../views/plan/CreateExamPage/ReplaceQuestions.vue'; -import PlanQuizPreviewPage from '../views/plan/PlanQuizPreviewPage'; import CoachExamsPage from '../views/plan/CoachExamsPage'; import QuizSummaryPage from '../views/plan/QuizSummaryPage'; -import PlanPracticeQuizPreviewPage from '../views/plan/CreateExamPage/PlanPracticeQuizPreviewPage'; export default [ { @@ -34,105 +17,34 @@ export default [ }, { name: PageNames.EXAM_CREATION_ROOT, - path: '/:classId/plan/quizzes/:quizId/edit', + path: '/:classId/plan/quizzes/:quizId/edit/:sectionIndex', component: CreateExamPage, children: [ { - name: PageNames.EXAM_SIDE_PANEL, - path: ':section_id', - component: SectionSidePanel, - children: [ - { - name: PageNames.QUIZ_SECTION_EDITOR, - path: 'edit', - component: SectionEditor, - }, - { - name: PageNames.QUIZ_REPLACE_QUESTIONS, - path: 'replace-questions', - component: ReplaceQuestions, - }, - { - name: PageNames.QUIZ_SELECT_RESOURCES, - path: 'select-resources/:topic_id?', - component: ResourceSelection, - }, - ], + name: PageNames.QUIZ_SECTION_EDITOR, + path: 'edit', + component: SectionEditor, + }, + { + name: PageNames.QUIZ_REPLACE_QUESTIONS, + path: 'replace-questions', + component: ReplaceQuestions, + }, + { + name: PageNames.QUIZ_SELECT_RESOURCES, + path: 'select-resources/:topic_id?', + component: ResourceSelection, + }, + { + name: PageNames.QUIZ_SELECT_PRACTICE_QUIZ, + path: 'select-quiz/:topic_id?', + component: ResourceSelection, + props: { + selectPracticeQuiz: true, + }, }, ], }, - { - name: PageNames.EXAM_CREATION_PRACTICE_QUIZ, - path: '/:classId/plan/quizzes/new/practice_quiz', - component: CreatePracticeQuizPage, - handler: toRoute => { - showPracticeQuizCreationRootPage(store, toRoute.params); - }, - }, - { - name: PageNames.EXAM_CREATION_SELECT_PRACTICE_QUIZ_TOPIC, - path: '/:classId/plan/quizzes/new/practice_quiz/topic/:topicId', - component: CreatePracticeQuizPage, - handler: toRoute => { - showPracticeQuizCreationTopicPage(store, toRoute.params); - }, - }, - { - name: PageNames.EXAM_CREATION_TOPIC, - path: '/:classId/plan/quizzes/new/topic/:topicId', - component: CreateExamPage, - handler: toRoute => { - showExamCreationTopicPage(store, toRoute.params); - }, - }, - { - name: PageNames.EXAM_CREATION_BOOKMARKS, - path: '/:classId/plan/quizzes/new/bookmark/:topicId', - component: CreateExamPage, - handler: toRoute => { - showExamCreationBookmarksPage(store, toRoute.params); - }, - }, - { - name: PageNames.EXAM_CREATION_BOOKMARKS_MAIN, - path: '/:classId/plan/quizzes/new/bookmarks', - component: CreateExamPage, - handler: toRoute => { - showExamCreationAllBookmarks(store, toRoute.params); - }, - }, - { - name: PageNames.EXAM_CREATION_SEARCH, - path: '/:classId/plan/quizzes/new/search/:searchTerm', - component: CreateExamPage, - handler: toRoute => { - showExamCreationSearchPage(store, toRoute.params, toRoute.query); - }, - }, - { - name: PageNames.EXAM_CREATION_QUESTION_SELECTION, - path: '/:classId/plan/quizzes/new/finalize', - component: CreateExamPreview, - handler: (toRoute, fromRoute) => { - showExamCreationQuestionSelectionPage(store, toRoute, fromRoute); - }, - }, - { - name: PageNames.EXAM_CREATION_PRACTICE_QUIZ_PREVIEW, - path: '/:classId/plan/quizzes/new/practice_quiz/preview/', - component: PlanPracticeQuizPreviewPage, - handler: toRoute => { - showPracticeQuizCreationPreviewPage(store, toRoute.params); - }, - }, - { - name: PageNames.EXAM_CREATION_PREVIEW, - path: '/:classId/plan/quizzes/new/preview/', - component: PlanQuizPreviewPage, - handler: (toRoute, fromRoute) => { - showExamCreationPreviewPage(store, toRoute.params, fromRoute); - }, - }, { name: QuizSummaryPage.name, path: '/:classId/plan/quizzes/:quizId', diff --git a/kolibri/plugins/coach/assets/src/utils/selectQuestions.js b/kolibri/plugins/coach/assets/src/utils/selectQuestions.js index cddff2c174..ce1c33cd37 100644 --- a/kolibri/plugins/coach/assets/src/utils/selectQuestions.js +++ b/kolibri/plugins/coach/assets/src/utils/selectQuestions.js @@ -1,4 +1,3 @@ -import find from 'lodash/find'; import range from 'lodash/range'; import sumBy from 'lodash/fp/sumBy'; import sortBy from 'lodash/sortBy'; @@ -9,35 +8,55 @@ const logging = logger.getLogger(__filename); const getTotalOfQuestions = sumBy(qArray => qArray.length); +export function exerciseToQuestionArray(exercise) { + return exercise.assessmentmetadata.assessment_item_ids.map((question_id, i) => { + return { + exercise_id: exercise.id, + question_id, + counter_in_exercise: i + 1, + // In the V3 schema, the title is user editable, and no longer + // simply the title of the exercise the question came from. + // We set it to blank to indicate that no user generated title + // has been created. + title: '', + item: `${exercise.id}:${question_id}`, + }; + }); +} + +export function getExerciseQuestionsMap(exercises, excludedQuestionIds = []) { + const excludedQuestionIdMap = {}; + for (const uId of excludedQuestionIds) { + excludedQuestionIdMap[uId] = true; + } + const allQuestionsByExercise = {}; + for (const exercise of exercises) { + allQuestionsByExercise[exercise.id] = exerciseToQuestionArray(exercise); + if (excludedQuestionIds.length) { + allQuestionsByExercise[exercise.id] = allQuestionsByExercise[exercise.id].filter( + question => !excludedQuestionIdMap[question.item] + ); + } + } + return allQuestionsByExercise; +} + /** * Choose a an evenly-distributed random selection of questions from exercises. Note that the order * of the arrays should correspond to each other, ie, exerciseIds[i] should correspond to * questionIdArrays[i] should correspond to exerciseTitles[i], etc. * * @param {Number} numQuestions - target number of questions - * @param {String[]} exerciseIds - QuizExercise IDs - * @param {String[]} exerciseTitle - QuizExercise titles - * @param {Array[String[]]} questionIdArrays - QuizQuestion (assessmentitem) unique IDs in the - * composite format `exercise_id:question_id` + * @param {String[]} exercises - Exercise objects * @param {number} seed - value to seed the random shuffle with * * @return {QuizQuestion[]} */ -export default function selectQuestions( - numQuestions, - exerciseIds, - exerciseTitles, - questionIdArrays, - seed, - excludedQuestionIds = [] -) { - if (exerciseIds.length !== questionIdArrays.length) { - logging.error('exerciseIds and questionIdArrays must have the same length'); - } - if (exerciseIds.length !== exerciseTitles.length) { - logging.error('exerciseIds and exerciseTitles must have the same length'); - } - if (getTotalOfQuestions(questionIdArrays) < numQuestions) { +export default function selectQuestions(numQuestions, exercises, seed, excludedQuestionIds = []) { + const allQuestionsByExercise = getExerciseQuestionsMap(exercises, excludedQuestionIds); + const exerciseIds = Object.keys(allQuestionsByExercise); + const questionArrays = Object.values(allQuestionsByExercise); + if (getTotalOfQuestions(questionArrays) < numQuestions) { logging.error('Not enough questions to reach numQuestions'); } if (numQuestions < exerciseIds.length) { @@ -50,15 +69,7 @@ export default function selectQuestions( const randomIndexes = shuffleWithSeed(range(exerciseIds.length)); // copy and shuffle the question IDs - const shuffledQuestionIdArrays = questionIdArrays.map(shuffleWithSeed); - - // reduced to remove excludedQuestionIds, ternary expression avoids iterating unnecessarily - const filteredQuestionIdArrays = !excludedQuestionIds.length - ? shuffledQuestionIdArrays - : shuffledQuestionIdArrays.reduce((acc, resourceQuestions) => { - acc.push(resourceQuestions.filter(uId => !excludedQuestionIds.includes(uId))); - return acc; - }, []); + const shuffledQuestionArrays = questionArrays.map(shuffleWithSeed); // fill up the output list const output = []; @@ -66,23 +77,10 @@ export default function selectQuestions( while (output.length < numQuestions) { const ri = randomIndexes[i]; // check if we've used up all questions in one exercise - if (filteredQuestionIdArrays[ri].length > 0) { - const uId = filteredQuestionIdArrays[ri].pop(); - - // Only add the question/assessment to the list if it is not already there - // from another identical exercise with a different exercise/node ID - if (!find(output, { id: uId })) { - output.push({ - counter_in_exercise: questionIdArrays[ri].indexOf(uId) + 1, - exercise_id: uId.includes(':') ? uId.split(':')[0] : uId, - question_id: uId.split(':')[1], - // TODO See #12127 re: replacing all `id` with `item` - id: uId, - item: uId, - title: exerciseTitles[ri], - }); - } - } else if (getTotalOfQuestions(filteredQuestionIdArrays) === 0) { + if (shuffledQuestionArrays[ri].length > 0) { + const question = shuffledQuestionArrays[ri].pop(); + output.push(question); + } else if (getTotalOfQuestions(shuffledQuestionArrays) === 0) { // If there are not enough questions, then break the loop break; } diff --git a/kolibri/plugins/coach/assets/src/views/plan/CoachExamsPage/index.vue b/kolibri/plugins/coach/assets/src/views/plan/CoachExamsPage/index.vue index 440622ca91..ce31b2fe0f 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/CoachExamsPage/index.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/CoachExamsPage/index.vue @@ -333,7 +333,7 @@ return this.quizzes; }, newExamRoute() { - return { name: PageNames.EXAM_CREATION_ROOT }; + return { name: PageNames.EXAM_CREATION_ROOT, params: { sectionIndex: 0 } }; }, dropdownOptions() { return [ @@ -398,12 +398,12 @@ handleSelect({ value }) { const nextRouteName = { MAKE_NEW_QUIZ: PageNames.EXAM_CREATION_ROOT, - SELECT_QUIZ: PageNames.EXAM_CREATION_PRACTICE_QUIZ, + SELECT_QUIZ: PageNames.QUIZ_SELECT_PRACTICE_QUIZ, }[value]; - const nextRoute = { name: nextRouteName, params: { ...this.$route.params } }; - if (value === 'MAKE_NEW_QUIZ') { - nextRoute.params.quizId = 'new'; - } + const nextRoute = { + name: nextRouteName, + params: { ...this.$route.params, quizId: 'new', sectionIndex: 0 }, + }; this.$router.push(nextRoute); }, }, diff --git a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/AssessmentQuestionListItem.vue b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/AssessmentQuestionListItem.vue index 84de402eb5..a7796d46e1 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/AssessmentQuestionListItem.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/AssessmentQuestionListItem.vue @@ -63,10 +63,6 @@ type: Boolean, required: true, }, - questionNumberOfExercise: { - type: Number, - default: null, - }, isSelected: { type: Boolean, required: true, @@ -97,13 +93,7 @@ if (!this.exerciseName) { return this.coreString('resourceNotFoundOnDevice'); } - if (this.questionNumberOfExercise === undefined || this.questionNumberOfExercise === null) { - return this.exerciseName; - } - return this.coachString('nthExerciseName', { - name: this.exerciseName, - number: this.questionNumberOfExercise, - }); + return this.exerciseName; }, focusRing() { return this.$computedClass({ ':focus': this.$coreOutline }); diff --git a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateExamPreview.vue b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateExamPreview.vue deleted file mode 100644 index bc12e14660..0000000000 --- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateExamPreview.vue +++ /dev/null @@ -1,326 +0,0 @@ - - - - - - - diff --git a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreatePracticeQuizPage.vue b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreatePracticeQuizPage.vue deleted file mode 100644 index 62a09e1114..0000000000 --- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreatePracticeQuizPage.vue +++ /dev/null @@ -1,159 +0,0 @@ - - - - - - - diff --git a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateQuizSection.vue b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateQuizSection.vue index 0bd3171c0d..1c1bddd34b 100644 --- a/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateQuizSection.vue +++ b/kolibri/plugins/coach/assets/src/views/plan/CreateExamPage/CreateQuizSection.vue @@ -15,9 +15,7 @@ tabsId="quizSectionTabs" class="section-tabs" :tabs="tabs" - :activeTabId="activeSection ? - activeSection.section_id : - '' " + :activeTabId="String(activeSectionIndex)" backgroundColor="transparent" hoverBackgroundColor="transparent" :aria-label="quizSectionsLabel$()" @@ -77,7 +75,7 @@ {{ addQuestionsLabel$() }} @@ -228,7 +226,9 @@ > - {{ title + " " + question.counter_in_exercise }} + {{ title }} @@ -321,7 +321,7 @@ :title="deleteSectionLabel$()" :submitText="coreString('deleteAction')" :cancelText="coreString('cancelAction')" - @cancel="handleShowConfirmation" + @cancel="showDeleteConfirmation = true" @submit="handleConfirmDelete" >