[Edovo] Draft inbound API for course completions#205
Draft
darennkomo wants to merge 17 commits into
Draft
Conversation
added 17 commits
June 10, 2026 10:19
Adds a runnable end-to-end check against the local case_triage_db Postgres with BigQuery person resolution mocked. Verifies 201 accepted, 200 duplicate replay, and 400 VALIDATION_ERROR for a missing field.
The DB table is correctly named edovo_course_completions; only the Python class was misspelled. Renaming before the schema is referenced anywhere outside this branch.
The person resolver hardcodes US_CO/US_CO_DOC_ID, but the request model previously accepted any valid StateCode. A payload with a different state_code would silently get resolved against CO. Tighten the model to Literal["US_CO"] so the request layer rejects mismatched states up front.
Edovo can send the same instant in multiple timezones (a +00:00 retry of a +05:00 original). Without normalization those two payloads produce different idempotency keys, defeating the dedupe path. Convert to UTC before stringifying the timestamp into the canonical key input.
Pydantic was coercing the JSON number to float and the route was
re-wrapping that float as Decimal(str(...)) — a lossy round-trip.
Declare the field as Decimal so it parses straight from the JSON
number, and drop the float→str→Decimal hop in the credit calculator
call. JSON serialization now emits a string ("4.5"), matching
Pydantic's default Decimal serialization.
The previous fallback returned constraint="invalid_state_code" for ANY unmapped Pydantic error. A bad course_name or person_id would surface to Edovo as an "invalid_state_code" complaint, which is wrong and confusing. Switch to explicit (field, error_type) → constraint mapping and add a new "invalid" literal as the honest fallback. The state-code path now matches on literal_error specifically rather than being the implicit default.
The previous regex required KeyId, Signature, Timestamp in that exact order. RFC 7235 auth-scheme params are unordered, so any reordering by Edovo's client (HTTP library upgrade, etc.) would 401 every request. Parse params into a dict and validate the set instead, rejecting both missing and unknown params explicitly.
The HMAC string-to-sign hardcoded "/edovo/course-completions" via a module constant. That doesn't compose for additional endpoints under the same scheme and silently breaks if a proxy ever rewrites the path between the signer and the verifier. Plumb the path through from the Flask route to the verifier and sign request.path.
Two concurrent webhooks for the same person could both read prior_hours before either insert had landed, each compute that they crossed the 6-hour boundary, and each emit a credit — double-issuing for one threshold crossing. Take a transaction-scoped Postgres advisory lock keyed on (state_code, person_id) before reading prior_hours so the two requests serialize through credit calculation. The lock auto-releases on commit/rollback, costs ~nothing for the non-conflict path, and requires no schema change.
Generate an ephemeral HMAC secret in-process and mock Secret Manager so the smoke test no longer requires real GSM access or GOOGLE_CLOUD_PROJECT. Randomize course_id per run to avoid dedup against prior rows.
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.
Adds a draft inbound API for Edovo course completions.
Running the smoke test
The test spins up the Flask app in-process and hits the real local Postgres (case_triage_db on port 5433). BigQuery and Secret Manager are mocked, and the HMAC secret is generated in-process — no GCP credentials needed. this will obviously be changed when we ship this
Start the local case_triage db:
docker-compose up -d case_triage_db
Run the smoke test:
uv run python -m recidiviz.tools.case_triage.edovo.smoke_test
--
This is an initial, reviewable implementation of the webhook flow that ingests course-completion events from Edovo (an education tablet provider) and turns accumulated learning hours into earned-time credits for people incarcerated in Colorado (
US_CO). All code lives underrecidiviz/case_triage/edovo/, exposed asPOST /edovo/course-completionson the existing case_triage Flask server. The endpoint can:person_idvia BigQuerycontent_hoursand emitting one credit per six accumulated hoursConcurrent webhooks for the same person serialize through a per-person Postgres advisory lock so two requests can't both read stale hours and double-issue a credit. Adds the
EdovoCourseCompletiontable and an Alembic migration, plus unit tests for every module.The endpoint is locked to
US_COand requires a valid HMAC signature; there is no Auth0/JWT path.Tickets: OBT-23206, OBT-23207, OBT-23208, OBT-23209, OBT-23210, OBT-23212, OBT-27564, OBT-31702