Skip to content

feat(b2b_dashboard): return per-status counts on the learner-progress envelope - #68

Merged
blarghmatey merged 6 commits into
mainfrom
learner-progress-status-counts
Sep 25, 2026
Merged

blarghmatey merged 6 commits into
mainfrom
learner-progress-status-counts

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

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-progress requests with limit=1 and a completion_status filter, each paying for an org-manager existence check, a page query, and a COUNT(*) over mv_b2b_learner_enrollment that scans the full matching set regardless of limit. Five requests per page load against starrocks_pool_max_size=10 per pod.

  • Adds completion_status_counts (not_started, in_progress, passed, certified) to LearnerProgressResponse, computed as conditional SUMs in the same count query alongside the existing outcomes_withheld_count SUM. No additional scan.
  • not_started/in_progress/passed/certified come 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 plus outcomes_withheld_count sum to total_count.
  • The counts share the response's own WHERE clause (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 with total_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 a limit=1 call 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 that CompletionStatusCounts field descriptions hold to the no-field-name-reference constraint) and uv run pytest for 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._cache has no per-key lock (unlike refresh_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-progress calls 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 Medium severity · 1 Low severity

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.

Comment thread tests/test_dashboard_learner_progress.py Outdated
Comment thread src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py Outdated
@blarghmatey

Copy link
Copy Markdown
Member Author

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.

@blarghmatey
blarghmatey force-pushed the learner-progress-status-counts branch from 149ee9f to 59f6c56 Compare September 22, 2026 18:22
@github-actions

Copy link
Copy Markdown

OpenAPI Changes

Show/hide changes
## Changes for b2b_dashboard.yaml:
1 changes: 0 error, 0 warning, 1 info
info	[response-required-property-added] at head/openapi/specs/b2b_dashboard.yaml
	in API GET /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/learner-progress
		added the required property `completion_status_counts` to the response with the `200` status



## Changes for b2b_learner_records.yaml:
No changes detected

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Comment thread src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py
blarghmatey and others added 4 commits September 25, 2026 14:09
… 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
@blarghmatey
blarghmatey force-pushed the learner-progress-status-counts branch from 59f6c56 to 63eb5e7 Compare September 25, 2026 18:09
blarghmatey and others added 2 commits September 25, 2026 14:28
…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
@blarghmatey

Copy link
Copy Markdown
Member Author

Addressed daniellefrappier18's review comment: added a docstring note (3bc81c6) on why completion_status_counts skips cohort suppression, plus the regenerated OpenAPI spec (fcbcf4f). Thread resolved, all checks green.

@blarghmatey
blarghmatey merged commit e0fedb2 into main Sep 25, 2026
6 checks passed
@blarghmatey
blarghmatey deleted the learner-progress-status-counts branch September 25, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Return per-status counts on the learner-progress envelope

3 participants