From 063be6a2bb6195de4c903edd3ae15c00db71cfe2 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 29 May 2026 11:58:16 -0700 Subject: [PATCH 1/2] Add unit_phase, active_unit_info, and test_learner_progress to CourseSessionViewset Co-Authored-By: Claude Sonnet 4.6 --- kolibri/core/courses/test/test_api.py | 218 ++++++++++++++++++++++++++ kolibri/core/courses/viewsets.py | 209 +++++++++++++++++++++--- 2 files changed, 401 insertions(+), 26 deletions(-) diff --git a/kolibri/core/courses/test/test_api.py b/kolibri/core/courses/test/test_api.py index 95e215eba94..fc97db42296 100644 --- a/kolibri/core/courses/test/test_api.py +++ b/kolibri/core/courses/test/test_api.py @@ -1,11 +1,16 @@ import uuid from django.urls import reverse +from django.utils import timezone +from le_utils.constants import content_kinds from le_utils.constants import modalities from rest_framework import status from rest_framework.test import APITestCase from .. import models +from ..models import TestType +from ..models import UnitPhase +from ..models import UnitTestAssignment from kolibri.core.auth.constants import collection_kinds from kolibri.core.auth.models import AdHocGroup from kolibri.core.auth.models import Classroom @@ -15,6 +20,9 @@ from kolibri.core.auth.test.helpers import provision_device from kolibri.core.content.models import ChannelMetadata from kolibri.core.content.models import ContentNode +from kolibri.core.logger.models import ContentSummaryLog +from kolibri.core.logger.models import MasteryLog +from kolibri.core.logger.utils.pre_post_test import get_synthetic_content_id DUMMY_PASSWORD = "password" @@ -694,6 +702,216 @@ def test_create_course_session_without_channel_sets_null(self): self.assertIsNone(session.channel_version) +class CourseSessionProgressAPITestCase(APITestCase): + """Tests for unit_phase, active_unit_number, active_unit_title, test_learner_progress.""" + + databases = "__all__" + + @classmethod + def setUpTestData(cls): + provision_device() + cls.facility = Facility.objects.create(name="ProgressFac") + cls.admin = FacilityUser.objects.create( + username="progressadmin", facility=cls.facility + ) + cls.admin.set_password(DUMMY_PASSWORD) + cls.admin.save() + cls.facility.add_admin(cls.admin) + + cls.learner1 = FacilityUser.objects.create( + username="learner1", facility=cls.facility + ) + cls.learner1.set_password(DUMMY_PASSWORD) + cls.learner1.save() + + cls.learner2 = FacilityUser.objects.create( + username="learner2", facility=cls.facility + ) + cls.learner2.set_password(DUMMY_PASSWORD) + cls.learner2.save() + + cls.classroom = Classroom.objects.create( + name="ProgressClass", parent=cls.facility + ) + cls.classroom.add_member(cls.learner1) + cls.classroom.add_member(cls.learner2) + + channel_id = uuid.uuid4().hex + cls.root_node = ContentNode.objects.create( + id=uuid.uuid4().hex, + channel_id=channel_id, + content_id=uuid.uuid4().hex, + available=True, + title="Channel Root", + ) + cls.channel = ChannelMetadata.objects.create( + id=channel_id, + name="Progress Test Channel", + version=1, + root=cls.root_node, + min_schema_version="1", + ) + cls.course = ContentNode.objects.create( + id=uuid.uuid4().hex, + channel_id=channel_id, + content_id=uuid.uuid4().hex, + parent=cls.root_node, + available=True, + modality=modalities.COURSE, + title="Test Course", + ) + cls.unit = ContentNode.objects.create( + id=uuid.uuid4().hex, + channel_id=channel_id, + content_id=uuid.uuid4().hex, + parent=cls.course, + available=True, + modality=modalities.UNIT, + title="Unit One", + ) + # Rebuild MPTT tree so lft/rght/level values are consistent + ContentNode.objects.rebuild() + + cls.course_session = models.CourseSession.objects.create( + is_active=True, + collection=cls.classroom, + created_by=cls.admin, + course=cls.course.id, + title=cls.course.title, + ) + # Assign the classroom as a recipient + models.CourseSessionAssignment.objects.create( + course_session=cls.course_session, + collection=cls.classroom, + assigned_by=cls.admin, + ) + + def _make_learner_log(self, user, content_id, complete): + sl = ContentSummaryLog.objects.create( + user=user, + content_id=content_id, + channel_id=None, + kind=content_kinds.QUIZ, + start_timestamp=timezone.now(), + ) + MasteryLog.objects.create( + user=user, + summarylog=sl, + complete=complete, + mastery_level=-1, + start_timestamp=timezone.now(), + ) + + def _get_session_data(self): + """Return the first (and only) item from the list endpoint.""" + self.client.login(username=self.admin.username, password=DUMMY_PASSWORD) + resp = self.client.get( + reverse("kolibri:core:coursesession-list"), + {"collection": self.classroom.id}, + ) + self.assertEqual(resp.status_code, 200) + items = [i for i in resp.data if str(i["id"]) == str(self.course_session.id)] + self.assertEqual(len(items), 1) + return items[0] + + def test_no_test_returns_pre_test_pending_and_null_progress(self): + data = self._get_session_data() + self.assertEqual(data["unit_phase"], UnitPhase.PreTestPending) + self.assertIsNone(data["test_learner_progress"]) + self.assertIsNotNone(data["active_unit_number"]) + self.assertEqual(data["active_unit_number"], 1) + self.assertEqual(data["active_unit_title"], self.unit.title) + + def test_pre_test_active_returns_correct_progress(self): + UnitTestAssignment.objects.create( + course_session=self.course_session, + unit_contentnode_id=self.unit.id, + collection=self.classroom, + test_type=TestType.Pre, + activated_by=self.admin, + closed=False, + ) + synthetic_cid = get_synthetic_content_id( + str(self.course_session.id), str(self.unit.id), TestType.Pre + ) + self._make_learner_log(self.learner1, synthetic_cid, True) + self._make_learner_log(self.learner2, synthetic_cid, False) + + data = self._get_session_data() + self.assertEqual(data["unit_phase"], UnitPhase.PreTestActive) + self.assertEqual(data["active_unit_number"], 1) + progress = data["test_learner_progress"] + self.assertIsNotNone(progress) + self.assertEqual(progress["completed"], 1) + self.assertEqual(progress["started"], 1) + self.assertEqual(progress["notStarted"], 0) + self.assertEqual(progress["total"], 2) + + def test_post_test_active_returns_correct_progress(self): + UnitTestAssignment.objects.create( + course_session=self.course_session, + unit_contentnode_id=self.unit.id, + collection=self.classroom, + test_type=TestType.Pre, + activated_by=self.admin, + closed=True, + ) + UnitTestAssignment.objects.create( + course_session=self.course_session, + unit_contentnode_id=self.unit.id, + collection=self.classroom, + test_type=TestType.Post, + activated_by=self.admin, + closed=False, + ) + synthetic_cid = get_synthetic_content_id( + str(self.course_session.id), str(self.unit.id), TestType.Post + ) + self._make_learner_log(self.learner1, synthetic_cid, True) + + data = self._get_session_data() + self.assertEqual(data["unit_phase"], UnitPhase.PostTestActive) + progress = data["test_learner_progress"] + self.assertIsNotNone(progress) + self.assertEqual(progress["completed"], 1) + self.assertEqual(progress["started"], 0) + self.assertEqual(progress["notStarted"], 1) + self.assertEqual(progress["total"], 2) + + def test_complete_phase_returns_post_test_progress(self): + UnitTestAssignment.objects.create( + course_session=self.course_session, + unit_contentnode_id=self.unit.id, + collection=self.classroom, + test_type=TestType.Pre, + activated_by=self.admin, + closed=True, + ) + UnitTestAssignment.objects.create( + course_session=self.course_session, + unit_contentnode_id=self.unit.id, + collection=self.classroom, + test_type=TestType.Post, + activated_by=self.admin, + closed=True, + ) + synthetic_cid = get_synthetic_content_id( + str(self.course_session.id), str(self.unit.id), TestType.Post + ) + self._make_learner_log(self.learner1, synthetic_cid, True) + self._make_learner_log(self.learner2, synthetic_cid, True) + + data = self._get_session_data() + self.assertEqual(data["unit_phase"], UnitPhase.Complete) + self.assertIsNone(data["active_unit_number"]) + progress = data["test_learner_progress"] + self.assertIsNotNone(progress) + self.assertEqual(progress["completed"], 2) + self.assertEqual(progress["started"], 0) + self.assertEqual(progress["notStarted"], 0) + self.assertEqual(progress["total"], 2) + + """" DISCLAIMER: Some parts of these tests were written with an AI assistance. I have reviewed and validated the generated tests diff --git a/kolibri/core/courses/viewsets.py b/kolibri/core/courses/viewsets.py index 6992ab54e68..85c5575bab8 100644 --- a/kolibri/core/courses/viewsets.py +++ b/kolibri/core/courses/viewsets.py @@ -1,4 +1,6 @@ +import itertools import logging +import types from collections import OrderedDict from django.db import transaction @@ -35,6 +37,8 @@ from kolibri.core.auth.utils.users import create_adhoc_group_for_learners from kolibri.core.content.models import ChannelMetadata from kolibri.core.content.models import ContentNode +from kolibri.core.logger.models import MasteryLog +from kolibri.core.logger.utils.pre_post_test import get_synthetic_content_id from kolibri.core.query import annotate_array_aggregate logger = logging.getLogger(__name__) @@ -377,6 +381,133 @@ def _compute_course_state(course_id, test): } +def _fetch_most_recent_tests(session_ids): + tests_by_session = {} + for t in ( + UnitTestAssignment.objects.filter(course_session_id__in=session_ids) + .annotate( + unit_lft=Subquery( + ContentNode.objects.filter(id=OuterRef("unit_contentnode_id")).values( + "lft" + )[:1] + ) + ) + # closed=False (open) sorts first; among closed, latest unit (highest lft) wins; + # for the same unit, "post" < "pre" alphabetically so post beats pre. + .order_by("course_session_id", "closed", "-unit_lft", "test_type") + .values("course_session_id", "unit_contentnode_id", "test_type", "closed") + ): + sid = t["course_session_id"] + if sid not in tests_by_session: + tests_by_session[sid] = types.SimpleNamespace( + unit_contentnode_id=t["unit_contentnode_id"], + test_type=t["test_type"], + closed=t["closed"], + ) + return tests_by_session + + +def _fetch_unit_info(course_ids): + unit_info = {} + rows = ( + ContentNode.objects.filter(parent_id__in=course_ids, modality=modalities.UNIT) + .order_by("parent_id", "lft") + .values("id", "title", "parent_id") + ) + for _, group in itertools.groupby(rows, key=lambda u: u["parent_id"]): + for number, unit in enumerate(group, start=1): + unit_info[str(unit["id"])] = {"number": number, "title": unit["title"]} + return unit_info + + +def _fetch_group_memberships(group_ids): + memberships_by_group = {} + if not group_ids: + return memberships_by_group + for m in Membership.objects.filter(collection_id__in=group_ids).values( + "collection_id", "user_id" + ): + gid = str(m["collection_id"]) + memberships_by_group.setdefault(gid, set()).add(m["user_id"]) + return memberships_by_group + + +def _assemble_learners(item, memberships_by_group): + group_members = set() + for gid in item.get("assignments", []): + group_members.update(memberships_by_group.get(str(gid), set())) + return group_members | set(item["learner_ids"]) + + +def _fetch_mastery_logs_batch(items, tests_by_session, learners_by_session): + content_id_to_learners = {} + for item in items: + test = tests_by_session.get(item["id"]) + if not test: + continue + content_id = get_synthetic_content_id( + str(item["id"]), str(test.unit_contentnode_id), test.test_type + ) + content_id_to_learners.setdefault(content_id, set()).update( + learners_by_session[item["id"]] + ) + + if not content_id_to_learners: + return {} + + mastery_by_content = {} + for ml in MasteryLog.objects.filter( + summarylog__content_id__in=content_id_to_learners, + ).values("summarylog__content_id", "summarylog__user_id", "complete"): + cid = ml["summarylog__content_id"] + uid = ml["summarylog__user_id"] + if uid not in mastery_by_content.setdefault(cid, {}) or ml["complete"]: + mastery_by_content[cid][uid] = ml["complete"] + + return mastery_by_content + + +def _compute_learner_progress(item, test, unit_info, all_learners, mastery_by_content): + course_state = _compute_course_state(str(item["course"]), test) + + item["unit_phase"] = course_state["unit_phase"] + active_unit_id = course_state["active_unit_id"] + + if active_unit_id and active_unit_id in unit_info: + info = unit_info[active_unit_id] + item["active_unit_number"] = info["number"] + item["active_unit_title"] = info["title"] + else: + item["active_unit_number"] = None + item["active_unit_title"] = None + + total = len(all_learners) + + if course_state["unit_phase"] == UnitPhase.PreTestPending or not test: + item["test_learner_progress"] = None + return + + content_id = get_synthetic_content_id( + str(item["id"]), + str(test.unit_contentnode_id), + test.test_type, + ) + learner_status = { + uid: complete + for uid, complete in mastery_by_content.get(content_id, {}).items() + if uid in all_learners + } + + completed = sum(1 for c in learner_status.values() if c) + started = len(learner_status) - completed + item["test_learner_progress"] = { + "completed": completed, + "started": started, + "notStarted": total - completed - started, + "total": total, + } + + class CourseSessionViewset(ValuesViewset): serializer_class = CourseSessionSerializer filter_backends = (KolibriAuthPermissionsFilter, DjangoFilterBackend) @@ -407,34 +538,60 @@ class CourseSessionViewset(ValuesViewset): } def consolidate(self, items, queryset): - if items: - course_session_ids = [l["id"] for l in items] - adhoc_assignments = CourseSessionAssignment.objects.filter( - course_session_id__in=course_session_ids, - collection__kind=ADHOCLEARNERSGROUP, + if not items: + return items + + course_session_ids = [l["id"] for l in items] + + adhoc_assignments = CourseSessionAssignment.objects.filter( + course_session_id__in=course_session_ids, + collection__kind=ADHOCLEARNERSGROUP, + ) + adhoc_assignments = annotate_array_aggregate( + adhoc_assignments, + filter=FacilityUser.get_is_active_q("collection__membership"), + learner_ids="collection__membership__user_id", + ) + adhoc_assignments = { + a["course_session"]: a + for a in adhoc_assignments.values( + "collection", "course_session", "learner_ids" ) - adhoc_assignments = annotate_array_aggregate( - adhoc_assignments, - filter=FacilityUser.get_is_active_q("collection__membership"), - learner_ids="collection__membership__user_id", + } + for item in items: + if item["id"] in adhoc_assignments: + adhoc_assignment = adhoc_assignments[item["id"]] + item["learner_ids"] = adhoc_assignments[item["id"]]["learner_ids"] + item["assignments"] = [ + i + for i in item["assignments"] + if i != adhoc_assignment["collection"] + ] + else: + item["learner_ids"] = [] + + tests_by_session = _fetch_most_recent_tests(course_session_ids) + course_ids = {str(item["course"]) for item in items} + unit_info = _fetch_unit_info(course_ids) + all_group_ids = set() + for item in items: + all_group_ids.update(str(gid) for gid in item.get("assignments", [])) + memberships_by_group = _fetch_group_memberships(all_group_ids) + learners_by_session = { + item["id"]: _assemble_learners(item, memberships_by_group) for item in items + } + mastery_by_content = _fetch_mastery_logs_batch( + items, tests_by_session, learners_by_session + ) + + for item in items: + _compute_learner_progress( + item, + tests_by_session.get(item["id"]), + unit_info, + learners_by_session[item["id"]], + mastery_by_content, ) - adhoc_assignments = { - a["course_session"]: a - for a in adhoc_assignments.values( - "collection", "course_session", "learner_ids" - ) - } - for item in items: - if item["id"] in adhoc_assignments: - adhoc_assignment = adhoc_assignments[item["id"]] - item["learner_ids"] = adhoc_assignments[item["id"]]["learner_ids"] - item["assignments"] = [ - i - for i in item["assignments"] - if i != adhoc_assignment["collection"] - ] - else: - item["learner_ids"] = [] return items From 3f41f56306a7511b9f7fe8516b68a7613d96c57e Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 29 May 2026 11:58:47 -0700 Subject: [PATCH 2/2] Replace placeholder columns with real Status, Recipients, and Learner Progress data Co-Authored-By: Claude Sonnet 4.6 --- .../views/courses/CoursesRootPage.vue | 92 ++++++++++++++++--- .../courses/__tests__/CoursesRootPage.spec.js | 71 +++++++++++++- .../kolibri-common/strings/coursesStrings.js | 14 +++ 3 files changed, 162 insertions(+), 15 deletions(-) diff --git a/kolibri/plugins/coach/frontend/views/courses/CoursesRootPage.vue b/kolibri/plugins/coach/frontend/views/courses/CoursesRootPage.vue index 55e5196900e..ec3f987c58b 100644 --- a/kolibri/plugins/coach/frontend/views/courses/CoursesRootPage.vue +++ b/kolibri/plugins/coach/frontend/views/courses/CoursesRootPage.vue @@ -90,8 +90,47 @@ /> -