Skip to content
Open
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
206 changes: 206 additions & 0 deletions openapi/specs/b2b_dashboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,98 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/v1/analytics/organizations/{organization_id}/needs-attention:
get:
tags:
- needs-attention
summary: Learners who may need a nudge, counted once each, per contract
operationId: organizations_needs_attention_retrieve
parameters:
- name: organization_id
in: path
required: true
schema:
type: string
title: Organization Id
- name: limit
in: query
required: false
schema:
type: integer
maximum: 1000
minimum: 1
default: 100
title: Limit
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
default: 0
title: Offset
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/OrgAnalyticsResponse_ContractNeedsAttention_'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/needs-attention:
get:
tags:
- needs-attention
summary: Learners who may need a nudge, counted once each, per contract
operationId: contracts_needs_attention_retrieve
parameters:
- name: organization_id
in: path
required: true
schema:
type: string
title: Organization Id
- name: contract_id
in: path
required: true
schema:
type: integer
title: Contract Id
- name: limit
in: query
required: false
schema:
type: integer
maximum: 1000
minimum: 1
default: 100
title: Limit
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
default: 0
title: Offset
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/OrgAnalyticsResponse_ContractNeedsAttention_'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/v1/analytics/admin/contract-health:
get:
tags:
Expand Down Expand Up @@ -1293,6 +1385,94 @@ components:
``monthly_active_learners`` across contracts can exceed the org''s own

figure. Activity totals, being sums of events, do add up.'
ContractNeedsAttention:
properties:
contract_id:
type: integer
title: Contract Id
description: The contract's ID in MITx Online.
learners_considered:
type: integer
title: Learners Considered
description: Learners with an active enrollment under the contract. Deactivated
enrollments, such as after unenrolling or a refund, are left out. When
too few learners are in the group, the whole row is withheld to avoid
identifying them.
learners_needing_attention:
anyOf:
- type: integer
- type: 'null'
title: Learners Needing Attention
description: 'Of those learners, how many may need a nudge: they never started
a course, or their last recorded activity was at least 30 days ago. A
learner counts once however many of the contract''s courses they are behind
in. Learners who haven''t agreed to share their progress aren''t counted.
Withheld when too few learners are in the group to report without identifying
them.'
learners_outcomes_withheld:
anyOf:
- type: integer
- type: 'null'
title: Learners Outcomes Withheld
description: Of those learners, how many are left out of the count above
because they haven't agreed to share their progress. Withheld when too
few learners are in the group to report without identifying them.
type: object
required:
- contract_id
- learners_considered
- learners_needing_attention
- learners_outcomes_withheld
title: ContractNeedsAttention
description: 'Distinct learners needing attention — grain: org x contract.


The one model here that does not mirror a materialized view. It is computed

at query time by ``learner_queries.needs_attention_aggregate`` over

``b2b_learner_records.mv_b2b_learner_enrollment``, which is what lets the

KPI tile and the learner directory beneath it share a single

needs-attention expression and a single per-request cutoff.


A column on ``mv_b2b_contract_utilization`` was the other candidate and was

not chosen (decided 2026-10-02). It would have restated the 30-day rule and

the completion-status CASE in dbt — two repos, two languages, no test that

can see both — and frozen the cutoff at MV-refresh time, so the tile and the

directory could disagree about the same learner for up to a refresh

interval. The rule has already changed once since it was written.


Served from its own endpoint rather than folded into ``ContractUtilization``

because the MV behind it refreshes on its own schedule. One ``as_of`` per

section is exactly what stops a lagging view from making another section

look fresher than it is, so a client renders this tile''s freshness from

this endpoint''s envelope, not from contract-utilization''s.


``learners_considered`` is the primary cohort and gates the row. The other

two counts are secondary and nulled on their own terms. As on

``ContractUtilization``, a published cohort beside a published subset of it

still leaves the complement derivable (42 considered and 40 needing

attention names 2 learners); that is the general across-column gap tracked

