feat(b2b_dashboard): filter learner-progress by needs-attention - #84
Merged
Merged
Conversation
PR #78 added `needs_attention_count` to the response envelope only, so mit-learn could show the number but not filter the directory down to it. Client-side filtering is not an option: `limit`/`offset` and `total_count` are server-side, so narrowing one page in the browser yields wrong counts and wrong page math. Adds both halves, built on the one `_NEEDS_ATTENTION` expression the count already uses, so a row, the filter and the count can never disagree: - `needs_attention` query param on `learner_progress`, consent-gated like the status filter -- a withheld row matches neither direction, so the filter can't reveal the outcome it is withholding. - `needs_attention` on the `LearnerProgress` row model, gated by the same `_OUTCOME_FIELDS` validator. This stops mit-learn recomputing the rule client-side, where it gets the date basis wrong: `CURRENT_DATE()` resolves in the StarRocks cluster timezone, and a browser-computed "today" can differ by a day. `_NEEDS_ATTENTION` is now wrapped in `COALESCE(..., FALSE)`. A row with a grade but no tracked activity makes the staleness comparison NULL. The count's `SUM(CASE WHEN ...)` already folded that into its ELSE, but an unguarded NULL in a WHERE clause is not FALSE, so such a row would have fallen out of `needs_attention=true` and `needs_attention=false` alike while still counting toward `total_count`, and would have serialized as `needs_attention: null` on a row whose outcomes are shared. The count's result is unchanged. `<=` is kept over PR #78's prose `<`: "at least 30 days ago" includes the 30th day, which is what the existing boundary test pins. The comment now says so. Needing attention is deliberately not a `CompletionStatusFilter` member: it cuts across the four statuses rather than partitioning them, so folding it in would break the "exactly one bucket per row" property `CompletionStatusCounts` rests on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OpenAPI ChangesShow/hide changesUnexpected changes? Ensure your branch is up-to-date with |
daniellefrappier18
marked this pull request as ready for review
September 30, 2026 16:31
daniellefrappier18
requested review from
blarghmatey
and
a balanced review from Copilot
September 30, 2026 16:48
Addresses Copilot's review on #84. The page and the count are two statements. With `CURRENT_DATE()` left in the SQL, each evaluated it independently, so a request straddling midnight in the cluster's timezone could filter the page on one cutoff and count on another. The cutoff is `CURRENT_DATE() - 30 days`, so crossing midnight moves it forward and more learners qualify: a row could read `needs_attention: false` while `needs_attention_count` counted it, and with the filter on, `total_count` could exceed the set the page was drawn from -- the wrong page math this endpoint exists to avoid. `learner_queries` no longer writes `CURRENT_DATE()` into either data query. The router resolves the cutoff first, with `NEEDS_ATTENTION_CUTOFF_QUERY`, and passes it to the one `_needs_attention(cutoff)` expression behind the row field, the filter and the count. This is the pattern `as_of` already uses: read one database-derived value per request, hold it, use it everywhere. Unlike `as_of` it is never cached -- the failure it prevents is a date boundary, so a cached cutoff would be wrong for exactly as long as the cache held it. The probe has no FROM clause, so StarRocks answers it without touching storage; a test pins that. The cutoff is spliced as a literal rather than bound, because it appears in the SELECT list, which precedes the WHERE clause the existing parameters bind to -- binding it would mean prepending it to a tuple the page and count queries share. `_date_literal` narrows the value to a `datetime.date` first, so the rendered text can only ever be `'YYYY-MM-DD'`. It accepts `date`, `datetime` and an ISO-8601 string, because which of those a DATE column arrives as depends on the driver and the StarRocks build, and rejecting the other two would turn a type surprise into a 500 on every request. `datetime` is truncated rather than passed through: it subclasses `date`, so rendering its isoformat unchanged would emit a time component and change the comparison. Written bare rather than as `DATE '...'` so it parses in sqlite too. That removes the string substitution the sqlite tests needed: they now execute the production expression verbatim. This does not make the page and count atomic, and is not meant to. They remain two statements over a materialized view, so an MV refresh landing between them still desynchronizes every count from the page. Only a single statement would close that, which is a separate change. The PR wording is corrected to claim what is true: one rule and one cutoff, not one snapshot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blarghmatey
approved these changes
Oct 1, 2026
This was referenced Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What are the relevant tickets?
N/A — tracked in witan as
tk-add-a-needs-attention-query-param-and-per-row-fi-71e3a4. Follows up #78 and unblocks the mit-learn filter UI (tk-add-the-needs-attention-filter-to-the-b2b-learne-225c49).Description (What does it do?)
PR #78 added
needs_attention_countto theLearnerProgressResponseenvelope only, which supports a count tile but not a filter. This adds the two pieces mit-learn needs to build the "Needs attention" filter on the contract learner directory:needs_attentionquery param onGET .../contracts/{contract_id}/learner-progress.needs_attentionfield on eachLearnerProgressrow, consent-gated like the other outcome fields.Both are built on the same expression
needs_attention_countalready uses, against a cutoff resolved once per request, so the row field, the filter and the count always apply the same rule on the same date.This does not make the page and the count atomic, and isn't meant to. They are still two statements over a materialized view, so a refresh landing between them desynchronizes every count from the page — which is true today and independent of this PR. Only collapsing them into one statement would close that.
Filtering client-side was not an option:
limit/offsetpaging andtotal_countare server-side, so narrowing one page in the browser yields wrong counts and wrong page math. Recomputing the rule client-side also gets the date basis wrong —CURRENT_DATE()resolves in the StarRocks cluster's timezone, and a browser-computed "today" can be a day off.Implementation details
The rule lives in
_needs_attention(cutoff), wrapped inCOALESCE(..., FALSE). Two subtleties worth a reviewer's attention.1.
COALESCE, for three-valued logic.A row with a grade but no tracked activity (
last_active_on IS NULL, socompletion_status = 'in_progress') makes the staleness comparison NULL, andFALSE OR NULLis NULL. The count'sSUM(CASE WHEN ... THEN 1 ELSE 0 END)already folded that into itsELSE, so it was never visible. But the same expression in aWHEREclause behaves differently: an unguarded NULL is not FALSE, so without the guard that row would haveneeds_attention=trueandneeds_attention=falsealike, while still counting towardtotal_count— so the two directions would not partition the page, andneeds_attention: nullon a row whoseoutcomes_sharedis true, contradicting the count that treats it as "no".COALESCE(..., FALSE)settles it as "no", matching the documented intent ("no timestamp to judge quiet against") and making the expression two-valued for all three readers.needs_attention_countis unchanged — confirmed by evaluating both the old and new expressions over the same rows (identical results under both consent settings).2. The cutoff is resolved once per request, not written as
CURRENT_DATE(). (Raised by Copilot; fixed in a056224.)The page and the count are two round trips. With
CURRENT_DATE()in the SQL each evaluated it separately, so a request straddling midnight in the cluster's timezone could filter the page on one cutoff and count on another. The cutoff isCURRENT_DATE() - 30 days, so crossing midnight moves it forward and more learners qualify: a row could readneeds_attention: falsewhileneeds_attention_countcounted it, and with the filter on,total_countcould exceed the set the page was drawn from.The router now resolves it first with
NEEDS_ATTENTION_CUTOFF_QUERYand threads it through. This is the patternas_ofalready uses — read one database-derived value per request, hold it, use it everywhere — except it is never cached, because the failure it prevents is a date boundary and a cached cutoff would be wrong for exactly as long as the cache held it. The probe has noFROM, so StarRocks answers it without touching storage; a test pins that.It is spliced as a literal rather than bound because it appears in the SELECT list, which precedes the WHERE clause the existing parameters bind to.
_date_literalnarrows the value to adatetime.datefirst, so the rendered text can only ever be'YYYY-MM-DD'. It acceptsdate,datetimeand an ISO-8601 string, since which of those a DATE column arrives as depends on the driver and the StarRocks build;datetimeis truncated rather than passed through, because it subclassesdateand rendering its isoformat unchanged would emit a time component.Consent gating. The filter is gated exactly like the status filter:
({shared} AND [NOT] (...)). A withheld row matches neither direction, so filtering can't reveal the outcome the row is withholding. Unlikecompletion_status, there is nounknownescape hatch — a bool has no third member — so withheld rows are reachable only by omitting the filter. The param's description says so.Row projection.
needs_attentionis derived in the outer select rather than added to_OUTCOMES, because it readsrecords.completion_status, an alias that only resolves there. It is added tolearner_models._OUTCOME_FIELDSso the model validator nulls it too, and a new test pins those two lists in step so an outcome added to the query alone can't reach a manager ungated.<=vs<. PR #78's description said the rule used<; the merged code uses<=. The code is correct — "at least 30 days ago" includes the 30th day, which is what the pre-existing boundary test already pinned — so the code is unchanged and the comment now states the intent explicitly. I've also corrected #78's description.Not a
CompletionStatusFiltermember. Needing attention cuts across the four statuses rather than partitioning them (a stalein_progressrow is both), so folding it in would break the "exactly one bucket per row" propertyCompletionStatusCountsrests on. It stays a separate boolean param.How can this be tested?
Local checks, all passing on this branch:
14 new tests in
tests/test_dashboard_learner_progress.py, including two that execute the real_COMPLETION_STATUSand_needs_attentionstrings against rows in sqlite rather than asserting on SQL text:test_needs_attention_is_two_valued_so_the_filter_partitions_rows— the case above: asserts no row evaluates to NULL, thattrue/falsepartition the rows, and thattrueselects exactly what the count counts.test_needs_attention_boundary_is_computed_from_real_rows(extended) — adds the grade-but-no-activity row to the day-29/day-30 boundary cases.The rest cover consent gating in both directions, the predicate being absent when the param is omitted, page and count sharing one WHERE clause, the row field reusing the count's expression, the field being withheld without consent, and a non-boolean value returning 422.
Plus coverage that the cutoff is resolved exactly once per request, reaches both statements, and that no
CURRENT_DATE()survives in either; and that_date_literalnormalizes every shape the driver might hand back and refuses anything it can't read as a date.Not exercised against a live StarRocks instance. These tests mock the DB layer (per the test module's docstring), so
COALESCE-over-boolean is verified in sqlite, not StarRocks. Two things a reviewer should check against a realmv_b2b_learner_enrollment— same caveat as #78:'YYYY-MM-DD'literal compares correctly against the DATE column;NEEDS_ATTENTION_CUTOFF_QUERY._date_literalhandlesdate,datetimeandstr, so any of those is fine — this is to confirm it isn't a fourth thing.Since the cutoff is now a literal rather than
DATE_SUB(CURRENT_DATE(), ...), the sqlite tests no longer rewrite the SQL to run it — they execute the production expression verbatim.The generated predicate, for reference (consent fail-open):
Additional Context
CompletionStatusFiltergained an explanatory comment rather than a docstring, deliberately: a docstring on that enum renders into the published spec as the parameter's description, which put internal RST notes in front of API consumers.needs_attentionis a required field onLearnerProgress(nullable, no default), consistent with the other outcome fields. Consumers deserializing the row model strictly will see a new key.🤖 Generated with Claude Code