From 4c262244db93b62d96193429010128c198f3a62b Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Thu, 26 Mar 2026 16:16:14 -0500 Subject: [PATCH 1/4] Format biome config --- biome.json | 303 +++++++++++++++++++++++++++-------------------------- 1 file changed, 154 insertions(+), 149 deletions(-) diff --git a/biome.json b/biome.json index 3a96e1661..f367b446e 100644 --- a/biome.json +++ b/biome.json @@ -1,151 +1,156 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", - "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, - "files": { - "ignoreUnknown": false, - "includes": ["**/*.js", "**/*.mjs", "!**/node_modules/**", "!frontend-dist/**"] - }, - "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 }, - "linter": { - "enabled": true, - "rules": { - "recommended": false, - "complexity": { - "noAdjacentSpacesInRegex": "error", - "noArguments": "error", - "noCommaOperator": "error", - "noExtraBooleanCast": "error", - "noImplicitCoercions": "off", - "noUselessCatch": "error", - "noUselessConstructor": "error", - "noUselessEscapeInRegex": "error", - "noUselessLabel": "error", - "noUselessLoneBlockStatements": "error", - "noUselessRename": "error", - "noUselessStringConcat": "error", - "noUselessTernary": "error", - "noUselessUndefinedInitialization": "error", - "noVoid": "error", - "useLiteralKeys": "error", - "useMaxParams": "off", - "useNumericLiterals": "error", - "useRegexLiterals": "error" - }, - "correctness": { - "noConstAssign": "error", - "noConstantCondition": "warn", - "noConstructorReturn": "error", - "noEmptyCharacterClassInRegex": "error", - "noEmptyPattern": "error", - "noGlobalObjectCalls": "error", - "noInnerDeclarations": "error", - "noInvalidConstructorSuper": "error", - "noInvalidUseBeforeDeclaration": "error", - "noNodejsModules": "off", - "noNonoctalDecimalEscape": "error", - "noPrecisionLoss": "error", - "noSelfAssign": "error", - "noSetterReturn": "error", - "noSwitchDeclarations": "error", - "noUndeclaredDependencies": "error", - "noUndeclaredVariables": "error", - "noUnreachable": "error", - "noUnreachableSuper": "error", - "noUnsafeFinally": "error", - "noUnsafeOptionalChaining": "error", - "noUnusedLabels": "error", - "noUnusedPrivateClassMembers": "off", - "noUnusedVariables": "error", - "useIsNan": "error", - "useParseIntRadix": "error", - "useValidForDirection": "error", - "useValidTypeof": "error", - "useYield": "error" - }, - "performance": { "noAwaitInLoops": "error" }, - "security": { "noGlobalEval": "error" }, - "style": { - "noCommonJs": "off", - "noDefaultExport": "off", - "noNegationElse": "off", - "noNestedTernary": "error", - "noParameterAssign": "error", - "noRestrictedGlobals": { - "level": "error", - "options": { - "deniedGlobals": { - "isFinite": "Use Number.isFinite instead https://github.com/airbnb/javascript#standard-library--isfinite", - "isNaN": "Use Number.isNaN instead https://github.com/airbnb/javascript#standard-library--isnan" - } - } - }, - "noRestrictedImports": "off", - "noYodaExpression": "error", - "useArrayLiterals": "error", - "useBlockStatements": "error", - "useCollapsedElseIf": "error", - "useConsistentArrowReturn": "error", - "useConsistentBuiltinInstantiation": "error", - "useConst": "error", - "useDefaultParameterLast": "error", - "useDefaultSwitchClause": "error", - "useExponentiationOperator": "error", - "useExportsLast": "off", - "useGroupedAccessorPairs": "error", - "useObjectSpread": "error", - "useShorthandAssign": "error", - "useSingleVarDeclarator": "error", - "useSymbolDescription": "error", - "useTemplate": "error" - }, - "suspicious": { - "noAlert": "warn", - "noAsyncPromiseExecutor": "error", - "noBitwiseOperators": "error", - "noCatchAssign": "error", - "noClassAssign": "error", - "noCompareNegZero": "error", - "noConsole": "warn", - "noControlCharactersInRegex": "error", - "noDebugger": "error", - "noDoubleEquals": "error", - "noDuplicateCase": "error", - "noDuplicateClassMembers": "error", - "noDuplicateElseIf": "error", - "noDuplicateObjectKeys": "error", - "noDuplicateParameters": "error", - "noEmptyBlockStatements": "error", - "noFallthroughSwitchClause": "error", - "noFunctionAssign": "error", - "noGlobalAssign": "error", - "noImportAssign": "error", - "noImportCycles": "error", - "noIrregularWhitespace": "error", - "noLabelVar": "error", - "noMisleadingCharacterClass": "error", - "noOctalEscape": "error", - "noPrototypeBuiltins": "error", - "noRedeclare": "error", - "noSelfCompare": "error", - "noShadowRestrictedNames": "error", - "noSparseArray": "error", - "noTemplateCurlyInString": "error", - "noUnsafeNegation": "error", - "noUnusedExpressions": "error", - "noUselessRegexBackrefs": "error", - "noVar": "error", - "noWith": "error", - "useAwait": "off", - "useDefaultSwitchClauseLast": "error", - "useGetterReturn": "error", - "useGuardForIn": "error", - "useIterableCallbackReturn": "error" - } - } - }, - "javascript": { "formatter": { "quoteStyle": "single" } }, - "assist": { - "enabled": true, - "actions": { "source": { "organizeImports": "on" } } - } + "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "ignoreUnknown": false, + "includes": [ + "**/*.js", + "**/*.mjs", + "!**/node_modules/**", + "!frontend-dist/**" + ] + }, + "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 }, + "linter": { + "enabled": true, + "rules": { + "recommended": false, + "complexity": { + "noAdjacentSpacesInRegex": "error", + "noArguments": "error", + "noCommaOperator": "error", + "noExtraBooleanCast": "error", + "noImplicitCoercions": "off", + "noUselessCatch": "error", + "noUselessConstructor": "error", + "noUselessEscapeInRegex": "error", + "noUselessLabel": "error", + "noUselessLoneBlockStatements": "error", + "noUselessRename": "error", + "noUselessStringConcat": "error", + "noUselessTernary": "error", + "noUselessUndefinedInitialization": "error", + "noVoid": "error", + "useLiteralKeys": "error", + "useMaxParams": "off", + "useNumericLiterals": "error", + "useRegexLiterals": "error" + }, + "correctness": { + "noConstAssign": "error", + "noConstantCondition": "warn", + "noConstructorReturn": "error", + "noEmptyCharacterClassInRegex": "error", + "noEmptyPattern": "error", + "noGlobalObjectCalls": "error", + "noInnerDeclarations": "error", + "noInvalidConstructorSuper": "error", + "noInvalidUseBeforeDeclaration": "error", + "noNodejsModules": "off", + "noNonoctalDecimalEscape": "error", + "noPrecisionLoss": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUndeclaredDependencies": "error", + "noUndeclaredVariables": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedLabels": "error", + "noUnusedPrivateClassMembers": "off", + "noUnusedVariables": "error", + "useIsNan": "error", + "useParseIntRadix": "error", + "useValidForDirection": "error", + "useValidTypeof": "error", + "useYield": "error" + }, + "performance": { "noAwaitInLoops": "error" }, + "security": { "noGlobalEval": "error" }, + "style": { + "noCommonJs": "off", + "noDefaultExport": "off", + "noNegationElse": "off", + "noNestedTernary": "error", + "noParameterAssign": "error", + "noRestrictedGlobals": { + "level": "error", + "options": { + "deniedGlobals": { + "isFinite": "Use Number.isFinite instead https://github.com/airbnb/javascript#standard-library--isfinite", + "isNaN": "Use Number.isNaN instead https://github.com/airbnb/javascript#standard-library--isnan" + } + } + }, + "noRestrictedImports": "off", + "noYodaExpression": "error", + "useArrayLiterals": "error", + "useBlockStatements": "error", + "useCollapsedElseIf": "error", + "useConsistentArrowReturn": "error", + "useConsistentBuiltinInstantiation": "error", + "useConst": "error", + "useDefaultParameterLast": "error", + "useDefaultSwitchClause": "error", + "useExponentiationOperator": "error", + "useExportsLast": "off", + "useGroupedAccessorPairs": "error", + "useObjectSpread": "error", + "useShorthandAssign": "error", + "useSingleVarDeclarator": "error", + "useSymbolDescription": "error", + "useTemplate": "error" + }, + "suspicious": { + "noAlert": "warn", + "noAsyncPromiseExecutor": "error", + "noBitwiseOperators": "error", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCompareNegZero": "error", + "noConsole": "warn", + "noControlCharactersInRegex": "error", + "noDebugger": "error", + "noDoubleEquals": "error", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateElseIf": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noEmptyBlockStatements": "error", + "noFallthroughSwitchClause": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "error", + "noImportAssign": "error", + "noImportCycles": "error", + "noIrregularWhitespace": "error", + "noLabelVar": "error", + "noMisleadingCharacterClass": "error", + "noOctalEscape": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noSelfCompare": "error", + "noShadowRestrictedNames": "error", + "noSparseArray": "error", + "noTemplateCurlyInString": "error", + "noUnsafeNegation": "error", + "noUnusedExpressions": "error", + "noUselessRegexBackrefs": "error", + "noVar": "error", + "noWith": "error", + "useAwait": "off", + "useDefaultSwitchClauseLast": "error", + "useGetterReturn": "error", + "useGuardForIn": "error", + "useIterableCallbackReturn": "error" + } + } + }, + "javascript": { "formatter": { "quoteStyle": "single" } }, + "assist": { + "enabled": true, + "actions": { "source": { "organizeImports": "on" } } + } } From 9ae746a2e762dfbe75bf2f3a49d53a360d897bfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:20:00 +0000 Subject: [PATCH 2/4] Add per-total-score quartile sparklines for each question in flow analytics - Add compute_session_quartile_map() to analytics.py to assign sessions to Q1-Q4 quartiles based on total score percentage - Add quartile_correctness_list field and quartile_correctness_json property to PageAnswerStats dataclass - Update make_page_answer_stats_list() to compute per-quartile average correctness for each page - Add frontend/js/analytics.js using sparklines npm package to render per-quartile sparklines for each question - Add bundle-analytics to rollup.config.mjs - Update analytics-flow.html to show sparkline charts and load analytics bundle - Install sparklines npm package - Add PageAnswerStatsTest tests for quartile_correctness_json" Co-authored-by: inducer <352067+inducer@users.noreply.github.com> Agent-Logs-Url: https://github.com/inducer/relate/sessions/90d505da-f8b0-4ecc-8317-d826bd923f6a Add unit tests for compute_session_quartile_map Tests cover: empty set, sessions without points, in-progress sessions, single-session, 4 sessions (one per quartile), small-N edge cases (2 and 3 sessions), and ordering by score regardless of insertion order. Agent-Logs-Url: https://github.com/inducer/relate/sessions/890b63ca-7ebb-4571-ab4c-6844838e9121 Co-authored-by: inducer <352067+inducer@users.noreply.github.com> Translations Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Add distinct Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Fix mid-file import Fix HTML --- .ci/run-tests-for-ci.sh | 1 + course/analytics.py | 65 ++++++++- course/templates/course/analytics-flow.html | 21 ++- frontend/js/analytics.js | 29 ++++ package-lock.json | 12 ++ package.json | 4 +- rollup.config.mjs | 10 ++ tests/test_analytics.py | 147 ++++++++++++++++++++ 8 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 frontend/js/analytics.js diff --git a/.ci/run-tests-for-ci.sh b/.ci/run-tests-for-ci.sh index af4cb1408..be9dcbf55 100755 --- a/.ci/run-tests-for-ci.sh +++ b/.ci/run-tests-for-ci.sh @@ -14,6 +14,7 @@ if [[ "$OSTYPE" != msys ]]; then fi staticfiles=( + bundle-analysis.js bundle-base.js bundle-base-with-markup.js bundle-codemirror.js diff --git a/course/analytics.py b/course/analytics.py index bffe3df61..f237c6362 100644 --- a/course/analytics.py +++ b/course/analytics.py @@ -23,6 +23,8 @@ THE SOFTWARE. """ +import json +import operator from dataclasses import dataclass from typing import TYPE_CHECKING, Any, ClassVar, final @@ -286,6 +288,42 @@ def make_grade_histogram(pctx: CoursePageContext, flow_id: str): return hist +NUM_QUARTILES = 4 + + +def compute_session_quartile_map( + pctx: CoursePageContext, + flow_id: str) -> dict[int, int]: + """Return a mapping from session ID to quartile index (0 = Q1, ..., 3 = Q4). + + Quartiles are assigned based on each session's percentage score, using only + completed sessions for participants with grade-statistics permission. + """ + sessions = FlowSession.objects.filter( + course=pctx.course, + flow_id=flow_id, + in_progress=False, + participation__roles__permissions__permission=( + PPerm.included_in_grade_statistics) + ).distinct() + + session_scores: list[tuple[int, float]] = [] + for session in sessions: + pperc = session.points_percentage() + if pperc is not None: + session_scores.append((session.id, float(pperc))) + + if not session_scores: + return {} + + session_scores.sort(key=operator.itemgetter(1)) + n = len(session_scores) + return { + sid: min(NUM_QUARTILES - 1, int(i * NUM_QUARTILES / n)) + for i, (sid, _score) in enumerate(session_scores) + } + + @dataclass class PageAnswerStats: group_id: str @@ -296,6 +334,13 @@ class PageAnswerStats: answer_count: int total_count: int url: str | None + # Average correctness (0–1) per score quartile (index 0 = Q1 … 3 = Q4). + # A quartile entry is None when no graded visits exist for that quartile. + quartile_correctness_list: list[float | None] + + @property + def quartile_correctness_json(self) -> str: + return json.dumps(self.quartile_correctness_list) @property def average_wrongness(self): @@ -343,6 +388,8 @@ def make_page_answer_stats_list( page_cache = PageInstanceCache(pctx.repo, pctx.course, flow_id) + session_quartile_map = compute_session_quartile_map(pctx, flow_id) + page_info_list: list[PageAnswerStats] = [] for group_desc in flow_desc.groups: for page_desc in group_desc.pages: @@ -353,6 +400,9 @@ def make_page_answer_stats_list( answer_count = 0 total_count = 0 + quartile_points: list[float] = [0.0] * NUM_QUARTILES + quartile_graded_counts: list[int] = [0] * NUM_QUARTILES + visits = (FlowPageVisit.objects .filter( flow_session__course=pctx.course, @@ -415,9 +465,21 @@ def make_page_answer_stats_list( graded_count += 1 + quartile = session_quartile_map.get( + visit.flow_session_id) + if quartile is not None: + quartile_points[quartile] += answer_feedback.correctness + quartile_graded_counts[quartile] += 1 + if not answer_expected: continue + quartile_correctness_list: list[float | None] = [ + safe_div(quartile_points[q], quartile_graded_counts[q]) + if quartile_graded_counts[q] > 0 + else None + for q in range(NUM_QUARTILES)] + page_info_list.append( PageAnswerStats( group_id=group_desc.id, @@ -435,7 +497,8 @@ def make_page_answer_stats_list( flow_id, group_desc.id, page_desc.id, - )))) + )), + quartile_correctness_list=quartile_correctness_list)) return page_info_list diff --git a/course/templates/course/analytics-flow.html b/course/templates/course/analytics-flow.html index 9d56f91bc..2b61822db 100644 --- a/course/templates/course/analytics-flow.html +++ b/course/templates/course/analytics-flow.html @@ -1,5 +1,10 @@ {% extends "course/course-base-with-markup.html" %} {% load i18n %} +{% load static %} + +{% block header_extra %} + +{% endblock %} {% block title %} {% trans "Analytics" %} - {{ relate_site_name }} @@ -49,8 +54,11 @@

