feat(b2b_dashboard): return per-status counts on the learner-progress envelope - #68
Conversation
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The manager-facing descriptions misstate exclusive bucket semantics, and the new test fixture violates the documented count invariant.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (2)
What changed in this PR
Adds filter-scoped completion-status aggregates to the learner-progress response, reducing clients’ need for repeated count requests.
Changes:
- Computes four status buckets in the existing count query.
- Exposes counts through a documented nested response model.
- Adds endpoint and schema tests.
| File | Description |
|---|---|
learner_queries.py |
Adds conditional status aggregates. |
learner_models.py |
Defines the status-count response schema. |
routers/learners.py |
Maps aggregate results into the response. |
test_dashboard_learner_progress.py |
Tests status counts, filtering, and descriptions. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Addressed Copilot's review: fixed the test fixture's total_count/bucket-sum mismatch (149ee9f) and clarified the completion_status_counts field descriptions to state bucket precedence (certified beats passed beats in_progress beats not_started), since a certified enrollment can also carry a passing grade but counts only once. Both threads resolved; all checks green. |
149ee9f to
59f6c56
Compare
OpenAPI ChangesShow/hide changesUnexpected changes? Ensure your branch is up-to-date with |
… envelope MIT Learn's learner directory renders four summary tiles (Enrollments, Not started, In progress, Completed) above the learner table. The other three came from three extra /learner-progress requests with limit=1 and a completion_status filter, each paying for an org-manager check, a page query and a full COUNT(*) scan regardless of limit. This adds completion_status_counts to LearnerProgressResponse, computed as conditional SUMs alongside the outcomes_withheld_count SUM already in the count query, so the breakdown costs no extra scan. not_started, in_progress, passed and certified come from mutually exclusive branches of _COMPLETION_STATUS, so the buckets never overlap, and consent-withheld rows land in none of them: the buckets plus outcomes_withheld_count sum to total_count. The counts share the response's own WHERE clause, so they narrow along with search/status/include_inactive filters rather than staying contract-wide. That's the cheaper option (the count query already has the clause) and keeps the envelope internally consistent; a contract-wide variant would need a second, unfiltered aggregate query. Fixes #67. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1inM1db2mZzptMmC9VJfs
Extends the manager-facing-description parametrization to the new CompletionStatusCounts model, so its fields are held to the same no-field-name-references constraint as LearnerProgress and LearnerProgressResponse instead of relying on manual inspection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1inM1db2mZzptMmC9VJfs
CompletionStatusCounts field descriptions read as if passed and in_progress could overlap with certified, but _COMPLETION_STATUS checks certificate status first: a certified enrollment counts only there even when it also has a passing grade. Each field's description now says what it excludes, and the fields are reordered to match the CASE precedence. test_completion_status_counts_reported_from_the_count_query used total_count=10 with buckets summing to 10 plus withheld=1, violating the buckets + outcomes_withheld_count == total_count invariant the endpoint promises. Derives total_count from the fixture values instead of a hardcoded one, and asserts the sum invariant directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1inM1db2mZzptMmC9VJfs
main picked up openapi-spec-export (#70) after this branch forked, so the committed spec predates completion_status_counts. Regenerated with bin/generate-openapi-spec after rebasing onto main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1inM1db2mZzptMmC9VJfs
59f6c56 to
63eb5e7
Compare
…uppression CompletionStatusCounts is the only b2b_analytics aggregate that never applies a cohort_policy floor. Document that it's intentional, since the learner-progress endpoint already exposes the individual matching rows, so there's nothing left to hide by suppressing the summary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VfT3zzM5d6LQ5BQvkJhN5N
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VfT3zzM5d6LQ5BQvkJhN5N


What are the relevant tickets?
Closes #67
Description (What does it do?)
MIT Learn's learner directory (mitodl/mit-learn#3958) renders four summary tiles (Enrollments, Not started, In progress, Completed) above the learner table. Until now the other three came from three extra
/learner-progressrequests withlimit=1and acompletion_statusfilter, each paying for an org-manager existence check, a page query, and aCOUNT(*)overmv_b2b_learner_enrollmentthat scans the full matching set regardless oflimit. Five requests per page load againststarrocks_pool_max_size=10per pod.completion_status_counts(not_started,in_progress,passed,certified) toLearnerProgressResponse, computed as conditionalSUMs in the same count query alongside the existingoutcomes_withheld_countSUM. No additional scan.not_started/in_progress/passed/certifiedcome from the mutually exclusive branches of_COMPLETION_STATUS, so the buckets never overlap, and consent-withheld rows land in none of them: the four buckets plusoutcomes_withheld_countsum tototal_count.WHEREclause (search, status filter,include_inactive), the same tradeoff the issue flagged as open. Going with the cheaper, filter-following option, since it's free (the count query already builds the clause) and keeps the envelope internally consistent withtotal_count. A tile that must stay contract-wide regardless of the table's filters would need a second, unfiltered aggregate query, not implemented here since MIT Learn's tiles read from alimit=1call with no filters applied today, so this changes nothing for the current caller.How can this be tested?
uv run pytest tests/test_dashboard_learner_progress.py(19 tests, includes new coverage for the per-status counts, that they share the response's WHERE clause, and thatCompletionStatusCountsfield descriptions hold to the no-field-name-reference constraint) anduv run pytestfor the full suite (278 tests) both pass.prek run(ruff, ruff format, mypy) is clean on the changed files.Additional Context
Out of scope, filed separately:
mitxonline_client._cachehas no per-key lock (unlikerefresh_metadata), so N concurrent cold-cache requests for one(sub, org)all issue the manager check. Once MIT Learn's directory drops its three extra/learner-progresscalls in favor of this field, that only leaves one MITx Online call per page load instead of up to five, which should make that race much rarer in practice but doesn't eliminate it.