diff --git a/kolibri/core/exams/api.py b/kolibri/core/exams/api.py index 51fde4ff0c8..0dd8f3fbf30 100644 --- a/kolibri/core/exams/api.py +++ b/kolibri/core/exams/api.py @@ -69,6 +69,7 @@ class ExamViewset(ValuesViewset): "creator", "data_model_version", "learners_see_fixed_order", + "instant_report_visibility", "date_created", ) @@ -82,7 +83,12 @@ class ExamViewset(ValuesViewset): draft_values = common_values + ("assignments", "learner_ids") - field_map = {"assignments": "assignment_collections"} + field_map = { + "assignments": "assignment_collections", + "instant_report_visibility": lambda x: True + if x["instant_report_visibility"] is None + else x["instant_report_visibility"], + } def get_draft_queryset(self): return models.DraftExam.objects.all() diff --git a/kolibri/core/exams/migrations/0010_add_exam_report_visibility_field.py b/kolibri/core/exams/migrations/0010_add_exam_report_visibility_field.py new file mode 100644 index 00000000000..7e925db2879 --- /dev/null +++ b/kolibri/core/exams/migrations/0010_add_exam_report_visibility_field.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.25 on 2025-02-20 17:37 +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("exams", "0009_alter_exam_date_created"), + ] + + operations = [ + migrations.AddField( + model_name="draftexam", + name="instant_report_visibility", + field=models.BooleanField(null=True), + ), + migrations.AddField( + model_name="exam", + name="instant_report_visibility", + field=models.BooleanField(null=True), + ), + migrations.AlterField( + model_name="draftexam", + name="instant_report_visibility", + field=models.BooleanField(default=True, null=True), + ), + migrations.AlterField( + model_name="exam", + name="instant_report_visibility", + field=models.BooleanField(default=True, null=True), + ), + ] diff --git a/kolibri/core/exams/models.py b/kolibri/core/exams/models.py index 7b7a191d2be..f72f03105fc 100644 --- a/kolibri/core/exams/models.py +++ b/kolibri/core/exams/models.py @@ -158,6 +158,10 @@ class Meta: """ data_model_version = models.SmallIntegerField(default=3) + # If True, learners have instant access to exam reports after submission. + # Otherwise, reports are visible only after the coach ends the exam. + instant_report_visibility = models.BooleanField(null=True, default=True) + def __str__(self): return self.title @@ -209,6 +213,7 @@ def to_exam(self): collection=self.collection, creator=self.creator, data_model_version=self.data_model_version, + instant_report_visibility=self.instant_report_visibility, date_created=self.date_created, ) return exam diff --git a/kolibri/core/exams/serializers.py b/kolibri/core/exams/serializers.py index f3de2931040..a5c79507864 100644 --- a/kolibri/core/exams/serializers.py +++ b/kolibri/core/exams/serializers.py @@ -70,6 +70,7 @@ class Meta: "archive", "assignments", "learners_see_fixed_order", + "instant_report_visibility", "learner_ids", "draft", ) @@ -270,6 +271,10 @@ def update(self, instance, validated_data): # noqa instance.learners_see_fixed_order = validated_data.pop( "learners_see_fixed_order", instance.learners_see_fixed_order ) + instance.instant_report_visibility = validated_data.pop( + "instant_report_visibility", + instance.instant_report_visibility, + ) if not instance_is_draft: # Update the non-draft specific fields instance.active = validated_data.pop("active", instance.active) diff --git a/kolibri/core/exams/test/test_exam_api.py b/kolibri/core/exams/test/test_exam_api.py index 8e7d42fe9b7..88f451b6835 100644 --- a/kolibri/core/exams/test/test_exam_api.py +++ b/kolibri/core/exams/test/test_exam_api.py @@ -84,6 +84,7 @@ def setUpTestData(cls): } ], "learners_see_fixed_order": False, + "instant_report_visibility": True, } ], ) @@ -98,6 +99,7 @@ def make_basic_exam(self): "draft": self.draft, "collection": self.classroom.id, "learners_see_fixed_order": False, + "instant_report_visibility": True, "question_sources": sections, "assignments": [], } @@ -421,6 +423,7 @@ def test_retrieve_exam(self): "creator", "data_model_version", "learners_see_fixed_order", + "instant_report_visibility", "date_created", ]: self.assertIn(field, response.data) @@ -433,6 +436,7 @@ def test_post_exam_v2_model_fails(self): "active": True, "collection": self.classroom.id, "learners_see_fixed_order": False, + "instant_report_visibility": True, "question_sources": [], "assignments": [], "date_activated": None, @@ -503,6 +507,14 @@ def test_admin_can_update_learner_sees_fixed_order(self): self.assertEqual(response.status_code, 200) self.assertExamExists(id=self.exam.id, learners_see_fixed_order=True) + def test_admin_can_update_instant_report_visibility(self): + self.login_as_admin() + response = self.patch_updated_exam( + self.exam.id, {"instant_report_visibility": False} + ) + self.assertEqual(response.status_code, 200) + self.assertExamExists(id=self.exam.id, instant_report_visibility=False) + class ExamAPITestCase(BaseExamTest, APITestCase): class_object = models.Exam diff --git a/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js b/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js index 495bb93b5f0..a80292036c6 100644 --- a/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js +++ b/kolibri/plugins/coach/assets/src/composables/quizCreationSpecs.js @@ -191,4 +191,9 @@ export const Quiz = { type: Boolean, default: true, }, + // Default to quiz reports being visible immediately after learner submits quiz + instant_report_visibility: { + type: Boolean, + default: true, + }, }; diff --git a/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js b/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js index d28586e2c0e..7b35a3448cb 100644 --- a/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js +++ b/kolibri/plugins/coach/assets/src/composables/useQuizCreation.js @@ -26,6 +26,7 @@ const fieldsToSave = [ 'learner_ids', 'collection', 'learners_see_fixed_order', + 'instant_report_visibility', 'draft', 'active', 'archive', diff --git a/kolibri/plugins/coach/assets/src/modules/examShared/exams.js b/kolibri/plugins/coach/assets/src/modules/examShared/exams.js index 3b7f55623a6..8303f081bb2 100644 --- a/kolibri/plugins/coach/assets/src/modules/examShared/exams.js +++ b/kolibri/plugins/coach/assets/src/modules/examShared/exams.js @@ -15,6 +15,7 @@ export function examState(exam) { questionSources: exam.question_sources, assignments: exam.assignments, learnersSeeFixedOrder: exam.learners_see_fixed_order, + instantReportVisibility: exam.instant_report_visibility, dataModelVersion: exam.data_model_version, seed: exam.seed, }; diff --git a/kolibri/plugins/coach/assets/src/views/common/QuizStatus.vue b/kolibri/plugins/coach/assets/src/views/common/QuizStatus.vue index 7f85a71396c..d7c4edfa2f8 100644 --- a/kolibri/plugins/coach/assets/src/views/common/QuizStatus.vue +++ b/kolibri/plugins/coach/assets/src/views/common/QuizStatus.vue @@ -90,7 +90,7 @@ {{ $tr('reportVisibleToLearnersLabel') }} @@ -158,6 +158,28 @@ + +
+ + {{ coachString('reportVisibilityLabel') }} + + + {{ reportVisibilityStatus }} + +
+
- + option.value === this.instantReportVisibility, + ) || {} + ); + }, }, watch: { title() { @@ -300,6 +354,9 @@ adHocLearners() { this.$emit('update', { learner_ids: this.adHocLearners }); }, + instantReportVisibility() { + this.$emit('update', { instant_report_visibility: this.instantReportVisibility }); + }, submitObject() { if (this.showServerError) { this.$nextTick(() => { @@ -406,6 +463,21 @@ margin-left: -1em; } + /deep/ .ui-select-feedback { + background: #ffffff !important; + } + + /deep/ .ui-select-label { + background: #f5f5f5; + border-bottom-color: #666666; + border-bottom-style: solid; + border-bottom-width: 1px; + } + + .visibility-score-select { + border-bottom: 0 !important; + } + .style-icon { width: 2em; height: 2em; @@ -413,6 +485,13 @@ margin-left: 1em; } + .checkmark-style-icon { + width: 2em; + height: 2em; + margin-top: 0.5em; + margin-left: -1em; + } + fieldset { padding: 0; margin: 24px 0; diff --git a/kolibri/plugins/coach/assets/src/views/common/commonCoachStrings.js b/kolibri/plugins/coach/assets/src/views/common/commonCoachStrings.js index 7cdf5927826..f20b423ed6d 100644 --- a/kolibri/plugins/coach/assets/src/views/common/commonCoachStrings.js +++ b/kolibri/plugins/coach/assets/src/views/common/commonCoachStrings.js @@ -369,6 +369,29 @@ const coachStrings = createTranslator('CommonCoachStrings', { message: 'Ungrouped learners', context: 'Refers to learners who are not part of a specific group.', }, + reportVisibilityLabel: { + message: 'Report visibility', + context: 'Label for the switch that controls the visibility of the quiz report to learners.', + }, + afterLearnerSubmitsQuizLabel: { + message: 'After learner submits quiz', + context: 'Refers to option for learners to see their quiz report after they submit their quiz.', + }, + afterCoachEndsQuizLabel: { + message: 'After coach ends the quiz', + context: + 'Refers to option for learners to see their quiz report only after the coach ends the quiz.', + }, + afterLearnerSubmitsQuizDescription: { + message: 'Learners see their quiz report immediately after submitting', + context: + 'Description of the "After coach ends the quiz" option for quiz report visibility to learners.', + }, + afterCoachEndsQuizDescription: { + message: 'Learners see their quiz report only when the coach ends the quiz', + context: + 'Description of the "After learner submits quiz" option for quiz report visibility to learners.', + }, // notifications updatedNotification: { diff --git a/kolibri/plugins/learn/assets/src/views/LearnExamReportViewer.vue b/kolibri/plugins/learn/assets/src/views/LearnExamReportViewer.vue index ede7c01b342..ced4e89d1f2 100644 --- a/kolibri/plugins/learn/assets/src/views/LearnExamReportViewer.vue +++ b/kolibri/plugins/learn/assets/src/views/LearnExamReportViewer.vue @@ -2,9 +2,10 @@ @@ -32,6 +33,17 @@

+
+ +
+ {{ $tr('quizReportComingSoonDetails') }} +
+
+
@@ -43,6 +55,7 @@ import ExamReport from 'kolibri-common/components/quizzes/QuizReport'; import ImmersivePage from 'kolibri/components/pages/ImmersivePage'; import useUser from 'kolibri/composables/useUser'; + import commonCoreStrings from 'kolibri/uiText/commonCoreStrings'; import { PageNames, ClassesPageNames } from '../constants'; export default { @@ -56,6 +69,7 @@ ExamReport, ImmersivePage, }, + mixins: [commonCoreStrings], setup() { const { full_name, user_id } = useUser(); return { userName: full_name, userId: user_id }; @@ -81,6 +95,10 @@ name: PageNames.HOME, }; }, + reportVisible() { + // Show report if quiz is closed or if instant_report_visibility is true + return this.exam.archive || this.exam.instant_report_visibility; + }, }, methods: { navigateTo(tryIndex, questionNumber, interaction) { @@ -101,6 +119,11 @@ params: { classId: this.classId }, }); }, + openHomePage() { + this.$router.push({ + name: PageNames.HOME, + }); + }, }, $trs: { documentTitle: { @@ -113,6 +136,14 @@ context: 'Error message a user sees if there was a problem accessing a quiz report page. This is because the resource has been removed.', }, + quizReportComingSoon: { + message: 'Quiz report coming soon', + context: 'Message displayed when a quiz report is not yet available.', + }, + quizReportComingSoonDetails: { + message: 'You can see your quiz report when your coach shares it', + context: 'Details message displayed when a quiz report is not yet available.', + }, }, }; diff --git a/kolibri/plugins/learn/assets/src/views/cards/BaseCard.vue b/kolibri/plugins/learn/assets/src/views/cards/BaseCard.vue index 64fb5516a09..2a5d88aa9af 100644 --- a/kolibri/plugins/learn/assets/src/views/cards/BaseCard.vue +++ b/kolibri/plugins/learn/assets/src/views/cards/BaseCard.vue @@ -51,7 +51,13 @@ icon="inProgress" /> +