From c86c7b60356cb735c7988d6cbd4e475da04f4201 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Fri, 21 Aug 2026 13:57:21 -0400 Subject: [PATCH 1/7] feat(openapi): publish a per-tenant spec and generate clients from it MIT Learn consumes this service through hand-written types and a hand-written axios client that mirror models.py column for column. That was the right call for the first cut - nothing here published a client, and blocking the dashboard on a cross-repo publish pipeline was not worth it - but it means the frontend drifts silently every time a materialized view gains or renames a column. It already has: ol-analytics-api#33 made three engagement totals nullable and added seven columns, and mit-learn's types still say otherwise. Each tenant is a mounted sub-app, so it owns its own /openapi.json and the root app's schema contains none of it. `openapi.py` builds the apps through the same create_app() the server runs and takes each tenant's document from there, with two fixups that exist because the output is for a client generator rather than for the tenant's own /docs: paths are re-prefixed with the mount path, since Starlette strips it before the sub-app sees a request and a client pointed at the service host would otherwise call URLs that do not exist; and the document version is pinned rather than read from the package, so a release that changes no route produces no diff. Every route now names its own operation_id. That string becomes the generated client's method name, and FastAPI's default derives one from the function name and the whole path - `contractUtilizationOrganizationsOrganizationIdContract UtilizationGet`, renamed whenever the path moves. The tag prefix is also what keeps the org and contract routers' identically-named panels apart. Verified rather than assumed, since the org and contract endpoints are registered in a loop over a table of specs with a runtime-parametrized generic: openapi-generator v7.2.0 emits a distinct TypeScript interface per row model and per envelope (no collapse to one untyped OrgAnalyticsResponse), resolves the 3.1 `anyOf: [integer, null]` columns to `number | null`, and the result typechecks clean under `tsc --strict`. A test asserts the non-collapse so a future registration change cannot quietly undo it. The spec is committed because it is a cross-repo interface: ol-infrastructure's api_clients_pipeline watches openapi/specs/*.yaml on release and publishes the TypeScript package from it, the same arrangement behind @mitodl/mitxonline-api-axios. Drift fails CI twice over - as a test, and as a --check run of the generator, which is the only thing that exercises the generator at all. openapi-diff.yml comments the changelog on any PR touching a spec and fails on a breaking change, because breaking one here means breaking a client someone already shipped. Still to land before mit-learn can drop its hand-written client: the ol-analytics-api-clients repo and the PIPELINE_CONFIGS entry pointing at it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AVSXwars1LvgtqV1YWhrB1 --- .github/workflows/ci.yml | 5 + .github/workflows/openapi-diff.yml | 99 + README.md | 32 + bin/generate-openapi-spec | 65 + openapi/specs/b2b_dashboard.yaml | 1587 +++++++++++++++++ pyproject.toml | 6 + src/ol_analytics_api/main.py | 19 +- src/ol_analytics_api/openapi.py | 78 + .../tenants/b2b_dashboard/routers/admin.py | 2 +- .../b2b_dashboard/routers/contracts.py | 5 + .../b2b_dashboard/routers/organizations.py | 7 + tests/test_lifespan.py | 4 +- tests/test_openapi_spec.py | 84 + uv.lock | 141 ++ 14 files changed, 2129 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/openapi-diff.yml create mode 100755 bin/generate-openapi-spec create mode 100644 openapi/specs/b2b_dashboard.yaml create mode 100644 src/ol_analytics_api/openapi.py create mode 100644 tests/test_openapi_spec.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6f1533..e306180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,4 +31,9 @@ jobs: - run: uv run ruff check . - run: uv run ruff format --check . - run: uv run mypy src + # tests/test_openapi_spec.py already asserts the spec is current. This + # step is here for the other half: nothing else runs the generator + # itself, and a build-time script that only ever runs by hand is one + # that breaks unnoticed and is discovered when someone needs it. + - run: uv run bin/generate-openapi-spec --check - run: uv run pytest --cov=ol_analytics_api --cov-report=term-missing diff --git a/.github/workflows/openapi-diff.yml b/.github/workflows/openapi-diff.yml new file mode 100644 index 0000000..620d93f --- /dev/null +++ b/.github/workflows/openapi-diff.yml @@ -0,0 +1,99 @@ +name: OpenAPI Diff + +# The committed spec is what the Concourse client pipeline generates the +# published TypeScript package from, so a diff here is a change to somebody +# else's build. This surfaces that change as a comment and fails the PR on a +# breaking one, rather than leaving it to whoever reads 1500 lines of YAML. + +on: + pull_request: + paths: + - "openapi/specs/**" + +permissions: {} + +jobs: + openapi-diff: + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout HEAD + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The exact commit under review, not the branch name: a push while + # this runs would otherwise diff a commit nobody reviewed. + ref: ${{ github.event.pull_request.head.sha }} + path: head + persist-credentials: false + - name: Checkout BASE + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + persist-credentials: false + - name: Generate oasdiff changelog + run: | # Write the comment body to a file rather than a step output. + # A large changelog interpolated into a JS action's `body:` input becomes a + # huge INPUT_BODY env var, which can blow past the OS argv+envp size limit + # and crash the action with "Argument list too long". Writing straight to a + # file and using `body-path` avoids that entirely. + { + echo "## OpenAPI Changes" + echo "" + echo "
" + echo "Show/hide changes" + echo "" + echo '```' + for spec in base/openapi/specs/*.yaml; do + head_spec="head/openapi/specs/$(basename "$spec")" + if [ -f "$head_spec" ]; then + echo "## Changes for $(basename "$spec"):" + docker run --rm \ + --workdir "$GITHUB_WORKSPACE" \ + --volume "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" \ + tufin/oasdiff changelog "$spec" "$head_spec" + echo "" + fi + done + echo '```' + echo "" + echo "Unexpected changes? Ensure your branch is up-to-date with \`main\` (consider rebasing)." + echo "
" + } > comment_body.md + - name: Find existing comment + id: find_comment + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + issue-number: ${{ github.event.pull_request.number }} + body-includes: "## OpenAPI Changes" + - name: Post changes as comment + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5 + # Even with no changes, update the old comment if one was found. + with: + token: ${{ secrets.GITHUB_TOKEN }} + edit-mode: "replace" + repository: ${{ github.repository }} + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find_comment.outputs.comment-id }} + body-path: comment_body.md + - name: Check for breaking changes + run: | + # Breaking here means breaking a client someone else already + # generated and shipped, so this fails the PR rather than warning. + for spec in base/openapi/specs/*.yaml; do + head_spec="head/openapi/specs/$(basename "$spec")" + if [ -f "$head_spec" ]; then + echo "Checking $(basename "$spec") for breaking changes..." + docker run --rm \ + --workdir "$GITHUB_WORKSPACE" \ + --volume "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" \ + tufin/oasdiff breaking \ + --fail-on ERR \ + --format githubactions \ + "$spec" "$head_spec" + fi + done diff --git a/README.md b/README.md index 4603134..ee40446 100644 --- a/README.md +++ b/README.md @@ -208,3 +208,35 @@ uv run pytest uv run ruff check . uv run mypy src ``` + +## The published API contract + +Each tenant's OpenAPI document is committed under `openapi/specs/.yaml` +and regenerated with: + +```bash +uv run bin/generate-openapi-spec +``` + +Run it whenever a response model, route or query parameter changes. CI fails +otherwise — both as a test (`tests/test_openapi_spec.py`) and as a +`--check` run of the generator itself. + +The spec is committed rather than served-and-forgotten because it is a +cross-repo interface. The Concourse pipeline in `ol-infrastructure` +(`ol_concourse/pipelines/libraries/api_clients_pipeline.py`) watches these +files on the `release` branch, runs `openapi-generator` over them, and +publishes the TypeScript client that MIT Learn's dashboard imports — the same +arrangement behind `@mitodl/mitxonline-api-axios` and +`@mitodl/mit-learn-api-axios`. A column that appears here without appearing in +the diff is a column a consumer finds out about at runtime. + +Two details are worth knowing before editing a route: + +- **`operation_id` is named explicitly on every route.** It becomes the + generated client's method name, so FastAPI's path-derived default would both + produce an unreadable name and rename the method whenever the path moves. +- **Published paths carry the tenant's mount prefix.** A mounted sub-app + describes its routes relative to its own root; `openapi.py` re-prefixes them + so a generated client configured with the service host requests the URLs the + service actually serves. diff --git a/bin/generate-openapi-spec b/bin/generate-openapi-spec new file mode 100755 index 0000000..33f597e --- /dev/null +++ b/bin/generate-openapi-spec @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Write each mounted tenant's OpenAPI document to openapi/specs/.yaml. + +Run as `uv run bin/generate-openapi-spec`. + +The output is committed, and that is the point: a materialized view gaining or +renaming a column changes a response model, which changes this file, which +shows up in review as an interface diff instead of silently drifting away from +the clients generated off it. `tests/test_openapi_spec.py` fails when the +committed file no longer matches what the code produces. + +The Concourse pipeline in ol-infrastructure +(`ol_concourse/pipelines/libraries/api_clients_pipeline.py`) watches +`openapi/specs/*.yaml` on the release branch and regenerates the published +TypeScript client from it — the same arrangement mitxonline and mit-learn use. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import cyclopts + +from ol_analytics_api.openapi import render, tenant_specs + +DEFAULT_DIRECTORY = Path("openapi/specs") + +app = cyclopts.App(name="generate-openapi-spec", help=__doc__) + + +@app.default +def generate(*, directory: Path = DEFAULT_DIRECTORY, check: bool = False) -> None: + """Write (or, with --check, verify) the per-tenant OpenAPI documents. + + Parameters + ---------- + directory + Where the .yaml files are written. + check + Compare against what is already on disk and exit non-zero on any + difference, without writing anything. + """ + stale = [] + for tenant_name, spec in tenant_specs().items(): + path = directory / f"{tenant_name}.yaml" + rendered = render(spec) + if check: + if not path.exists() or path.read_text() != rendered: + stale.append(path) + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(rendered) + sys.stdout.write(f"wrote {path}\n") + if stale: + names = ", ".join(str(path) for path in stale) + sys.stderr.write( + f"OpenAPI spec is out of date: {names}. " + "Regenerate with `uv run bin/generate-openapi-spec`.\n" + ) + raise SystemExit(1) + + +if __name__ == "__main__": + app() diff --git a/openapi/specs/b2b_dashboard.yaml b/openapi/specs/b2b_dashboard.yaml new file mode 100644 index 0000000..a97a280 --- /dev/null +++ b/openapi/specs/b2b_dashboard.yaml @@ -0,0 +1,1587 @@ +openapi: 3.1.0 +info: + title: B2B Analytics Dashboard + description: Aggregated-only B2B site-license analytics for org managers and MIT + contract admins. No individual learner PII. + version: 0.0.1 +paths: + /api/v1/analytics/organizations/{organization_id}/contract-utilization: + get: + tags: + - organizations + summary: Contract Utilization + operationId: organizations_contract_utilization_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_ContractUtilization_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/enrollment-funnel: + get: + tags: + - organizations + summary: Enrollment Funnel + operationId: organizations_enrollment_funnel_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_EnrollmentCompletionFunnel_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/engagement-trend: + get: + tags: + - organizations + summary: Engagement Trend + operationId: organizations_engagement_trend_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_MonthlyEngagementTrend_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/program-funnel: + get: + tags: + - organizations + summary: Program Funnel + operationId: organizations_program_funnel_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_ProgramFunnel_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/content-engagement: + get: + tags: + - organizations + summary: Content Engagement + operationId: organizations_content_engagement_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_ContentEngagementDepth_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/contract-utilization: + get: + tags: + - contracts + summary: Contract Utilization + operationId: contracts_contract_utilization_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: string + 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_ContractUtilization_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/enrollment-funnel: + get: + tags: + - contracts + summary: Enrollment Funnel + operationId: contracts_enrollment_funnel_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: string + 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_EnrollmentCompletionFunnel_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/engagement-trend: + get: + tags: + - contracts + summary: Engagement Trend + operationId: contracts_engagement_trend_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: string + 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_ContractMonthlyEngagementTrend_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/program-funnel: + get: + tags: + - contracts + summary: Program Funnel + operationId: contracts_program_funnel_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: string + 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_ProgramFunnel_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/content-engagement: + get: + tags: + - contracts + summary: Content Engagement + operationId: contracts_content_engagement_retrieve + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + title: Organization Id + - name: contract_id + in: path + required: true + schema: + type: string + 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_ContractContentEngagementDepth_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/admin/contract-health: + get: + tags: + - admin + summary: Contract Health + operationId: admin_contract_health_retrieve + parameters: + - 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/AdminAnalyticsResponse_MitAdminContractHealth_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + AdminAnalyticsResponse_MitAdminContractHealth_: + properties: + 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/MitAdminContractHealth' + type: array + title: Data + type: object + required: + - as_of + - total_count + - data + title: AdminAnalyticsResponse[MitAdminContractHealth] + ContentEngagementDepth: + properties: + organization_key: + type: string + title: Organization Key + organization_name: + type: string + title: Organization Name + courserun_readable_id: + type: string + title: Courserun Readable Id + courserun_title: + type: string + title: Courserun Title + total_enrolled_learners: + type: integer + title: Total Enrolled Learners + engaged_learners: + anyOf: + - type: integer + - type: 'null' + title: Engaged Learners + engagement_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Engagement Rate Pct + total_videos_watched: + anyOf: + - type: integer + - type: 'null' + title: Total Videos Watched + video_watchers: + anyOf: + - type: integer + - type: 'null' + title: Video Watchers + avg_videos_per_engaged_learner: + anyOf: + - type: number + - type: 'null' + title: Avg Videos Per Engaged Learner + total_problems_attempted: + anyOf: + - type: integer + - type: 'null' + title: Total Problems Attempted + problem_attempters: + anyOf: + - type: integer + - type: 'null' + title: Problem Attempters + avg_problems_per_engaged_learner: + anyOf: + - type: number + - type: 'null' + title: Avg Problems Per Engaged Learner + total_chatbot_interactions: + anyOf: + - type: integer + - type: 'null' + title: Total Chatbot Interactions + chatbot_users: + anyOf: + - type: integer + - type: 'null' + title: Chatbot Users + chatbot_adoption_pct: + anyOf: + - type: number + - type: 'null' + title: Chatbot Adoption Pct + certificates_earned: + anyOf: + - type: integer + - type: 'null' + title: Certificates Earned + type: object + required: + - organization_key + - organization_name + - courserun_readable_id + - courserun_title + - total_enrolled_learners + - engaged_learners + - engagement_rate_pct + - total_videos_watched + - video_watchers + - avg_videos_per_engaged_learner + - total_problems_attempted + - problem_attempters + - avg_problems_per_engaged_learner + - total_chatbot_interactions + - chatbot_users + - chatbot_adoption_pct + - certificates_earned + title: ContentEngagementDepth + description: 'mv_b2b_content_engagement_depth — grain: org x course_run (all-time). + + + The chatbot columns are exact: ``total_chatbot_interactions`` sums over, + + and ``chatbot_adoption_pct`` divides by, ``chatbot_users`` — which this + + view does emit, so both are correctly floored. ``engagement_rate_pct`` is + + ``engaged_learners / total_enrolled_learners``, also correct. + + + The video and problem columns are floored through the cohorts the view now + + publishes (ol-data-platform PR #2520): ``total_videos_watched`` is summed + + over ``video_watchers`` and ``total_problems_attempted`` over + + ``problem_attempters``, each a strict subset of ``engaged_learners`` + + because watching a video or attempting a problem is one of the activities + + that sets ``active_count``. (Every cohort this view emits is such a + + subset. That is a property of these particular cohorts, not a general + + rule — see ``MonthlyEngagementTrend``, where ``enrolling_learners`` is + + not a subset of its primary because enrolling does not set + + ``active_count``.) + + + The ``avg_*_per_engaged_learner`` columns are derived from *two* cohorts, + + which is why each names both. The denominator is ``engaged_learners`` — + + that is what the dbt SQL divides by, so the naming is now accurate — but + + the numerator is the activity SUM, contributed by only the narrower + + cohort. Mapping the average to its denominator alone would leave the + + numerator recoverable: an unsuppressed average multiplied by a published + + ``engaged_learners`` yields the suppressed total exactly, and when the + + contributing cohort is a single learner that total *is* that learner''s + + value. Naming both cohorts nulls the average whenever either is sub-floor. + + + ``certificates_earned`` is the one column still floored as a count of + + itself: it is ``sum(certificate_count)``, an event count, and this view + + emits no certified-learner cohort to attribute it to (unlike + + ``MonthlyEngagementTrend``, which has ``certified_learners``). Flooring an + + event count is weaker than flooring a cohort — several certificates can + + come from one learner — but strictly better than not flooring it. Emitting + + the cohort from dbt would close this the same way #2520 closed the others.' + ContractContentEngagementDepth: + properties: + organization_key: + type: string + title: Organization Key + organization_name: + type: string + title: Organization Name + courserun_readable_id: + type: string + title: Courserun Readable Id + courserun_title: + type: string + title: Courserun Title + total_enrolled_learners: + type: integer + title: Total Enrolled Learners + engaged_learners: + anyOf: + - type: integer + - type: 'null' + title: Engaged Learners + engagement_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Engagement Rate Pct + total_videos_watched: + anyOf: + - type: integer + - type: 'null' + title: Total Videos Watched + video_watchers: + anyOf: + - type: integer + - type: 'null' + title: Video Watchers + avg_videos_per_engaged_learner: + anyOf: + - type: number + - type: 'null' + title: Avg Videos Per Engaged Learner + total_problems_attempted: + anyOf: + - type: integer + - type: 'null' + title: Total Problems Attempted + problem_attempters: + anyOf: + - type: integer + - type: 'null' + title: Problem Attempters + avg_problems_per_engaged_learner: + anyOf: + - type: number + - type: 'null' + title: Avg Problems Per Engaged Learner + total_chatbot_interactions: + anyOf: + - type: integer + - type: 'null' + title: Total Chatbot Interactions + chatbot_users: + anyOf: + - type: integer + - type: 'null' + title: Chatbot Users + chatbot_adoption_pct: + anyOf: + - type: number + - type: 'null' + title: Chatbot Adoption Pct + certificates_earned: + anyOf: + - type: integer + - type: 'null' + title: Certificates Earned + contract_pk: + type: string + title: Contract Pk + contract_id: + type: string + title: Contract Id + b2b_contract_name: + type: string + title: B2B Contract Name + type: object + required: + - organization_key + - organization_name + - courserun_readable_id + - courserun_title + - total_enrolled_learners + - engaged_learners + - engagement_rate_pct + - total_videos_watched + - video_watchers + - avg_videos_per_engaged_learner + - total_problems_attempted + - problem_attempters + - avg_problems_per_engaged_learner + - total_chatbot_interactions + - chatbot_users + - chatbot_adoption_pct + - certificates_earned + - contract_pk + - contract_id + - b2b_contract_name + title: ContractContentEngagementDepth + description: 'mv_b2b_contract_content_engagement_depth — grain: org x contract + x run. + + + The contract-scoped sibling of ``ContentEngagementDepth``, inherited for + + the same reason as ``ContractMonthlyEngagementTrend``. + + + Unlike the trend view, these rows ARE a strict partition of the org-level + + view: a course run belongs to exactly one contract, so naming the contract + + labels a row rather than splitting it, and every count here equals its + + org-level counterpart for the same course run. + + + That equality is why this pair needs no cross-grain guard, where the trend + + pair does. Nothing is aggregated away going from contract grain to org + + grain, so there is no remainder to subtract: a course run''s org row and its + + contract row hold the same numbers, the floor makes the same call on both, + + and a caller reading one learns nothing the other withholds.' + ContractMonthlyEngagementTrend: + properties: + organization_key: + type: string + title: Organization Key + organization_name: + type: string + title: Organization Name + activity_year_and_month: + type: string + title: Activity Year And Month + monthly_active_learners: + type: integer + title: Monthly Active Learners + new_enrollments: + anyOf: + - type: integer + - type: 'null' + title: New Enrollments + enrolling_learners: + anyOf: + - type: integer + - type: 'null' + title: Enrolling Learners + certificates_earned: + anyOf: + - type: integer + - type: 'null' + title: Certificates Earned + certified_learners: + anyOf: + - type: integer + - type: 'null' + title: Certified Learners + total_videos_watched: + anyOf: + - type: integer + - type: 'null' + title: Total Videos Watched + video_watchers: + anyOf: + - type: integer + - type: 'null' + title: Video Watchers + total_problems_attempted: + anyOf: + - type: integer + - type: 'null' + title: Total Problems Attempted + problem_attempters: + anyOf: + - type: integer + - type: 'null' + title: Problem Attempters + total_chatbot_interactions: + anyOf: + - type: integer + - type: 'null' + title: Total Chatbot Interactions + chatbot_users: + anyOf: + - type: integer + - type: 'null' + title: Chatbot Users + contract_pk: + type: string + title: Contract Pk + contract_id: + type: string + title: Contract Id + b2b_contract_name: + type: string + title: B2B Contract Name + type: object + required: + - organization_key + - organization_name + - activity_year_and_month + - monthly_active_learners + - new_enrollments + - enrolling_learners + - certificates_earned + - certified_learners + - total_videos_watched + - video_watchers + - total_problems_attempted + - problem_attempters + - total_chatbot_interactions + - chatbot_users + - contract_pk + - contract_id + - b2b_contract_name + title: ContractMonthlyEngagementTrend + description: 'mv_b2b_contract_monthly_engagement_trend — grain: org x contract + x month. + + + The contract-scoped sibling of ``MonthlyEngagementTrend``, backing the + + endpoints nested under a contract. Subclassed rather than redeclared so the + + two can''t drift: the column set and the ``cohort_policy`` — which is what + + the anonymization floor reads — are inherited verbatim, and only contract + + identity is added. The dbt models are siblings in the same way. + + + The contract columns are not cohorts and take no part in the policy. + + + A learner active under two of an org''s contracts appears in both rows, so + + these rows do not partition the org-level view''s learner counts; summing + + ``monthly_active_learners`` across contracts can exceed the org''s own + + figure. Activity totals, being sums of events, do add up — which is what + + makes a contract-month the floor withholds recoverable from the org + + endpoint as ``org_total - sum(the visible contract months)``. The org + + endpoint defends against that itself: it probes this view for the months + + it withholds and blanks its own additive totals for them (see + + ``routers.organizations._FinerGrain``). The learner counts are left alone, + + because not adding up is exactly what stops them from being recovered by + + subtraction.' + ContractUtilization: + properties: + organization_key: + type: string + title: Organization Key + organization_name: + type: string + title: Organization Name + contract_pk: + type: string + title: Contract Pk + contract_id: + type: string + title: Contract Id + b2b_contract_name: + type: string + title: B2B Contract Name + b2b_contract_is_active: + type: boolean + title: B2B Contract Is Active + b2b_contract_start_date: + anyOf: + - type: string + format: date + - type: 'null' + title: B2B Contract Start Date + b2b_contract_end_date: + anyOf: + - type: string + format: date + - type: 'null' + title: B2B Contract End Date + seat_limit: + anyOf: + - type: integer + - type: 'null' + title: Seat Limit + b2b_contract_membership_type: + anyOf: + - type: string + - type: 'null' + title: B2B Contract Membership Type + seats_consumed: + type: integer + title: Seats Consumed + active_learners: + anyOf: + - type: integer + - type: 'null' + title: Active Learners + learners_certified: + anyOf: + - type: integer + - type: 'null' + title: Learners Certified + seat_utilization_pct: + anyOf: + - type: number + - type: 'null' + title: Seat Utilization Pct + completion_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Completion Rate Pct + type: object + required: + - organization_key + - organization_name + - contract_pk + - contract_id + - b2b_contract_name + - b2b_contract_is_active + - b2b_contract_start_date + - b2b_contract_end_date + - seat_limit + - b2b_contract_membership_type + - seats_consumed + - active_learners + - learners_certified + - seat_utilization_pct + - completion_rate_pct + title: ContractUtilization + description: 'mv_b2b_contract_utilization — grain: org x contract.' + EnrollmentCompletionFunnel: + properties: + organization_key: + type: string + title: Organization Key + organization_name: + type: string + title: Organization Name + contract_pk: + type: string + title: Contract Pk + contract_id: + type: string + title: Contract Id + b2b_contract_name: + type: string + title: B2B Contract Name + courserun_pk: + type: string + title: Courserun Pk + courserun_readable_id: + type: string + title: Courserun Readable Id + courserun_title: + type: string + title: Courserun Title + enrolled_learners: + type: integer + title: Enrolled Learners + active_learners: + anyOf: + - type: integer + - type: 'null' + title: Active Learners + passing_learners: + anyOf: + - type: integer + - type: 'null' + title: Passing Learners + certified_learners: + anyOf: + - type: integer + - type: 'null' + title: Certified Learners + active_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Active Rate Pct + completion_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Completion Rate Pct + type: object + required: + - organization_key + - organization_name + - contract_pk + - contract_id + - b2b_contract_name + - courserun_pk + - courserun_readable_id + - courserun_title + - enrolled_learners + - active_learners + - passing_learners + - certified_learners + - active_rate_pct + - completion_rate_pct + title: EnrollmentCompletionFunnel + description: 'mv_b2b_enrollment_completion_funnel — grain: org x contract x + course_run.' + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + MitAdminContractHealth: + properties: + organization_key: + type: string + title: Organization Key + organization_name: + type: string + title: Organization Name + contract_pk: + type: string + title: Contract Pk + contract_id: + type: string + title: Contract Id + b2b_contract_name: + type: string + title: B2B Contract Name + b2b_contract_is_active: + type: boolean + title: B2B Contract Is Active + b2b_contract_start_date: + anyOf: + - type: string + format: date + - type: 'null' + title: B2B Contract Start Date + b2b_contract_end_date: + anyOf: + - type: string + format: date + - type: 'null' + title: B2B Contract End Date + seat_limit: + anyOf: + - type: integer + - type: 'null' + title: Seat Limit + b2b_contract_membership_type: + anyOf: + - type: string + - type: 'null' + title: B2B Contract Membership Type + seats_consumed: + type: integer + title: Seats Consumed + active_learners: + anyOf: + - type: integer + - type: 'null' + title: Active Learners + certified_learners: + anyOf: + - type: integer + - type: 'null' + title: Certified Learners + seat_utilization_pct: + anyOf: + - type: number + - type: 'null' + title: Seat Utilization Pct + completion_rate_pct: + anyOf: + - type: number + - type: 'null' + title: Completion Rate Pct + health_status: + type: string + title: Health Status + type: object + required: + - organization_key + - organization_name + - contract_pk + - contract_id + - b2b_contract_name + - b2b_contract_is_active + - b2b_contract_start_date + - b2b_contract_end_date + - seat_limit + - b2b_contract_membership_type + - seats_consumed + - active_learners + - certified_learners + - seat_utilization_pct + - completion_rate_pct + - health_status + title: MitAdminContractHealth + description: 'mv_b2b_mit_admin_contract_health — grain: org x contract (MIT + admin only).' + MonthlyEngagementTrend: + properties: + organization_key: + type: string + title: Organization Key + organization_name: + type: string + title: Organization Name + activity_year_and_month: + type: string + title: Activity Year And Month + monthly_active_learners: + type: integer + title: Monthly Active Learners + new_enrollments: + anyOf: + - type: integer + - type: 'null' + title: New Enrollments + enrolling_learners: + anyOf: + - type: integer + - type: 'null' + title: Enrolling Learners + certificates_earned: + anyOf: + - type: integer + - type: 'null' + title: Certificates Earned + certified_learners: + anyOf: + - type: integer + - type: 'null' + title: Certified Learners + total_videos_watched: + anyOf: + - type: integer + - type: 'null' + title: Total Videos Watched + video_watchers: + anyOf: + - type: integer + - type: 'null' + title: Video Watchers + total_problems_attempted: + anyOf: + - type: integer + - type: 'null' + title: Total Problems Attempted + problem_attempters: + anyOf: + - type: integer + - type: 'null' + title: Problem Attempters + total_chatbot_interactions: + anyOf: + - type: integer + - type: 'null' + title: Total Chatbot Interactions + chatbot_users: + anyOf: + - type: integer + - type: 'null' + title: Chatbot Users + type: object + required: + - organization_key + - organization_name + - activity_year_and_month + - monthly_active_learners + - new_enrollments + - enrolling_learners + - certificates_earned + - certified_learners + - total_videos_watched + - video_watchers + - total_problems_attempted + - problem_attempters + - total_chatbot_interactions + - chatbot_users + title: MonthlyEngagementTrend + description: "mv_b2b_monthly_engagement_trend — grain: org x year_month.\n\n\ + Every aggregate here is floored through the cohort that contributes to it,\n\ + which the view publishes alongside it (ol-data-platform PR #2520).\n\nNone\ + \ of them is attributable to ``monthly_active_learners``. Each is a\nplain\ + \ SUM over the source report, so only the learners who did that\nspecific\ + \ thing contribute — and clearing the primary floor says nothing\nabout whether\ + \ that narrower cohort cleared it. A month with 40 active\nlearners can carry\ + \ a chatbot total contributed by exactly one of them,\nwhich is why each total\ + \ is ``derived`` from its own cohort rather than\nfrom the primary.\n\nHow\ + \ each cohort relates to the primary differs, and neither case makes\nmapping\ + \ to the primary safe:\n\n- ``certified_learners``, ``video_watchers``, ``problem_attempters``\ + \ and\n ``chatbot_users`` are strict *subsets*. ``active_count`` is 1 when\ + \ any\n of navigation, discussion, videos, problems, chatbot or certificate\n\ + \ activity is nonzero (organization_administration_report.sql), so each\n\ + \ of those actions sets it.\n- ``enrolling_learners`` is *not* a subset.\ + \ ``enrolled_count`` is absent\n from that expression, so enrolling alone\ + \ never sets ``active_count``\n and a learner who only enrolled is counted\ + \ here but not in the primary.\n The row gate is unaffected — a month whose\ + \ primary is sub-floor is\n dropped whole, which over-suppresses a large\ + \ enrollment cohort rather\n than disclosing one — but the subset reasoning\ + \ does not apply, and\n ``new_enrollments`` is floored through ``enrolling_learners``\ + \ on its\n own terms.\n\n``new_enrollments`` and ``certificates_earned``\ + \ are SUMs of\nper-learner-per-course-run markers, so they count *events*,\ + \ not learners:\none learner enrolling in six runs reads as ``new_enrollments\ + \ == 6`` and\nwould clear a floor of 5 on its own. Flooring them directly\ + \ is therefore\nthe wrong instrument — they are ``derived`` from ``enrolling_learners``\n\ + and ``certified_learners``, the distinct-learner counts they are actually\n\ + attributable to, which do carry the floor." + OrgAnalyticsResponse_ContentEngagementDepth_: + 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/ContentEngagementDepth' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[ContentEngagementDepth] + OrgAnalyticsResponse_ContractContentEngagementDepth_: + 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/ContractContentEngagementDepth' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[ContractContentEngagementDepth] + OrgAnalyticsResponse_ContractMonthlyEngagementTrend_: + 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/ContractMonthlyEngagementTrend' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[ContractMonthlyEngagementTrend] + OrgAnalyticsResponse_ContractUtilization_: + 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/ContractUtilization' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[ContractUtilization] + OrgAnalyticsResponse_EnrollmentCompletionFunnel_: + 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/EnrollmentCompletionFunnel' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[EnrollmentCompletionFunnel] + OrgAnalyticsResponse_MonthlyEngagementTrend_: + 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/MonthlyEngagementTrend' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[MonthlyEngagementTrend] + OrgAnalyticsResponse_ProgramFunnel_: + 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/ProgramFunnel' + type: array + title: Data + type: object + required: + - organization_id + - as_of + - total_count + - data + title: OrgAnalyticsResponse[ProgramFunnel] + ProgramFunnel: + properties: + organization_key: + type: string + title: Organization Key + organization_name: + type: string + title: Organization Name + contract_pk: + type: string + title: Contract Pk + contract_id: + type: string + title: Contract Id + b2b_contract_name: + type: string + title: B2B Contract Name + program_pk: + type: string + title: Program Pk + program_title: + type: string + title: Program Title + total_courses: + type: integer + title: Total Courses + enrolled_in_contract_courses: + type: integer + title: Enrolled In Contract Courses + enrolled_via_program: + anyOf: + - type: integer + - type: 'null' + title: Enrolled Via Program + program_course_completers: + anyOf: + - type: integer + - type: 'null' + title: Program Course Completers + type: object + required: + - organization_key + - organization_name + - contract_pk + - contract_id + - b2b_contract_name + - program_pk + - program_title + - total_courses + - enrolled_in_contract_courses + - enrolled_via_program + - program_course_completers + title: ProgramFunnel + description: 'mv_b2b_program_funnel — grain: org x contract x program. + + + ``total_courses`` counts courses, not learners, so it is not a cohort.' + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + input: + title: Input + ctx: + type: object + title: Context + type: object + required: + - loc + - msg + - type + title: ValidationError diff --git a/pyproject.toml b/pyproject.toml index 9f11af1..4537986 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,9 @@ dev = [ "types-hvac>=2.3", "asgi-lifespan>=2.1.0", "pytest-cov>=7.1.0", + "cyclopts>=4.22.5", + "pyyaml>=6.0.3", + "types-pyyaml>=6.0.12.20260724", ] [build-system] @@ -106,6 +109,9 @@ skip_covered = false [tool.ruff] line-length = 100 target-version = "py312" +# bin/ scripts are extensionless with a shebang, matching the bin/starrocks-auth +# convention. Ruff discovers *.py only, so `ruff check .` would skip them. +extend-include = ["bin/*"] [tool.ruff.lint] select = ["ALL"] diff --git a/src/ol_analytics_api/main.py b/src/ol_analytics_api/main.py index b2011e2..7a8c25d 100644 --- a/src/ol_analytics_api/main.py +++ b/src/ol_analytics_api/main.py @@ -98,8 +98,14 @@ class Tenant: idiom a tenant author already writes) makes the lifecycle contract structural: a tenant declares its lifespan in one place and hands it over, instead of remembering to wire a bespoke hook pair into a registry. + + ``name`` is the tenant's stable slug — the tenant's own ``TENANT_NAME``, + which already names its readiness sub-path. It also names the tenant's + published OpenAPI document (openapi/specs/.yaml), so it is part of a + consumer-visible filename and should not be renamed casually. """ + name: str mount_path: str create_app: Callable[[], FastAPI] lifespan: Callable[[FastAPI], AbstractAsyncContextManager[None]] | None = None @@ -107,8 +113,17 @@ class Tenant: # Add a new tenant by appending a Tenant() entry here. TENANTS: list[Tenant] = [ - Tenant("/api/v1/analytics", b2b_dashboard.create_app, b2b_dashboard.lifespan), - Tenant("/api/v1/learner-records", b2b_learner_records.create_app), + Tenant( + b2b_dashboard.TENANT_NAME, + "/api/v1/analytics", + b2b_dashboard.create_app, + b2b_dashboard.lifespan, + ), + Tenant( + b2b_learner_records.TENANT_NAME, + "/api/v1/learner-records", + b2b_learner_records.create_app, + ), ] diff --git a/src/ol_analytics_api/openapi.py b/src/ol_analytics_api/openapi.py new file mode 100644 index 0000000..ca6192e --- /dev/null +++ b/src/ol_analytics_api/openapi.py @@ -0,0 +1,78 @@ +"""Compose one OpenAPI document per mounted tenant. + +Each tenant is an independent ``FastAPI()`` mounted under the root app (see +main.py), so it owns its own ``/openapi.json`` and the root app's schema does +not contain a single tenant path. Dumping ``app.openapi()`` therefore yields +the health endpoints and nothing a client would generate against — the schema +a consumer needs has to come from the sub-app. + +Two things are fixed up on the way out, and both exist because the document is +written for a *client generator* rather than for the sub-app's own ``/docs``: + +- **Paths are re-prefixed with the mount path.** A sub-app describes its routes + relative to its own root ("/organizations/{id}/..."), because Starlette's + Mount strips the prefix before the sub-app ever sees the request. A generated + client configured with the service host as its base URL would then request + the wrong URL. Prefixing here keeps the generated client's paths identical to + the absolute paths the service actually serves, which is also what + mit-learn's hand-written client hardcodes today. + +- **The document version is pinned, not read from the package.** Sourcing it + from the package's CalVer would rewrite every spec on every release, and the + committed spec exists to make *interface* changes visible in review. A + release that changes no route should produce no diff here. + +``tenant_specs()`` builds the documents and ``render()`` serializes one exactly +as the committed file holds it. Both live here rather than in +``bin/generate-openapi-spec`` so the drift test compares against the same +serializer that wrote the file, instead of a second one that can disagree. + +This module is a build-time tool. Nothing the server imports reaches it, which +is why PyYAML and cyclopts are dev dependencies: the running service never +needs either. +""" + +from __future__ import annotations + +from typing import Any + +import yaml + +from ol_analytics_api.main import TENANTS, create_app + +# Pinned rather than derived from the package version — see the module +# docstring. Bump deliberately when a tenant's interface breaks. +SPEC_VERSION = "0.0.1" + + +def _prefix_paths(paths: dict[str, Any], mount_path: str) -> dict[str, Any]: + return {f"{mount_path}{path}": item for path, item in paths.items()} + + +def tenant_specs() -> dict[str, dict[str, Any]]: + """Returns ``{tenant name: OpenAPI document}`` for every mounted tenant. + + Builds the apps through the same ``create_app()`` the server runs, so a + route the registry does not actually mount cannot reach a published spec. + """ + root = create_app() + tenant_apps = root.state.tenant_apps + specs: dict[str, dict[str, Any]] = {} + for tenant in TENANTS: + spec = tenant_apps[tenant.mount_path].openapi() + spec["info"]["version"] = SPEC_VERSION + spec["paths"] = _prefix_paths(spec["paths"], tenant.mount_path) + specs[tenant.name] = spec + return specs + + +def render(spec: dict[str, Any]) -> str: + """Serialize one OpenAPI document exactly as the committed file holds it. + + ``sort_keys=False`` keeps FastAPI's own ordering (paths in registration + order, then components) rather than alphabetising the whole document. That + order is already deterministic across runs, and alphabetising it would put + every path's method, parameters and responses in an order nobody wrote, + making real changes harder to find in a diff. + """ + return yaml.safe_dump(spec, sort_keys=False, default_flow_style=False, allow_unicode=True) diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/routers/admin.py b/src/ol_analytics_api/tenants/b2b_dashboard/routers/admin.py index 9292cc9..5beff3e 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/admin.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/admin.py @@ -38,7 +38,7 @@ _ORDER_BY = ("organization_key", "contract_pk") -@router.get("/contract-health") +@router.get("/contract-health", operation_id="admin_contract_health_retrieve") async def contract_health( page: Annotated[Pagination, Depends(pagination)], ) -> AdminAnalyticsResponse[MitAdminContractHealth]: diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py b/src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py index ac38c28..02ddb64 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py @@ -156,6 +156,11 @@ async def endpoint( # its concrete row model; mypy can't type a value used as a type param. response_model=OrgAnalyticsResponse[spec.model], # type: ignore[name-defined] name=endpoint.__name__, + # See the same call in organizations.py: named explicitly so a + # generated client's method name is ours rather than a derivative of + # the path. The `contracts_` prefix is what separates these from the + # org router's identically-named panels. + operation_id=f"contracts_{endpoint.__name__}_retrieve", ) diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py b/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py index b3cc645..cf2e9b9 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py @@ -157,6 +157,13 @@ async def endpoint( # its concrete row model; mypy can't type a value used as a type param. response_model=OrgAnalyticsResponse[spec.model], # type: ignore[name-defined] name=endpoint.__name__, + # Named explicitly because this is what a generated client's method is + # called. FastAPI's default derives one from the function name *and* + # the whole path, which would make the TS method + # `contractUtilizationOrganizationsOrganizationIdContractUtilizationGet` + # and — worse — churn it whenever the path changes. The tag prefix is + # what keeps this distinct from the contract router's same-named panel. + operation_id=f"organizations_{endpoint.__name__}_retrieve", ) diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index 59a4009..9309e74 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -67,8 +67,8 @@ async def failing_lifespan(_app: object): yield # unreachable, but keeps this an async generator fake_tenants = [ - Tenant("/ok", create_app=object, lifespan=ok_lifespan), - Tenant("/broken", create_app=object, lifespan=failing_lifespan), + Tenant("ok", "/ok", create_app=object, lifespan=ok_lifespan), + Tenant("broken", "/broken", create_app=object, lifespan=failing_lifespan), ] root_app = SimpleNamespace( state=SimpleNamespace(tenant_apps={"/ok": object(), "/broken": object()}) diff --git a/tests/test_openapi_spec.py b/tests/test_openapi_spec.py new file mode 100644 index 0000000..45e8564 --- /dev/null +++ b/tests/test_openapi_spec.py @@ -0,0 +1,84 @@ +"""The published OpenAPI contract. + +`openapi/specs/.yaml` is what the Concourse client pipeline generates +the TypeScript package from, so these assertions are about what consumers +receive, not about FastAPI's internals. +""" + +from pathlib import Path + +import pytest + +from ol_analytics_api.main import TENANTS +from ol_analytics_api.openapi import render, tenant_specs + +SPECS_DIR = Path(__file__).resolve().parent.parent / "openapi" / "specs" + + +@pytest.fixture(scope="module") +def specs(): + return tenant_specs() + + +def test_committed_spec_matches_the_code(specs): + """The whole reason the spec is committed: drift is a failing test, not a + consumer discovering a renamed column at runtime.""" + for tenant_name, spec in specs.items(): + path = SPECS_DIR / f"{tenant_name}.yaml" + assert path.exists(), f"{path} is missing. Run `uv run bin/generate-openapi-spec`." + assert path.read_text() == render(spec), ( + f"{path} is out of date. Run `uv run bin/generate-openapi-spec`." + ) + + +def test_every_mounted_tenant_publishes_a_spec(specs): + assert set(specs) == {tenant.name for tenant in TENANTS} + + +def test_paths_carry_the_mount_prefix(specs): + """A sub-app describes its routes relative to its own root, but a generated + client is configured with the service host as its base URL. Publishing the + unprefixed paths would produce a client that requests URLs the service does + not serve.""" + for tenant in TENANTS: + paths = specs[tenant.name]["paths"] + assert paths, f"{tenant.name} published no paths at all" + assert all(path.startswith(f"{tenant.mount_path}/") for path in paths) + + +def test_each_row_model_gets_its_own_response_schema(specs): + """The org and contract endpoints are registered in a loop over a table of + specs, parametrizing one generic envelope at runtime. If that collapsed to + a single `OrgAnalyticsResponse` component, every panel would generate the + same untyped row and the whole point of generating a client would be lost. + """ + schemas = specs["b2b_dashboard"]["components"]["schemas"] + envelopes = {name for name in schemas if name.startswith("OrgAnalyticsResponse")} + row_models = { + "ContractUtilization", + "EnrollmentCompletionFunnel", + "MonthlyEngagementTrend", + "ProgramFunnel", + "ContentEngagementDepth", + "ContractMonthlyEngagementTrend", + "ContractContentEngagementDepth", + } + assert envelopes == {f"OrgAnalyticsResponse_{model}_" for model in row_models} + assert row_models <= set(schemas) + + +def test_operation_ids_are_unique_and_stable(specs): + """openapi-generator names a client method after its operationId, so a + collision silently drops a method and a path-derived default renames every + method whenever a route moves. Both are named explicitly in the routers.""" + operation_ids = [ + operation["operationId"] + for spec in specs.values() + for path_item in spec["paths"].values() + for operation in path_item.values() + ] + assert len(operation_ids) == len(set(operation_ids)) + # The org and contract routers expose identically-named panels; the tag + # prefix is what keeps them apart. + assert "organizations_contract_utilization_retrieve" in operation_ids + assert "contracts_contract_utilization_retrieve" in operation_ids diff --git a/uv.lock b/uv.lock index 7cace63..00c23b9 100644 --- a/uv.lock +++ b/uv.lock @@ -185,6 +185,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/0b/32f3c8162cb5b33f24bea94503dbdd6b55aee72d367d23a55f1338f3e1b7/ast_serialize-0.11.0-cp39-abi3-win_arm64.whl", hash = "sha256:eb22d9300e7a064fa8c45e2c1e568a4e36c4c91487ea0da5e7f589905365c866", size = 1134422, upload-time = "2026-09-08T14:58:38.267Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "cachetools" version = "7.1.8" @@ -451,6 +460,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] +[[package]] +name = "cyclopts" +version = "4.22.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/05/689617b7e86503417c172f577d791524cb13b9697303d5d44409a971ba10/cyclopts-4.22.5.tar.gz", hash = "sha256:94044506317462cad90fb01a917dadce1f48a0915ba3605dc8d178dea1229e24", size = 195144, upload-time = "2026-08-04T13:53:00.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/58/bcab9c33fb7a25a1f5970f357c5b19729bc81d50615d2f737b20c4255909/cyclopts-4.22.5-py3-none-any.whl", hash = "sha256:cf9ce285836053d156730ea4ea0ad0c75cf63beb3f3d8edf222a795bc57666ab", size = 234557, upload-time = "2026-08-04T13:52:58.509Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -781,6 +814,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy" version = "2.3.1" @@ -869,14 +923,17 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "asgi-lifespan" }, + { name = "cyclopts" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-httpx" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "types-cachetools" }, { name = "types-hvac" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -901,14 +958,17 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "asgi-lifespan", specifier = ">=2.1.0" }, + { name = "cyclopts", specifier = ">=4.22.5" }, { name = "mypy", specifier = ">=1.13" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-httpx", specifier = ">=0.35" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "ruff", specifier = ">=0.8" }, { name = "types-cachetools", specifier = ">=5.5" }, { name = "types-hvac", specifier = ">=2.3" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20260724" }, ] [[package]] @@ -1293,6 +1353,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1308,6 +1414,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-rst" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, +] + [[package]] name = "ruff" version = "0.16.6" @@ -1452,6 +1584,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/6d/b2098609cc9b577cd61a1170e724a390de0ae9809338738afd2106e83bbc/types_hvac-2.4.0.20260731-py3-none-any.whl", hash = "sha256:e45270eacbdbdb64756e62b1faa0ea3b48d2ff151b42020d76699d35229eb647", size = 42830, upload-time = "2026-07-31T05:22:12.724Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260724" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From fffa05c31ab66b6181e7e363de01cca1bd8d9ac2 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Mon, 24 Aug 2026 10:51:25 -0400 Subject: [PATCH 2/7] fix(openapi): diff the union of base/head specs, pin oasdiff, describe the pipeline as future The base-only spec loop skipped added specs in the changelog and let the -f guard silently skip deleted specs in the breaking-change check -- deleting a whole published API would have passed CI. Iterate the union of base and head filenames instead, and fail explicitly on a removed spec. Pin oasdiff by digest in both invocations so an upstream image change can't silently alter breaking-change classification. The README, generator script, and workflow comment described the ol-infrastructure client-publishing pipeline as already wired up; none of it exists yet (no PIPELINE_CONFIGS entry, no release branch, MIT Learn still on its hand-written client). Reworded to the intended future state. Added a test asserting every route's operation_id is explicit: the existing uniqueness check still passes for a route that fell back to FastAPI's path-derived default, which is unique but not stable. --- .github/workflows/openapi-diff.yml | 64 +++++++++++++++++++++++------- README.md | 20 ++++++---- bin/generate-openapi-spec | 11 +++-- tests/test_openapi_spec.py | 25 ++++++++++-- 4 files changed, 89 insertions(+), 31 deletions(-) diff --git a/.github/workflows/openapi-diff.yml b/.github/workflows/openapi-diff.yml index 620d93f..0081647 100644 --- a/.github/workflows/openapi-diff.yml +++ b/.github/workflows/openapi-diff.yml @@ -1,9 +1,10 @@ name: OpenAPI Diff -# The committed spec is what the Concourse client pipeline generates the -# published TypeScript package from, so a diff here is a change to somebody -# else's build. This surfaces that change as a comment and fails the PR on a -# breaking one, rather than leaving it to whoever reads 1500 lines of YAML. +# The committed spec is meant to be what a future Concourse client pipeline +# generates a published TypeScript package from (see README.md), so a diff +# here is a preview of a change to somebody else's build. This surfaces that +# change as a comment and fails the PR on a breaking one, rather than leaving +# it to whoever reads 1500 lines of YAML. on: pull_request: @@ -39,6 +40,17 @@ jobs: # huge INPUT_BODY env var, which can blow past the OS argv+envp size limit # and crash the action with "Argument list too long". Writing straight to a # file and using `body-path` avoids that entirely. + # + # The spec list is the union of base and head filenames, not just base's: + # a base-only loop silently drops both a spec added in this PR (never in + # base, so never iterated) and a spec removed in this PR (caught by the + # -f guard below, so skipped instead of reported as a removal). + specs=$( + { + [ -d base/openapi/specs ] && (cd base/openapi/specs && ls -1 ./*.yaml) + [ -d head/openapi/specs ] && (cd head/openapi/specs && ls -1 ./*.yaml) + } 2>/dev/null | xargs -n1 basename | sort -u + ) { echo "## OpenAPI Changes" echo "" @@ -46,14 +58,22 @@ jobs: echo "Show/hide changes" echo "" echo '```' - for spec in base/openapi/specs/*.yaml; do - head_spec="head/openapi/specs/$(basename "$spec")" - if [ -f "$head_spec" ]; then - echo "## Changes for $(basename "$spec"):" + for name in $specs; do + base_spec="base/openapi/specs/$name" + head_spec="head/openapi/specs/$name" + if [ -f "$base_spec" ] && [ -f "$head_spec" ]; then + echo "## Changes for $name:" docker run --rm \ --workdir "$GITHUB_WORKSPACE" \ --volume "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" \ - tufin/oasdiff changelog "$spec" "$head_spec" + tufin/oasdiff@sha256:6065c16a4c9ce12504752f444d4981091e58c2a35436fac90b649be47d833db3 \ + changelog "$base_spec" "$head_spec" + echo "" + elif [ -f "$head_spec" ]; then + echo "## $name: added" + echo "" + elif [ -f "$base_spec" ]; then + echo "## $name: removed" echo "" fi done @@ -84,16 +104,30 @@ jobs: run: | # Breaking here means breaking a client someone else already # generated and shipped, so this fails the PR rather than warning. - for spec in base/openapi/specs/*.yaml; do - head_spec="head/openapi/specs/$(basename "$spec")" - if [ -f "$head_spec" ]; then - echo "Checking $(basename "$spec") for breaking changes..." + # A spec removed outright is the most breaking change there is — + # deleting the whole published API for a tenant — so it's checked + # explicitly rather than relying on the -f guard to skip it. + specs=$( + { + [ -d base/openapi/specs ] && (cd base/openapi/specs && ls -1 ./*.yaml) + [ -d head/openapi/specs ] && (cd head/openapi/specs && ls -1 ./*.yaml) + } 2>/dev/null | xargs -n1 basename | sort -u + ) + for name in $specs; do + base_spec="base/openapi/specs/$name" + head_spec="head/openapi/specs/$name" + if [ -f "$base_spec" ] && [ -f "$head_spec" ]; then + echo "Checking $name for breaking changes..." docker run --rm \ --workdir "$GITHUB_WORKSPACE" \ --volume "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" \ - tufin/oasdiff breaking \ + tufin/oasdiff@sha256:6065c16a4c9ce12504752f444d4981091e58c2a35436fac90b649be47d833db3 \ + breaking \ --fail-on ERR \ --format githubactions \ - "$spec" "$head_spec" + "$base_spec" "$head_spec" + elif [ -f "$base_spec" ]; then + echo "::error::$name was removed — deleting a published spec is a breaking change." + exit 1 fi done diff --git a/README.md b/README.md index ee40446..4e0377d 100644 --- a/README.md +++ b/README.md @@ -222,14 +222,18 @@ Run it whenever a response model, route or query parameter changes. CI fails otherwise — both as a test (`tests/test_openapi_spec.py`) and as a `--check` run of the generator itself. -The spec is committed rather than served-and-forgotten because it is a -cross-repo interface. The Concourse pipeline in `ol-infrastructure` -(`ol_concourse/pipelines/libraries/api_clients_pipeline.py`) watches these -files on the `release` branch, runs `openapi-generator` over them, and -publishes the TypeScript client that MIT Learn's dashboard imports — the same -arrangement behind `@mitodl/mitxonline-api-axios` and -`@mitodl/mit-learn-api-axios`. A column that appears here without appearing in -the diff is a column a consumer finds out about at runtime. +The spec is committed rather than served-and-forgotten because it is meant to +become a cross-repo interface. The intended pipeline mirrors the one already +running for `mitxonline` and `mit-learn`: a Concourse pipeline in +`ol-infrastructure` (`ol_concourse/pipelines/libraries/api_clients_pipeline.py`) +watching these files on a release branch, running `openapi-generator` over +them, and publishing a TypeScript client the same way +`@mitodl/mitxonline-api-axios` and `@mitodl/mit-learn-api-axios` are today. +None of that is wired up yet — this repo has no entry in `PIPELINE_CONFIGS` +and no `release` branch, and MIT Learn's dashboard still uses its hand-written +client. Until it is, committing the spec still buys the same thing locally: a +column that appears here without appearing in the diff is a column a +consumer would find out about at runtime once the pipeline exists. Two details are worth knowing before editing a route: diff --git a/bin/generate-openapi-spec b/bin/generate-openapi-spec index 33f597e..41c3782 100755 --- a/bin/generate-openapi-spec +++ b/bin/generate-openapi-spec @@ -9,10 +9,13 @@ shows up in review as an interface diff instead of silently drifting away from the clients generated off it. `tests/test_openapi_spec.py` fails when the committed file no longer matches what the code produces. -The Concourse pipeline in ol-infrastructure -(`ol_concourse/pipelines/libraries/api_clients_pipeline.py`) watches -`openapi/specs/*.yaml` on the release branch and regenerates the published -TypeScript client from it — the same arrangement mitxonline and mit-learn use. +The intended consumer is a Concourse pipeline in ol-infrastructure +(`ol_concourse/pipelines/libraries/api_clients_pipeline.py`), mirroring the one +mitxonline and mit-learn already use: watch `openapi/specs/*.yaml` on a +release branch and regenerate the published TypeScript client from it. That +pipeline isn't wired up for this repo yet (no `PIPELINE_CONFIGS` entry, no +`release` branch) — this file exists so the spec is ready to publish once it +is. """ from __future__ import annotations diff --git a/tests/test_openapi_spec.py b/tests/test_openapi_spec.py index 45e8564..f0b43b7 100644 --- a/tests/test_openapi_spec.py +++ b/tests/test_openapi_spec.py @@ -1,15 +1,17 @@ """The published OpenAPI contract. -`openapi/specs/.yaml` is what the Concourse client pipeline generates -the TypeScript package from, so these assertions are about what consumers -receive, not about FastAPI's internals. +`openapi/specs/.yaml` is what a future Concourse client pipeline is +meant to generate the TypeScript package from (see README.md), so these +assertions are about what consumers would receive, not about FastAPI's +internals. """ from pathlib import Path import pytest +from fastapi.routing import APIRoute -from ol_analytics_api.main import TENANTS +from ol_analytics_api.main import TENANTS, create_app from ol_analytics_api.openapi import render, tenant_specs SPECS_DIR = Path(__file__).resolve().parent.parent / "openapi" / "specs" @@ -82,3 +84,18 @@ def test_operation_ids_are_unique_and_stable(specs): # prefix is what keeps them apart. assert "organizations_contract_utilization_retrieve" in operation_ids assert "contracts_contract_utilization_retrieve" in operation_ids + + +def test_operation_ids_are_explicit(): + """Uniqueness alone doesn't catch a route that never set operation_id: + FastAPI falls back to a path-derived default, which is unique but not + stable, so a route relying on it would pass the test above and still + rename its generated client method whenever the path moves.""" + root = create_app() + for tenant in TENANTS: + tenant_app = root.state.tenant_apps[tenant.mount_path] + for route in tenant_app.routes: + if isinstance(route, APIRoute): + assert route.operation_id is not None, ( + f"{tenant.name} route {route.path} has no explicit operation_id" + ) From 03b770cd8c4e94c73d3da51cb37630de0498e326 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 12:50:30 -0400 Subject: [PATCH 3/7] feat(openapi): publish the learner-records spec and refresh the dashboard's The spec export was written before the b2b_learner_records tenant existed and before the dashboard gained /learner-progress, so this is the first generator run that covers what main actually serves. b2b_learner_records.yaml is new; b2b_dashboard.yaml picks up the response-model and description changes that landed in the meantime. /learner-progress had no explicit operation_id, so FastAPI derived one from the function name and the whole path (learner_progress_organizations__organization_id__contracts__contract_id__learner_progress_get), which renames the generated client method whenever the route moves. test_operation_ids_are_explicit catches exactly this; naming it learners_progress_retrieve follows the tag-prefixed convention the other two routers already use. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6 --- openapi/specs/b2b_dashboard.yaml | 627 ++++++++++++- openapi/specs/b2b_learner_records.yaml | 862 ++++++++++++++++++ .../tenants/b2b_dashboard/routers/learners.py | 1 + 3 files changed, 1449 insertions(+), 41 deletions(-) create mode 100644 openapi/specs/b2b_learner_records.yaml diff --git a/openapi/specs/b2b_dashboard.yaml b/openapi/specs/b2b_dashboard.yaml index a97a280..3f1caa4 100644 --- a/openapi/specs/b2b_dashboard.yaml +++ b/openapi/specs/b2b_dashboard.yaml @@ -1,8 +1,10 @@ openapi: 3.1.0 info: title: B2B Analytics Dashboard - description: Aggregated-only B2B site-license analytics for org managers and MIT - contract admins. No individual learner PII. + description: B2B site-license analytics for org managers and MIT contract admins. + Aggregate endpoints apply a k-anonymity floor. The contract-scoped learner-progress + endpoint returns individual learners on the organization's own seats, with outcome + fields consent-gated. version: 0.0.1 paths: /api/v1/analytics/organizations/{organization_id}/contract-utilization: @@ -237,7 +239,7 @@ paths: in: path required: true schema: - type: string + type: integer title: Contract Id - name: limit in: query @@ -286,7 +288,7 @@ paths: in: path required: true schema: - type: string + type: integer title: Contract Id - name: limit in: query @@ -335,7 +337,7 @@ paths: in: path required: true schema: - type: string + type: integer title: Contract Id - name: limit in: query @@ -384,7 +386,7 @@ paths: in: path required: true schema: - type: string + type: integer title: Contract Id - name: limit in: query @@ -433,7 +435,7 @@ paths: in: path required: true schema: - type: string + type: integer title: Contract Id - name: limit in: query @@ -465,6 +467,102 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/learner-progress: + get: + tags: + - learners + summary: Each learner's enrollment and completion in each course run under the + contract + operationId: learners_progress_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: search + in: query + required: false + schema: + anyOf: + - type: string + minLength: 1 + maxLength: 254 + - type: 'null' + description: Case-insensitive match on email or name. + title: Search + description: Case-insensitive match on email or name. + - name: completion_status + in: query + required: false + schema: + anyOf: + - type: array + items: + $ref: '#/components/schemas/CompletionStatusFilter' + - type: 'null' + description: Repeat for several. `unknown` selects rows with withheld outcomes. + title: Completion Status + description: Repeat for several. `unknown` selects rows with withheld outcomes. + - name: include_inactive + in: query + required: false + schema: + type: boolean + description: Include deactivated enrollments (unenrolled, refunded). + default: false + title: Include Inactive + description: Include deactivated enrollments (unenrolled, refunded). + - name: sort + in: query + required: false + schema: + $ref: '#/components/schemas/SortKey' + default: full_name + - name: descending + in: query + required: false + schema: + type: boolean + default: false + title: Descending + - 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/LearnerProgressResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /api/v1/analytics/admin/contract-health: get: tags: @@ -526,83 +624,139 @@ components: - total_count - data title: AdminAnalyticsResponse[MitAdminContractHealth] + CompletionStatus: + type: string + enum: + - not_started + - in_progress + - passed + - certified + title: CompletionStatus + description: '``passed`` without ``certified`` is normal: certificates are issued + on a + + schedule after grading, and audit-mode enrollments never certify.' + CompletionStatusFilter: + type: string + enum: + - not_started + - in_progress + - passed + - certified + - unknown + title: CompletionStatusFilter ContentEngagementDepth: properties: organization_key: type: string title: Organization Key + description: Internal identifier for the organization. organization_name: type: string title: Organization Name + description: The organization's name. courserun_readable_id: type: string title: Courserun Readable Id + description: The course run's ID, e.g. course-v1:MITxT+14.310x+2T2026. courserun_title: type: string title: Courserun Title + description: The course's title. total_enrolled_learners: type: integer title: Total Enrolled Learners + description: Learners who have ever enrolled in this course run. When too + few learners are in the group, the whole row is withheld to avoid identifying + them. engaged_learners: anyOf: - type: integer - type: 'null' title: Engaged Learners + description: 'Enrolled learners who did anything in the course: watched + a video, attempted a problem, posted in a discussion, used the chatbot, + moved through course pages or earned a certificate. Withheld when too + few learners are in the group to report without identifying them.' engagement_rate_pct: anyOf: - type: number - type: 'null' title: Engagement Rate Pct + description: Percentage of enrolled learners who did anything in the course. + Withheld when the learner count it is based on is withheld. total_videos_watched: anyOf: - type: integer - type: 'null' title: Total Videos Watched + description: Videos watched in this course run, counting every view. Withheld + when the learner count it is based on is withheld. video_watchers: anyOf: - type: integer - type: 'null' title: Video Watchers + description: Learners who watched at least one video in this course run. + Withheld when too few learners are in the group to report without identifying + them. avg_videos_per_engaged_learner: anyOf: - type: number - type: 'null' title: Avg Videos Per Engaged Learner + description: Average videos watched per learner who did anything in the + course. Withheld when the learner count it is based on is withheld. total_problems_attempted: anyOf: - type: integer - type: 'null' title: Total Problems Attempted + description: Problem attempts in this course run, counting every attempt. + Withheld when the learner count it is based on is withheld. problem_attempters: anyOf: - type: integer - type: 'null' title: Problem Attempters + description: Learners who attempted at least one problem in this course + run. Withheld when too few learners are in the group to report without + identifying them. avg_problems_per_engaged_learner: anyOf: - type: number - type: 'null' title: Avg Problems Per Engaged Learner + description: Average problem attempts per learner who did anything in the + course. Withheld when the learner count it is based on is withheld. total_chatbot_interactions: anyOf: - type: integer - type: 'null' title: Total Chatbot Interactions + description: Chatbot interactions in this course run. Withheld when the + learner count it is based on is withheld. chatbot_users: anyOf: - type: integer - type: 'null' title: Chatbot Users + description: Learners who used the chatbot in this course run. Withheld + when too few learners are in the group to report without identifying them. chatbot_adoption_pct: anyOf: - type: number - type: 'null' title: Chatbot Adoption Pct + description: Percentage of enrolled learners who used the chatbot. Withheld + when the learner count it is based on is withheld. certificates_earned: anyOf: - type: integer - type: 'null' title: Certificates Earned + description: Certificates earned in this course run. Withheld when too few + learners are in the group to report without identifying them. type: object required: - organization_key @@ -693,87 +847,125 @@ components: organization_key: type: string title: Organization Key + description: Internal identifier for the organization. organization_name: type: string title: Organization Name + description: The organization's name. courserun_readable_id: type: string title: Courserun Readable Id + description: The course run's ID, e.g. course-v1:MITxT+14.310x+2T2026. courserun_title: type: string title: Courserun Title + description: The course's title. total_enrolled_learners: type: integer title: Total Enrolled Learners + description: Learners who have ever enrolled in this course run. When too + few learners are in the group, the whole row is withheld to avoid identifying + them. engaged_learners: anyOf: - type: integer - type: 'null' title: Engaged Learners + description: 'Enrolled learners who did anything in the course: watched + a video, attempted a problem, posted in a discussion, used the chatbot, + moved through course pages or earned a certificate. Withheld when too + few learners are in the group to report without identifying them.' engagement_rate_pct: anyOf: - type: number - type: 'null' title: Engagement Rate Pct + description: Percentage of enrolled learners who did anything in the course. + Withheld when the learner count it is based on is withheld. total_videos_watched: anyOf: - type: integer - type: 'null' title: Total Videos Watched + description: Videos watched in this course run, counting every view. Withheld + when the learner count it is based on is withheld. video_watchers: anyOf: - type: integer - type: 'null' title: Video Watchers + description: Learners who watched at least one video in this course run. + Withheld when too few learners are in the group to report without identifying + them. avg_videos_per_engaged_learner: anyOf: - type: number - type: 'null' title: Avg Videos Per Engaged Learner + description: Average videos watched per learner who did anything in the + course. Withheld when the learner count it is based on is withheld. total_problems_attempted: anyOf: - type: integer - type: 'null' title: Total Problems Attempted + description: Problem attempts in this course run, counting every attempt. + Withheld when the learner count it is based on is withheld. problem_attempters: anyOf: - type: integer - type: 'null' title: Problem Attempters + description: Learners who attempted at least one problem in this course + run. Withheld when too few learners are in the group to report without + identifying them. avg_problems_per_engaged_learner: anyOf: - type: number - type: 'null' title: Avg Problems Per Engaged Learner + description: Average problem attempts per learner who did anything in the + course. Withheld when the learner count it is based on is withheld. total_chatbot_interactions: anyOf: - type: integer - type: 'null' title: Total Chatbot Interactions + description: Chatbot interactions in this course run. Withheld when the + learner count it is based on is withheld. chatbot_users: anyOf: - type: integer - type: 'null' title: Chatbot Users + description: Learners who used the chatbot in this course run. Withheld + when too few learners are in the group to report without identifying them. chatbot_adoption_pct: anyOf: - type: number - type: 'null' title: Chatbot Adoption Pct + description: Percentage of enrolled learners who used the chatbot. Withheld + when the learner count it is based on is withheld. certificates_earned: anyOf: - type: integer - type: 'null' title: Certificates Earned + description: Certificates earned in this course run. Withheld when too few + learners are in the group to report without identifying them. contract_pk: type: string title: Contract Pk + description: Internal identifier for the contract. contract_id: - type: string + type: integer title: Contract Id + description: The contract's ID in MITx Online. b2b_contract_name: type: string title: B2B Contract Name + description: The contract's name. type: object required: - organization_key @@ -812,91 +1004,118 @@ components: labels a row rather than splitting it, and every count here equals its - org-level counterpart for the same course run. - - - That equality is why this pair needs no cross-grain guard, where the trend + org-level counterpart for the same course run. That equality is exactly - pair does. Nothing is aggregated away going from contract grain to org + what makes a suppressed contract recoverable by subtraction once an org - grain, so there is no remainder to subtract: a course run''s org row and its + holds more than one — the k-anonymity floor here is per-row and does not - contract row hold the same numbers, the floor makes the same call on both, - - and a caller reading one learns nothing the other withholds.' + defend against differencing across the two grains.' ContractMonthlyEngagementTrend: properties: organization_key: type: string title: Organization Key + description: Internal identifier for the organization. organization_name: type: string title: Organization Name + description: The organization's name. activity_year_and_month: type: string title: Activity Year And Month + description: The month, e.g. 2026-08. monthly_active_learners: type: integer title: Monthly Active Learners + description: 'Learners who did anything in a course this month: watched + a video, attempted a problem, posted in a discussion, used the chatbot, + moved through course pages or earned a certificate. Enrolling alone doesn''t + count. If too few learners were active, the whole month is withheld to + avoid identifying them.' new_enrollments: anyOf: - type: integer - type: 'null' title: New Enrollments + description: Course enrollments made this month. A learner who enrolled + in six courses counts six times. Withheld when the learner count it is + based on is withheld. enrolling_learners: anyOf: - type: integer - type: 'null' title: Enrolling Learners + description: Learners who enrolled in at least one course this month. Withheld + when too few learners are in the group to report without identifying them. certificates_earned: anyOf: - type: integer - type: 'null' title: Certificates Earned + description: Certificates earned this month. A learner who earned two counts + twice. Withheld when the learner count it is based on is withheld. certified_learners: anyOf: - type: integer - type: 'null' title: Certified Learners + description: Learners who earned at least one certificate this month. Withheld + when too few learners are in the group to report without identifying them. total_videos_watched: anyOf: - type: integer - type: 'null' title: Total Videos Watched + description: Videos watched this month, counting every view. Withheld when + the learner count it is based on is withheld. video_watchers: anyOf: - type: integer - type: 'null' title: Video Watchers + description: Learners who watched at least one video this month. Withheld + when too few learners are in the group to report without identifying them. total_problems_attempted: anyOf: - type: integer - type: 'null' title: Total Problems Attempted + description: Problem attempts this month, counting every attempt. Withheld + when the learner count it is based on is withheld. problem_attempters: anyOf: - type: integer - type: 'null' title: Problem Attempters + description: Learners who attempted at least one problem this month. Withheld + when too few learners are in the group to report without identifying them. total_chatbot_interactions: anyOf: - type: integer - type: 'null' title: Total Chatbot Interactions + description: Chatbot interactions this month. Withheld when the learner + count it is based on is withheld. chatbot_users: anyOf: - type: integer - type: 'null' title: Chatbot Users + description: Learners who used the chatbot this month. Withheld when too + few learners are in the group to report without identifying them. contract_pk: type: string title: Contract Pk + description: Internal identifier for the contract. contract_id: - type: string + type: integer title: Contract Id + description: The contract's ID in MITx Online. b2b_contract_name: type: string title: B2B Contract Name + description: The contract's name. type: object required: - organization_key @@ -941,86 +1160,95 @@ components: ``monthly_active_learners`` across contracts can exceed the org''s own - figure. Activity totals, being sums of events, do add up — which is what - - makes a contract-month the floor withholds recoverable from the org - - endpoint as ``org_total - sum(the visible contract months)``. The org - - endpoint defends against that itself: it probes this view for the months - - it withholds and blanks its own additive totals for them (see - - ``routers.organizations._FinerGrain``). The learner counts are left alone, - - because not adding up is exactly what stops them from being recovered by - - subtraction.' + figure. Activity totals, being sums of events, do add up.' ContractUtilization: properties: organization_key: type: string title: Organization Key + description: Internal identifier for the organization. organization_name: type: string title: Organization Name + description: The organization's name. contract_pk: type: string title: Contract Pk + description: Internal identifier for the contract. contract_id: - type: string + type: integer title: Contract Id + description: The contract's ID in MITx Online. b2b_contract_name: type: string title: B2B Contract Name + description: The contract's name. b2b_contract_is_active: type: boolean title: B2B Contract Is Active + description: Whether the contract is currently active. b2b_contract_start_date: anyOf: - type: string format: date - type: 'null' title: B2B Contract Start Date + description: When the contract starts. Empty if no start date is set. b2b_contract_end_date: anyOf: - type: string format: date - type: 'null' title: B2B Contract End Date + description: When the contract ends. Empty if it has no end date. seat_limit: anyOf: - type: integer - type: 'null' title: Seat Limit + description: How many seats the contract includes. Empty or zero means unlimited. b2b_contract_membership_type: anyOf: - type: string - type: 'null' title: B2B Contract Membership Type + description: The contract's membership type. Empty if not set. seats_consumed: type: integer title: Seats Consumed + description: Learners enrolled in at least one course under the contract. + When too few learners are in the group, the whole row is withheld to avoid + identifying them. active_learners: anyOf: - type: integer - type: 'null' title: Active Learners + description: Learners on the contract whose enrollment is still active. + Withheld when too few learners are in the group to report without identifying + them. learners_certified: anyOf: - type: integer - type: 'null' title: Learners Certified + description: Learners on the contract who earned a certificate that hasn't + been revoked. Withheld when too few learners are in the group to report + without identifying them. seat_utilization_pct: anyOf: - type: number - type: 'null' title: Seat Utilization Pct + description: Percentage of the contract's seats in use. Empty when the contract + has unlimited seats. completion_rate_pct: anyOf: - type: number - type: 'null' title: Completion Rate Pct + description: Percentage of enrolled learners who earned a certificate. Withheld + when the learner count it is based on is withheld. type: object required: - organization_key @@ -1039,61 +1267,95 @@ components: - seat_utilization_pct - completion_rate_pct title: ContractUtilization - description: 'mv_b2b_contract_utilization — grain: org x contract.' + description: 'mv_b2b_contract_utilization — grain: org x contract. + + + ``seats_consumed`` is the primary cohort. ``active_learners`` and + + ``learners_certified`` are secondary counts, nulled when nonzero but below + + the floor. ``completion_rate_pct`` is ``learners_certified`` over + + ``seats_consumed`` and is nulled with it. ``seat_utilization_pct`` is + + ``seats_consumed`` over ``seat_limit``, null when the limit is zero or null + + (the view divides by ``nullif(seat_limit, 0)``).' EnrollmentCompletionFunnel: properties: organization_key: type: string title: Organization Key + description: Internal identifier for the organization. organization_name: type: string title: Organization Name + description: The organization's name. contract_pk: type: string title: Contract Pk + description: Internal identifier for the contract. contract_id: - type: string + type: integer title: Contract Id + description: The contract's ID in MITx Online. b2b_contract_name: type: string title: B2B Contract Name + description: The contract's name. courserun_pk: type: string title: Courserun Pk + description: Internal identifier for the course run. courserun_readable_id: type: string title: Courserun Readable Id + description: The course run's ID, e.g. course-v1:MITxT+14.310x+2T2026. courserun_title: type: string title: Courserun Title + description: The course's title. enrolled_learners: type: integer title: Enrolled Learners + description: Learners enrolled in this course run. When too few learners + are in the group, the whole row is withheld to avoid identifying them. active_learners: anyOf: - type: integer - type: 'null' title: Active Learners + description: Enrolled learners whose enrollment is still active. Withheld + when too few learners are in the group to report without identifying them. passing_learners: anyOf: - type: integer - type: 'null' title: Passing Learners + description: Enrolled learners with a passing grade. Withheld when too few + learners are in the group to report without identifying them. certified_learners: anyOf: - type: integer - type: 'null' title: Certified Learners + description: Enrolled learners who earned a certificate that hasn't been + revoked. Withheld when too few learners are in the group to report without + identifying them. active_rate_pct: anyOf: - type: number - type: 'null' title: Active Rate Pct + description: Percentage of enrolled learners whose enrollment is still active. + Withheld when the learner count it is based on is withheld. completion_rate_pct: anyOf: - type: number - type: 'null' title: Completion Rate Pct + description: Percentage of enrolled learners who earned a certificate. Withheld + when the learner count it is based on is withheld. type: object required: - organization_key @@ -1112,7 +1374,14 @@ components: - completion_rate_pct title: EnrollmentCompletionFunnel description: 'mv_b2b_enrollment_completion_funnel — grain: org x contract x - course_run.' + course_run. + + + ``enrolled_learners`` is the primary cohort. ``active_rate_pct`` and + + ``completion_rate_pct`` are ``active_learners`` and ``certified_learners`` + + over ``enrolled_learners``, each nulled with its numerator.' HTTPValidationError: properties: detail: @@ -1122,74 +1391,281 @@ components: title: Detail type: object title: HTTPValidationError + LearnerProgress: + properties: + learner_id: + type: string + title: Learner Id + description: The learner's account ID. It stays the same if their email + address changes. + email: + anyOf: + - type: string + - type: 'null' + title: Email + description: The learner's email address. It may differ from the one they + enrolled with. + full_name: + anyOf: + - type: string + - type: 'null' + title: Full Name + description: The learner's name, if they've provided one. + courserun_readable_id: + type: string + title: Courserun Readable Id + description: The course run's ID, e.g. course-v1:MITxT+14.310x+2T2026. + courserun_title: + type: string + title: Courserun Title + description: The course's title. + courserun_start_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun Start On + description: When the course run starts. Empty if no start date is set. + courserun_end_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun End On + description: When the course run ends. Empty for self-paced courses. + enrolled_on: + type: string + format: date-time + title: Enrolled On + description: When the learner enrolled in this course run. + enrollment_is_active: + type: boolean + title: Enrollment Is Active + description: Whether the learner is still enrolled. False if the enrollment + was deactivated, for example after unenrolling. + enrollment_mode: + anyOf: + - type: string + - type: 'null' + title: Enrollment Mode + description: The enrollment track, for example verified or audit. Audit + enrollments don't earn certificates. + outcomes_shared: + type: boolean + title: Outcomes Shared + description: Whether the learner has agreed to share their progress. If + not, their status, grades, certificate and activity are hidden. + completion_status: + anyOf: + - $ref: '#/components/schemas/CompletionStatus' + - type: 'null' + description: 'Where the learner is in the course: not started, in progress, + passed or certified. Hidden if the learner hasn''t agreed to share their + progress.' + is_passing: + anyOf: + - type: boolean + - type: 'null' + title: Is Passing + description: Whether the learner currently has a passing grade. Empty if + no grade has been calculated yet. Hidden if the learner hasn't agreed + to share their progress. + grade: + anyOf: + - type: number + - type: 'null' + title: Grade + description: The learner's current grade, from 0 to 1. Hidden if the learner + hasn't agreed to share their progress. + letter_grade: + anyOf: + - type: string + - type: 'null' + title: Letter Grade + description: The learner's letter grade, when the course assigns one. Hidden + if the learner hasn't agreed to share their progress. + certificate_issued_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Certificate Issued On + description: When the learner's certificate was issued. Empty if they don't + have one. Hidden if the learner hasn't agreed to share their progress. + certificate_is_revoked: + anyOf: + - type: boolean + - type: 'null' + title: Certificate Is Revoked + description: Whether the learner's certificate was revoked. A revoked certificate + doesn't count toward completion. Empty if they don't have one. Hidden + if the learner hasn't agreed to share their progress. + last_active_on: + anyOf: + - type: string + 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. + type: object + required: + - learner_id + - email + - full_name + - courserun_readable_id + - courserun_title + - courserun_start_on + - courserun_end_on + - enrolled_on + - enrollment_is_active + - enrollment_mode + - outcomes_shared + - completion_status + - is_passing + - grade + - letter_grade + - certificate_issued_on + - certificate_is_revoked + - last_active_on + title: LearnerProgress + description: One learner's enrollment in one course run under the contract. + LearnerProgressResponse: + properties: + organization_id: + type: string + title: Organization Id + description: The organization's ID. + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + description: When the data was last updated. Empty before the first update. + total_count: + type: integer + title: Total Count + description: Matching enrollments across all pages, not just this one. + outcomes_withheld_count: + type: integer + title: Outcomes Withheld Count + description: How many of those enrollments have progress hidden because + the learner hasn't agreed to share it. + data: + items: + $ref: '#/components/schemas/LearnerProgress' + type: array + title: Data + description: This page of enrollments. + type: object + required: + - organization_id + - as_of + - total_count + - outcomes_withheld_count + - data + title: LearnerProgressResponse + description: 'The org envelope (``organization_id``, ``as_of``, ``total_count``, + + ``data``) plus ``outcomes_withheld_count``, so a client can show how many + + rows carry withheld outcomes without paging through all of them.' MitAdminContractHealth: properties: organization_key: type: string title: Organization Key + description: Internal identifier for the organization. organization_name: type: string title: Organization Name + description: The organization's name. contract_pk: type: string title: Contract Pk + description: Internal identifier for the contract. contract_id: - type: string + type: integer title: Contract Id + description: The contract's ID in MITx Online. b2b_contract_name: type: string title: B2B Contract Name + description: The contract's name. b2b_contract_is_active: type: boolean title: B2B Contract Is Active + description: Whether the contract is currently active. b2b_contract_start_date: anyOf: - type: string format: date - type: 'null' title: B2B Contract Start Date + description: When the contract starts. Empty if no start date is set. b2b_contract_end_date: anyOf: - type: string format: date - type: 'null' title: B2B Contract End Date + description: When the contract ends. Empty if it has no end date. seat_limit: anyOf: - type: integer - type: 'null' title: Seat Limit + description: How many seats the contract includes. Empty or zero means unlimited. b2b_contract_membership_type: anyOf: - type: string - type: 'null' title: B2B Contract Membership Type + description: The contract's membership type. Empty if not set. seats_consumed: type: integer title: Seats Consumed + description: Learners enrolled in at least one course under the contract. + When too few learners are in the group, the whole row is withheld to avoid + identifying them. active_learners: anyOf: - type: integer - type: 'null' title: Active Learners + description: Learners on the contract whose enrollment is still active. + Withheld when too few learners are in the group to report without identifying + them. certified_learners: anyOf: - type: integer - type: 'null' title: Certified Learners + description: Learners on the contract who earned a certificate that hasn't + been revoked. Withheld when too few learners are in the group to report + without identifying them. seat_utilization_pct: anyOf: - type: number - type: 'null' title: Seat Utilization Pct + description: Percentage of the contract's seats in use. Empty when the contract + has unlimited seats. completion_rate_pct: anyOf: - type: number - type: 'null' title: Completion Rate Pct + description: Percentage of enrolled learners who earned a certificate. Withheld + when the learner count it is based on is withheld. health_status: type: string title: Health Status + description: 'Overall contract health: inactive (the contract isn''t active), + high_utilization (90% or more of seats in use), at_risk (under 25% of + seats in use and the contract ends within 90 days), healthy (50% or more + of seats in use), or early_stage (anything else).' type: object required: - organization_key @@ -1210,71 +1686,107 @@ components: - health_status title: MitAdminContractHealth description: 'mv_b2b_mit_admin_contract_health — grain: org x contract (MIT - admin only).' + admin only). + + + Floored like ``ContractUtilization``. ``health_status`` is computed in the + + view from ``b2b_contract_is_active``, ``seat_utilization_pct`` and + + ``b2b_contract_end_date``.' MonthlyEngagementTrend: properties: organization_key: type: string title: Organization Key + description: Internal identifier for the organization. organization_name: type: string title: Organization Name + description: The organization's name. activity_year_and_month: type: string title: Activity Year And Month + description: The month, e.g. 2026-08. monthly_active_learners: type: integer title: Monthly Active Learners + description: 'Learners who did anything in a course this month: watched + a video, attempted a problem, posted in a discussion, used the chatbot, + moved through course pages or earned a certificate. Enrolling alone doesn''t + count. If too few learners were active, the whole month is withheld to + avoid identifying them.' new_enrollments: anyOf: - type: integer - type: 'null' title: New Enrollments + description: Course enrollments made this month. A learner who enrolled + in six courses counts six times. Withheld when the learner count it is + based on is withheld. enrolling_learners: anyOf: - type: integer - type: 'null' title: Enrolling Learners + description: Learners who enrolled in at least one course this month. Withheld + when too few learners are in the group to report without identifying them. certificates_earned: anyOf: - type: integer - type: 'null' title: Certificates Earned + description: Certificates earned this month. A learner who earned two counts + twice. Withheld when the learner count it is based on is withheld. certified_learners: anyOf: - type: integer - type: 'null' title: Certified Learners + description: Learners who earned at least one certificate this month. Withheld + when too few learners are in the group to report without identifying them. total_videos_watched: anyOf: - type: integer - type: 'null' title: Total Videos Watched + description: Videos watched this month, counting every view. Withheld when + the learner count it is based on is withheld. video_watchers: anyOf: - type: integer - type: 'null' title: Video Watchers + description: Learners who watched at least one video this month. Withheld + when too few learners are in the group to report without identifying them. total_problems_attempted: anyOf: - type: integer - type: 'null' title: Total Problems Attempted + description: Problem attempts this month, counting every attempt. Withheld + when the learner count it is based on is withheld. problem_attempters: anyOf: - type: integer - type: 'null' title: Problem Attempters + description: Learners who attempted at least one problem this month. Withheld + when too few learners are in the group to report without identifying them. total_chatbot_interactions: anyOf: - type: integer - type: 'null' title: Total Chatbot Interactions + description: Chatbot interactions this month. Withheld when the learner + count it is based on is withheld. chatbot_users: anyOf: - type: integer - type: 'null' title: Chatbot Users + description: Learners who used the chatbot this month. Withheld when too + few learners are in the group to report without identifying them. type: object required: - organization_key @@ -1507,40 +2019,58 @@ components: organization_key: type: string title: Organization Key + description: Internal identifier for the organization. organization_name: type: string title: Organization Name + description: The organization's name. contract_pk: type: string title: Contract Pk + description: Internal identifier for the contract. contract_id: - type: string + type: integer title: Contract Id + description: The contract's ID in MITx Online. b2b_contract_name: type: string title: B2B Contract Name + description: The contract's name. program_pk: type: string title: Program Pk + description: Internal identifier for the program. program_title: type: string title: Program Title + description: The program's title. total_courses: type: integer title: Total Courses + description: Courses in the program that the contract covers. enrolled_in_contract_courses: type: integer title: Enrolled In Contract Courses + description: Learners enrolled in at least one of the program's courses + under the contract. When too few learners are in the group, the whole + row is withheld to avoid identifying them. enrolled_via_program: anyOf: - type: integer - type: 'null' title: Enrolled Via Program + description: Of those learners, how many enrolled in the program itself + rather than directly in one of its courses. Withheld when too few learners + are in the group to report without identifying them. program_course_completers: anyOf: - type: integer - type: 'null' title: Program Course Completers + description: Learners who earned a certificate in at least one of the program's + courses under the contract. This isn't the same as completing the whole + program. Withheld when too few learners are in the group to report without + identifying them. type: object required: - organization_key @@ -1558,7 +2088,22 @@ components: description: 'mv_b2b_program_funnel — grain: org x contract x program. - ``total_courses`` counts courses, not learners, so it is not a cohort.' + ``total_courses`` counts courses, not learners, so it is not a cohort. + + + ``program_course_completers`` approximates program completion: it counts a + + non-revoked certificate in any contract-covered course of the program, not + + a program-level certificate, pending a program-certificate fact table.' + SortKey: + type: string + enum: + - full_name + - email + - enrolled_on + - courserun_readable_id + title: SortKey ValidationError: properties: loc: diff --git a/openapi/specs/b2b_learner_records.yaml b/openapi/specs/b2b_learner_records.yaml new file mode 100644 index 0000000..356b669 --- /dev/null +++ b/openapi/specs/b2b_learner_records.yaml @@ -0,0 +1,862 @@ +openapi: 3.1.0 +info: + title: B2B Learner Records + description: Machine-to-machine, read-only, organization-scoped learner progress + records for B2B site-license partners. Records identify individual learners. + version: 0.0.1 +paths: + /api/v1/learner-records/organizations/{organization_id}/learners: + get: + tags: + - learners + summary: Learner roster with progress rollups + operationId: listLearners + security: + - oauth2ClientCredentials: + - learner-records:read + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + format: uuid + title: Organization Id + - name: contract_id + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Contract Id + - name: learner_id + in: query + required: false + schema: + anyOf: + - type: array + items: + type: string + format: uuid + maxItems: 100 + - type: 'null' + description: Repeat for several. + title: Learner Id + description: Repeat for several. + - name: updated_since + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + description: Return only records changed at or after this instant. + title: Updated Since + description: Return only records changed at or after this instant. + - name: include_inactive + in: query + required: false + schema: + type: boolean + description: Include deactivated enrollments (unenrolled, refunded, transferred). + default: false + title: Include Inactive + description: Include deactivated enrollments (unenrolled, refunded, transferred). + - 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/LearnerRecordsResponse_Learner_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/learner-records/organizations/{organization_id}/enrollments: + get: + tags: + - enrollments + summary: Learner-by-course-run enrollment and completion records + operationId: listEnrollments + security: + - oauth2ClientCredentials: + - learner-records:read + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + format: uuid + title: Organization Id + - name: contract_id + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Contract Id + - name: courserun_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Courserun Id + - name: learner_id + in: query + required: false + schema: + anyOf: + - type: array + items: + type: string + format: uuid + maxItems: 100 + - type: 'null' + description: Repeat for several. + title: Learner Id + description: Repeat for several. + - name: completion_status + in: query + required: false + schema: + anyOf: + - type: array + items: + $ref: '#/components/schemas/CompletionStatusFilter' + - type: 'null' + title: Completion Status + - name: updated_since + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + description: Return only records changed at or after this instant. + title: Updated Since + description: Return only records changed at or after this instant. + - name: include_inactive + in: query + required: false + schema: + type: boolean + description: Include deactivated enrollments (unenrolled, refunded, transferred). + default: false + title: Include Inactive + description: Include deactivated enrollments (unenrolled, refunded, transferred). + - 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/LearnerRecordsResponse_Enrollment_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/learner-records/organizations/{organization_id}/courses: + get: + tags: + - catalog + summary: Contracts and course runs covered by the organization's licence + operationId: listCourses + security: + - oauth2ClientCredentials: + - learner-records:read + parameters: + - name: organization_id + in: path + required: true + schema: + type: string + format: uuid + title: Organization Id + - name: contract_id + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + 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/LearnerRecordsResponse_CourseRun_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + CompletionStatus: + type: string + enum: + - not_started + - in_progress + - passed + - certified + title: CompletionStatus + description: '``passed`` without ``certified`` is normal — certificates are + issued on + + a schedule after grading, and audit-mode enrollments never certify.' + CompletionStatusFilter: + type: string + enum: + - not_started + - in_progress + - passed + - certified + - unknown + title: CompletionStatusFilter + CourseRun: + properties: + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + organization_name: + type: string + title: Organization Name + description: Display name of the organization. + contract_id: + type: integer + format: int64 + title: Contract Id + description: Numeric identifier of the B2B contract. + contract_name: + type: string + title: Contract Name + description: Name of the B2B contract. + contract_is_active: + type: boolean + title: Contract Is Active + description: Whether the contract is currently active. + contract_start_date: + anyOf: + - type: string + format: date + - type: 'null' + title: Contract Start Date + description: Null where the contract records no start date. + contract_end_date: + anyOf: + - type: string + format: date + - type: 'null' + title: Contract End Date + description: Null where the contract records no end date. + seat_limit: + anyOf: + - type: integer + minimum: 0.0 + - type: 'null' + title: Seat Limit + description: Null means uncapped, not zero. + courserun_id: + type: string + title: Courserun Id + description: Readable course-run identifier, e.g. `course-v1:MITxT+14.310x+2T2026`. + courserun_title: + type: string + title: Courserun Title + description: Mutable display title — key on courserun_id. + courserun_start_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun Start On + description: Start date/time of the course run, or null if unscheduled. + courserun_end_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun End On + description: Null for self-paced runs. + type: object + required: + - organization_id + - organization_name + - contract_id + - contract_name + - contract_is_active + - contract_start_date + - contract_end_date + - seat_limit + - courserun_id + - courserun_title + - courserun_start_on + - courserun_end_on + title: CourseRun + description: 'One course run covered by one of the organization''s contracts. + No personal data, + + so no consent gate.' + Enrollment: + properties: + learner_id: + type: string + format: uuid + title: Learner Id + description: Stable opaque identifier, consistent across endpoints and stable + across email changes. Use as the join key. + email: + anyOf: + - type: string + - type: 'null' + title: Email + description: Not a join key; may differ from the enrolling address. + full_name: + anyOf: + - type: string + - type: 'null' + title: Full Name + description: Often null. + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + contract_id: + type: integer + format: int64 + title: Contract Id + description: Numeric identifier of the B2B contract the enrollment is attributed + to. + contract_name: + type: string + title: Contract Name + description: Name of the B2B contract. + courserun_id: + type: string + title: Courserun Id + description: Readable course-run identifier, e.g. `course-v1:MITxT+14.310x+2T2026`. + courserun_title: + type: string + title: Courserun Title + description: Mutable display title — key on courserun_id. + courserun_start_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun Start On + description: Start date/time of the course run, or null if unscheduled. + courserun_end_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Courserun End On + description: Null for self-paced runs. + enrolled_on: + type: string + format: date-time + title: Enrolled On + description: This run's enrollment, not an earlier run of the same course. + Not consent-gated. + enrollment_is_active: + type: boolean + title: Enrollment Is Active + description: Not consent-gated. + enrollment_mode: + anyOf: + - type: string + - type: 'null' + title: Enrollment Mode + description: e.g. `verified`, `audit`. Determines whether the run is certificate-bearing. + enrollment_status: + anyOf: + - type: string + - type: 'null' + title: Enrollment Status + description: Deactivation reason where one was recorded. + outcomes_shared: + type: boolean + title: Outcomes Shared + description: Whether the learner has opted in to sharing their course status. + False means every field below is null. + completion_status: + anyOf: + - $ref: '#/components/schemas/CompletionStatus' + - type: 'null' + description: 'Single derived answer per row. Null when outcomes are withheld. + Until activity data lands, not_started and in_progress come from the grade + alone: in_progress means a nonzero grade, so a learner active in the run + with no graded work yet reads not_started.' + is_passing: + anyOf: + - type: boolean + - type: 'null' + title: Is Passing + description: Null where no grade has been computed. + grade: + anyOf: + - type: number + - type: 'null' + title: Grade + description: Numeric grade between 0 and 1. + letter_grade: + anyOf: + - type: string + - type: 'null' + title: Letter Grade + description: Frequently null — not every platform records one. + certificate_issued_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Certificate Issued On + description: Timestamp the certificate was issued, or null if none exists. + certificate_is_revoked: + anyOf: + - type: boolean + - type: 'null' + title: Certificate Is Revoked + description: 'Null where no certificate exists. A revoked certificate doesn''t + count as certified: completion_status then follows the grade, so it can + read passed, in_progress or not_started.' + last_active_on: + anyOf: + - type: string + format: date + - type: 'null' + title: Last Active On + description: 'Most recent day with recorded activity in this course run. + Not yet populated upstream: null for every record until activity data + lands, whatever outcomes_shared says.' + days_active: + anyOf: + - type: integer + - type: 'null' + title: Days Active + description: 'Distinct days with recorded activity in this course run. Not + yet populated upstream: null for every record until activity data lands, + whatever outcomes_shared says.' + videos_watched: + anyOf: + - type: integer + - type: 'null' + title: Videos Watched + description: 'Distinct video blocks played. Not yet populated upstream: + null for every record until activity data lands, whatever outcomes_shared + says.' + problems_attempted: + anyOf: + - type: integer + - type: 'null' + title: Problems Attempted + description: 'Distinct problem blocks attempted. Not yet populated upstream: + null for every record until activity data lands, whatever outcomes_shared + says.' + chatbot_interactions: + anyOf: + - type: integer + - type: 'null' + title: Chatbot Interactions + description: 'Chatbot interactions recorded for this course run. Not yet + populated upstream: null for every record until activity data lands, whatever + outcomes_shared says.' + type: object + required: + - learner_id + - email + - full_name + - organization_id + - contract_id + - contract_name + - courserun_id + - courserun_title + - courserun_start_on + - courserun_end_on + - enrolled_on + - enrollment_is_active + - enrollment_mode + - enrollment_status + - outcomes_shared + - completion_status + - is_passing + - grade + - letter_grade + - certificate_issued_on + - certificate_is_revoked + - last_active_on + - days_active + - videos_watched + - problems_attempted + - chatbot_interactions + title: Enrollment + description: One learner's enrollment in one course run under one contract. + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + Learner: + properties: + learner_id: + type: string + format: uuid + title: Learner Id + description: Stable opaque identifier, consistent across endpoints and stable + across email changes. Use as the join key. + email: + anyOf: + - type: string + - type: 'null' + title: Email + description: Not a join key; may differ from the enrolling address. + full_name: + anyOf: + - type: string + - type: 'null' + title: Full Name + description: Often null. + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + organization_name: + type: string + title: Organization Name + description: Display name of the organization. + membership_source: + $ref: '#/components/schemas/MembershipSource' + description: How the learner is associated with the organization; see MembershipSource + for the individual values. + is_organization_manager: + type: boolean + title: Is Organization Manager + description: Administers the organization in MITx Online. + first_enrolled_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: First Enrolled On + description: Null for a roster member with no enrollments. + last_enrolled_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Last Enrolled On + description: Timestamp of the learner's most recent enrollment. + courses_enrolled: + type: integer + title: Courses Enrolled + description: Distinct course runs under the organization's contracts. Not + consent-gated. + outcomes_shared: + type: boolean + title: Outcomes Shared + description: Whether this learner has opted in to sharing their course status. + False means every field below is null. + outcomes_consent_on: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Outcomes Consent On + description: When consent was recorded. Null when outcomes_shared is false. + last_active_on: + anyOf: + - type: string + format: date + - type: 'null' + title: Last Active On + description: 'Most recent day with recorded course activity. A date, not + a timestamp: activity is aggregated per day. Not yet populated upstream: + null for every record until activity data lands, whatever outcomes_shared + says.' + courses_in_progress: + anyOf: + - type: integer + - type: 'null' + title: Courses In Progress + description: 'Distinct course runs the learner has started but not yet passed + or certified. Not yet populated upstream: null for every record until + activity data lands, whatever outcomes_shared says.' + courses_passed: + anyOf: + - type: integer + - type: 'null' + title: Courses Passed + description: Distinct course runs where the learner has a passing grade. + courses_certified: + anyOf: + - type: integer + - type: 'null' + title: Courses Certified + description: Distinct course runs where the learner holds a non-revoked + certificate. + certificates_earned: + anyOf: + - type: integer + - type: 'null' + title: Certificates Earned + description: Includes program certificates, which have no course run and + so are not in courses_certified. + type: object + required: + - learner_id + - email + - full_name + - organization_id + - organization_name + - membership_source + - is_organization_manager + - first_enrolled_on + - last_enrolled_on + - courses_enrolled + - outcomes_shared + - outcomes_consent_on + - last_active_on + - courses_in_progress + - courses_passed + - courses_certified + - certificates_earned + title: Learner + description: One learner's association with the organization. + LearnerRecordsResponse_CourseRun_: + properties: + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + description: Last refresh of the backing data. Null before an organization's + first refresh. + total_count: + type: integer + title: Total Count + description: Matching records across all pages, not this page. + outcomes_withheld_count: + type: integer + title: Outcomes Withheld Count + description: 'Records in total_count carrying outcomes_shared: false. Always + 0 on endpoints with no consent gate.' + data: + items: + $ref: '#/components/schemas/CourseRun' + type: array + title: Data + description: The requested page of records. + type: object + required: + - organization_id + - as_of + - total_count + - outcomes_withheld_count + - data + title: LearnerRecordsResponse[CourseRun] + LearnerRecordsResponse_Enrollment_: + properties: + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + description: Last refresh of the backing data. Null before an organization's + first refresh. + total_count: + type: integer + title: Total Count + description: Matching records across all pages, not this page. + outcomes_withheld_count: + type: integer + title: Outcomes Withheld Count + description: 'Records in total_count carrying outcomes_shared: false. Always + 0 on endpoints with no consent gate.' + data: + items: + $ref: '#/components/schemas/Enrollment' + type: array + title: Data + description: The requested page of records. + type: object + required: + - organization_id + - as_of + - total_count + - outcomes_withheld_count + - data + title: LearnerRecordsResponse[Enrollment] + LearnerRecordsResponse_Learner_: + properties: + organization_id: + type: string + format: uuid + title: Organization Id + description: The organization's Keycloak organization UUID. + as_of: + anyOf: + - type: string + format: date-time + - type: 'null' + title: As Of + description: Last refresh of the backing data. Null before an organization's + first refresh. + total_count: + type: integer + title: Total Count + description: Matching records across all pages, not this page. + outcomes_withheld_count: + type: integer + title: Outcomes Withheld Count + description: 'Records in total_count carrying outcomes_shared: false. Always + 0 on endpoints with no consent gate.' + data: + items: + $ref: '#/components/schemas/Learner' + type: array + title: Data + description: The requested page of records. + type: object + required: + - organization_id + - as_of + - total_count + - outcomes_withheld_count + - data + title: LearnerRecordsResponse[Learner] + MembershipSource: + type: string + enum: + - roster + - enrollment + - both + title: MembershipSource + description: '``roster`` = on the organization''s membership roster with no + enrollments + + (an assigned, unstarted seat). ``enrollment`` = enrolled under a contract + + but absent from the roster (usually a provisioning lag). ``both`` = the + + expected state.' + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + input: + title: Input + ctx: + type: object + title: Context + type: object + required: + - loc + - msg + - type + title: ValidationError + securitySchemes: + oauth2ClientCredentials: + type: oauth2 + flows: + clientCredentials: + scopes: + learner-records:read: Read records, identity fields included. + tokenUrl: https://sso.ol.mit.edu/realms/olapps/protocol/openid-connect/token 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 28d4cf3..69ec206 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py @@ -54,6 +54,7 @@ class CompletionStatusFilter(StrEnum): "/learner-progress", response_model=LearnerProgressResponse, name="learner_progress", + operation_id="learners_progress_retrieve", summary="Each learner's enrollment and completion in each course run under the contract", ) async def learner_progress( # noqa: PLR0913 From 43f343ef1f03d2032155cd1cffa53ac64bfe71dc Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 12:51:00 -0400 Subject: [PATCH 4/7] fix(openapi): declare list query params as arrays, not optional arrays `Optional[list[X]] = None` renders as `anyOf: [{type: array}, {type: null}]`. openapi-generator's typescript-axios cannot reduce that to `Array | null`, so it falls back to treating the parameter as an arbitrary object and spreads it with `Object.entries`. The generated call then serializes `["passed", "certified"]` as `?0=passed&1=certified` instead of the repeated `?completion_status=passed&completion_status=certified` FastAPI parses, and the generated file fails `tsc --strict`. Three parameters are affected: b2b_learner_records' `learner_id` (on /learners and /enrollments) and `completion_status`, and b2b_dashboard's `completion_status` on /learner-progress. All three already collapse the two cases at the call site (`tuple(learner_id or ())`), so an omitted parameter and an empty list were never distinguishable to the query layer. Declaring them as plain arrays with `Query(default_factory=list)` makes the schema say what the endpoints already mean, and gives a fresh list per request rather than a shared mutable default. Verified with openapi-generator 7.2.0: both tenants' clients now assign the array to the parameter's own key, which common.ts's setFlattenedQueryParams appends once per element, and both typecheck clean under `tsc --strict`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6 --- openapi/specs/b2b_dashboard.yaml | 8 ++--- openapi/specs/b2b_learner_records.yaml | 32 ++++++++----------- .../tenants/b2b_dashboard/routers/learners.py | 9 ++++-- .../routers/organizations.py | 14 +++++--- 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/openapi/specs/b2b_dashboard.yaml b/openapi/specs/b2b_dashboard.yaml index 3f1caa4..e03fb12 100644 --- a/openapi/specs/b2b_dashboard.yaml +++ b/openapi/specs/b2b_dashboard.yaml @@ -503,11 +503,9 @@ paths: in: query required: false schema: - anyOf: - - type: array - items: - $ref: '#/components/schemas/CompletionStatusFilter' - - type: 'null' + type: array + items: + $ref: '#/components/schemas/CompletionStatusFilter' description: Repeat for several. `unknown` selects rows with withheld outcomes. title: Completion Status description: Repeat for several. `unknown` selects rows with withheld outcomes. diff --git a/openapi/specs/b2b_learner_records.yaml b/openapi/specs/b2b_learner_records.yaml index 356b669..b8112f4 100644 --- a/openapi/specs/b2b_learner_records.yaml +++ b/openapi/specs/b2b_learner_records.yaml @@ -34,13 +34,11 @@ paths: in: query required: false schema: - anyOf: - - type: array - items: - type: string - format: uuid - maxItems: 100 - - type: 'null' + type: array + items: + type: string + format: uuid + maxItems: 100 description: Repeat for several. title: Learner Id description: Repeat for several. @@ -131,13 +129,11 @@ paths: in: query required: false schema: - anyOf: - - type: array - items: - type: string - format: uuid - maxItems: 100 - - type: 'null' + type: array + items: + type: string + format: uuid + maxItems: 100 description: Repeat for several. title: Learner Id description: Repeat for several. @@ -145,11 +141,9 @@ paths: in: query required: false schema: - anyOf: - - type: array - items: - $ref: '#/components/schemas/CompletionStatusFilter' - - type: 'null' + type: array + items: + $ref: '#/components/schemas/CompletionStatusFilter' title: Completion Status - name: updated_since in: query 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 69ec206..90ece14 100644 --- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py +++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py @@ -67,9 +67,12 @@ async def learner_progress( # noqa: PLR0913 Query(min_length=1, max_length=254, description="Case-insensitive match on email or name."), ] = None, completion_status: Annotated[ - list[CompletionStatusFilter] | None, - Query(description="Repeat for several. `unknown` selects rows with withheld outcomes."), - ] = None, + list[CompletionStatusFilter], + Query( + description="Repeat for several. `unknown` selects rows with withheld outcomes.", + default_factory=list, + ), + ], include_inactive: Annotated[ bool, Query(description="Include deactivated enrollments (unenrolled, refunded).") ] = False, diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py b/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py index 590d18b..5d06ffb 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/routers/organizations.py @@ -53,8 +53,12 @@ def page( PageParams = Annotated[Page, Depends(page)] LearnerIds = Annotated[ - list[uuid.UUID] | None, - Query(max_length=settings.max_learner_ids, description="Repeat for several."), + list[uuid.UUID], + Query( + max_length=settings.max_learner_ids, + description="Repeat for several.", + default_factory=list, + ), ] UpdatedSince = Annotated[ datetime.datetime | None, @@ -103,7 +107,7 @@ async def list_learners( # noqa: PLR0913 organization_id: uuid.UUID, page: PageParams, contract_id: int | None = None, - learner_id: LearnerIds = None, + learner_id: LearnerIds, updated_since: UpdatedSince = None, include_inactive: IncludeInactive = False, ) -> LearnerRecordsResponse[BaseModel]: @@ -132,8 +136,8 @@ async def list_enrollments( # noqa: PLR0913 page: PageParams, contract_id: int | None = None, courserun_id: str | None = None, - learner_id: LearnerIds = None, - completion_status: Annotated[list[CompletionStatusFilter] | None, Query()] = None, + learner_id: LearnerIds, + completion_status: Annotated[list[CompletionStatusFilter], Query(default_factory=list)], updated_since: UpdatedSince = None, include_inactive: IncludeInactive = False, ) -> LearnerRecordsResponse[BaseModel]: From fba8ee585e6a493be8c502f473f1646096d23365 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 12:51:33 -0400 Subject: [PATCH 5/7] docs: record the list-query-param rule and which learner-records spec is which The `anyOf: [array, null]` footgun is invisible until someone generates a client, which nothing in this repo does yet, so the constraint needs to be written down next to the other two generator constraints. The tenant now has two YAML files with near-identical names and different jobs: the hand-written partner draft under docs/openapi/ and the generated contract under openapi/specs/. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6 --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e0377d..b2413fb 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ client. Until it is, committing the spec still buys the same thing locally: a column that appears here without appearing in the diff is a column a consumer would find out about at runtime once the pipeline exists. -Two details are worth knowing before editing a route: +Three details are worth knowing before editing a route: - **`operation_id` is named explicitly on every route.** It becomes the generated client's method name, so FastAPI's path-derived default would both @@ -244,3 +244,13 @@ Two details are worth knowing before editing a route: describes its routes relative to its own root; `openapi.py` re-prefixes them so a generated client configured with the service host requests the URLs the service actually serves. +- **A repeatable query parameter is a plain `list[X]`, never `list[X] | None`.** + The optional form renders as `anyOf: [array, null]`, which openapi-generator + cannot reduce; it emits a client that spreads the value with `Object.entries` + and sends `?0=a&1=b` instead of repeating the parameter name. Use + `Query(default_factory=list)` and treat the empty list as "no filter". + +Note that `docs/openapi/b2b-learner-records-v1.yaml` is a different artifact: +a hand-written draft published so partners could review the record shape +before it was built. `openapi/specs/b2b_learner_records.yaml` is generated +from the running code and is the one a client is built from. From e0c8b3157efc7ee36962e0a6e984c1d4898a0246 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 13:08:00 -0400 Subject: [PATCH 6/7] test(openapi): pin that array query params never publish a nullable variant The previous commit argued from the call sites that an omitted list filter and an empty one are the same query. Nothing enforced it, and nothing stopped a new route from reintroducing `list[X] | None` and quietly breaking the generated client again. test_array_query_params_are_not_nullable sweeps every published parameter for an `anyOf` containing an array, so it covers routes nobody has written yet rather than the three that exist. The endpoint test pins the behavior half: omitting both filters adds no predicate and binds only the org and paging parameters. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6 --- tests/test_learner_records.py | 23 +++++++++++++++++++++++ tests/test_openapi_spec.py | 18 ++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/tests/test_learner_records.py b/tests/test_learner_records.py index 6690d33..0b6a67b 100644 --- a/tests/test_learner_records.py +++ b/tests/test_learner_records.py @@ -399,6 +399,29 @@ async def test_enrollment_filters_are_bound_in_order(app, monkeypatch): assert pool.count_call()[1] == params[:-2] +async def test_omitted_list_filters_add_no_predicate(app, monkeypatch): + """`learner_id` and `completion_status` are plain arrays defaulting to [], + not `list | None`: the optional form publishes `anyOf: [array, null]`, which + openapi-generator cannot reduce to a usable client type. An empty array is + not expressible in a query string (a generated client omits the parameter, + and `?learner_id=` is an empty *string*, rejected as a bad UUID), so the + default is only ever reached by omitting the parameter. This pins that + reaching it adds no predicate.""" + pool = _FakePool(rows=[_enrollment_row()], total_count=1, withheld=1) + response = await _get( + app, + f"/organizations/{ORG_ID}/enrollments", + _partner_header(ORG_ID), + pool, + monkeypatch, + ) + assert response.status_code == 200 + query, params = pool.page_call() + assert "learner_id IN" not in query + assert "completion_status IN" not in query + assert params == (ORG_ID, 100, 0) + + @pytest.mark.parametrize( "query", [ diff --git a/tests/test_openapi_spec.py b/tests/test_openapi_spec.py index f0b43b7..2088582 100644 --- a/tests/test_openapi_spec.py +++ b/tests/test_openapi_spec.py @@ -99,3 +99,21 @@ def test_operation_ids_are_explicit(): assert route.operation_id is not None, ( f"{tenant.name} route {route.path} has no explicit operation_id" ) + + +def test_array_query_params_are_not_nullable(specs): + """A `list[X] | None` parameter publishes `anyOf: [{type: array}, null]`. + openapi-generator's typescript-axios cannot reduce that, so it treats the + parameter as an arbitrary object and spreads it with `Object.entries`, + emitting `?0=a&1=b` instead of repeating the parameter name, and failing + `tsc --strict`. Declare repeatable filters as plain arrays with + `Query(default_factory=list)` instead.""" + offenders = [ + f"{tenant_name} {path} {parameter['name']}" + for tenant_name, spec in specs.items() + for path, path_item in spec["paths"].items() + for operation in path_item.values() + for parameter in operation.get("parameters", []) + if any(option.get("type") == "array" for option in parameter["schema"].get("anyOf", [])) + ] + assert not offenders From 41679a1977d72197222aae7103f9288deb374784 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 14:04:48 -0400 Subject: [PATCH 7/7] fix(openapi): keep the breaking-change check running on fork PRs A fork's GITHUB_TOKEN is read-only no matter what the job requests, so both comment steps fail there. They run before the oasdiff breaking check, so the whole job dies and the one thing that actually gates the PR never runs. Both comment steps now skip unless the PR head is in this repo. Also fixes the "adding a new tenant" example, which still called Tenant with the old positional signature: copying it would have bound the mount path to `name` and then raised for the missing factory. The lifespan argument moved from third to fourth with it. Reported by Copilot on #70. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6 --- .github/workflows/openapi-diff.yml | 6 ++++++ README.md | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/openapi-diff.yml b/.github/workflows/openapi-diff.yml index 0081647..dfb62a3 100644 --- a/.github/workflows/openapi-diff.yml +++ b/.github/workflows/openapi-diff.yml @@ -82,8 +82,13 @@ jobs: echo "Unexpected changes? Ensure your branch is up-to-date with \`main\` (consider rebasing)." echo "" } > comment_body.md + # A fork's GITHUB_TOKEN is read-only whatever this job asks for, so both + # comment steps would fail and take the breaking-change check below down + # with them. The check is the gate; the comment is a convenience. Skip + # the convenience rather than lose the gate. - name: Find existing comment id: find_comment + if: github.event.pull_request.head.repo.full_name == github.repository uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -93,6 +98,7 @@ jobs: - name: Post changes as comment uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5 # Even with no changes, update the old comment if one was found. + if: github.event.pull_request.head.repo.full_name == github.repository with: token: ${{ secrets.GITHUB_TOKEN }} edit-mode: "replace" diff --git a/README.md b/README.md index b2413fb..b3f8316 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,13 @@ state. Wire it up with one entry in `main.py`'s `TENANTS` list: ```python TENANTS: list[Tenant] = [ - Tenant("/api/v1/analytics", b2b_dashboard.create_app, b2b_dashboard.lifespan), - Tenant("/api/v1/", new_tenant.create_app), + Tenant( + b2b_dashboard.TENANT_NAME, + "/api/v1/analytics", + b2b_dashboard.create_app, + b2b_dashboard.lifespan, + ), + Tenant(new_tenant.TENANT_NAME, "/api/v1/", new_tenant.create_app), ] ``` @@ -107,11 +112,15 @@ A `Tenant` takes a `create_app` *factory* (not a pre-built instance) so the root app constructs every sub-app after OpenTelemetry is configured — a tenant is instrumented regardless of import order. If the tenant owns resources that need startup/shutdown (e.g. an httpx client), it exposes them -as an ordinary `lifespan` context manager and passes it as the third +as an ordinary `lifespan` context manager and passes it as the fourth argument: a mounted sub-app's own `lifespan=` is never invoked by the ASGI server (only the root app's is), so the root lifespan enters each tenant's explicitly. +The leading `name` is the tenant's own `TENANT_NAME`, which already names its +readiness sub-path. It also names the tenant's published OpenAPI document +(`openapi/specs/.yaml`), so it ends up in a consumer-visible filename. + Each tenant gets independent OpenAPI docs at `/docs`. ### Auth