Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add quiz report visibility control for coaches #13064

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion kolibri/core/exams/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class ExamViewset(ValuesViewset):
"creator",
"data_model_version",
"learners_see_fixed_order",
"instant_report_visibility",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing I can think of here, is that we should probably return True for this if the backend value is null - that will save the frontend from having to worry about the nullability of the field, and leave it entirely as a backend concern (as we only really introduced it for the purposes of migration).

"date_created",
)

Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
5 changes: 5 additions & 0 deletions kolibri/core/exams/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions kolibri/core/exams/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class Meta:
"archive",
"assignments",
"learners_see_fixed_order",
"instant_report_visibility",
"learner_ids",
"draft",
)
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions kolibri/core/exams/test/test_exam_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def setUpTestData(cls):
}
],
"learners_see_fixed_order": False,
"instant_report_visibility": True,
}
],
)
Expand All @@ -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": [],
}
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const fieldsToSave = [
'learner_ids',
'collection',
'learners_see_fixed_order',
'instant_report_visibility',
'draft',
'active',
'archive',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
29 changes: 28 additions & 1 deletion kolibri/plugins/coach/assets/src/views/common/QuizStatus.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
<span> {{ $tr('reportVisibleToLearnersLabel') }} </span>
<StatusElapsedTime
v-if="exam.active"
:date="examDateOpened"
:date="exam.instant_report_visibility ? examDateOpened : examDateArchived"
actionType="madeVisible"
style="font-weight: normal"
/>
Expand Down Expand Up @@ -158,6 +158,28 @@
</KGridItem>
</div>

<!-- Report Visibility -->
<div
v-if="!exam.archive"
class="status-item"
>
<KGridItem
class="status-label"
:layout4="{ span: 4 }"
:layout8="{ span: 4 }"
:layout12="layout12Label"
>
<span>{{ coachString('reportVisibilityLabel') }}</span>
</KGridItem>
<KGridItem
:layout4="{ span: 4 }"
:layout8="{ span: 4 }"
:layout12="layout12Value"
>
<span>{{ reportVisibilityStatus }}</span>
</KGridItem>
</div>

<!-- Class name -->
<div class="status-item">
<KGridItem
Expand Down Expand Up @@ -394,6 +416,11 @@
return null;
}
},
reportVisibilityStatus() {
return this.exam.instant_report_visibility
? this.coachString('afterLearnerSubmitsQuizLabel')
: this.coachString('afterCoachEndsQuizLabel');
},
layout12Label() {
return { span: this.$isPrint ? 3 : 12 };
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@

<KGridItem
:layout4="{ span: 3 }"
:layout8="{ span: 7 }"
:layout12="{ span: 11 }"
:layout8="{ span: assignmentIsQuiz ? 3 : 7 }"
:layout12="{ span: assignmentIsQuiz ? 5 : 11 }"
>
<KTextbox
ref="titleField"
Expand All @@ -47,11 +47,37 @@
@keydown.enter="submitData"
/>
</KGridItem>
<KGridItem
:layout4="{ span: 1 }"
:layout8="{ span: 1 }"
:layout12="{ span: 1 }"
/>
<template v-if="assignmentIsQuiz">
<KGridItem
:layout4="{ span: 1 }"
:layout8="{ span: 1, alignment: 'left' }"
:layout12="{ span: 1, alignment: 'left' }"
>
<KIcon
icon="circleCheckmark"
:class="windowIsSmall ? 'style-icon' : 'checkmark-style-icon'"
/>
</KGridItem>
<KGridItem
:layout4="{ span: 3 }"
:layout8="{ span: 3 }"
:layout12="{ span: 5 }"
>
<KSelect
:label="reportVisibilityLabel$()"
:options="reportVisibilityOptions"
:value="reportVisibilityValue"
:help="
instantReportVisibility
? afterLearnerSubmitsQuizDescription$()
: afterCoachEndsQuizDescription$()
"
:style="windowIsSmall ? 'margin-left: -1em' : 'margin-left: -3em'"
class="visibility-score-select"
@change="option => (instantReportVisibility = option.value)"
/>
</KGridItem>
</template>
<KGridItem
:layout4="{ span: 3 }"
:layout8="{ span: 7 }"
Expand Down Expand Up @@ -132,6 +158,7 @@
import UiAlert from 'kolibri-design-system/lib/keen/UiAlert';
import BottomAppBar from 'kolibri/components/BottomAppBar';
import commonCoreStrings from 'kolibri/uiText/commonCoreStrings';
import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow';
import { coachStrings } from '../../common/commonCoachStrings';
import RecipientSelector from './RecipientSelector';
import SidePanelRecipientsSelector from './SidePanelRecipientsSelector';
Expand All @@ -146,6 +173,7 @@
},
mixins: [commonCoreStrings],
setup() {
const { windowIsSmall } = useKResponsiveWindow();
const {
recipientsLabel$,
descriptionLabel$,
Expand All @@ -154,15 +182,26 @@
saveQuizError$,
quizDuplicateTitleError$,
lessonDuplicateTitleError$,
reportVisibilityLabel$,
afterLearnerSubmitsQuizLabel$,
afterCoachEndsQuizLabel$,
afterLearnerSubmitsQuizDescription$,
afterCoachEndsQuizDescription$,
} = coachStrings;
return {
windowIsSmall,
recipientsLabel$,
descriptionLabel$,
titleLabel$,
saveLessonError$,
saveQuizError$,
quizDuplicateTitleError$,
lessonDuplicateTitleError$,
reportVisibilityLabel$,
afterLearnerSubmitsQuizLabel$,
afterCoachEndsQuizLabel$,
afterLearnerSubmitsQuizDescription$,
afterCoachEndsQuizDescription$,
};
},
props: {
Expand Down Expand Up @@ -217,6 +256,7 @@
formIsSubmitted: false,
showServerError: false,
showTitleError: false,
instantReportVisibility: this.assignment.instant_report_visibility,
};
},
computed: {
Expand Down Expand Up @@ -284,8 +324,22 @@
assignments: this.selectedCollectionIds,
active: this.activeIsSelected,
learner_ids: this.adHocLearners,
instant_report_visibility: this.instantReportVisibility,
};
},
reportVisibilityOptions() {
return [
{ label: this.afterLearnerSubmitsQuizLabel$(), value: true },
{ label: this.afterCoachEndsQuizLabel$(), value: false },
];
},
reportVisibilityValue() {
return (
this.reportVisibilityOptions.find(
option => option.value === this.instantReportVisibility,
) || {}
);
},
},
watch: {
title() {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -406,13 +463,35 @@
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;
margin-top: 0.5em;
margin-left: 1em;
}

.checkmark-style-icon {
width: 2em;
height: 2em;
margin-top: 0.5em;
margin-left: -1em;
}

fieldset {
padding: 0;
margin: 24px 0;
Expand Down
Loading
Loading