Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions openapi/specs/b2b_dashboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Expand All @@ -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``,
Expand Down
48 changes: 47 additions & 1 deletion src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -167,4 +207,10 @@ class LearnerProgressResponse(BaseModel):
"agreed to share it."
)
)
completion_status_counts: CompletionStatusCounts = Field(
Comment thread
blarghmatey marked this conversation as resolved.
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.")
11 changes: 10 additions & 1 deletion src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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],
)
69 changes: 65 additions & 4 deletions tests/test_dashboard_learner_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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%"})
Expand Down Expand Up @@ -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
Expand Down
Loading