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..dfb62a3
--- /dev/null
+++ b/.github/workflows/openapi-diff.yml
@@ -0,0 +1,139 @@
+name: OpenAPI Diff
+
+# 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:
+ 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.
+ #
+ # 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 ""
+ echo ""
+ echo "Show/hide changes
"
+ echo ""
+ echo '```'
+ 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@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
+ echo '```'
+ echo ""
+ 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 }}
+ 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.
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ 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.
+ # 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@sha256:6065c16a4c9ce12504752f444d4981091e58c2a35436fac90b649be47d833db3 \
+ breaking \
+ --fail-on ERR \
+ --format githubactions \
+ "$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 4603134..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
@@ -208,3 +217,49 @@ 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 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.
+
+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
+ 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.
+- **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.
diff --git a/bin/generate-openapi-spec b/bin/generate-openapi-spec
new file mode 100755
index 0000000..41c3782
--- /dev/null
+++ b/bin/generate-openapi-spec
@@ -0,0 +1,68 @@
+#!/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 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
+
+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..e03fb12
--- /dev/null
+++ b/openapi/specs/b2b_dashboard.yaml
@@ -0,0 +1,2130 @@
+openapi: 3.1.0
+info:
+ title: B2B Analytics Dashboard
+ 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:
+ 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: integer
+ title: Contract Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_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: integer
+ title: Contract Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_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: integer
+ title: Contract Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_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: integer
+ title: Contract Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_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: integer
+ title: Contract Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_ContractContentEngagementDepth_'
+ '422':
+ description: Validation Error
+ content:
+ 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:
+ 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.
+ - 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:
+ - 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]
+ 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
+ - 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
+ 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: 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
+ - 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 exactly
+
+ what makes a suppressed contract recoverable by subtraction once an org
+
+ holds more than one — the k-anonymity floor here is per-row and does not
+
+ 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: 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
+ - 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.'
+ 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: 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
+ - 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.
+
+
+ ``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: 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
+ - 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.
+
+
+ ``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:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ 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: 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
+ - 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).
+
+
+ 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
+ - 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
+ 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: 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
+ - 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.
+
+
+ ``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:
+ 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/openapi/specs/b2b_learner_records.yaml b/openapi/specs/b2b_learner_records.yaml
new file mode 100644
index 0000000..b8112f4
--- /dev/null
+++ b/openapi/specs/b2b_learner_records.yaml
@@ -0,0 +1,856 @@
+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:
+ type: array
+ items:
+ type: string
+ format: uuid
+ maxItems: 100
+ 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:
+ type: array
+ items:
+ type: string
+ format: uuid
+ maxItems: 100
+ description: Repeat for several.
+ title: Learner Id
+ description: Repeat for several.
+ - name: completion_status
+ in: query
+ required: false
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/CompletionStatusFilter'
+ 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/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/learners.py b/src/ol_analytics_api/tenants/b2b_dashboard/routers/learners.py
index 28d4cf3..90ece14 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
@@ -66,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_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/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]:
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_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..2088582
--- /dev/null
+++ b/tests/test_openapi_spec.py
@@ -0,0 +1,119 @@
+"""The published OpenAPI contract.
+
+`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, create_app
+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
+
+
+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"
+ )
+
+
+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
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"