diff --git a/openapi/specs/b2b_dashboard.yaml b/openapi/specs/b2b_dashboard.yaml index e03fb12..2091fd7 100644 --- a/openapi/specs/b2b_dashboard.yaml +++ b/openapi/specs/b2b_dashboard.yaml @@ -634,6 +634,63 @@ components: on a schedule after grading, and audit-mode enrollments never certify.' + CompletionStatusCounts: + properties: + certified: + type: integer + title: Certified + description: Matching enrollments with an unrevoked certificate, whatever + their grade. + passed: + type: integer + title: Passed + description: Matching enrollments with a currently passing grade, other + than those already counted as certified above. + in_progress: + type: integer + title: In Progress + description: Matching enrollments with a nonzero grade so far that isn't + yet passing, other than those already counted as certified or passed above. + not_started: + type: integer + title: Not Started + description: Matching enrollments with no certificate and no grade recorded + yet. + type: object + required: + - certified + - passed + - in_progress + - not_started + title: CompletionStatusCounts + description: 'Matches ``LearnerProgressResponse.total_count``''s own filters, + not the + + contract as a whole, so it narrows along with the table it summarizes. + + + Each field counts a disjoint slice of the matching enrollments: every + + enrollment falls into exactly one, in the order below (certificate beats + + grade beats no grade), so summing the four plus ``outcomes_withheld_count`` + + always equals ``total_count``. An unrevoked certificate always wins even + + when the same enrollment also carries a passing or in-progress grade, + + which is why each field''s own description calls out what it excludes. + + + These counts are never suppressed for small cohorts, unlike the + + ``cohort_policy``-gated aggregates elsewhere in b2b_analytics (e.g. + + ``ContractUtilization``, ``EnrollmentCompletionFunnel``): this endpoint''s + + ``data`` already exposes the individual matching rows, so there''s nothing + + left to hide by suppressing the summary.' CompletionStatusFilter: type: string enum: @@ -1550,6 +1607,10 @@ components: title: Outcomes Withheld Count description: How many of those enrollments have progress hidden because the learner hasn't agreed to share it. + completion_status_counts: + $ref: '#/components/schemas/CompletionStatusCounts' + description: How many of those enrollments are in each stage of completion. + Enrollments with hidden progress aren't counted in any stage. data: items: $ref: '#/components/schemas/LearnerProgress' @@ -1562,6 +1623,7 @@ components: - as_of - total_count - outcomes_withheld_count + - completion_status_counts - data title: LearnerProgressResponse description: 'The org envelope (``organization_id``, ``as_of``, ``total_count``, diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py b/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py index b4ed010..fb00fc5 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py @@ -25,7 +25,9 @@ - ``last_active_on`` is NULL for every row until activity data lands, whatever ``outcomes_shared`` says. - ``outcomes_withheld_count`` counts the rows in ``total_count`` whose - ``outcomes_shared`` is false. + ``outcomes_shared`` is false. ``completion_status_counts`` buckets the rest + by status; the two together add up to ``total_count``, since + ``CompletionStatus`` is exhaustive and its branches don't overlap. """ from __future__ import annotations @@ -149,6 +151,44 @@ def _gate_outcomes(self) -> Self: return self +class CompletionStatusCounts(BaseModel): + """Matches ``LearnerProgressResponse.total_count``'s own filters, not the + contract as a whole, so it narrows along with the table it summarizes. + + Each field counts a disjoint slice of the matching enrollments: every + enrollment falls into exactly one, in the order below (certificate beats + grade beats no grade), so summing the four plus ``outcomes_withheld_count`` + always equals ``total_count``. An unrevoked certificate always wins even + when the same enrollment also carries a passing or in-progress grade, + which is why each field's own description calls out what it excludes. + + These counts are never suppressed for small cohorts, unlike the + ``cohort_policy``-gated aggregates elsewhere in b2b_analytics (e.g. + ``ContractUtilization``, ``EnrollmentCompletionFunnel``): this endpoint's + ``data`` already exposes the individual matching rows, so there's nothing + left to hide by suppressing the summary. + """ + + certified: int = Field( + description="Matching enrollments with an unrevoked certificate, whatever their grade." + ) + passed: int = Field( + description=( + "Matching enrollments with a currently passing grade, other than those already " + "counted as certified above." + ) + ) + in_progress: int = Field( + description=( + "Matching enrollments with a nonzero grade so far that isn't yet passing, other " + "than those already counted as certified or passed above." + ) + ) + not_started: int = Field( + description="Matching enrollments with no certificate and no grade recorded yet." + ) + + class LearnerProgressResponse(BaseModel): """The org envelope (``organization_id``, ``as_of``, ``total_count``, ``data``) plus ``outcomes_withheld_count``, so a client can show how many @@ -167,4 +207,10 @@ class LearnerProgressResponse(BaseModel): "agreed to share it." ) ) + completion_status_counts: CompletionStatusCounts = Field( + description=( + "How many of those enrollments are in each stage of completion. Enrollments with " + "hidden progress aren't counted in any stage." + ) + ) data: list[LearnerProgress] = Field(description="This page of enrollments.") diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py b/src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py index 2d5f083..cfd9592 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py @@ -45,6 +45,10 @@ " END" ) +# The four branches of _COMPLETION_STATUS. Mutually exclusive, so these buckets +# never overlap; a row with withheld outcomes falls into none of them. +_STATUSES = ("not_started", "in_progress", "passed", "certified") + _COLUMNS = ( "learner_id", "email", @@ -162,9 +166,14 @@ def learner_progress(filters: ProgressFilters) -> ProgressQuery: f"SELECT {projection} FROM ({records}) records{where}" # noqa: S608 f" ORDER BY {order_by} LIMIT %s OFFSET %s" ) + status_sums = ", ".join( + f"SUM(CASE WHEN {shared} AND completion_status = '{value}' THEN 1 ELSE 0 END) AS {value}" + for value in _STATUSES + ) count = ( "SELECT COUNT(*) AS total_count," # noqa: S608 - f" SUM(CASE WHEN {shared} THEN 0 ELSE 1 END) AS outcomes_withheld_count" + f" SUM(CASE WHEN {shared} THEN 0 ELSE 1 END) AS outcomes_withheld_count," + f" {status_sums}" f" FROM ({records}) records{where}" ) return ProgressQuery(page, count, tuple(params)) diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py b/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py index 90ece14..9d0b197 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py @@ -28,6 +28,7 @@ ) from ol_analytics_api.tenants.b2b_dashboard.config import settings from ol_analytics_api.tenants.b2b_dashboard.learner_models import ( + CompletionStatusCounts, LearnerProgress, LearnerProgressResponse, ) @@ -103,5 +104,11 @@ async def learner_progress( # noqa: PLR0913 total_count=int(counts["total_count"]), # SUM over zero rows is NULL. outcomes_withheld_count=int(counts["outcomes_withheld_count"] or 0), + completion_status_counts=CompletionStatusCounts( + not_started=int(counts["not_started"] or 0), + in_progress=int(counts["in_progress"] or 0), + passed=int(counts["passed"] or 0), + certified=int(counts["certified"] or 0), + ), data=[LearnerProgress(**row) for row in rows], ) diff --git a/tests/test_dashboard_learner_progress.py b/tests/test_dashboard_learner_progress.py index 133eb78..c20a16b 100644 --- a/tests/test_dashboard_learner_progress.py +++ b/tests/test_dashboard_learner_progress.py @@ -20,6 +20,7 @@ from ol_analytics_api.tenants.b2b_dashboard import learner_queries from ol_analytics_api.tenants.b2b_dashboard.config import settings from ol_analytics_api.tenants.b2b_dashboard.learner_models import ( + CompletionStatusCounts, LearnerProgress, LearnerProgressResponse, ) @@ -63,9 +64,19 @@ class _FakePool: """Answers the as_of probe, the contract gate, the count query and the page query, recording every call.""" - def __init__(self, rows=(), total_count=0, withheld=0, *, contract_exists=True): + def __init__( + self, rows=(), total_count=0, withheld=0, status_counts=None, *, contract_exists=True + ): self.rows = list(rows) - self.counts = {"total_count": total_count, "outcomes_withheld_count": withheld} + self.counts = { + "total_count": total_count, + "outcomes_withheld_count": withheld, + "not_started": 0, + "in_progress": 0, + "passed": 0, + "certified": 0, + **(status_counts or {}), + } self.contract_exists = contract_exists self.calls = [] @@ -122,6 +133,12 @@ async def test_envelope_withholds_outcomes_and_counts_them(app): assert body["as_of"] == "2026-09-15T06:00:00Z" assert body["total_count"] == 12 assert body["outcomes_withheld_count"] == 12 + assert body["completion_status_counts"] == { + "not_started": 0, + "in_progress": 0, + "passed": 0, + "certified": 0, + } [row] = body["data"] assert row["enrolled_on"] == "2026-02-03T14:22:11Z" assert row["email"] == "rgarcia@contoso.example" @@ -172,6 +189,44 @@ async def test_consent_fail_open_discloses_outcomes(app, monkeypatch): assert "SUM(CASE WHEN TRUE THEN 0 ELSE 1 END)" in pool.count_call()[0] +async def test_completion_status_counts_reported_from_the_count_query(app): + status_counts = {"not_started": 2, "in_progress": 3, "passed": 1, "certified": 4} + withheld = 1 + pool = _FakePool( + # The buckets plus outcomes_withheld_count sum to total_count (11), the + # invariant the endpoint promises; keep this fixture consistent with it. + total_count=sum(status_counts.values()) + withheld, + withheld=withheld, + status_counts=status_counts, + ) + response = await _get(app, pool) + + body = response.json() + assert body["completion_status_counts"] == status_counts + assert sum(status_counts.values()) + body["outcomes_withheld_count"] == body["total_count"] + + +async def test_completion_status_counts_share_the_response_filters(app): + pool = _FakePool() + await _get(app, pool, params={"completion_status": ["passed"]}) + count_query, _ = pool.count_call() + # Buckets come off the same WHERE clause as total_count, so they narrow + # along with the rest of the envelope rather than staying contract-wide. + assert count_query.count("WHERE") == 2 + for status in ("not_started", "in_progress", "passed", "certified"): + assert ( + f"SUM(CASE WHEN FALSE AND completion_status = '{status}' THEN 1 ELSE 0 END)" + f" AS {status}" in count_query + ) + + +def test_completion_status_buckets_are_mutually_exclusive_and_exhaustive(): + # Each row's completion_status is exactly one CASE branch + # (learner_queries._COMPLETION_STATUS), so the four buckets never overlap + # and, with outcomes_withheld_count, always sum to total_count. + assert learner_queries._STATUSES == ("not_started", "in_progress", "passed", "certified") # noqa: SLF001 + + async def test_search_is_bound_with_wildcards_escaped(app): pool = _FakePool() await _get(app, pool, params={"search": "Garcia_50%"}) @@ -229,12 +284,18 @@ def test_every_outcome_column_is_consent_gated_in_the_query(): assert f"CASE WHEN FALSE THEN {name} END AS {name}" in query.page -@pytest.mark.parametrize("model", [LearnerProgress, LearnerProgressResponse]) +@pytest.mark.parametrize( + "model", [LearnerProgress, LearnerProgressResponse, CompletionStatusCounts] +) def test_every_field_has_a_manager_facing_description(model): # The dashboard can show these as help text to a manager, who never sees # field names, so every field needs one and none may lean on another field's # name. - field_names = set(LearnerProgress.model_fields) | set(LearnerProgressResponse.model_fields) + field_names = ( + set(LearnerProgress.model_fields) + | set(LearnerProgressResponse.model_fields) + | set(CompletionStatusCounts.model_fields) + ) for name, field in model.model_fields.items(): assert field.description, f"{name} has no description" named = set(re.findall(r"\b[a-z]+(?:_[a-z]+)+\b", field.description)) & field_names