separately, not something specific to this model.'
ContractUtilization:
properties:
organization_key:
Expand Down Expand Up @@ -2131,6 +2311,32 @@ components:
- total_count
- data
title: OrgAnalyticsResponse[ContractMonthlyEngagementTrend]
OrgAnalyticsResponse_ContractNeedsAttention_:
properties:
organization_id:
type: string
title: Organization Id
as_of:
anyOf:
- type: string
format: date-time
- type: 'null'
title: As Of
total_count:
type: integer
title: Total Count
data:
items:
$ref: '#/components/schemas/ContractNeedsAttention'
type: array
title: Data
type: object
required:
- organization_id
- as_of
- total_count
- data
title: OrgAnalyticsResponse[ContractNeedsAttention]
OrgAnalyticsResponse_ContractUtilization_:
properties:
organization_id:
Expand Down
12 changes: 11 additions & 1 deletion src/ol_analytics_api/tenants/b2b_dashboard/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@
from ol_analytics_api.core.errors import add_shared_error_handlers
from ol_analytics_api.core.health import register_readiness_check
from ol_analytics_api.tenants.b2b_dashboard.mitxonline_client import mitxonline_client
from ol_analytics_api.tenants.b2b_dashboard.routers import admin, contracts, learners, organizations
from ol_analytics_api.tenants.b2b_dashboard.routers import (
admin,
contracts,
learners,
needs_attention,
organizations,
)

# Names this tenant's readiness sub-path (/health/readiness/b2b_dashboard/).
TENANT_NAME = "b2b_dashboard"
Expand Down Expand Up @@ -69,6 +75,10 @@ def create_app() -> FastAPI:
# literal segments, so the two cannot shadow each other either way.
app.include_router(contracts.router)
app.include_router(learners.router)
# Both of its routes hang off /organizations/{id}, one of them under
# /contracts/{id}, so it carries the org gate for both and adds the
# contract gate on the nested route alone.
app.include_router(needs_attention.router)
app.include_router(admin.router)

# Turn a saturated shared StarRocks pool into a fast 503 for this tenant's
Expand Down
104 changes: 104 additions & 0 deletions src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
suppress every one of them. Org-manager authorization and the contract gate
still apply (routers/learners.py).

``needs_attention_aggregate`` is the one exception, and it goes the other way:
it returns no learner rows at all, only per-contract distinct-learner counts,
so it DOES go through that chokepoint and is floored like every other
aggregate in this tenant. It lives in this module rather than with the
materialized-view endpoints so that it can share ``_needs_attention`` and
``_COMPLETION_STATUS`` with the row-level queries above -- one rule and one
cutoff for the KPI tile and the learner directory alike.

Every query is a fixed template. The identifiers are this module's constants
plus the validated schema name, and every caller-supplied value is a bound
parameter. The only per-request variation is which of a fixed set of
Expand Down Expand Up @@ -289,6 +297,102 @@ def learner_progress(filters: ProgressFilters, cutoff: datetime.date) -> Progres
return ProgressQuery(page, count, tuple(params))


@dataclass(frozen=True)
class AggregateQuery:
"""``page`` takes ``params`` plus the anonymization floor, LIMIT and
OFFSET; ``count`` takes ``params`` plus the floor."""

page: str
count: str
params: tuple[Any, ...]


def needs_attention_aggregate(
organization_id: str, contract_id: int | None, cutoff: datetime.date
) -> AggregateQuery:
"""Distinct learners needing attention, grouped by contract.

The aggregate behind the dashboard's needs-attention KPI tile. It reads the
same learner-grain MV as ``learner_progress`` and reuses the same
``_needs_attention`` expression against the same per-request cutoff, so the
tile and the directory beneath it cannot disagree about which learners are
quiet -- not even for a learner whose 30th quiet day is today.

``COUNT(DISTINCT CASE WHEN ... THEN learner_id END)`` is the point of the
whole query. ``learner_progress``'s ``needs_attention_count`` is a
``SUM(CASE WHEN ...)`` over a learner x course-run grain, so a learner
stale in three of the contract's courses counts three times. That cannot
back a tile sitting beside ``active_learners``, which is itself a
distinct-learner count: two adjacent tiles would read as learner counts
while using different units. Here that learner counts once.

``contract_id`` narrows to one contract for the contract-scoped route;
``None`` returns one row per contract the organization holds, which is what
the org-wide analytics view renders a card group from.
"""
table = f"{validate_sql_identifier(settings.learner_records_schema)}.{ENROLLMENT_MV}"
# The org predicate stays next to the contract one, as in learner_progress:
# contract ids are globally unique but not secret.
scope = ["sso_organization_id = %s"]
params: list[Any] = [organization_id]
if contract_id is not None:
scope.append("contract_id = %s")
params.append(contract_id)
# Active enrollments only, with no caller override, and that is what makes
# the tile and the directory count one population: learner_progress's
# `include_inactive` defaults to false, so a manager clicking through from
# the tile lands on exactly these learners.
scope.append("enrollment_is_active = TRUE")
records = (
"SELECT user_global_id AS learner_id, contract_id," # noqa: S608
f" {_COMPLETION_STATUS} AS completion_status, last_active_on"
f" FROM {table} WHERE {' AND '.join(scope)}"
)

