From bba66788e8d542ac72b592084789382a3a71c744 Mon Sep 17 00:00:00 2001 From: Marcella Maki Date: Tue, 1 Sep 2026 10:35:27 -0400 Subject: [PATCH 1/2] Ensure coach course table progress is dynamically updated, without a refresh needed --- .../useCourseNotificationPolling.spec.js | 145 ++++++++++++++++++ .../composables/__tests__/useCourses.spec.js | 15 ++ .../useCourseNotificationPolling.js | 26 +++- .../coach/frontend/composables/useCourses.js | 11 +- .../views/courses/CoursesRootPage.vue | 11 ++ .../courses/__tests__/CoursesRootPage.spec.js | 82 +++++++++- 6 files changed, 284 insertions(+), 6 deletions(-) diff --git a/kolibri/plugins/coach/frontend/composables/__tests__/useCourseNotificationPolling.spec.js b/kolibri/plugins/coach/frontend/composables/__tests__/useCourseNotificationPolling.spec.js index d57c9036653..8f900ef1b9d 100644 --- a/kolibri/plugins/coach/frontend/composables/__tests__/useCourseNotificationPolling.spec.js +++ b/kolibri/plugins/coach/frontend/composables/__tests__/useCourseNotificationPolling.spec.js @@ -122,4 +122,149 @@ describe('useCourseNotificationPolling', () => { await nextTick(); expect(callback).toHaveBeenCalledTimes(1); }); + + describe('classroom-wide mode (courseSessionId is null)', () => { + // coachNotifications is already scoped to the current classroom (see + // modules/coachNotifications/index.js), so callers with no single + // course session to filter on (e.g. the Courses table) pass `null` + // for courseSessionId and get notified of any new notification. + + it('does not fire during setup, treating the existing timestamp as the baseline', async () => { + const store = makeStore({ timestamp: '2024-01-01T09:00:00Z' }); + const callback = jest.fn(); + + useCourseNotificationPolling(store, null, callback); + await nextTick(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('fires for a course-related notification, regardless of which session', async () => { + const notifications = reactive([]); + const store = makeStore({ notifications }); + const callback = jest.fn(); + + useCourseNotificationPolling(store, null, callback); + + notifications.push({ + id: 1, + course_session_id: 'some-other-session', + timestamp: '2024-01-01T10:00:00Z', + }); + store.getters['coachNotifications/maxNotificationTimestamp'] = '2024-01-01T10:00:00Z'; + + await nextTick(); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('ignores a notification with no course_session_id (e.g. a lesson or exercise event)', async () => { + const notifications = reactive([]); + const store = makeStore({ notifications }); + const callback = jest.fn(); + + useCourseNotificationPolling(store, null, callback); + + notifications.push({ + id: 1, + course_session_id: null, + timestamp: '2024-01-01T10:00:00Z', + }); + store.getters['coachNotifications/maxNotificationTimestamp'] = '2024-01-01T10:00:00Z'; + + await nextTick(); + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not fire again if the timestamp is unchanged', async () => { + const notifications = reactive([ + { id: 1, course_session_id: 'session-123', timestamp: '2024-01-01T10:00:00Z' }, + ]); + const store = makeStore({ timestamp: '2024-01-01T10:00:00Z', notifications }); + const callback = jest.fn(); + + useCourseNotificationPolling(store, null, callback); + + // Advance once - the callback should fire and the baseline should move. + notifications.push({ + id: 2, + course_session_id: 'session-123', + timestamp: '2024-01-01T10:01:00Z', + }); + store.getters['coachNotifications/maxNotificationTimestamp'] = '2024-01-01T10:01:00Z'; + await nextTick(); + expect(callback).toHaveBeenCalledTimes(1); + + // Reassigning an earlier timestamp is a genuine change (from Vue's + // perspective) but should be rejected by the newMs <= baselineMs guard. + store.getters['coachNotifications/maxNotificationTimestamp'] = '2024-01-01T10:00:30Z'; + await nextTick(); + expect(callback).toHaveBeenCalledTimes(1); + }); + + describe('scoped to the current classroom', () => { + it('ignores a notification for a different classroom', async () => { + const notifications = reactive([]); + const store = makeStore({ notifications }); + const callback = jest.fn(); + const classId = ref('classroom-123'); + + useCourseNotificationPolling(store, null, callback, classId); + + notifications.push({ + id: 1, + course_session_id: 'session-123', + classroom_id: 'other-classroom', + timestamp: '2024-01-01T10:00:00Z', + }); + store.getters['coachNotifications/maxNotificationTimestamp'] = '2024-01-01T10:00:00Z'; + + await nextTick(); + expect(callback).not.toHaveBeenCalled(); + }); + + it('fires for a notification matching the current classroom', async () => { + const notifications = reactive([]); + const store = makeStore({ notifications }); + const callback = jest.fn(); + const classId = ref('classroom-123'); + + useCourseNotificationPolling(store, null, callback, classId); + + notifications.push({ + id: 1, + course_session_id: 'session-123', + classroom_id: 'classroom-123', + timestamp: '2024-01-01T10:00:00Z', + }); + store.getters['coachNotifications/maxNotificationTimestamp'] = '2024-01-01T10:00:00Z'; + + await nextTick(); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('resets when classId changes, so a stale timestamp does not prevent polling', async () => { + const notifications = reactive([]); + const store = makeStore({ timestamp: '2024-01-01T10:00:00Z', notifications }); + const callback = jest.fn(); + const classId = ref('classroom-a'); + + useCourseNotificationPolling(store, null, callback, classId); + + classId.value = 'classroom-b'; + store.getters['coachNotifications/maxNotificationTimestamp'] = '2024-01-01T09:00:00Z'; + await nextTick(); + + notifications.push({ + id: 1, + course_session_id: 'session-123', + classroom_id: 'classroom-b', + timestamp: '2024-01-01T09:30:00Z', + }); + store.getters['coachNotifications/maxNotificationTimestamp'] = '2024-01-01T09:30:00Z'; + + await nextTick(); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/kolibri/plugins/coach/frontend/composables/__tests__/useCourses.spec.js b/kolibri/plugins/coach/frontend/composables/__tests__/useCourses.spec.js index d72b91f56ed..791325aa7ae 100644 --- a/kolibri/plugins/coach/frontend/composables/__tests__/useCourses.spec.js +++ b/kolibri/plugins/coach/frontend/composables/__tests__/useCourses.spec.js @@ -79,6 +79,21 @@ describe('useCourses', () => { expect(coursesAreLoading.value).toBe(false); }); + + it('does not toggle when refreshClassCourses is called with silent=true', async () => { + CourseSessionResource.list.mockResolvedValue([ + { id: 'session-1', course: 'content-1', missing_resource: false }, + ]); + + const { refreshClassCourses, coursesAreLoading } = useCourses(); + const refreshPromise = refreshClassCourses(true); + + expect(coursesAreLoading.value).toBe(false); + + await refreshPromise; + + expect(coursesAreLoading.value).toBe(false); + }); }); describe('refreshClassCourses', () => { diff --git a/kolibri/plugins/coach/frontend/composables/useCourseNotificationPolling.js b/kolibri/plugins/coach/frontend/composables/useCourseNotificationPolling.js index 10c1359538a..36df5ad3a47 100644 --- a/kolibri/plugins/coach/frontend/composables/useCourseNotificationPolling.js +++ b/kolibri/plugins/coach/frontend/composables/useCourseNotificationPolling.js @@ -1,9 +1,13 @@ import { watch } from 'vue'; +// courseSessionId is a ref to the single course session to filter on, or +// `null` for classroom-wide mode (any new notification is relevant, since +// coachNotifications is already scoped to the current classroom). export default function useCourseNotificationPolling( store, courseSessionId, onRelevantNotifications, + classId, ) { const initial = store.getters['coachNotifications/maxNotificationTimestamp']; let baselineMs = initial ? new Date(initial).getTime() : 0; @@ -11,8 +15,28 @@ export default function useCourseNotificationPolling( watch( () => store.getters['coachNotifications/maxNotificationTimestamp'], newTimestamp => { - if (!newTimestamp || !courseSessionId.value) return; + if (!newTimestamp) return; const newMs = new Date(newTimestamp).getTime(); + if (newMs <= baselineMs) return; + + if (!courseSessionId) { + // Classroom-wide mode: coachNotifications is already scoped to the + // current classroom, so any advance in the timestamp is relevant. + const notifications = store.state.coachNotifications.notifications; + const hasRelevant = notifications.some( + n => + n.course_session_id && + (!classId || n.classroom_id === classId.value) && + new Date(n.timestamp).getTime() > baselineMs, + ); + baselineMs = newMs; + if (hasRelevant) { + onRelevantNotifications(); + } + return; + } + + if (!courseSessionId.value) return; const notifications = store.state.coachNotifications.notifications; const hasRelevant = notifications.some( n => diff --git a/kolibri/plugins/coach/frontend/composables/useCourses.js b/kolibri/plugins/coach/frontend/composables/useCourses.js index 21b96b87a56..f5dc727d4c9 100644 --- a/kolibri/plugins/coach/frontend/composables/useCourses.js +++ b/kolibri/plugins/coach/frontend/composables/useCourses.js @@ -33,8 +33,11 @@ export function useCourses() { removeCourse(courseId); } - async function refreshClassCourses() { - setCoursesAreLoading(true); + async function refreshClassCourses(silent = false) { + // for polling requests; prevents a blank flash + if (!silent) { + setCoursesAreLoading(true); + } try { const courseSessions = await CourseSessionResource.list({ collection: classId.value }); @@ -49,7 +52,9 @@ export function useCourses() { setCourses(mappedSessions); return mappedSessions; } finally { - setCoursesAreLoading(false); + if (!silent) { + setCoursesAreLoading(false); + } } } diff --git a/kolibri/plugins/coach/frontend/views/courses/CoursesRootPage.vue b/kolibri/plugins/coach/frontend/views/courses/CoursesRootPage.vue index f14607f217e..b91ddb9a693 100644 --- a/kolibri/plugins/coach/frontend/views/courses/CoursesRootPage.vue +++ b/kolibri/plugins/coach/frontend/views/courses/CoursesRootPage.vue @@ -237,6 +237,7 @@ import CoachHeader from '../common/CoachHeader.vue'; import { overrideRoute } from '../../utils'; import { useCourses } from '../../composables/useCourses'; + import useCourseNotificationPolling from '../../composables/useCourseNotificationPolling'; import { coachStrings } from '../common/commonCoachStrings'; import emptyPlusCloudSvg from '../../images/empty_plus_cloud.svg'; import useClassSummary from '../../composables/useClassSummary'; @@ -479,6 +480,16 @@ }, ); + const currentClassId = computed(() => route.params.classId); + useCourseNotificationPolling( + store, + null, + // swallow failures, then retry on polling errors + // prevents the global error page when user didn't initiate + () => refreshClassCourses(true).catch(() => {}), + currentClassId, + ); + function courseHasRecipients(course) { return ( (course.assignments && course.assignments.length > 0) || diff --git a/kolibri/plugins/coach/frontend/views/courses/__tests__/CoursesRootPage.spec.js b/kolibri/plugins/coach/frontend/views/courses/__tests__/CoursesRootPage.spec.js index d8120841ff5..eb56aa93af1 100644 --- a/kolibri/plugins/coach/frontend/views/courses/__tests__/CoursesRootPage.spec.js +++ b/kolibri/plugins/coach/frontend/views/courses/__tests__/CoursesRootPage.spec.js @@ -5,6 +5,7 @@ import VueRouter from 'vue-router'; import '@testing-library/jest-dom'; import { coreStrings } from 'kolibri/uiText/commonCoreStrings'; import { coursesStrings } from 'kolibri-common/strings/coursesStrings'; +import { handleApiError } from 'kolibri/utils/appError'; import CoursesRootPage from '../CoursesRootPage.vue'; import { UnitPhase } from '../../../constants/courseConstants'; // eslint-disable-next-line import-x/named @@ -21,6 +22,10 @@ const { entireClassLabel$ } = coachStrings; jest.mock('../../../composables/useCourses'); jest.mock('../../../composables/useClassSummary'); +jest.mock('kolibri/utils/appError', () => ({ + ...jest.requireActual('kolibri/utils/appError'), + handleApiError: jest.fn(), +})); function makeStore() { return new Vuex.Store({ @@ -32,13 +37,27 @@ function makeStore() { namespaced: true, state: { id: 'class-123' }, }, + coachNotifications: { + namespaced: true, + state: { notifications: [] }, + getters: { + maxNotificationTimestamp: state => + state.notifications.length > 0 ? state.notifications[0].timestamp : 0, + }, + mutations: { + SET_NOTIFICATIONS(state, notifications) { + state.notifications = notifications; + }, + }, + }, }, }); } function renderComponent() { - return render(CoursesRootPage, { - store: makeStore(), + const store = makeStore(); + const utils = render(CoursesRootPage, { + store, routes: new VueRouter({ routes: [ { path: '/', name: 'CoursesRoot' }, @@ -46,6 +65,7 @@ function renderComponent() { ], }), }); + return { ...utils, store }; } describe('CoursesRootPage', () => { @@ -132,6 +152,64 @@ describe('CoursesRootPage', () => { expect(toggle).toBeDisabled(); }); + it('refetches courses when a new coach notification arrives', async () => { + const { refreshClassCourses } = useCoursesMock(); + useCourses.mockImplementation(() => + useCoursesMock({ + refreshClassCourses, + courses: ref([{ id: 'session-1', title: 'Course 1', active: true, contentMissing: false }]), + }), + ); + + const { store } = renderComponent(); + await global.flushPromises(); + refreshClassCourses.mockClear(); + + store.commit('coachNotifications/SET_NOTIFICATIONS', [ + { + id: 1, + course_session_id: 'session-1', + classroom_id: undefined, + timestamp: '2024-01-01T10:00:00Z', + }, + ]); + await global.flushPromises(); + + expect(refreshClassCourses).toHaveBeenCalledTimes(1); + }); + + it('does not surface a global error when a poll-triggered refresh fails', async () => { + const COURSE_TITLE = 'Course 1'; + const refreshClassCourses = jest.fn().mockRejectedValue(new Error('network error')); + useCourses.mockImplementation(() => + useCoursesMock({ + refreshClassCourses, + courses: ref([ + { id: 'session-1', title: COURSE_TITLE, active: true, contentMissing: false }, + ]), + }), + ); + + const { store } = renderComponent(); + await global.flushPromises(); + refreshClassCourses.mockClear(); + handleApiError.mockClear(); + + store.commit('coachNotifications/SET_NOTIFICATIONS', [ + { + id: 1, + course_session_id: 'session-1', + classroom_id: undefined, + timestamp: '2024-01-01T10:00:00Z', + }, + ]); + await global.flushPromises(); + + expect(refreshClassCourses).toHaveBeenCalledTimes(1); + expect(handleApiError).not.toHaveBeenCalled(); + expect(screen.getByText(COURSE_TITLE)).toBeInTheDocument(); + }); + it('should enable visibility toggle for courses with content present', () => { useCourses.mockImplementation(() => useCoursesMock({ From b31109d2bac5daac7b14bc758b0128b5a2a13234 Mon Sep 17 00:00:00 2001 From: Marcella Maki Date: Tue, 1 Sep 2026 19:19:48 -0400 Subject: [PATCH 2/2] Add backend changes to support pre-post test notifications --- kolibri/core/notifications/api.py | 84 +++++++++++- kolibri/core/notifications/test/test_api.py | 126 ++++++++++++++++++ .../useCourseNotificationPolling.js | 7 + 3 files changed, 211 insertions(+), 6 deletions(-) diff --git a/kolibri/core/notifications/api.py b/kolibri/core/notifications/api.py index 45175469985..8829070e62b 100644 --- a/kolibri/core/notifications/api.py +++ b/kolibri/core/notifications/api.py @@ -6,10 +6,12 @@ from django.db.models import Sum from django.db.models import When from le_utils.constants import content_kinds +from le_utils.constants import exercises from kolibri.core.content.models import ContentNode from kolibri.core.courses.models import CourseSession from kolibri.core.courses.models import CourseSessionAssignment +from kolibri.core.courses.models import UnitTestAssignment from kolibri.core.exams.models import Exam from kolibri.core.exams.models import ExamAssignment from kolibri.core.lessons.models import Lesson @@ -18,6 +20,7 @@ from kolibri.core.logger.models import ExamAttemptLog from kolibri.core.logger.models import ExamLog from kolibri.core.logger.models import MasteryLog +from kolibri.core.logger.utils.pre_post_test import get_synthetic_content_id from kolibri.core.logger.utils.quiz import annotate_response_summary from kolibri.core.query import annotate_array_aggregate @@ -908,28 +911,97 @@ def parse_attemptslog(attemptlog, contentnode_id=None, course_session_id=None): save_notifications(notifications) +_IS_COACH_MONITORED_QUIZ = Q( + masterylog__mastery_criterion__contains="coach_assigned" +) | Q(masterylog__mastery_criterion__contains=exercises.PRE_POST_TEST) +_IS_COACH_MONITORED_QUIZ_MASTERYLOG = Q( + mastery_criterion__contains="coach_assigned" +) | Q(mastery_criterion__contains=exercises.PRE_POST_TEST) + + +def _resolve_prepost_test_course_session_id(user, content_id, cache=None): + """ + A pre/post test's content_id is a one-way hash of + (course_session_id, unit_id, test_type) - see get_synthetic_content_id - + so it can't be decoded back into a course_session_id directly. Instead, + reverse-match it against the small set of UnitTestAssignment rows + reachable via the user's classroom memberships. + + Returns the matching course_session_id, or None if none matches (e.g. + the assignment has since been deleted). + """ + cache_key = (user.id, content_id) + if cache is not None and cache_key in cache: + return cache[cache_key] + + candidate_collection_ids = user.memberships.all().values_list( + "collection_id", flat=True + ) + candidates = ( + UnitTestAssignment.objects.filter(collection_id__in=candidate_collection_ids) + .values("course_session_id", "unit_contentnode_id", "test_type") + .distinct() + ) + result = None + for candidate in candidates: + if ( + get_synthetic_content_id( + candidate["course_session_id"], + candidate["unit_contentnode_id"], + candidate["test_type"], + ) + == content_id + ): + result = candidate["course_session_id"] + break + + if cache is not None: + cache[cache_key] = result + return result + + def batch_process_attemptlogs(attemptlog_ids): for attemptlog in AttemptLog.objects.filter(id__in=attemptlog_ids).exclude( - masterylog__mastery_criterion__contains="coach_assigned" + _IS_COACH_MONITORED_QUIZ ): parse_attemptslog(attemptlog) def batch_process_masterylogs_for_quizzes(masterylog_ids, attemptlog_ids): + course_session_cache = {} for attemptlog in ( AttemptLog.objects.filter(id__in=attemptlog_ids) - .filter(masterylog__mastery_criterion__contains="coach_assigned") + .filter(_IS_COACH_MONITORED_QUIZ) + .select_related("masterylog", "user") .annotate(quiz_id=F("masterylog__summarylog__content_id")) .order_by("start_timestamp") ): - quiz_answered_notification(attemptlog, attemptlog.quiz_id) + course_session_id = None + if ( + attemptlog.masterylog.mastery_criterion.get("type") + == exercises.PRE_POST_TEST + ): + course_session_id = _resolve_prepost_test_course_session_id( + attemptlog.user, attemptlog.quiz_id, cache=course_session_cache + ) + if course_session_id is None: + continue + quiz_answered_notification(attemptlog, attemptlog.quiz_id, course_session_id) for masterylog in ( MasteryLog.objects.filter(id__in=masterylog_ids) - .filter(mastery_criterion__contains="coach_assigned") + .filter(_IS_COACH_MONITORED_QUIZ_MASTERYLOG) + .select_related("user") .annotate(quiz_id=F("summarylog__content_id")) ): - quiz_started_notification(masterylog, masterylog.quiz_id) - quiz_completed_notification(masterylog, masterylog.quiz_id) + course_session_id = None + if masterylog.mastery_criterion.get("type") == exercises.PRE_POST_TEST: + course_session_id = _resolve_prepost_test_course_session_id( + masterylog.user, masterylog.quiz_id, cache=course_session_cache + ) + if course_session_id is None: + continue + quiz_started_notification(masterylog, masterylog.quiz_id, course_session_id) + quiz_completed_notification(masterylog, masterylog.quiz_id, course_session_id) def batch_process_examlogs(examlog_ids, examattemptlog_ids): diff --git a/kolibri/core/notifications/test/test_api.py b/kolibri/core/notifications/test/test_api.py index 5fad5098f69..bb9e38d15d2 100644 --- a/kolibri/core/notifications/test/test_api.py +++ b/kolibri/core/notifications/test/test_api.py @@ -1,7 +1,10 @@ import uuid from datetime import timedelta +from django.db import connection +from django.test.utils import CaptureQueriesContext from le_utils.constants import content_kinds +from le_utils.constants import exercises from mock import patch from rest_framework.test import APITestCase @@ -13,6 +16,8 @@ from kolibri.core.content.models import ContentNode from kolibri.core.courses.models import CourseSession from kolibri.core.courses.models import CourseSessionAssignment +from kolibri.core.courses.models import TestType +from kolibri.core.courses.models import UnitTestAssignment from kolibri.core.exams.models import Exam from kolibri.core.exams.models import ExamAssignment from kolibri.core.lessons.models import Lesson @@ -1986,6 +1991,13 @@ def setUpTestData(cls): cls.synthetic_content_id = get_synthetic_content_id( cls.course_session.id, cls.unit_node.id, "pre" ) + cls.unit_test_assignment = UnitTestAssignment.objects.create( + course_session=cls.course_session, + unit_contentnode_id=cls.unit_node.id, + collection=cls.classroom, + test_type=TestType.Pre, + activated_by=cls.superuser, + ) def _create_prepost_masterylog(self, complete=False): summarylog = ContentSummaryLogFactory.create( @@ -2002,6 +2014,11 @@ def _create_prepost_masterylog(self, complete=False): mastery_level=-1, complete=complete, completion_timestamp=now if complete else None, + mastery_criterion={ + "type": exercises.PRE_POST_TEST, + "version": "A", + "test_type": "pre", + }, ) return masterylog @@ -2200,6 +2217,115 @@ def test_prepost_test_completed_not_duplicated(self): == 1 ) + def test_batch_process_masterylogs_for_quizzes_creates_prepost_notifications(self): + """ + Regression test: the sync-time batch processor (used by the morango + post-transfer hook to regenerate notifications for an LOD sync) used + to filter on mastery_criterion__contains="coach_assigned", which + pre/post-test masterylogs never have, silently dropping them. It + should now recover the course_session_id via UnitTestAssignment + reverse-matching and create correctly-tagged notifications. + """ + masterylog = self._create_prepost_masterylog(complete=True) + batch_process_masterylogs_for_quizzes([masterylog.id], []) + + started = LearnerProgressNotification.objects.filter( + quiz_id=self.synthetic_content_id, + notification_event=NotificationEventType.Started, + ) + assert started.count() == 1 + assert started[0].course_session_id == self.course_session.id + assert started[0].classroom_id == self.classroom.id + + completed = LearnerProgressNotification.objects.filter( + quiz_id=self.synthetic_content_id, + notification_event=NotificationEventType.Completed, + ) + assert completed.count() == 1 + assert completed[0].course_session_id == self.course_session.id + assert completed[0].classroom_id == self.classroom.id + + def test_batch_process_masterylogs_for_quizzes_no_notification_without_assignment( + self, + ): + """ + If no UnitTestAssignment matches the synthetic content_id (e.g. the + assignment was deleted, or this isn't actually a course pre/post + test), no course_session_id can be recovered - no notification + should be created rather than one with a broken/empty classroom_id. + """ + self.unit_test_assignment.delete() + masterylog = self._create_prepost_masterylog(complete=True) + batch_process_masterylogs_for_quizzes([masterylog.id], []) + + assert not LearnerProgressNotification.objects.filter( + quiz_id=self.synthetic_content_id, + ).exists() + + def test_batch_process_masterylogs_for_quizzes_creates_prepost_answered(self): + masterylog = self._create_prepost_masterylog() + sessionlog = ContentSessionLogFactory.create( + user=self.user1, + content_id=self.synthetic_content_id, + channel_id=None, + kind=content_kinds.QUIZ, + ) + now = local_now() + attemptlog = AttemptLog.objects.create( + masterylog=masterylog, + sessionlog=sessionlog, + user=self.user1, + item="test_item", + start_timestamp=now, + end_timestamp=now + timedelta(seconds=5), + time_spent=5.0, + complete=True, + correct=1, + ) + batch_process_masterylogs_for_quizzes([], [attemptlog.id]) + + answered = LearnerProgressNotification.objects.filter( + quiz_id=self.synthetic_content_id, + notification_event=NotificationEventType.Answered, + ) + assert answered.count() == 1 + assert answered[0].course_session_id == self.course_session.id + assert answered[0].classroom_id == self.classroom.id + + def test_batch_process_masterylogs_for_quizzes_reuses_course_session_lookup(self): + masterylog = self._create_prepost_masterylog() + sessionlog = ContentSessionLogFactory.create( + user=self.user1, + content_id=self.synthetic_content_id, + channel_id=None, + kind=content_kinds.QUIZ, + ) + now = local_now() + attemptlogs = [ + AttemptLog.objects.create( + masterylog=masterylog, + sessionlog=sessionlog, + user=self.user1, + item="test_item_{}".format(i), + start_timestamp=now, + end_timestamp=now + timedelta(seconds=5), + time_spent=5.0, + complete=True, + correct=1, + ) + for i in range(3) + ] + + with CaptureQueriesContext(connection) as ctx: + batch_process_masterylogs_for_quizzes([], [a.id for a in attemptlogs]) + + unit_test_assignment_queries = [ + q + for q in ctx.captured_queries + if "courses_unittestassignment" in q["sql"].lower() + ] + assert len(unit_test_assignment_queries) == 1 + class CourseSessionRegressionTestCase(APITestCase): """ diff --git a/kolibri/plugins/coach/frontend/composables/useCourseNotificationPolling.js b/kolibri/plugins/coach/frontend/composables/useCourseNotificationPolling.js index 36df5ad3a47..986648a493a 100644 --- a/kolibri/plugins/coach/frontend/composables/useCourseNotificationPolling.js +++ b/kolibri/plugins/coach/frontend/composables/useCourseNotificationPolling.js @@ -12,6 +12,13 @@ export default function useCourseNotificationPolling( const initial = store.getters['coachNotifications/maxNotificationTimestamp']; let baselineMs = initial ? new Date(initial).getTime() : 0; + if (classId) { + watch(classId, () => { + const current = store.getters['coachNotifications/maxNotificationTimestamp']; + baselineMs = current ? new Date(current).getTime() : 0; + }); + } + watch( () => store.getters['coachNotifications/maxNotificationTimestamp'], newTimestamp => {