Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
80c6d0a
Update dependency social-auth-app-django to v5.6.0 [SECURITY] (#3850)
renovate[bot] Aug 27, 2026
78d2fd0
Update dependency Django to v5.2.17 [SECURITY] (#3849)
renovate[bot] Aug 27, 2026
e10bb8e
staleness penalty for vector search results (#3834)
shanbady Aug 27, 2026
aeb8a6b
Show Stay Updated based only on the CMS page flag (#3841)
ChristopherChudzicki Aug 28, 2026
bf2da2e
Overridable Mutation Error Toast (#3837)
ChristopherChudzicki Aug 28, 2026
7ab92e2
ci: add a ci-gate job so one required check can cover the whole suite…
blarghmatey Aug 28, 2026
ff91243
feat(cohort-1): MicroMasters/MITx Online certificate warehouse-pull s…
blarghmatey Aug 28, 2026
d1522be
Update redis Docker tag to v8.10.0 (#2706)
renovate[bot] Aug 28, 2026
56557f5
Update dependency litellm to v1.96.2 (#3857)
renovate[bot] Aug 28, 2026
959ee89
Submit and link each resource under one URL, from learn_url (#3843)
ahtesham-quraish Aug 28, 2026
c7c0f81
Update dependency ruff to v0.16.3 (#3859)
renovate[bot] Aug 28, 2026
65ba190
Update dependency drf-spectacular to >=0.30,<0.31 (#3858)
renovate[bot] Aug 28, 2026
4a68caa
Remove remaining API endpoint N+1 queries (#3845)
mbertrand Aug 28, 2026
0152fcf
feat: show podcast episode transcripts from the podcast:transcript ta…
daniellefrappier18 Aug 31, 2026
ec8abc8
Use the green token instead of a hardcoded hex on product pages (#3847)
ChristopherChudzicki Aug 31, 2026
5e7efc4
fix: report the real status from handle_error instead of 405 (#3791)
blarghmatey Aug 31, 2026
9311a7e
feat(b2b): render the analytics dashboard scoped to a contract (#3773)
blarghmatey Aug 31, 2026
90d79d0
Update dependency tiktoken to >=0.13,<0.14 (#3860)
renovate[bot] Aug 31, 2026
0798e91
Per-product Stay Updated HubSpot form id (frontend) (#3844)
zamanafzal Aug 31, 2026
65f69de
adding initial fix (#3872)
shanbady Aug 31, 2026
653b15c
Release 0.78.1
odlbot Aug 31, 2026
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
12 changes: 12 additions & 0 deletions .github/instructions/frontend-tests.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,18 @@ expect(consoleError).toHaveBeenCalled()
// In rare cases, TestingErrorBoundary can be used to test thrown errors.
```

**Mutation failures and the global error toast:**

Every mutation failure raises a global error toast unless the call site opts out (`meta: SILENCE_ERROR_TOAST`), and an `afterEach` fails any test that leaves a toast unacknowledged.

```tsx
import { expectErrorToast } from "@/test-utils"
// After driving a mutation failure whose intended error surface is the toast:
await expectErrorToast("Something went wrong")
// If the component shows its own inline error instead, don't acknowledge —
// opt the component out of the toast with `meta: SILENCE_ERROR_TOAST`.
```

## Troubleshooting

- **"No response specified"** → Mock the API call with `setMockResponse`
Expand Down
8 changes: 8 additions & 0 deletions .github/instructions/frontend.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@ applyTo: "**/*.ts,**/*.tsx,**/package.json"
- `api` contains generated API client code and react-query hooks
- For reusable UI, use components from `@mitodl/smoot-design`, `ol-components` preferentially
- Within `main`, use `@/` for root-relative imports

## Mutation error handling

Every mutation failure in the browser shows a global error toast by default (`MutationCache.onError` in `main/src/app/getQueryClient.ts`), so failures are never silent. Tune it per call site via React Query `meta` (typed in `api/mutation-meta`):

- Component renders its own inline error for the failure → pass `meta: SILENCE_ERROR_TOAST` to the mutation hook, or the user sees a double alert. Required even if you catch the `mutateAsync` rejection yourself — catching does not suppress the toast.
- Custom toast copy → `meta: { errorMessage: "Could not save your changes." }`, or `getErrorMessage(error, variables)` for data-driven copy.
- Shared `api/` hooks accept `{ meta }` (`MutationHookOptions`) and forward it; the opt-out belongs at the _consumer_, never baked into a shared hook (many hooks are surfaced in one place and silent in another).
61 changes: 60 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- 5432:5432

redis:
image: redis:8.2.2
image: redis:8.10.0@sha256:344e3945a0b431c8ff1eecd58c5573538126bd756f02fc7e218ddf1fc2546366
ports:
- 6379:6379

Expand Down Expand Up @@ -272,3 +272,62 @@ jobs:
run: |
diff $GENERATOR_OUTPUT_DIR_CI $GENERATOR_OUTPUT_DIR_VC \
|| { echo "OpenAPI spec is out of date. Please regenerate via ./scripts/generate_openapi.sh"; exit 1; }

# ONE required status check standing for this whole workflow.
#
# GitHub's required status checks take an exact list of context names -- no
# wildcards, no "all checks must pass" option -- so requiring these six jobs
# directly means restating all six names in mitodl/ol-infrastructure, where the
# ruleset lives. That list then goes stale in two ways:
#
# A renamed job keeps being required under its old name, which GitHub will never
# report again, so every PR waits forever on it. mitxonline hit exactly this when
# `python-tests` became a 4-way matrix and the checks turned into
# `python-tests (1)`..`(4)`.
#
# A newly added job is not required until somebody remembers to go and add it, so
# new CI silently cannot block a merge.
#
# Requiring `ci-gate` instead keeps the list of what must pass in the same file as
# the jobs it names, so adding or renaming a job is one edit in one repo.
#
# `openapi-diff` is absent from `needs` because `needs` can only reference jobs in
# this same workflow file, and `openapi-diff` runs in its own workflow (triggered on
# `pull_request` rather than `push`). It now has an `--err-ignore` allowlist
# (openapi/oasdiff-err-ignore.txt) for intentional breaking changes, so it
# should be required directly, alongside `ci-gate`, in the ol-infrastructure ruleset
# -- its job name is stable and isn't subject to the renaming/matrix drift `ci-gate`
# exists to solve.
ci-gate:
needs:
- python-tests
- javascript-tests
- build-nextjs-container
- build-storybook
- openapi-generated-client-check-v0
- openapi-generated-client-check-v1
# Without this the gate is skipped when a dependency fails, and a skipped required
# check leaves the PR pending rather than failing it.
if: always()
runs-on: ubuntu-24.04
permissions: {}
steps:
- name: Evaluate CI result
env:
NEEDS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"'
# Read from `needs` rather than naming each job again: adding a job to the
# list above is then the only edit, which is the entire point of this job.
# `skipped` passes so that a job legitimately gated behind an `if:` does not
# wedge the branch; `failure` and `cancelled` do not.
failed=$(echo "$NEEDS" | jq -r '
to_entries[]
| select(.value.result != "success" and .value.result != "skipped")
| .key')
if [ -n "$failed" ]; then
echo "::error::CI did not succeed: $(echo "$failed" | paste -sd, -)"
exit 1
fi
echo "All CI jobs succeeded."
1 change: 1 addition & 0 deletions .github/workflows/openapi-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ jobs:
--volume ${{ github.workspace }}:${{ github.workspace }}:ro \
-e GITHUB_WORKSPACE=${{ github.workspace }} \
tufin/oasdiff breaking \
--err-ignore head/openapi/oasdiff-err-ignore.txt \
--fail-on ERR \
--format githubactions \
--composed --flatten-allof \
Expand Down
24 changes: 24 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
Release Notes
=============

Version 0.78.1
--------------

- adding initial fix (#3872)
- Per-product Stay Updated HubSpot form id (frontend) (#3844)
- Update dependency tiktoken to >=0.13,<0.14 (#3860)
- feat(b2b): render the analytics dashboard scoped to a contract (#3773)
- fix: report the real status from handle_error instead of 405 (#3791)
- Use the green token instead of a hardcoded hex on product pages (#3847)
- feat: show podcast episode transcripts from the podcast:transcript tag (#3821)
- Remove remaining API endpoint N+1 queries (#3845)
- Update dependency drf-spectacular to >=0.30,<0.31 (#3858)
- Update dependency ruff to v0.16.3 (#3859)
- Submit and link each resource under one URL, from learn_url (#3843)
- Update dependency litellm to v1.96.2 (#3857)
- Update redis Docker tag to v8.10.0 (#2706)
- feat(cohort-1): MicroMasters/MITx Online certificate warehouse-pull sync (#3808)
- ci: add a ci-gate job so one required check can cover the whole suite (#3825)
- Overridable Mutation Error Toast (#3837)
- Show Stay Updated based only on the CMS page flag (#3841)
- staleness penalty for vector search results (#3834)
- Update dependency Django to v5.2.17 [SECURITY] (#3849)
- Update dependency social-auth-app-django to v5.6.0 [SECURITY] (#3850)

Version 0.77.15 (Released August 27, 2026)
---------------

Expand Down
11 changes: 11 additions & 0 deletions channels/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,21 @@ def with_detail_relations(self) -> "ChannelQuerySet":
"sub_channels__channel",
queryset=Channel.objects.annotate_channel_url(),
),
# LearningResourceOfferor.channel_url reads
# channel_unit_details.first(); ordering the prefetch by pk
# lets that resolve from the cache and pick the same row it
# would have queried for.
Prefetch(
"unit_detail__unit__channel_unit_details",
queryset=ChannelUnitDetail.objects.select_related(
"channel"
).order_by("pk"),
),
)
.annotate_channel_url()
.select_related(
"featured_list",
"unit_detail__unit",
"topic_detail",
"department_detail",
"unit_detail",
Expand Down
50 changes: 44 additions & 6 deletions channels/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
from django.urls import reverse

from channels.constants import ChannelType
from channels.factories import ChannelFactory, ChannelListFactory, SubChannelFactory
from channels.factories import (
ChannelFactory,
ChannelListFactory,
ChannelUnitDetailFactory,
SubChannelFactory,
)
from channels.models import Channel
from channels.serializers import ChannelSerializer
from learning_resources.factories import LearningResourceFactory
Expand All @@ -16,7 +21,6 @@
pytestmark = pytest.mark.django_db


@pytest.mark.skip_nplusone_check
def test_list_channels(user_client):
"""Test that all published channels are returned."""
ChannelFactory.create_batch(2, published=False)
Expand All @@ -33,6 +37,44 @@ def test_list_channels(user_client):
assert response_channels[idx] == ChannelSerializer(instance=channel).data


@pytest.mark.parametrize("channel_count", [2, 6])
def test_list_unit_channels_query_count(
client, django_assert_num_queries, channel_count
):
"""Unit channels cost the same number of queries however many are listed.

unit_detail.unit is nested by the serializer and its channel_url is a
cached_property, so without the prefetch each row costs two extra queries.
"""
channels = ChannelFactory.create_batch(channel_count, is_unit=True)
# An offeror shared with later, unpublished channels: channel_url has to
# resolve to the same row the serializer would pick on its own, so the
# prefetch behind it can't be an unordered subquery.
for _ in range(3):
extra = ChannelFactory.create(
is_unit=True, published=False, create_unit_detail=False
)
ChannelUnitDetailFactory.create(
channel=extra, unit=channels[0].unit_detail.unit
)

url = reverse("channels:v0:channels_api-list")
with django_assert_num_queries(5):
results = client.get(url).json()["results"]

assert len(results) == channel_count
assert all(item["unit_detail"]["unit"]["channel_url"] for item in results)
# The listing must agree with serializing each instance directly.
for channel in channels:
listed = next(item for item in results if item["id"] == channel.id)
assert (
listed["unit_detail"]["unit"]["channel_url"]
== ChannelSerializer(instance=channel).data["unit_detail"]["unit"][
"channel_url"
]
)


def test_channel_detail_has_no_is_moderator(client):
"""Channel detail no longer exposes moderator-specific fields."""
channel = ChannelFactory.create()
Expand Down Expand Up @@ -153,7 +195,6 @@ def test_no_excess_list_queries(client, user, django_assert_num_queries, channel
assert channel["channel_url"] is not None


@pytest.mark.skip_nplusone_check
def test_channel_counts_view(client):
"""Channel counts should return per-channel resource counts."""
url = reverse(
Expand Down Expand Up @@ -194,7 +235,6 @@ def enabled_view_cache(settings, request):
}


@pytest.mark.skip_nplusone_check
@pytest.mark.usefixtures("enabled_view_cache")
def test_channel_detail_cache_is_global(client):
"""Cached detail responses are shared globally across users."""
Expand All @@ -210,7 +250,6 @@ def test_channel_detail_cache_is_global(client):
assert client.get(url).json()["title"] == "Original title"


@pytest.mark.skip_nplusone_check
@pytest.mark.usefixtures("enabled_view_cache")
def test_channel_by_type_name_cache_is_global(client):
"""Cached by-type-name responses are shared globally across users."""
Expand All @@ -229,7 +268,6 @@ def test_channel_by_type_name_cache_is_global(client):
assert client.get(url).json()["title"] == "Original title"


@pytest.mark.skip_nplusone_check
@pytest.mark.usefixtures("enabled_view_cache")
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_channel_counts_view_is_cached(client, is_authenticated):
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ services:
redis:
profiles:
- backend
image: redis:8.2.2
image: redis:8.10.0@sha256:344e3945a0b431c8ff1eecd58c5573538126bd756f02fc7e218ddf1fc2546366
healthcheck:
test: ["CMD", "redis-cli", "ping", "|", "grep", "PONG"]
interval: 3s
Expand Down
5 changes: 1 addition & 4 deletions drf_lint_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,5 @@
"channels/serializers.py:132:24:ORM002",
"channels/serializers.py:134:24:ORM002",
"channels/serializers.py:136:24:ORM002",
"profiles/serializers.py:136:31:ORM002",
"profiles/serializers.py:196:16:ORM002",
"profiles/serializers.py:446:15:ORM001",
"profiles/serializers.py:447:27:ORM001"
"profiles/serializers.py:205:16:ORM002"
]
1 change: 0 additions & 1 deletion env/codespaces.env
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,5 @@ NEXT_PUBLIC_ORIGIN=${MITOL_APP_BASE_URL}
NEXT_PUBLIC_MITOL_API_BASE_URL=${MITOL_API_BASE_URL}
NEXT_PUBLIC_CSRF_COOKIE_NAME=${CSRF_COOKIE_NAME}
NEXT_PUBLIC_MITOL_SUPPORT_EMAIL=${MITOL_SUPPORT_EMAIL}
NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID=${STAY_UPDATED_HUBSPOT_FORM_ID}
NEXT_PUBLIC_SITE_NAME="MIT Learn"
NEXT_PUBLIC_MITOL_AXIOS_WITH_CREDENTIALS=true
1 change: 0 additions & 1 deletion env/frontend.env
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ NEXT_PUBLIC_POSTHOG_API_KEY=${POSTHOG_PROJECT_API_KEY}
NEXT_PUBLIC_POSTHOG_UI_HOST=${POSTHOG_UI_HOST}

NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY}
NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID=${STAY_UPDATED_HUBSPOT_FORM_ID}

NEXT_PUBLIC_SITE_NAME="MIT Learn"
NEXT_PUBLIC_MITOL_AXIOS_WITH_CREDENTIALS=true
Expand Down
2 changes: 0 additions & 2 deletions env/frontend.local.example.env
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID=""

# Optional local GTM overrides (server-side runtime)
# GTM_TRACKING_ID=
# GTM_AUTH=
Expand Down
3 changes: 2 additions & 1 deletion frontends/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"./test-utils/mockAxios": "./src/test-utils/mockAxios.ts",
"./test-utils": "./src/test-utils/index.ts",
"./mitxonline-hooks/*": "./src/mitxonline/hooks/*/index.ts",
"./mutation-meta": "./src/mutations/mutationMeta.ts",
"./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts",
"./analytics-hooks/*": "./src/analytics/hooks/*/index.ts",
"./analytics-types": "./src/analytics/types.ts",
Expand All @@ -35,7 +36,7 @@
},
"dependencies": {
"@mitodl/mit-learn-api-axios": "2026.8.17",
"@mitodl/mitxonline-api-axios": "2026.8.18",
"@mitodl/mitxonline-api-axios": "2026.8.31",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.12.2",
"tiny-invariant": "^1.3.3"
Expand Down
Loading
Loading