shared = _outcomes_shared()
needs_attention = _needs_attention(cutoff)
# learners_considered is deliberately NOT consent-gated and the other two
# are, which mirrors learner_progress exactly: being enrolled is not an
# outcome, so it is disclosed, while anything about a learner's progress is
# gated. So the three do not sum -- a withheld learner is counted in the
# first and the third, never the second -- and the third is what stops a
# fail-closed stack from reading as "nobody needs attention".
aggregates = (
"COUNT(DISTINCT learner_id) AS learners_considered,"
f" COUNT(DISTINCT CASE WHEN {shared} AND ({needs_attention}) THEN learner_id END)"
" AS learners_needing_attention,"
f" COUNT(DISTINCT CASE WHEN NOT {shared} THEN learner_id END)"
" AS learners_outcomes_withheld"
)
# The primary-cohort gate, applied to the page AND the count, written once
# so the two cannot drift. suppress_small_cohorts would drop a sub-floor row
# anyway, but dropping it in Python AFTER the LIMIT is not equivalent:
#
# - A page whose rows are all sub-floor comes back empty beside a positive
# total_count, and the visible contracts behind it are reachable only by
# guessing an offset. OrgAnalyticsResponse tells clients to compare
# total_count against len(data) plus offset, which that breaks.
# - Varying the offset and watching which contracts surface reveals how
# many suppressed ones precede each visible one -- exactly the count of
# sub-floor cohorts that gating the count query exists to withhold.
#
# Gating in SQL is what makes the page and the count describe one set.
cohort_gate = "HAVING COUNT(DISTINCT learner_id) >= %s"
# Grouping by the grain key makes it unique per row, so it is also a
# deterministic ORDER BY for LIMIT/OFFSET paging.
page = (
f"SELECT contract_id, {aggregates} FROM ({records}) records" # noqa: S608
f" GROUP BY contract_id {cohort_gate}"
" ORDER BY contract_id LIMIT %s OFFSET %s"
)
Comment on lines +383 to +387

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 4e95e64. This was a real bug, not just a theoretical probe.

Reproduced before the fix — two sub-floor contracts (2 learners each) ordered ahead of one visible contract (6 learners), limit=2, offset=0:

raw page (limit=2, offset=0): [101, 102]
after Python suppression:     []
total_count reported:         1

So the client got data: [] beside total_count: 1, and contract 103 was reachable only by guessing offset=2. That also breaks what OrgAnalyticsResponse's docstring instructs clients to do — compare total_count against len(data) plus offset.

After the fix:

first page (limit=2, offset=0): [103]
total_count:                    1
offset=1 ->                     []

Implementation follows your suggestion: the gate is written once as HAVING COUNT(DISTINCT learner_id) >= %s and spliced into both the page and the count, so they can't drift. The floor is bound twice per request — once into the page's HAVING, once for fetch_and_suppress, which still nulls sub-floor secondary counts within a surviving row. Regression test is test_page_and_count_describe_the_same_gated_set.

Worth flagging that this is not specific to this endpoint. build_select emits no cohort gate while build_count does, so all five MV-backed endpoints plus the admin one have the identical mismatch:

build_select gates on the primary cohort?  False
build_count  gates on the primary cohort?  True

That one needs a signature change on shared machinery and touches three routers plus test_column_contract.py, so it's out of scope here and tracked separately as tk-build-select-pages-ungated-while-build-count-gat-8e6b21.

count = (
"SELECT COUNT(*) AS total_count FROM (" # noqa: S608
f"SELECT contract_id FROM ({records}) records"
f" GROUP BY contract_id {cohort_gate}) gated"
)
return AggregateQuery(page, count, tuple(params))


@dataclass(frozen=True)
class CourseRunsQuery:
"""``params`` binds ``count``; ``page`` takes ``params`` plus LIMIT and OFFSET."""
Expand Down
Loading
Loading