Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions .github/workflows/openapi-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ jobs:
'head/openapi/specs/*.yaml' \
| head -1)
echo "summary=$SUMMARY" >> $GITHUB_OUTPUT
{
echo "## OpenAPI Changes"
echo ""
echo "${SUMMARY:-No detectable change.}"
echo ""
echo "[View full changelog](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
echo ""
echo "Unexpected changes? Ensure your branch is up-to-date with \`main\` (consider rebasing)."
} > comment_body.md
- name: Find existing comment
id: find_comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4
Expand All @@ -44,14 +53,7 @@ jobs:
repository: ${{ github.repository }}
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find_comment.outputs.comment-id }}
body: |
## OpenAPI Changes

${{ steps.oasdif_changelog.outputs.summary || 'No detectable change.' }}

[View full changelog](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})

Unexpected changes? Ensure your branch is up-to-date with `main` (consider rebasing).
body-path: comment_body.md
- name: Check for breaking changes
id: oasdif_breaking
run: |
Expand Down
15 changes: 15 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
Release Notes
=============

Version 0.76.1
--------------

- test: pin course-run end dates so session options stay unambiguous (#3697)
- Readd dropped GTM calls (#3696)
- feat(b2b): org analytics dashboard for the manager surface (#3679)
- Fix flaky test: deterministically sort choices (#3689)
- Defer AI library imports to reduce process startup cost (#3683)
- add ocw topic to search (#3692)
- Identify posthog persons by keycloak global_id, not django pk (#3693)
- Fix oasdiff comment for large diffs (#3694)
- fix garbled non-Latin characters in certificate PDFs (#3685)
- fix: Show error message when a podcast episode fails to play (#3687)
- Strip trailing slash from channel_url (#3691)

Version 0.76.0 (Released July 29, 2026)
--------------

Expand Down
3 changes: 1 addition & 2 deletions channels/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ def annotate_channel_url(self):
"channel_type",
models.Value("/"),
"name",
models.Value("/"),
),
),
default=None,
Expand Down Expand Up @@ -163,7 +162,7 @@ def __str__(self):
def channel_url(self) -> str | None:
"""Return the channel url"""
if self.published:
return frontend_absolute_url(f"/c/{self.channel_type}/{self.name}/")
return frontend_absolute_url(f"/c/{self.channel_type}/{self.name}")
return None

@property
Expand Down
2 changes: 1 addition & 1 deletion channels/models_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def test_channel_url_for_departments(published, channel_type, detail_factory):
if published:
assert (
urlparse(channel.channel_url).path
== f"/c/{channel_type.name}/{channel.name}/"
== f"/c/{channel_type.name}/{channel.name}"
)
else:
assert channel.channel_url is None
Expand Down
2 changes: 1 addition & 1 deletion channels/serializers_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def test_serialize_channel( # pylint: disable=too-many-arguments
"created_on": mocker.ANY,
"id": channel.id,
"channel_url": frontend_absolute_url(
f"/c/{channel.channel_type}/{channel.name}/"
f"/c/{channel.channel_type}/{channel.name}"
),
"lists": [
LearningPathPreviewSerializer(channel_list.channel_list).data
Expand Down
7 changes: 7 additions & 0 deletions env/frontend.env
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ NEXT_PUBLIC_LEARN_AI_CSRF_COOKIE_NAME=csrftoken
NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT=https://api.rc.learn.mit.edu/ai/http/recommendation_agent/
NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT=https://api.rc.learn.mit.edu/ai/http/syllabus_agent/

# OL Analytics API (ol-analytics-api). Auth is cookie-based via APISIX — the
# frontend attaches no token. Leave unset when there is no analytics API to talk
# to: the org analytics dashboard then reports itself unavailable rather than
# loading data. Access to the route itself is gated by the
# b2b-analytics-dashboard PostHog flag, not by this variable.
NEXT_PUBLIC_ANALYTICS_API_BASE_URL=${ANALYTICS_API_BASE_URL}

NEXT_PUBLIC_MITX_ONLINE_BASE_URL=${MITX_ONLINE_BASE_URL}
NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL=http://mitxonline.odl.local:8065/
NEXT_PUBLIC_VERSION="local-dev"
Expand Down
9 changes: 9 additions & 0 deletions env/frontend.local.example.env
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,12 @@ NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID=""
# in the README. Requires learn-ai running locally.
# NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT=http://open.odl.local:8065/ai/http/recommendation_agent/
# NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT=http://open.odl.local:8065/ai/http/syllabus_agent/

# ol-analytics-api integration: supplies the data for the B2B org analytics
# dashboard at /dashboard/organization/<org>/analytics. Whether that route is
# reachable at all is controlled by the b2b-analytics-dashboard PostHog flag,
# not by this variable: with the flag on and this unset the page still renders,
# reporting "Analytics is not available in this environment".
# Point at the APISIX route rather than the API directly — the API expects the
# JWT that APISIX mints from the session cookie.
# NEXT_PUBLIC_ANALYTICS_API_BASE_URL=http://open.odl.local:8065/analytics/
5 changes: 4 additions & 1 deletion frontends/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
"./test-utils/mockAxios": "./src/test-utils/mockAxios.ts",
"./test-utils": "./src/test-utils/index.ts",
"./mitxonline-hooks/*": "./src/mitxonline/hooks/*/index.ts",
"./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts"
"./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts",
"./analytics-hooks/*": "./src/analytics/hooks/*/index.ts",
"./analytics-types": "./src/analytics/types.ts",
"./analytics-test-utils": "./src/analytics/test-utils/index.ts"
},
"peerDependencies": {
"react": "^19.2.1"
Expand Down
14 changes: 14 additions & 0 deletions frontends/api/src/analytics/axios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createConfigurableAxios } from "../configurableAxios"

/**
* Axios instance for the OL Analytics API (`ol-analytics-api`).
*
* Auth is entirely cookie-based: the analytics API sits behind APISIX, which
* resolves the session cookie into the JWT the API expects. The browser sends
* the cookie because of `withCredentials`, so nothing here attaches a token.
*/
export const analyticsAxiosClient = createConfigurableAxios(
"mit-learn.api.axios.analytics",
)

export default analyticsAxiosClient.instance
107 changes: 107 additions & 0 deletions frontends/api/src/analytics/clients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { AxiosResponse } from "axios"
import axiosInstance from "./axios"
import type {
AnalyticsPageParams,
ContentEngagementDepth,
ContractUtilization,
EnrollmentCompletionFunnel,
MonthlyEngagementTrend,
OrgAnalyticsResponse,
ProgramFunnel,
} from "./types"

/**
* The b2b_dashboard tenant is mounted at this path by `ol-analytics-api`'s
* root ASGI app (`main.py`'s tenant registry). The configured axios `baseURL`
* is the API host, so every path here is relative to it.
*/
const B2B_DASHBOARD_ROOT = "/api/v1/analytics"

/**
* `organizationId` is the **Keycloak organization UUID** (`sso_organization_id`),
* not the org slug and not MITx Online's numeric org id. It is the only
* identifier that is stable across the JWT, MITx Online and StarRocks, so the
* analytics API keys every org endpoint on it — see mitodl/ol-analytics-api#13.
*/
const orgRoot = (organizationId: string) =>
`${B2B_DASHBOARD_ROOT}/organizations/${encodeURIComponent(organizationId)}`

const getOrgResource = <RowT>(
organizationId: string,
resource: string,
page: AnalyticsPageParams | undefined,
signal: AbortSignal | undefined,
): Promise<AxiosResponse<OrgAnalyticsResponse<RowT>>> =>
axiosInstance.get<OrgAnalyticsResponse<RowT>>(
`${orgRoot(organizationId)}/${resource}`,
{ params: page, signal },
)

/**
* One method per org-scoped endpoint of the analytics API's b2b_dashboard
* tenant. Thin by design — paging and the response envelope are the only
* shared concerns, and both live in `getOrgResource`.
*/
const analyticsOrganizationsApi = {
contractUtilization: (
organizationId: string,
page?: AnalyticsPageParams,
signal?: AbortSignal,
) =>
getOrgResource<ContractUtilization>(
organizationId,
"contract-utilization",
page,
signal,
),

enrollmentFunnel: (
organizationId: string,
page?: AnalyticsPageParams,
signal?: AbortSignal,
) =>
getOrgResource<EnrollmentCompletionFunnel>(
organizationId,
"enrollment-funnel",
page,
signal,
),

engagementTrend: (
organizationId: string,
page?: AnalyticsPageParams,
signal?: AbortSignal,
) =>
getOrgResource<MonthlyEngagementTrend>(
organizationId,
"engagement-trend",
page,
signal,
),

programFunnel: (
organizationId: string,
page?: AnalyticsPageParams,
signal?: AbortSignal,
) =>
getOrgResource<ProgramFunnel>(
organizationId,
"program-funnel",
page,
signal,
),

contentEngagement: (
organizationId: string,
page?: AnalyticsPageParams,
signal?: AbortSignal,
) =>
getOrgResource<ContentEngagementDepth>(
organizationId,
"content-engagement",
page,
signal,
),
}

export { analyticsOrganizationsApi, B2B_DASHBOARD_ROOT }
14 changes: 14 additions & 0 deletions frontends/api/src/analytics/hooks/organizations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export {
analyticsOrganizationQueries,
analyticsOrganizationKeys,
} from "./queries"

export type {
AnalyticsPageParams,
ContentEngagementDepth,
ContractUtilization,
EnrollmentCompletionFunnel,
MonthlyEngagementTrend,
OrgAnalyticsResponse,
ProgramFunnel,
} from "../../types"
117 changes: 117 additions & 0 deletions frontends/api/src/analytics/hooks/organizations/queries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { renderHook, waitFor } from "@testing-library/react"
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import { setupReactQueryTest } from "../../../hooks/test-utils"
import { setMockResponse, makeRequest } from "../../../test-utils"
import { factories, urls } from "../../test-utils"
import { analyticsOrganizationQueries } from "./queries"

const ORG_UUID = "3fa85f64-5717-4562-b3fc-2c963f66afa6"

/**
* Each factory returns options for its own row type, so a `test.each` table of
* them is a union that `useQuery` cannot resolve to a single overload. The
* table only needs "does this options object request that URL", so erase the
* row type here rather than splitting the table into five near-identical tests.
*/
type AnyQueryOptions = UseQueryOptions<
unknown,
Error,
unknown,
readonly unknown[]
>
const erase = (options: unknown) => options as AnyQueryOptions

describe("analyticsOrganizationQueries", () => {
test.each([
{
name: "contractUtilization",
query: () =>
erase(analyticsOrganizationQueries.contractUtilization(ORG_UUID)),
url: urls.organizations.contractUtilization(ORG_UUID),
response: factories.envelope([factories.contractUtilization()]),
},
{
name: "enrollmentFunnel",
query: () =>
erase(analyticsOrganizationQueries.enrollmentFunnel(ORG_UUID)),
url: urls.organizations.enrollmentFunnel(ORG_UUID),
response: factories.envelope([factories.enrollmentCompletionFunnel()]),
},
{
name: "engagementTrend",
query: () =>
erase(analyticsOrganizationQueries.engagementTrend(ORG_UUID)),
url: urls.organizations.engagementTrend(ORG_UUID),
response: factories.envelope([factories.monthlyEngagementTrend()]),
},
{
name: "programFunnel",
query: () => erase(analyticsOrganizationQueries.programFunnel(ORG_UUID)),
url: urls.organizations.programFunnel(ORG_UUID),
response: factories.envelope([factories.programFunnel()]),
},
{
name: "contentEngagement",
query: () =>
erase(analyticsOrganizationQueries.contentEngagement(ORG_UUID)),
url: urls.organizations.contentEngagement(ORG_UUID),
response: factories.envelope([factories.contentEngagementDepth()]),
},
])("$name requests its endpoint and returns the envelope", async (spec) => {
setMockResponse.get(spec.url, spec.response)
const { wrapper } = setupReactQueryTest()

const { result } = renderHook(() => useQuery(spec.query()), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(spec.response)
expect(makeRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: "get", url: spec.url }),
)
})

test("paging params reach the request and change the query key", async () => {
const page = { limit: 200, offset: 0 }
const url = urls.organizations.contractUtilization(ORG_UUID, page)
const response = factories.envelope([factories.contractUtilization()])
setMockResponse.get(url, response)
const { wrapper } = setupReactQueryTest()

const { result } = renderHook(
() =>
useQuery(
analyticsOrganizationQueries.contractUtilization(ORG_UUID, page),
),
{ wrapper },
)

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(makeRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: "get", url }),
)
expect(
analyticsOrganizationQueries.contractUtilization(ORG_UUID, page).queryKey,
).not.toEqual(
analyticsOrganizationQueries.contractUtilization(ORG_UUID).queryKey,
)
})

test("query keys are namespaced per org so one org cannot read another's cache", () => {
const [namespace, resource, orgId] =
analyticsOrganizationQueries.contractUtilization(ORG_UUID).queryKey
expect([namespace, resource, orgId]).toEqual([
"analytics",
"organizations",
ORG_UUID,
])
expect(
analyticsOrganizationQueries.contractUtilization("other").queryKey,
).not.toEqual(
analyticsOrganizationQueries.contractUtilization(ORG_UUID).queryKey,
)
})

test("the org id is url-encoded rather than spliced into the path raw", () => {
expect(urls.organizations.contractUtilization("a/b")).toContain("a%2Fb")
})
})
Loading
Loading