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..1a48bfb --- /dev/null +++ b/openapi/specs/b2b_dashboard.yaml @@ -0,0 +1,1611 @@ +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: + anyOf: + - type: integer + - type: 'null' + 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 in + + general; summing ``monthly_active_learners`` across contracts can exceed + + the org''s own figure. Activity totals, being sums of events, always 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 don''t get to skip that defense on the strength of "not + + adding up in general": two contracts that happen to share no learners *do* + + add up exactly, and a hidden one comes back from the visible sibling''s + + total the same as a hidden event sum would. Nothing here can tell that + + case from an overlapping one, so the org endpoint guards every cohort + + column — not just the additive totals — for any month it hides anything + + for (``CrossGrainAdditives.guarded_cohorts``), accepting the cost of + + blanking counts that overlap would have made safe to publish.' + 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: + anyOf: + - type: integer + - type: 'null' + 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.\n\n``monthly_active_learners``\ + \ is Optional even though it is the primary —\neverywhere else the primary\ + \ gates the row (below floor, the row is dropped\nwhole, never nulled) rather\ + \ than being nulled itself. The org grain is the\nexception: it is also this\ + \ endpoint's ``_FinerGrain.guarded_cohorts``\ntarget, so a month whose contract-level\ + \ breakdown hides anything gets its\norg-level ``monthly_active_learners``\ + \ blanked post hoc, after its own row\ngate already passed. See ``routers.organizations``\ + \ and\n``ContractMonthlyEngagementTrend``." + 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/core/anonymization.py b/src/ol_analytics_api/core/anonymization.py index bbe7d33..986fa98 100644 --- a/src/ol_analytics_api/core/anonymization.py +++ b/src/ol_analytics_api/core/anonymization.py @@ -17,15 +17,47 @@ floor is enforced per-column, driven by a `CohortPolicy` the row model declares: - the ``primary`` cohort gates the whole row (below floor -> row withheld), -- each ``secondary`` count is independently nulled when it is sub-floor, and +- each ``secondary`` count is independently nulled when it is sub-floor, +- each ``secondary`` count is *also* nulled when its COMPLEMENT within a cohort + containing it is sub-floor, and - each ``derived`` value is nulled whenever a cohort it is computed over is suppressed (else the hidden count is trivially back-computed from the rate, or read off directly as an average over k