From 58ff60ea58ab516acfa158d5b64998174750945f Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Tue, 29 Sep 2026 11:17:58 -0400 Subject: [PATCH 1/5] feat(b2b_dashboard): serve activity and add needs-attention aggregate on learner-progress ol-data-platform#2693 added a real last_active_on to mv_b2b_learner_enrollment; the learner-progress endpoint still projected it as a hardcoded NULL. Wire it through, match completion_status's in_progress branch to the activity-aware definition already used by b2b_learner_records, and add needs_attention_count to the envelope (not_started, or quiet for 30+ days) per wp-needs-attention- aggregate-for-b2b-learner-progre-09344a. Co-Authored-By: Claude Sonnet 5 --- openapi/specs/b2b_dashboard.yaml | 12 +++++-- .../tenants/b2b_dashboard/learner_models.py | 23 ++++++++++---- .../tenants/b2b_dashboard/learner_queries.py | 25 +++++++++++---- .../tenants/b2b_dashboard/routers/learners.py | 1 + tests/test_dashboard_learner_progress.py | 31 ++++++++++++++++++- 5 files changed, 77 insertions(+), 15 deletions(-) diff --git a/openapi/specs/b2b_dashboard.yaml b/openapi/specs/b2b_dashboard.yaml index 2091fd7..0061577 100644 --- a/openapi/specs/b2b_dashboard.yaml +++ b/openapi/specs/b2b_dashboard.yaml @@ -1561,8 +1561,9 @@ components: format: date - type: 'null' title: Last Active On - description: The last day the learner did anything in the course. Not available - yet, so always empty for now. + description: The last day the learner did anything in the course. Empty + if they haven't yet. Hidden if the learner hasn't agreed to share their + progress. type: object required: - learner_id @@ -1611,6 +1612,12 @@ components: $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. + needs_attention_count: + type: integer + title: Needs Attention Count + description: 'How many of those enrollments need attention: the learner + never started, or they started but haven''t done anything in the course + for over 30 days. Enrollments with hidden progress aren''t counted.' data: items: $ref: '#/components/schemas/LearnerProgress' @@ -1624,6 +1631,7 @@ components: - total_count - outcomes_withheld_count - completion_status_counts + - needs_attention_count - 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 fb00fc5..324fcc3 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py @@ -20,14 +20,18 @@ everything in ``_OUTCOME_FIELDS`` is. - ``completion_status``: an unrevoked certificate is ``certified``. A revoked certificate doesn't count, and the status then follows the grade, so it can - read ``passed``, ``in_progress`` or ``not_started``. Until learner-grain - activity data lands, ``in_progress`` means a nonzero grade. -- ``last_active_on`` is NULL for every row until activity data lands, whatever - ``outcomes_shared`` says. + read ``passed``, ``in_progress`` or ``not_started``. ``in_progress`` means a + nonzero grade or any tracked activity. +- ``last_active_on`` is NULL, whatever ``outcomes_shared`` says, until the + learner has any tracked activity. - ``outcomes_withheld_count`` counts the rows in ``total_count`` whose ``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. +- ``needs_attention_count`` overlaps ``completion_status_counts`` rather than + adding to it: a learner needs attention if they never started, or if + they've gone quiet for more than 30 days, so the same row can be + ``in_progress`` and also counted here. """ from __future__ import annotations @@ -138,8 +142,8 @@ class LearnerProgress(BaseModel): ) last_active_on: datetime.date | None = Field( description=( - "The last day the learner did anything in the course. Not available yet, so always " - "empty for now." + f"The last day the learner did anything in the course. Empty if they haven't yet. " + f"{_HIDDEN}" ) ) @@ -213,4 +217,11 @@ class LearnerProgressResponse(BaseModel): "hidden progress aren't counted in any stage." ) ) + needs_attention_count: int = Field( + description=( + "How many of those enrollments need attention: the learner never started, or they " + "started but haven't done anything in the course for over 30 days. Enrollments with " + "hidden progress aren't counted." + ) + ) 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 754ea4d..fd48f3a 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py @@ -34,17 +34,28 @@ # Matches the b2b_learner_records tenant. An unrevoked certificate is certified # without requiring is_passing, since production has unrevoked certificates with -# is_passing false (ol-data-platform#2669). Until activity data lands, -# "in progress" can only mean a nonzero grade. +# is_passing false (ol-data-platform#2669). in_progress must match +# mv_b2b_learner.courses_in_progress: a nonzero grade or any tracked activity +# (ol-data-platform#2693). _COMPLETION_STATUS = ( "CASE" " WHEN certificate_is_revoked = FALSE THEN 'certified'" " WHEN is_passing = TRUE THEN 'passed'" - " WHEN grade_value > 0 THEN 'in_progress'" + " WHEN grade_value > 0 OR last_active_on IS NOT NULL THEN 'in_progress'" " ELSE 'not_started'" " END" ) +# A learner needs attention if they never started, or if they started but have +# gone quiet for more than 30 days (product definition, Danielle Frappier). +# A NULL last_active_on on a non-not_started row (grade but no tracked +# activity) doesn't match the staleness branch -- there's no timestamp to +# judge quiet against. +_NEEDS_ATTENTION = ( + "completion_status = 'not_started'" + " OR last_active_on < DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)" +) + # Upstream stores "" rather than NULL for learners who never set a name. Null # blank names so they sort with the missing ones instead of before every name. _BLANK_AS_NULL_NAME = "NULLIF(TRIM(full_name), '')" @@ -72,6 +83,7 @@ "letter_grade", "certificate_issued_on", "certificate_is_revoked", + "last_active_on", ) @@ -127,7 +139,7 @@ def learner_progress(filters: ProgressFilters) -> ProgressQuery: " courserun_readable_id, courserun_title, courserun_start_on, courserun_end_on," " enrollment_created_on AS enrolled_on, enrollment_is_active, enrollment_mode," f" {_COMPLETION_STATUS} AS completion_status, is_passing, grade_value AS grade," - " letter_grade, certificate_issued_on, certificate_is_revoked" + " letter_grade, certificate_issued_on, certificate_is_revoked, last_active_on" f" FROM {table} WHERE {' AND '.join(scope)}" ) @@ -164,7 +176,6 @@ def learner_progress(filters: ProgressFilters) -> ProgressQuery: *_COLUMNS, f"{shared} AS outcomes_shared", *(f"CASE WHEN {shared} THEN {name} END AS {name}" for name in _OUTCOMES), - "NULL AS last_active_on", ] ) page = ( @@ -178,7 +189,9 @@ def learner_progress(filters: ProgressFilters) -> ProgressQuery: count = ( "SELECT COUNT(*) AS total_count," # noqa: S608 f" SUM(CASE WHEN {shared} THEN 0 ELSE 1 END) AS outcomes_withheld_count," - f" {status_sums}" + f" {status_sums}," + f" SUM(CASE WHEN {shared} AND ({_NEEDS_ATTENTION}) THEN 1 ELSE 0 END)" + " AS needs_attention_count" 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 9d0b197..e8423ff 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py @@ -110,5 +110,6 @@ async def learner_progress( # noqa: PLR0913 passed=int(counts["passed"] or 0), certified=int(counts["certified"] or 0), ), + needs_attention_count=int(counts["needs_attention_count"] 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 75a93c3..72e577e 100644 --- a/tests/test_dashboard_learner_progress.py +++ b/tests/test_dashboard_learner_progress.py @@ -75,6 +75,7 @@ def __init__( "in_progress": 0, "passed": 0, "certified": 0, + "needs_attention_count": 0, **(status_counts or {}), } self.contract_exists = contract_exists @@ -220,6 +221,34 @@ async def test_completion_status_counts_share_the_response_filters(app): ) +async def test_in_progress_also_counts_tracked_activity(app): + pool = _FakePool() + await _get(app, pool) + assert "grade_value > 0 OR last_active_on IS NOT NULL THEN 'in_progress'" in pool.page_call()[0] + + +async def test_needs_attention_count_reported_from_the_count_query(app): + pool = _FakePool(status_counts={"needs_attention_count": 7}) + response = await _get(app, pool) + + assert response.json()["needs_attention_count"] == 7 + count_query, _ = pool.count_call() + assert ( + "SUM(CASE WHEN FALSE AND (completion_status = 'not_started'" + " OR last_active_on < DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))" + " THEN 1 ELSE 0 END) AS needs_attention_count" in count_query + ) + + +async def test_needs_attention_count_shares_the_response_filters(app): + pool = _FakePool() + await _get(app, pool, params={"completion_status": ["passed"]}) + count_query, _ = pool.count_call() + # Same query, same WHERE clause as total_count and the status buckets. + assert count_query.count("WHERE") == 2 + assert "needs_attention_count" 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 @@ -286,7 +315,7 @@ def test_every_outcome_column_is_consent_gated_in_the_query(): query = learner_queries.learner_progress( learner_queries.ProgressFilters(organization_id=ORG_ID, contract_id=CONTRACT_ID) ) - for name in ("completion_status", "is_passing", "grade", "letter_grade"): + for name in ("completion_status", "is_passing", "grade", "letter_grade", "last_active_on"): assert f"CASE WHEN FALSE THEN {name} END AS {name}" in query.page From ea79a040824f2aeec1c9bc244935c576208d5fc0 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Tue, 29 Sep 2026 12:02:12 -0400 Subject: [PATCH 2/5] fix(b2b_dashboard): include day-30 in needs-attention, document activity buckets Switch the needs-attention staleness check from < to <= so a learner qualifies once 30 full days have passed with no activity, matching the product definition, instead of requiring 31. Also update the in_progress/not_started descriptions on CompletionStatusCounts to mention activity, since in_progress already includes activity-only enrollments with no grade. Co-Authored-By: Claude Sonnet 5 --- openapi/specs/b2b_dashboard.yaml | 21 +++++++++------- .../tenants/b2b_dashboard/learner_models.py | 25 +++++++++++-------- .../tenants/b2b_dashboard/learner_queries.py | 4 +-- tests/test_dashboard_learner_progress.py | 2 +- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/openapi/specs/b2b_dashboard.yaml b/openapi/specs/b2b_dashboard.yaml index 0061577..f89ed44 100644 --- a/openapi/specs/b2b_dashboard.yaml +++ b/openapi/specs/b2b_dashboard.yaml @@ -649,13 +649,14 @@ components: 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. + description: Matching enrollments with a nonzero grade that isn't yet passing, + or with no grade yet but some activity in the course, 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. + description: Matching enrollments with no certificate, no grade, and no + activity in the course yet. type: object required: - certified @@ -673,13 +674,15 @@ components: enrollment falls into exactly one, in the order below (certificate beats - grade beats no grade), so summing the four plus ``outcomes_withheld_count`` + grade beats activity beats neither), so summing the four plus - always equals ``total_count``. An unrevoked certificate always wins even + ``outcomes_withheld_count`` always equals ``total_count``. An unrevoked - when the same enrollment also carries a passing or in-progress grade, + certificate always wins even when the same enrollment also carries a - which is why each field''s own description calls out what it excludes. + 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 @@ -1617,7 +1620,7 @@ components: title: Needs Attention Count description: 'How many of those enrollments need attention: the learner never started, or they started but haven''t done anything in the course - for over 30 days. Enrollments with hidden progress aren''t counted.' + for 30 days or more. Enrollments with hidden progress aren''t counted.' data: items: $ref: '#/components/schemas/LearnerProgress' 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 324fcc3..64b6d99 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py @@ -30,7 +30,7 @@ ``CompletionStatus`` is exhaustive and its branches don't overlap. - ``needs_attention_count`` overlaps ``completion_status_counts`` rather than adding to it: a learner needs attention if they never started, or if - they've gone quiet for more than 30 days, so the same row can be + they've had no activity in 30 days or more, so the same row can be ``in_progress`` and also counted here. """ @@ -161,10 +161,11 @@ class CompletionStatusCounts(BaseModel): 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. + grade beats activity beats neither), 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. @@ -184,12 +185,16 @@ class CompletionStatusCounts(BaseModel): ) 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." + "Matching enrollments with a nonzero grade that isn't yet passing, or with no grade " + "yet but some activity in the course, 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." + description=( + "Matching enrollments with no certificate, no grade, and no activity in the course " + "yet." + ) ) @@ -220,8 +225,8 @@ class LearnerProgressResponse(BaseModel): needs_attention_count: int = Field( description=( "How many of those enrollments need attention: the learner never started, or they " - "started but haven't done anything in the course for over 30 days. Enrollments with " - "hidden progress aren't counted." + "started but haven't done anything in the course for 30 days or more. Enrollments " + "with hidden progress aren't counted." ) ) 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 fd48f3a..982fc09 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py @@ -47,13 +47,13 @@ ) # A learner needs attention if they never started, or if they started but have -# gone quiet for more than 30 days (product definition, Danielle Frappier). +# had no activity in 30 days or more (product definition, Danielle Frappier). # A NULL last_active_on on a non-not_started row (grade but no tracked # activity) doesn't match the staleness branch -- there's no timestamp to # judge quiet against. _NEEDS_ATTENTION = ( "completion_status = 'not_started'" - " OR last_active_on < DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)" + " OR last_active_on <= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)" ) # Upstream stores "" rather than NULL for learners who never set a name. Null diff --git a/tests/test_dashboard_learner_progress.py b/tests/test_dashboard_learner_progress.py index 72e577e..7ce4cb8 100644 --- a/tests/test_dashboard_learner_progress.py +++ b/tests/test_dashboard_learner_progress.py @@ -235,7 +235,7 @@ async def test_needs_attention_count_reported_from_the_count_query(app): count_query, _ = pool.count_call() assert ( "SUM(CASE WHEN FALSE AND (completion_status = 'not_started'" - " OR last_active_on < DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))" + " OR last_active_on <= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))" " THEN 1 ELSE 0 END) AS needs_attention_count" in count_query ) From a4f299dfd70bc72b134e4b447902785fe93bc4ac Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Tue, 29 Sep 2026 12:04:08 -0400 Subject: [PATCH 3/5] style(b2b_dashboard): satisfy ruff format on not_started description Co-Authored-By: Claude Sonnet 5 --- src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 64b6d99..73c7b4b 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py @@ -192,8 +192,7 @@ class CompletionStatusCounts(BaseModel): ) not_started: int = Field( description=( - "Matching enrollments with no certificate, no grade, and no activity in the course " - "yet." + "Matching enrollments with no certificate, no grade, and no activity in the course yet." ) ) From e2c6ad8b257a941b2e313b7ececdcf32272808c8 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Tue, 29 Sep 2026 12:50:29 -0400 Subject: [PATCH 4/5] test(b2b_dashboard): execute the needs-attention SQL against real rows The existing test fabricated needs_attention_count via _FakePool, so it couldn't catch a broken day-30 boundary or consent gate. Run the actual _COMPLETION_STATUS/_NEEDS_ATTENTION strings against sqlite with never-started, day-29 and exactly-day-30 rows, and with consent fail-closed/open, so a regression in either fails here instead of only passing through a mocked count. Co-Authored-By: Claude Sonnet 5 --- tests/test_dashboard_learner_progress.py | 85 ++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/test_dashboard_learner_progress.py b/tests/test_dashboard_learner_progress.py index 7ce4cb8..c1098a9 100644 --- a/tests/test_dashboard_learner_progress.py +++ b/tests/test_dashboard_learner_progress.py @@ -10,6 +10,7 @@ import datetime import json import re +import sqlite3 from unittest.mock import AsyncMock, patch import pytest @@ -249,6 +250,90 @@ async def test_needs_attention_count_shares_the_response_filters(app): assert "needs_attention_count" in count_query +def _needs_attention_sql(cutoff): + # sqlite has no DATE_SUB/INTERVAL syntax, so swap in the one computed + # literal StarRocks would evaluate server-side. Every column, CASE branch + # and comparison operator below this is the real production string. + return learner_queries._NEEDS_ATTENTION.replace( # noqa: SLF001 + "DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)", f"'{cutoff.isoformat()}'" + ) + + +def test_needs_attention_boundary_is_computed_from_real_rows(): + # test_needs_attention_count_reported_from_the_count_query pins the SQL + # text; this actually runs learner_queries._COMPLETION_STATUS and + # ._NEEDS_ATTENTION against rows in sqlite, so a day-30 regression (or a + # reverted `<=`) fails here even though _FakePool never evaluates a WHERE + # clause on its own. + today = datetime.date.today() # noqa: DTZ011 - the boundary is date-only + cutoff = today - datetime.timedelta(days=30) + needs_attention = _needs_attention_sql(cutoff) + + conn = sqlite3.connect(":memory:") + conn.execute( + "CREATE TABLE enrollment (certificate_is_revoked INTEGER, is_passing INTEGER," + " grade_value REAL, last_active_on TEXT)" + ) + conn.executemany( + "INSERT INTO enrollment VALUES (?, ?, ?, ?)", + [ + (1, 0, None, None), # never started + (1, 0, None, (today - datetime.timedelta(days=29)).isoformat()), # active 29 days ago + (1, 0, None, cutoff.isoformat()), # active exactly 30 days ago + ], + ) + rows = conn.execute( + "SELECT completion_status," # noqa: S608 + f" ({needs_attention}) AS needs_attention FROM" + f" (SELECT *, {learner_queries._COMPLETION_STATUS} AS completion_status FROM enrollment)" # noqa: SLF001 + ).fetchall() + conn.close() + + assert rows == [ + ("not_started", 1), # never started: needs attention + ("in_progress", 0), # active 29 days ago: still recent + ("in_progress", 1), # active exactly 30 days ago: needs attention + ] + + +def test_needs_attention_count_respects_the_consent_gate(monkeypatch): + # The same rows, but through the full SUM(CASE WHEN shared AND (...)) + # aggregate, with consent fail-closed (the default, so every row's + # outcome -- including needs-attention -- is withheld) and fail-open. + today = datetime.date.today() # noqa: DTZ011 - the boundary is date-only + cutoff = today - datetime.timedelta(days=30) + needs_attention = _needs_attention_sql(cutoff) + + conn = sqlite3.connect(":memory:") + conn.execute( + "CREATE TABLE enrollment (certificate_is_revoked INTEGER, is_passing INTEGER," + " grade_value REAL, last_active_on TEXT)" + ) + conn.executemany( + "INSERT INTO enrollment VALUES (?, ?, ?, ?)", + [ + (1, 0, None, None), # never started + (1, 0, None, (today - datetime.timedelta(days=29)).isoformat()), # active 29 days ago + (1, 0, None, cutoff.isoformat()), # active exactly 30 days ago + ], + ) + + def count(shared): + query = ( + f"SELECT SUM(CASE WHEN {shared} AND ({needs_attention}) THEN 1 ELSE 0 END) FROM" # noqa: S608 + f" (SELECT *, {learner_queries._COMPLETION_STATUS} AS completion_status" # noqa: SLF001 + " FROM enrollment)" + ) + return conn.execute(query).fetchone()[0] + + assert type(settings)().consent_fail_open is False + assert count(learner_queries._outcomes_shared()) == 0 # noqa: SLF001 + + monkeypatch.setattr(settings, "consent_fail_open", True) + assert count(learner_queries._outcomes_shared()) == 2 # noqa: SLF001 + conn.close() + + 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 From 38970771db0b72f1423b66f44d177c755a957ea5 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Tue, 29 Sep 2026 13:30:22 -0400 Subject: [PATCH 5/5] docs(b2b_dashboard): tighten needs-attention wording for grade-only rows "No activity for 30 days or more" oversells the check: a grade-only in_progress enrollment with no last_active_on has nothing to judge staleness against, so it's never flagged even though it has no recorded activity. Reword to "last recorded activity was at least 30 days ago" and regenerate the OpenAPI spec. Co-Authored-By: Claude Sonnet 5 --- openapi/specs/b2b_dashboard.yaml | 4 ++-- .../tenants/b2b_dashboard/learner_models.py | 12 +++++++----- .../tenants/b2b_dashboard/learner_queries.py | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/openapi/specs/b2b_dashboard.yaml b/openapi/specs/b2b_dashboard.yaml index f89ed44..c22e12c 100644 --- a/openapi/specs/b2b_dashboard.yaml +++ b/openapi/specs/b2b_dashboard.yaml @@ -1619,8 +1619,8 @@ components: type: integer title: Needs Attention Count description: 'How many of those enrollments need attention: the learner - never started, or they started but haven''t done anything in the course - for 30 days or more. Enrollments with hidden progress aren''t counted.' + never started, or their last recorded activity was at least 30 days ago. + Enrollments with hidden progress aren''t counted.' data: items: $ref: '#/components/schemas/LearnerProgress' 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 73c7b4b..8e8fe94 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/learner_models.py @@ -30,8 +30,10 @@ ``CompletionStatus`` is exhaustive and its branches don't overlap. - ``needs_attention_count`` overlaps ``completion_status_counts`` rather than adding to it: a learner needs attention if they never started, or if - they've had no activity in 30 days or more, so the same row can be - ``in_progress`` and also counted here. + their last recorded activity was at least 30 days ago, so the same row + can be ``in_progress`` and also counted here. A grade-only ``in_progress`` + row with no ``last_active_on`` has no recorded activity to judge stale, + so it isn't counted either. """ from __future__ import annotations @@ -223,9 +225,9 @@ class LearnerProgressResponse(BaseModel): ) needs_attention_count: int = Field( description=( - "How many of those enrollments need attention: the learner never started, or they " - "started but haven't done anything in the course for 30 days or more. Enrollments " - "with hidden progress aren't counted." + "How many of those enrollments need attention: the learner never started, or their " + "last recorded activity was at least 30 days ago. Enrollments with hidden progress " + "aren't counted." ) ) 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 982fc09..48a963c 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/learner_queries.py @@ -46,8 +46,8 @@ " END" ) -# A learner needs attention if they never started, or if they started but have -# had no activity in 30 days or more (product definition, Danielle Frappier). +# A learner needs attention if they never started, or if their last recorded +# activity was at least 30 days ago (product definition, Danielle Frappier). # A NULL last_active_on on a non-not_started row (grade but no tracked # activity) doesn't match the staleness branch -- there's no timestamp to # judge quiet against.