diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c7fbd8a..9fa68ff 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..0081647
--- /dev/null
+++ b/.github/workflows/openapi-diff.yml
@@ -0,0 +1,133 @@
+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
+ - name: Find existing comment
+ id: find_comment
+ uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ repository: ${{ github.repository }}
+ issue-number: ${{ github.event.pull_request.number }}
+ body-includes: "## OpenAPI Changes"
+ - name: Post changes as comment
+ uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5
+ # Even with no changes, update the old comment if one was found.
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ edit-mode: "replace"
+ repository: ${{ github.repository }}
+ issue-number: ${{ github.event.pull_request.number }}
+ comment-id: ${{ steps.find_comment.outputs.comment-id }}
+ body-path: comment_body.md
+ - name: Check for breaking changes
+ run: |
+ # Breaking here means breaking a client someone else already
+ # generated and shipped, so this fails the PR rather than warning.
+ # 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 bf9e315..ae68d27 100644
--- a/README.md
+++ b/README.md
@@ -177,3 +177,39 @@ 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.
+
+Two details are worth knowing before editing a route:
+
+- **`operation_id` is named explicitly on every route.** It becomes the
+ generated client's method name, so FastAPI's path-derived default would both
+ produce an unreadable name and rename the method whenever the path moves.
+- **Published paths carry the tenant's mount prefix.** A mounted sub-app
+ describes its routes relative to its own root; `openapi.py` re-prefixes them
+ so a generated client configured with the service host requests the URLs the
+ service actually serves.
diff --git a/bin/generate-openapi-spec b/bin/generate-openapi-spec
new file mode 100755
index 0000000..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..a97a280
--- /dev/null
+++ b/openapi/specs/b2b_dashboard.yaml
@@ -0,0 +1,1587 @@
+openapi: 3.1.0
+info:
+ title: B2B Analytics Dashboard
+ description: Aggregated-only B2B site-license analytics for org managers and MIT
+ contract admins. No individual learner PII.
+ version: 0.0.1
+paths:
+ /api/v1/analytics/organizations/{organization_id}/contract-utilization:
+ get:
+ tags:
+ - organizations
+ summary: Contract Utilization
+ operationId: organizations_contract_utilization_retrieve
+ parameters:
+ - name: organization_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Organization Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_ContractUtilization_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/analytics/organizations/{organization_id}/enrollment-funnel:
+ get:
+ tags:
+ - organizations
+ summary: Enrollment Funnel
+ operationId: organizations_enrollment_funnel_retrieve
+ parameters:
+ - name: organization_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Organization Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_EnrollmentCompletionFunnel_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/analytics/organizations/{organization_id}/engagement-trend:
+ get:
+ tags:
+ - organizations
+ summary: Engagement Trend
+ operationId: organizations_engagement_trend_retrieve
+ parameters:
+ - name: organization_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Organization Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_MonthlyEngagementTrend_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/analytics/organizations/{organization_id}/program-funnel:
+ get:
+ tags:
+ - organizations
+ summary: Program Funnel
+ operationId: organizations_program_funnel_retrieve
+ parameters:
+ - name: organization_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Organization Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_ProgramFunnel_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/analytics/organizations/{organization_id}/content-engagement:
+ get:
+ tags:
+ - organizations
+ summary: Content Engagement
+ operationId: organizations_content_engagement_retrieve
+ parameters:
+ - name: organization_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Organization Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_ContentEngagementDepth_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/contract-utilization:
+ get:
+ tags:
+ - contracts
+ summary: Contract Utilization
+ operationId: contracts_contract_utilization_retrieve
+ parameters:
+ - name: organization_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Organization Id
+ - name: contract_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Contract Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_ContractUtilization_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/enrollment-funnel:
+ get:
+ tags:
+ - contracts
+ summary: Enrollment Funnel
+ operationId: contracts_enrollment_funnel_retrieve
+ parameters:
+ - name: organization_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Organization Id
+ - name: contract_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Contract Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_EnrollmentCompletionFunnel_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/engagement-trend:
+ get:
+ tags:
+ - contracts
+ summary: Engagement Trend
+ operationId: contracts_engagement_trend_retrieve
+ parameters:
+ - name: organization_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Organization Id
+ - name: contract_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Contract Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_ContractMonthlyEngagementTrend_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/program-funnel:
+ get:
+ tags:
+ - contracts
+ summary: Program Funnel
+ operationId: contracts_program_funnel_retrieve
+ parameters:
+ - name: organization_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Organization Id
+ - name: contract_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Contract Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_ProgramFunnel_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/analytics/organizations/{organization_id}/contracts/{contract_id}/content-engagement:
+ get:
+ tags:
+ - contracts
+ summary: Content Engagement
+ operationId: contracts_content_engagement_retrieve
+ parameters:
+ - name: organization_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Organization Id
+ - name: contract_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Contract Id
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OrgAnalyticsResponse_ContractContentEngagementDepth_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/analytics/admin/contract-health:
+ get:
+ tags:
+ - admin
+ summary: Contract Health
+ operationId: admin_contract_health_retrieve
+ parameters:
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 1000
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AdminAnalyticsResponse_MitAdminContractHealth_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+components:
+ schemas:
+ AdminAnalyticsResponse_MitAdminContractHealth_:
+ properties:
+ as_of:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: As Of
+ total_count:
+ type: integer
+ title: Total Count
+ data:
+ items:
+ $ref: '#/components/schemas/MitAdminContractHealth'
+ type: array
+ title: Data
+ type: object
+ required:
+ - as_of
+ - total_count
+ - data
+ title: AdminAnalyticsResponse[MitAdminContractHealth]
+ ContentEngagementDepth:
+ properties:
+ organization_key:
+ type: string
+ title: Organization Key
+ organization_name:
+ type: string
+ title: Organization Name
+ courserun_readable_id:
+ type: string
+ title: Courserun Readable Id
+ courserun_title:
+ type: string
+ title: Courserun Title
+ total_enrolled_learners:
+ type: integer
+ title: Total Enrolled Learners
+ engaged_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Engaged Learners
+ engagement_rate_pct:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Engagement Rate Pct
+ total_videos_watched:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Videos Watched
+ video_watchers:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Video Watchers
+ avg_videos_per_engaged_learner:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Avg Videos Per Engaged Learner
+ total_problems_attempted:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Problems Attempted
+ problem_attempters:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Problem Attempters
+ avg_problems_per_engaged_learner:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Avg Problems Per Engaged Learner
+ total_chatbot_interactions:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Chatbot Interactions
+ chatbot_users:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Chatbot Users
+ chatbot_adoption_pct:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Chatbot Adoption Pct
+ certificates_earned:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Certificates Earned
+ type: object
+ required:
+ - organization_key
+ - organization_name
+ - courserun_readable_id
+ - courserun_title
+ - total_enrolled_learners
+ - engaged_learners
+ - engagement_rate_pct
+ - total_videos_watched
+ - video_watchers
+ - avg_videos_per_engaged_learner
+ - total_problems_attempted
+ - problem_attempters
+ - avg_problems_per_engaged_learner
+ - total_chatbot_interactions
+ - chatbot_users
+ - chatbot_adoption_pct
+ - certificates_earned
+ title: ContentEngagementDepth
+ description: 'mv_b2b_content_engagement_depth — grain: org x course_run (all-time).
+
+
+ The chatbot columns are exact: ``total_chatbot_interactions`` sums over,
+
+ and ``chatbot_adoption_pct`` divides by, ``chatbot_users`` — which this
+
+ view does emit, so both are correctly floored. ``engagement_rate_pct`` is
+
+ ``engaged_learners / total_enrolled_learners``, also correct.
+
+
+ The video and problem columns are floored through the cohorts the view now
+
+ publishes (ol-data-platform PR #2520): ``total_videos_watched`` is summed
+
+ over ``video_watchers`` and ``total_problems_attempted`` over
+
+ ``problem_attempters``, each a strict subset of ``engaged_learners``
+
+ because watching a video or attempting a problem is one of the activities
+
+ that sets ``active_count``. (Every cohort this view emits is such a
+
+ subset. That is a property of these particular cohorts, not a general
+
+ rule — see ``MonthlyEngagementTrend``, where ``enrolling_learners`` is
+
+ not a subset of its primary because enrolling does not set
+
+ ``active_count``.)
+
+
+ The ``avg_*_per_engaged_learner`` columns are derived from *two* cohorts,
+
+ which is why each names both. The denominator is ``engaged_learners`` —
+
+ that is what the dbt SQL divides by, so the naming is now accurate — but
+
+ the numerator is the activity SUM, contributed by only the narrower
+
+ cohort. Mapping the average to its denominator alone would leave the
+
+ numerator recoverable: an unsuppressed average multiplied by a published
+
+ ``engaged_learners`` yields the suppressed total exactly, and when the
+
+ contributing cohort is a single learner that total *is* that learner''s
+
+ value. Naming both cohorts nulls the average whenever either is sub-floor.
+
+
+ ``certificates_earned`` is the one column still floored as a count of
+
+ itself: it is ``sum(certificate_count)``, an event count, and this view
+
+ emits no certified-learner cohort to attribute it to (unlike
+
+ ``MonthlyEngagementTrend``, which has ``certified_learners``). Flooring an
+
+ event count is weaker than flooring a cohort — several certificates can
+
+ come from one learner — but strictly better than not flooring it. Emitting
+
+ the cohort from dbt would close this the same way #2520 closed the others.'
+ ContractContentEngagementDepth:
+ properties:
+ organization_key:
+ type: string
+ title: Organization Key
+ organization_name:
+ type: string
+ title: Organization Name
+ courserun_readable_id:
+ type: string
+ title: Courserun Readable Id
+ courserun_title:
+ type: string
+ title: Courserun Title
+ total_enrolled_learners:
+ type: integer
+ title: Total Enrolled Learners
+ engaged_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Engaged Learners
+ engagement_rate_pct:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Engagement Rate Pct
+ total_videos_watched:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Videos Watched
+ video_watchers:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Video Watchers
+ avg_videos_per_engaged_learner:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Avg Videos Per Engaged Learner
+ total_problems_attempted:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Problems Attempted
+ problem_attempters:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Problem Attempters
+ avg_problems_per_engaged_learner:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Avg Problems Per Engaged Learner
+ total_chatbot_interactions:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Chatbot Interactions
+ chatbot_users:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Chatbot Users
+ chatbot_adoption_pct:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Chatbot Adoption Pct
+ certificates_earned:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Certificates Earned
+ contract_pk:
+ type: string
+ title: Contract Pk
+ contract_id:
+ type: string
+ title: Contract Id
+ b2b_contract_name:
+ type: string
+ title: B2B Contract Name
+ type: object
+ required:
+ - organization_key
+ - organization_name
+ - courserun_readable_id
+ - courserun_title
+ - total_enrolled_learners
+ - engaged_learners
+ - engagement_rate_pct
+ - total_videos_watched
+ - video_watchers
+ - avg_videos_per_engaged_learner
+ - total_problems_attempted
+ - problem_attempters
+ - avg_problems_per_engaged_learner
+ - total_chatbot_interactions
+ - chatbot_users
+ - chatbot_adoption_pct
+ - certificates_earned
+ - contract_pk
+ - contract_id
+ - b2b_contract_name
+ title: ContractContentEngagementDepth
+ description: 'mv_b2b_contract_content_engagement_depth — grain: org x contract
+ x run.
+
+
+ The contract-scoped sibling of ``ContentEngagementDepth``, inherited for
+
+ the same reason as ``ContractMonthlyEngagementTrend``.
+
+
+ Unlike the trend view, these rows ARE a strict partition of the org-level
+
+ view: a course run belongs to exactly one contract, so naming the contract
+
+ labels a row rather than splitting it, and every count here equals its
+
+ org-level counterpart for the same course run.
+
+
+ That equality is why this pair needs no cross-grain guard, where the trend
+
+ pair does. Nothing is aggregated away going from contract grain to org
+
+ grain, so there is no remainder to subtract: a course run''s org row and its
+
+ contract row hold the same numbers, the floor makes the same call on both,
+
+ and a caller reading one learns nothing the other withholds.'
+ ContractMonthlyEngagementTrend:
+ properties:
+ organization_key:
+ type: string
+ title: Organization Key
+ organization_name:
+ type: string
+ title: Organization Name
+ activity_year_and_month:
+ type: string
+ title: Activity Year And Month
+ monthly_active_learners:
+ type: integer
+ title: Monthly Active Learners
+ new_enrollments:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: New Enrollments
+ enrolling_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Enrolling Learners
+ certificates_earned:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Certificates Earned
+ certified_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Certified Learners
+ total_videos_watched:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Videos Watched
+ video_watchers:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Video Watchers
+ total_problems_attempted:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Problems Attempted
+ problem_attempters:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Problem Attempters
+ total_chatbot_interactions:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Chatbot Interactions
+ chatbot_users:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Chatbot Users
+ contract_pk:
+ type: string
+ title: Contract Pk
+ contract_id:
+ type: string
+ title: Contract Id
+ b2b_contract_name:
+ type: string
+ title: B2B Contract Name
+ type: object
+ required:
+ - organization_key
+ - organization_name
+ - activity_year_and_month
+ - monthly_active_learners
+ - new_enrollments
+ - enrolling_learners
+ - certificates_earned
+ - certified_learners
+ - total_videos_watched
+ - video_watchers
+ - total_problems_attempted
+ - problem_attempters
+ - total_chatbot_interactions
+ - chatbot_users
+ - contract_pk
+ - contract_id
+ - b2b_contract_name
+ title: ContractMonthlyEngagementTrend
+ description: 'mv_b2b_contract_monthly_engagement_trend — grain: org x contract
+ x month.
+
+
+ The contract-scoped sibling of ``MonthlyEngagementTrend``, backing the
+
+ endpoints nested under a contract. Subclassed rather than redeclared so the
+
+ two can''t drift: the column set and the ``cohort_policy`` — which is what
+
+ the anonymization floor reads — are inherited verbatim, and only contract
+
+ identity is added. The dbt models are siblings in the same way.
+
+
+ The contract columns are not cohorts and take no part in the policy.
+
+
+ A learner active under two of an org''s contracts appears in both rows, so
+
+ these rows do not partition the org-level view''s learner counts; summing
+
+ ``monthly_active_learners`` across contracts can exceed the org''s own
+
+ figure. Activity totals, being sums of events, do add up — which is what
+
+ makes a contract-month the floor withholds recoverable from the org
+
+ endpoint as ``org_total - sum(the visible contract months)``. The org
+
+ endpoint defends against that itself: it probes this view for the months
+
+ it withholds and blanks its own additive totals for them (see
+
+ ``routers.organizations._FinerGrain``). The learner counts are left alone,
+
+ because not adding up is exactly what stops them from being recovered by
+
+ subtraction.'
+ ContractUtilization:
+ properties:
+ organization_key:
+ type: string
+ title: Organization Key
+ organization_name:
+ type: string
+ title: Organization Name
+ contract_pk:
+ type: string
+ title: Contract Pk
+ contract_id:
+ type: string
+ title: Contract Id
+ b2b_contract_name:
+ type: string
+ title: B2B Contract Name
+ b2b_contract_is_active:
+ type: boolean
+ title: B2B Contract Is Active
+ b2b_contract_start_date:
+ anyOf:
+ - type: string
+ format: date
+ - type: 'null'
+ title: B2B Contract Start Date
+ b2b_contract_end_date:
+ anyOf:
+ - type: string
+ format: date
+ - type: 'null'
+ title: B2B Contract End Date
+ seat_limit:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Seat Limit
+ b2b_contract_membership_type:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: B2B Contract Membership Type
+ seats_consumed:
+ type: integer
+ title: Seats Consumed
+ active_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Active Learners
+ learners_certified:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Learners Certified
+ seat_utilization_pct:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Seat Utilization Pct
+ completion_rate_pct:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Completion Rate Pct
+ type: object
+ required:
+ - organization_key
+ - organization_name
+ - contract_pk
+ - contract_id
+ - b2b_contract_name
+ - b2b_contract_is_active
+ - b2b_contract_start_date
+ - b2b_contract_end_date
+ - seat_limit
+ - b2b_contract_membership_type
+ - seats_consumed
+ - active_learners
+ - learners_certified
+ - seat_utilization_pct
+ - completion_rate_pct
+ title: ContractUtilization
+ description: 'mv_b2b_contract_utilization — grain: org x contract.'
+ EnrollmentCompletionFunnel:
+ properties:
+ organization_key:
+ type: string
+ title: Organization Key
+ organization_name:
+ type: string
+ title: Organization Name
+ contract_pk:
+ type: string
+ title: Contract Pk
+ contract_id:
+ type: string
+ title: Contract Id
+ b2b_contract_name:
+ type: string
+ title: B2B Contract Name
+ courserun_pk:
+ type: string
+ title: Courserun Pk
+ courserun_readable_id:
+ type: string
+ title: Courserun Readable Id
+ courserun_title:
+ type: string
+ title: Courserun Title
+ enrolled_learners:
+ type: integer
+ title: Enrolled Learners
+ active_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Active Learners
+ passing_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Passing Learners
+ certified_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Certified Learners
+ active_rate_pct:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Active Rate Pct
+ completion_rate_pct:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Completion Rate Pct
+ type: object
+ required:
+ - organization_key
+ - organization_name
+ - contract_pk
+ - contract_id
+ - b2b_contract_name
+ - courserun_pk
+ - courserun_readable_id
+ - courserun_title
+ - enrolled_learners
+ - active_learners
+ - passing_learners
+ - certified_learners
+ - active_rate_pct
+ - completion_rate_pct
+ title: EnrollmentCompletionFunnel
+ description: 'mv_b2b_enrollment_completion_funnel — grain: org x contract x
+ course_run.'
+ HTTPValidationError:
+ properties:
+ detail:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ title: Detail
+ type: object
+ title: HTTPValidationError
+ MitAdminContractHealth:
+ properties:
+ organization_key:
+ type: string
+ title: Organization Key
+ organization_name:
+ type: string
+ title: Organization Name
+ contract_pk:
+ type: string
+ title: Contract Pk
+ contract_id:
+ type: string
+ title: Contract Id
+ b2b_contract_name:
+ type: string
+ title: B2B Contract Name
+ b2b_contract_is_active:
+ type: boolean
+ title: B2B Contract Is Active
+ b2b_contract_start_date:
+ anyOf:
+ - type: string
+ format: date
+ - type: 'null'
+ title: B2B Contract Start Date
+ b2b_contract_end_date:
+ anyOf:
+ - type: string
+ format: date
+ - type: 'null'
+ title: B2B Contract End Date
+ seat_limit:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Seat Limit
+ b2b_contract_membership_type:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: B2B Contract Membership Type
+ seats_consumed:
+ type: integer
+ title: Seats Consumed
+ active_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Active Learners
+ certified_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Certified Learners
+ seat_utilization_pct:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Seat Utilization Pct
+ completion_rate_pct:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Completion Rate Pct
+ health_status:
+ type: string
+ title: Health Status
+ type: object
+ required:
+ - organization_key
+ - organization_name
+ - contract_pk
+ - contract_id
+ - b2b_contract_name
+ - b2b_contract_is_active
+ - b2b_contract_start_date
+ - b2b_contract_end_date
+ - seat_limit
+ - b2b_contract_membership_type
+ - seats_consumed
+ - active_learners
+ - certified_learners
+ - seat_utilization_pct
+ - completion_rate_pct
+ - health_status
+ title: MitAdminContractHealth
+ description: 'mv_b2b_mit_admin_contract_health — grain: org x contract (MIT
+ admin only).'
+ MonthlyEngagementTrend:
+ properties:
+ organization_key:
+ type: string
+ title: Organization Key
+ organization_name:
+ type: string
+ title: Organization Name
+ activity_year_and_month:
+ type: string
+ title: Activity Year And Month
+ monthly_active_learners:
+ type: integer
+ title: Monthly Active Learners
+ new_enrollments:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: New Enrollments
+ enrolling_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Enrolling Learners
+ certificates_earned:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Certificates Earned
+ certified_learners:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Certified Learners
+ total_videos_watched:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Videos Watched
+ video_watchers:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Video Watchers
+ total_problems_attempted:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Problems Attempted
+ problem_attempters:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Problem Attempters
+ total_chatbot_interactions:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Chatbot Interactions
+ chatbot_users:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Chatbot Users
+ type: object
+ required:
+ - organization_key
+ - organization_name
+ - activity_year_and_month
+ - monthly_active_learners
+ - new_enrollments
+ - enrolling_learners
+ - certificates_earned
+ - certified_learners
+ - total_videos_watched
+ - video_watchers
+ - total_problems_attempted
+ - problem_attempters
+ - total_chatbot_interactions
+ - chatbot_users
+ title: MonthlyEngagementTrend
+ description: "mv_b2b_monthly_engagement_trend — grain: org x year_month.\n\n\
+ Every aggregate here is floored through the cohort that contributes to it,\n\
+ which the view publishes alongside it (ol-data-platform PR #2520).\n\nNone\
+ \ of them is attributable to ``monthly_active_learners``. Each is a\nplain\
+ \ SUM over the source report, so only the learners who did that\nspecific\
+ \ thing contribute — and clearing the primary floor says nothing\nabout whether\
+ \ that narrower cohort cleared it. A month with 40 active\nlearners can carry\
+ \ a chatbot total contributed by exactly one of them,\nwhich is why each total\
+ \ is ``derived`` from its own cohort rather than\nfrom the primary.\n\nHow\
+ \ each cohort relates to the primary differs, and neither case makes\nmapping\
+ \ to the primary safe:\n\n- ``certified_learners``, ``video_watchers``, ``problem_attempters``\
+ \ and\n ``chatbot_users`` are strict *subsets*. ``active_count`` is 1 when\
+ \ any\n of navigation, discussion, videos, problems, chatbot or certificate\n\
+ \ activity is nonzero (organization_administration_report.sql), so each\n\
+ \ of those actions sets it.\n- ``enrolling_learners`` is *not* a subset.\
+ \ ``enrolled_count`` is absent\n from that expression, so enrolling alone\
+ \ never sets ``active_count``\n and a learner who only enrolled is counted\
+ \ here but not in the primary.\n The row gate is unaffected — a month whose\
+ \ primary is sub-floor is\n dropped whole, which over-suppresses a large\
+ \ enrollment cohort rather\n than disclosing one — but the subset reasoning\
+ \ does not apply, and\n ``new_enrollments`` is floored through ``enrolling_learners``\
+ \ on its\n own terms.\n\n``new_enrollments`` and ``certificates_earned``\
+ \ are SUMs of\nper-learner-per-course-run markers, so they count *events*,\
+ \ not learners:\none learner enrolling in six runs reads as ``new_enrollments\
+ \ == 6`` and\nwould clear a floor of 5 on its own. Flooring them directly\
+ \ is therefore\nthe wrong instrument — they are ``derived`` from ``enrolling_learners``\n\
+ and ``certified_learners``, the distinct-learner counts they are actually\n\
+ attributable to, which do carry the floor."
+ OrgAnalyticsResponse_ContentEngagementDepth_:
+ properties:
+ organization_id:
+ type: string
+ title: Organization Id
+ as_of:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: As Of
+ total_count:
+ type: integer
+ title: Total Count
+ data:
+ items:
+ $ref: '#/components/schemas/ContentEngagementDepth'
+ type: array
+ title: Data
+ type: object
+ required:
+ - organization_id
+ - as_of
+ - total_count
+ - data
+ title: OrgAnalyticsResponse[ContentEngagementDepth]
+ OrgAnalyticsResponse_ContractContentEngagementDepth_:
+ properties:
+ organization_id:
+ type: string
+ title: Organization Id
+ as_of:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: As Of
+ total_count:
+ type: integer
+ title: Total Count
+ data:
+ items:
+ $ref: '#/components/schemas/ContractContentEngagementDepth'
+ type: array
+ title: Data
+ type: object
+ required:
+ - organization_id
+ - as_of
+ - total_count
+ - data
+ title: OrgAnalyticsResponse[ContractContentEngagementDepth]
+ OrgAnalyticsResponse_ContractMonthlyEngagementTrend_:
+ properties:
+ organization_id:
+ type: string
+ title: Organization Id
+ as_of:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: As Of
+ total_count:
+ type: integer
+ title: Total Count
+ data:
+ items:
+ $ref: '#/components/schemas/ContractMonthlyEngagementTrend'
+ type: array
+ title: Data
+ type: object
+ required:
+ - organization_id
+ - as_of
+ - total_count
+ - data
+ title: OrgAnalyticsResponse[ContractMonthlyEngagementTrend]
+ OrgAnalyticsResponse_ContractUtilization_:
+ properties:
+ organization_id:
+ type: string
+ title: Organization Id
+ as_of:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: As Of
+ total_count:
+ type: integer
+ title: Total Count
+ data:
+ items:
+ $ref: '#/components/schemas/ContractUtilization'
+ type: array
+ title: Data
+ type: object
+ required:
+ - organization_id
+ - as_of
+ - total_count
+ - data
+ title: OrgAnalyticsResponse[ContractUtilization]
+ OrgAnalyticsResponse_EnrollmentCompletionFunnel_:
+ properties:
+ organization_id:
+ type: string
+ title: Organization Id
+ as_of:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: As Of
+ total_count:
+ type: integer
+ title: Total Count
+ data:
+ items:
+ $ref: '#/components/schemas/EnrollmentCompletionFunnel'
+ type: array
+ title: Data
+ type: object
+ required:
+ - organization_id
+ - as_of
+ - total_count
+ - data
+ title: OrgAnalyticsResponse[EnrollmentCompletionFunnel]
+ OrgAnalyticsResponse_MonthlyEngagementTrend_:
+ properties:
+ organization_id:
+ type: string
+ title: Organization Id
+ as_of:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: As Of
+ total_count:
+ type: integer
+ title: Total Count
+ data:
+ items:
+ $ref: '#/components/schemas/MonthlyEngagementTrend'
+ type: array
+ title: Data
+ type: object
+ required:
+ - organization_id
+ - as_of
+ - total_count
+ - data
+ title: OrgAnalyticsResponse[MonthlyEngagementTrend]
+ OrgAnalyticsResponse_ProgramFunnel_:
+ properties:
+ organization_id:
+ type: string
+ title: Organization Id
+ as_of:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: As Of
+ total_count:
+ type: integer
+ title: Total Count
+ data:
+ items:
+ $ref: '#/components/schemas/ProgramFunnel'
+ type: array
+ title: Data
+ type: object
+ required:
+ - organization_id
+ - as_of
+ - total_count
+ - data
+ title: OrgAnalyticsResponse[ProgramFunnel]
+ ProgramFunnel:
+ properties:
+ organization_key:
+ type: string
+ title: Organization Key
+ organization_name:
+ type: string
+ title: Organization Name
+ contract_pk:
+ type: string
+ title: Contract Pk
+ contract_id:
+ type: string
+ title: Contract Id
+ b2b_contract_name:
+ type: string
+ title: B2B Contract Name
+ program_pk:
+ type: string
+ title: Program Pk
+ program_title:
+ type: string
+ title: Program Title
+ total_courses:
+ type: integer
+ title: Total Courses
+ enrolled_in_contract_courses:
+ type: integer
+ title: Enrolled In Contract Courses
+ enrolled_via_program:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Enrolled Via Program
+ program_course_completers:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Program Course Completers
+ type: object
+ required:
+ - organization_key
+ - organization_name
+ - contract_pk
+ - contract_id
+ - b2b_contract_name
+ - program_pk
+ - program_title
+ - total_courses
+ - enrolled_in_contract_courses
+ - enrolled_via_program
+ - program_course_completers
+ title: ProgramFunnel
+ description: 'mv_b2b_program_funnel — grain: org x contract x program.
+
+
+ ``total_courses`` counts courses, not learners, so it is not a cohort.'
+ ValidationError:
+ properties:
+ loc:
+ items:
+ anyOf:
+ - type: string
+ - type: integer
+ type: array
+ title: Location
+ msg:
+ type: string
+ title: Message
+ type:
+ type: string
+ title: Error Type
+ input:
+ title: Input
+ ctx:
+ type: object
+ title: Context
+ type: object
+ required:
+ - loc
+ - msg
+ - type
+ title: ValidationError
diff --git a/pyproject.toml b/pyproject.toml
index f1e00b0..c28c509 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 5a163b9..c2d7a3d 100644
--- a/src/ol_analytics_api/main.py
+++ b/src/ol_analytics_api/main.py
@@ -88,8 +88,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
@@ -97,7 +103,12 @@ 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(
+ b2b_dashboard.TENANT_NAME,
+ "/api/v1/analytics",
+ b2b_dashboard.create_app,
+ b2b_dashboard.lifespan,
+ ),
]
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 6ab76f8..6b3c261 100644
--- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py
+++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py
@@ -156,6 +156,11 @@ async def endpoint(
# its concrete row model; mypy can't type a value used as a type param.
response_model=OrgAnalyticsResponse[spec.model], # type: ignore[name-defined]
name=endpoint.__name__,
+ # See the same call in organizations.py: named explicitly so a
+ # generated client's method name is ours rather than a derivative of
+ # the path. The `contracts_` prefix is what separates these from the
+ # org router's identically-named panels.
+ operation_id=f"contracts_{endpoint.__name__}_retrieve",
)
diff --git a/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py b/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py
index d2db3d6..e696bba 100644
--- a/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py
+++ b/src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py
@@ -296,6 +296,13 @@ async def endpoint(
# its concrete row model; mypy can't type a value used as a type param.
response_model=OrgAnalyticsResponse[spec.model], # type: ignore[name-defined]
name=endpoint.__name__,
+ # Named explicitly because this is what a generated client's method is
+ # called. FastAPI's default derives one from the function name *and*
+ # the whole path, which would make the TS method
+ # `contractUtilizationOrganizationsOrganizationIdContractUtilizationGet`
+ # and — worse — churn it whenever the path changes. The tag prefix is
+ # what keeps this distinct from the contract router's same-named panel.
+ operation_id=f"organizations_{endpoint.__name__}_retrieve",
)
diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py
index 59a4009..9309e74 100644
--- a/tests/test_lifespan.py
+++ b/tests/test_lifespan.py
@@ -67,8 +67,8 @@ async def failing_lifespan(_app: object):
yield # unreachable, but keeps this an async generator
fake_tenants = [
- Tenant("/ok", create_app=object, lifespan=ok_lifespan),
- Tenant("/broken", create_app=object, lifespan=failing_lifespan),
+ Tenant("ok", "/ok", create_app=object, lifespan=ok_lifespan),
+ Tenant("broken", "/broken", create_app=object, lifespan=failing_lifespan),
]
root_app = SimpleNamespace(
state=SimpleNamespace(tenant_apps={"/ok": object(), "/broken": object()})
diff --git a/tests/test_openapi_spec.py b/tests/test_openapi_spec.py
new file mode 100644
index 0000000..f0b43b7
--- /dev/null
+++ b/tests/test_openapi_spec.py
@@ -0,0 +1,101 @@
+"""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"
+ )
diff --git a/uv.lock b/uv.lock
index a9a4b1b..8fdd992 100644
--- a/uv.lock
+++ b/uv.lock
@@ -162,6 +162,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" },
]
+[[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.4"
@@ -343,6 +352,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" },
]
+[[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.139.0"
@@ -624,6 +657,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" },
]
+[[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.1.0"
@@ -702,14 +756,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]
@@ -734,14 +791,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]]
@@ -1126,6 +1186,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
+[[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"
@@ -1141,6 +1247,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.15.20"
@@ -1291,6 +1423,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/34/7a493b4a37cf73bbbc9108a23f9c9e0dd3eceb5e8c9d56eb030d558bc2cb/types_hvac-2.4.0.20260610-py3-none-any.whl", hash = "sha256:534137803ae181274834bd92c45b9f763c6df61cf4ce0e6f78b425cbd32d5134", size = 42400, upload-time = "2026-06-10T06:11:20.286Z" },
]
+[[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"