Skip to content
Closed
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
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
17 changes: 17 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
Release Notes
=============

Version 0.78.0
--------------

- 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: 1 addition & 0 deletions 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 Down
Loading
Loading