{% trans "Grade Distribution" %}

{% trans "Page-by-Page Statistics" %}

+ + {% for astats in page_answer_stats_list %} -
+
+ + + + {% endfor %} -
+
{% trans "Question" %}{% trans "Score by quartile" %}
{% if astats.url %} {{ astats.group_id }}/{{ astats.page_id }}: @@ -86,9 +94,16 @@

{% trans "Page-by-Page Statistics" %}

{{ astats.average_emptiness_percent|floatformat:1 }}% - +
+ +

{% trans "Time Distribution" %}

diff --git a/frontend/js/analytics.js b/frontend/js/analytics.js new file mode 100644 index 000000000..b70e12363 --- /dev/null +++ b/frontend/js/analytics.js @@ -0,0 +1,29 @@ +import Sparkline from 'sparklines'; + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.relate-sparkline').forEach((el) => { + const rawData = el.dataset.points; + if (!rawData) { + return; + } + const raw = JSON.parse(rawData); + // Only include quartiles that have actual data (non-null). + const available = raw + .map((v, i) => ({ quartile: i + 1, pct: v === null ? null : v * 100 })) + .filter((d) => d.pct !== null); + if (available.length === 0) { + return; + } + const points = available.map((d) => d.pct); + Sparkline.draw(el, points, { + width: 64, + lineColor: '#0d6efd', + startColor: 'transparent', + endColor: 'transparent', + minValue: 0, + maxValue: 100, + tooltip: (_value, index) => + `Q${available[index].quartile}: ${points[index].toFixed(1)}%`, + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 2417ee1f2..e9ac5eafa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "prosemirror-view": "^1.41.6", "select2": "^4.0.5", "select2-bootstrap-theme": "^0.1.0-beta.10", + "sparklines": "^1.3.0", "video.js": "^8.23.7" }, "devDependencies": { @@ -6294,6 +6295,12 @@ "node": ">=0.10.0" } }, + "node_modules/sparklines": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sparklines/-/sparklines-1.3.0.tgz", + "integrity": "sha512-CkFtpDE3hmOeu1IJyIQIOH0AQtHnPj1c61ALxJZQ9cPEFKDgWC1fcNAHuwPi1i1klTDYvlKKseoYHSwe7JmdLA==", + "license": "MIT" + }, "node_modules/spdx-correct": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", @@ -11210,6 +11217,11 @@ } } }, + "sparklines": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sparklines/-/sparklines-1.3.0.tgz", + "integrity": "sha512-CkFtpDE3hmOeu1IJyIQIOH0AQtHnPj1c61ALxJZQ9cPEFKDgWC1fcNAHuwPi1i1klTDYvlKKseoYHSwe7JmdLA==" + }, "spdx-correct": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", diff --git a/package.json b/package.json index 4cdcc0b8f..91f8169d8 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "prosemirror-view": "^1.41.6", "select2": "^4.0.5", "select2-bootstrap-theme": "^0.1.0-beta.10", + "sparklines": "^1.3.0", "video.js": "^8.23.7" }, "//dependencies": { @@ -80,8 +81,9 @@ "build:datatables": "rollup --config --configBundle=datatables", "build:codemirror": "rollup --config --configBundle=codemirror", "build:prosemirror": "rollup --config --configBundle=prosemirror", + "build:analytics": "rollup --config --configBundle=analytics", "build": "run-p build:*", "dev": "rollup --config --watch", "lint": "biome check frontend/js/ rollup.config.mjs" } -} +} \ No newline at end of file diff --git a/rollup.config.mjs b/rollup.config.mjs index 57d1eb1ac..f7ac10133 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -88,6 +88,16 @@ const bundles = { name: 'rlProsemirror', }, }, + analytics: { + input: 'frontend/js/analytics.js', + output: { + // "analytics" as a file name is commonly blocked (e.g. by uBlock) + file: 'frontend-dist/bundle-analysis.js', + format: 'iife', + sourcemap: true, + }, + plugins: defaultPlugins, + }, }; export default function (commandLineArgs) { diff --git a/tests/test_analytics.py b/tests/test_analytics.py index dd9f18c2b..b430cc1d1 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -22,6 +22,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +import json import pytest from django.core.exceptions import ObjectDoesNotExist @@ -140,6 +141,152 @@ def test_non_wide_html_template_render(self): self.assertTemplateUsed("course/histogram.html") +class PageAnswerStatsTest(CoursesTestMixinBase, TestCase): + """Test analytics.PageAnswerStats JSON serialization.""" + + def _make_stats(self, quartile_correctness_list): + return analytics.PageAnswerStats( + group_id="grp", + page_id="pg", + title="Test", + average_correctness=0.5, + average_emptiness=0.1, + answer_count=5, + total_count=6, + url=None, + quartile_correctness_list=quartile_correctness_list, + ) + + def test_quartile_correctness_json_values(self): + stats = self._make_stats([0.25, 0.5, 0.75, 1.0]) + parsed = json.loads(stats.quartile_correctness_json) + self.assertEqual(parsed, [0.25, 0.5, 0.75, 1.0]) + + def test_quartile_correctness_json_with_none(self): + stats = self._make_stats([None, 0.5, None, 1.0]) + parsed = json.loads(stats.quartile_correctness_json) + self.assertIsNone(parsed[0]) + self.assertEqual(parsed[1], 0.5) + self.assertIsNone(parsed[2]) + self.assertEqual(parsed[3], 1.0) + + def test_quartile_correctness_json_all_none(self): + stats = self._make_stats([None, None, None, None]) + parsed = json.loads(stats.quartile_correctness_json) + self.assertEqual(parsed, [None, None, None, None]) + + +@pytest.mark.slow +class ComputeSessionQuartileMapTest(SingleCourseTestMixin, TestCase): + """Test analytics.compute_session_quartile_map.""" + + FLOW_ID = "test-quartile-flow" + + def _make_pctx(self): + pctx = mock.MagicMock() + pctx.course = self.course + return pctx + + def _make_session(self, participation, points, max_points, + in_progress=False): + return factories.FlowSessionFactory( + participation=participation, + flow_id=self.FLOW_ID, + points=points, + max_points=max_points, + in_progress=in_progress, + ) + + def test_no_sessions_returns_empty_map(self): + pctx = self._make_pctx() + result = analytics.compute_session_quartile_map(pctx, self.FLOW_ID) + self.assertEqual(result, {}) + + def test_sessions_without_points_excluded(self): + pctx = self._make_pctx() + self._make_session(self.student_participation, None, 10) + result = analytics.compute_session_quartile_map(pctx, self.FLOW_ID) + self.assertEqual(result, {}) + + def test_in_progress_sessions_excluded(self): + pctx = self._make_pctx() + self._make_session( + self.student_participation, 80, 100, in_progress=True) + result = analytics.compute_session_quartile_map(pctx, self.FLOW_ID) + self.assertEqual(result, {}) + + def test_single_session_assigned_to_first_quartile(self): + pctx = self._make_pctx() + session = self._make_session(self.student_participation, 7, 10) + result = analytics.compute_session_quartile_map(pctx, self.FLOW_ID) + # n=1: i=0 → int(0*4/1)=0 + self.assertEqual(result, {session.id: 0}) + + def test_four_sessions_one_per_quartile(self): + pctx = self._make_pctx() + participations = [ + factories.ParticipationFactory(course=self.course) + for _ in range(4) + ] + sessions = [ + self._make_session(p, score, 100) + for p, score in zip( + participations, [25, 50, 75, 100], strict=True) + ] + result = analytics.compute_session_quartile_map(pctx, self.FLOW_ID) + self.assertEqual(result[sessions[0].id], 0) # 25% -> Q1 + self.assertEqual(result[sessions[1].id], 1) # 50% -> Q2 + self.assertEqual(result[sessions[2].id], 2) # 75% -> Q3 + self.assertEqual(result[sessions[3].id], 3) # 100% -> Q4 + + def test_two_sessions_small_n(self): + pctx = self._make_pctx() + p1 = factories.ParticipationFactory(course=self.course) + p2 = factories.ParticipationFactory(course=self.course) + s_low = self._make_session(p1, 30, 100) + s_high = self._make_session(p2, 80, 100) + result = analytics.compute_session_quartile_map(pctx, self.FLOW_ID) + # n=2: i=0 → int(0*4/2)=0; i=1 → int(1*4/2)=2 + self.assertEqual(result[s_low.id], 0) + self.assertEqual(result[s_high.id], 2) + + def test_three_sessions_small_n(self): + pctx = self._make_pctx() + participations = [ + factories.ParticipationFactory(course=self.course) + for _ in range(3) + ] + sessions = [ + self._make_session(p, score, 100) + for p, score in zip( + participations, [10, 50, 90], strict=True) + ] + result = analytics.compute_session_quartile_map(pctx, self.FLOW_ID) + # n=3: i=0→0, i=1→int(4/3)=1, i=2→int(8/3)=2 + self.assertEqual(result[sessions[0].id], 0) + self.assertEqual(result[sessions[1].id], 1) + self.assertEqual(result[sessions[2].id], 2) + + def test_sorted_by_score(self): + """Sessions are ranked by score, not by insertion order.""" + pctx = self._make_pctx() + p1 = factories.ParticipationFactory(course=self.course) + p2 = factories.ParticipationFactory(course=self.course) + p3 = factories.ParticipationFactory(course=self.course) + p4 = factories.ParticipationFactory(course=self.course) + # Create sessions in non-sorted order + s90 = self._make_session(p1, 90, 100) + s10 = self._make_session(p2, 10, 100) + s50 = self._make_session(p3, 50, 100) + s70 = self._make_session(p4, 70, 100) + result = analytics.compute_session_quartile_map(pctx, self.FLOW_ID) + # Sorted order: 10→Q1, 50→Q2, 70→Q3, 90→Q4 + self.assertEqual(result[s10.id], 0) + self.assertEqual(result[s50.id], 1) + self.assertEqual(result[s70.id], 2) + self.assertEqual(result[s90.id], 3) + + @pytest.mark.slow class PageAnalyticsTest(SingleCourseTestMixin, TestCase): """test analytics.page_analytics, (for cases not covered by other tests)""" From 26bf91853ae398322954a05a7e36ffc98c16b487 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Thu, 26 Mar 2026 16:11:10 -0500 Subject: [PATCH 3/4] Optimize flow analytics via a subquery --- course/analytics.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/course/analytics.py b/course/analytics.py index f237c6362..aeb2ffe06 100644 --- a/course/analytics.py +++ b/course/analytics.py @@ -33,13 +33,14 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import connection +from django.db.models import FloatField, OuterRef, Subquery from django.urls import reverse from django.utils.translation import gettext as _, pgettext from pytools import not_none from course.constants import FlowPermission, ParticipationPermission as PPerm from course.content import FlowDesc, get_flow_desc -from course.models import FlowPageVisit, FlowSession +from course.models import FlowPageVisit, FlowPageVisitGrade, FlowSession from course.utils import ( CoursePageContext, PageInstanceCache, @@ -425,9 +426,17 @@ def make_page_answer_stats_list( .distinct("page_data__id") .order_by("page_data__id", "-visit_time")) + latest_grade_correctness = Subquery( + FlowPageVisitGrade.objects.filter( + visit=OuterRef("pk"), + ).order_by("-grade_time").values("correctness")[:1], + output_field=FloatField(), + ) + visits = (visits .select_related("flow_session") - .select_related("page_data")) + .select_related("page_data") + .annotate(latest_grade_correctness=latest_grade_correctness)) answer_expected = False @@ -447,7 +456,7 @@ def make_page_answer_stats_list( title = page.page_title(grading_page_context, visit.page_data.data) - answer_feedback = visit.get_most_recent_feedback() + correctness: float | None = visit.latest_grade_correctness if visit.answer is not None: answer_count += 1 @@ -456,19 +465,18 @@ def make_page_answer_stats_list( total_count += 1 - if (answer_feedback is not None - and answer_feedback.correctness is not None): + if correctness is not None: if visit.answer is None: - assert answer_feedback.correctness == 0 + assert correctness == 0 else: - points += answer_feedback.correctness + points += correctness graded_count += 1 quartile = session_quartile_map.get( visit.flow_session_id) if quartile is not None: - quartile_points[quartile] += answer_feedback.correctness + quartile_points[quartile] += correctness quartile_graded_counts[quartile] += 1 if not answer_expected: From e99858775f04b4c3e657d72c1786ba7cbca45465 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Thu, 26 Mar 2026 16:17:56 -0500 Subject: [PATCH 4/4] Update baseline --- .basedpyright/baseline.json | 104 ++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index 7d57f000a..9324164a5 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -3173,6 +3173,30 @@ "lineCount": 1 } }, + { + "code": "reportUnknownVariableType", + "range": { + "startColumn": 16, + "endColumn": 27, + "lineCount": 1 + } + }, + { + "code": "reportUnknownMemberType", + "range": { + "startColumn": 44, + "endColumn": 74, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 50, + "endColumn": 74, + "lineCount": 1 + } + }, { "code": "reportUnknownMemberType", "range": { @@ -3189,6 +3213,46 @@ "lineCount": 1 } }, + { + "code": "reportUnknownVariableType", + "range": { + "startColumn": 24, + "endColumn": 30, + "lineCount": 1 + } + }, + { + "code": "reportUnknownMemberType", + "range": { + "startColumn": 28, + "endColumn": 49, + "lineCount": 1 + } + }, + { + "code": "reportUnknownArgumentType", + "range": { + "startColumn": 28, + "endColumn": 49, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 34, + "endColumn": 49, + "lineCount": 1 + } + }, + { + "code": "reportUnknownArgumentType", + "range": { + "startColumn": 53, + "endColumn": 59, + "lineCount": 1 + } + }, { "code": "reportUnknownMemberType", "range": { @@ -43651,6 +43715,46 @@ "lineCount": 1 } }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 26, + "endColumn": 51, + "lineCount": 1 + } + }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 28, + "endColumn": 41, + "lineCount": 1 + } + }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 43, + "endColumn": 49, + "lineCount": 1 + } + }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 51, + "endColumn": 61, + "lineCount": 1 + } + }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 22, + "endColumn": 33, + "lineCount": 1 + } + }, { "code": "reportImplicitOverride", "range": {