Skip to content

Release 0.80.2 - #3932

Merged
odlbot merged 10 commits into
releasefrom
release-candidate
Sep 14, 2026
Merged

odlbot merged 10 commits into
releasefrom
release-candidate

Conversation

@odlbot

@odlbot odlbot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Carey P Gumaer

Ahtisham Shahid

Tobias Macey

Ahtesham Quraish

renovate[bot]

Matt Bertrand

Peter Pinch

pdpinch and others added 10 commits September 10, 2026 08:25
…3924)

The Institute has strict guidelines around the use of the MIT name
* Build internal resource links from learn_url

Eleven components built links to related resources from (id, parent, title),
slugifying each title locally: the podcast and video listings, the "more from
this playlist" and "up next" rails, the series navigation, and the drawer's
call-to-action. They now read the resource's learn_url, so the backend is the
only thing that decides how a URL is spelled.

Where a link is scoped by context rather than by the resource — a video in the
playlist you are browsing, an episode under the podcast you arrived from — only
the slug comes from learn_url and the parent segment stays as given. Podcasts
and playlists have no such parent and use learn_url whole. Share URLs use it
whole too, so sharing hands out the URL that owns the content rather than the
context it was found in.

The call-to-action keeps its membership guards: a video outside any playlist, or
an episode with no podcast, still falls through to the source URL rather than to
a Learn page.

`PodcastEpisodeParentSerializer` gains learn_url. The episode page names its
series in a breadcrumb and in its PodcastEpisode JSON-LD, and the embedded
parent carried only an id, so `partOfSeries.url` would otherwise have to name a
URL that redirects. `parent` is already select_related by the `_podcasts`
prefetch, so this costs no extra query.

With those callers converted, pathSlug and the four title-based path builders
are unused and removed. slugify remains only for the drawer's cosmetic
`resource_title`, which the backend cannot supply per-resource: a resource with
a dedicated page has no resource_title in its learn_url.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Document the slug contract these path helpers actually have

The block described a `title` argument that no helper takes, and attributed the
"resource" fallback to the frontend when the backend owns it. State what the
helpers do: take an already-derived slug, emit the bare redirecting path when
it is undefined, and in generateVideoPlaylistPath's case return that bare form
unconditionally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Assert the episode row hrefs on the podcast page

The fixture set each episode's parent ids but left its own learn_url
drawer-shaped, so learnUrlSlug yielded "search" and every row rendered
/podcast/<id>/podcast_episode/<id>/search. Nothing asserted the href, so the
malformed link was invisible.

Give episodes a dedicated-page learn_url, and pin the hrefs in the list test so
a drawer-shaped fixture cannot quietly return.

Add a case where the episode's canonical learn_url names a different parent —
an episode in several podcasts is viewable under any of them — proving the row
keeps the podcast being viewed as context and borrows only the slug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Assert the "More from" row hrefs on the episode page

The section's tests only checked that titles rendered, and the factory left each
episode's learn_url drawer-shaped, so learnUrlSlug yielded "search" and every
row rendered /podcast/<context>/podcast_episode/<id>/search undetected.

Give factory episodes a dedicated-page learn_url scoped to a deliberately
different canonical parent, so every row exercises the case an episode in
several podcasts presents. Assert the hrefs by role: the context segment is the
podcast being viewed, the slug comes from learn_url.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Assert the remaining context-plus-slug hrefs

Two more rails read their slug from learn_url while keeping their own context,
and neither had an href pinned: the latest-episodes list on /podcasts, and the
"more from this playlist" rows on the video page. Both fixtures kept the
factory's drawer-shaped learn_url, so each row rendered a "/search" slug with
no test failing.

Scope both fixtures' learn_url to a deliberately different canonical parent, so
every row exercises the multi-parent case, and assert the hrefs by role.

In VideoDetailPage the fixture default is applied only when a caller has not
overridden learn_url, so the share-URL test still names its own value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop justifying learn_url by naming the UI that reads it

A fixture that explains a field by pointing at a breadcrumb and a JSON-LD block
goes stale the moment either changes, and nobody returns to a fixture comment to
notice. Drop it from both factories.

The serializer docstring loses the same sentence but keeps what a reader cannot
infer from the code: why the parent list is empty, and that `parent` is already
select_related so the field costs no query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Return a resource's URL slug as a top-level API field

Consumers that build a resource page path need the slug segment, and the
only way to get it today is to parse it back out of learn_url. Serve it
directly as url_slug.

* Consume url_slug; keep context for UI and share, canonical for crawlers

Replaces the fifteen `learnUrlSlug(learn_url)` call sites with the
resource's `url_slug` and drops both `learnUrlSlug` and `learnUrlPath`:
Next treats an absolute same-origin href the same as a path, and the
redirect pages now build their canonical from the slug directly.

Splits the three uses of a resource URL the way the review asks:

- HTML UI (breadcrumbs, carousels, "view all") keeps parent context
- Share keeps parent context too, as `main` did — a video shared from
  the xTalk playlist should not land on a different series
- JSON-LD is canonical throughout. `buildPodcastEpisodeStructuredData`
  now takes the canonical parent from the episode itself, so `url` and
  `partOfSeries` can no longer name two different podcasts.

* Pass a playlist id as the string the API declares

`VideoResource.playlists` is Array<string>, unlike an episode's
`podcasts`, which is Array<number>.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Build the episode share URL with absoluteUrl

Matches the two video pages and drops the file's only use of env().

* Let the podcast fixture keep the factory's learn_url

The href assertion compares against the fixture's own value, so a
hand-written URL asserted nothing. The factory's drawer-shaped default
makes it fail if the component builds the path instead of using learn_url.

* Compare both sides of a canonical redirect with the same builder

The incoming path was written out by hand at all four [slug] pages, so it
repeated what the builder already knows and could drift from it. It also
escaped the id segment differently: the builder runs ids through
generatePath, the hand-written copy did not.

videoDetailPath now takes a string id and playlist so the video page can
pass its route params straight in.

* Drop a String() on a value already typed string

podcastEpisodePath takes both ids as strings, unlike the builders that
accept number | string and need the conversion.

---------

Co-authored-by: Ahtesham Quraish <ahtesham.quraish@arbisoft.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ahtesham Quraish <ahtesham.quraish@192.168.1.215>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
* feat(ovs): sanitize and render rich-text descriptions from OVS

OVS is gaining a rich-text editor for video and collection descriptions
(mitodl/hq#13064, mitodl/odl-video-service#1585). Two things follow for Learn.

First, and independent of that work: the OVS ETL copied `description`
straight through, and six components render it with dangerouslySetInnerHTML.
Descriptions are plain text today so nothing is exploitable, but the moment
OVS allows formatting this is live HTML from a source we do not control.
`clean_description` runs both the video and the collection description through
the same allowlist the podcast ETL uses for show notes - links kept, because
links are the point of the feature.

Second, the display side assumed plain text in places:

- the series header interpolated `playlist.description`, so an author's
  formatting would have reached the learner as visible tags.
- VideoCard, SeriesVideoList and MoreFromPlaylist inject the description
  inside a Link, where an author's <a> is a nested anchor - invalid HTML that
  browsers resolve by splitting the row's own link. They now use the existing
  stripAnchorTags helper, keeping the words.
- descriptions had no styling for lists, links or emphasis. A shared
  richTextDescription rule set covers the full-width surfaces; the clamped
  previews flatten lists inline so a list cannot blow the box out.
- the schema.org VideoObject description carried raw markup. It is stripped
  locally rather than with common/htmlToPlainText, which is documented
  server-only (it pulls in isomorphic-dompurify) and this module is imported
  by a "use client" component.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* chore: prettier formatting

* fix(ovs): strip tags to a fixed point in the JSON-LD description

CodeQL, correctly: a single-pass replace(/<[^>]*>/g, "") is not idempotent -
removing one match can join its neighbours into a new tag, so <scr<div>ipt>
survives as <script>. This value lands inside a
<script type="application/ld+json"> block.

Not exploitable as it stood, because the injection point escapes </ - but a
helper that claims to return plain text should not lean on its caller for that,
and residual markup in schema.org data is wrong regardless.

Now loops to a fixed point, decodes entities before dropping any surviving
angle bracket so an entity cannot smuggle one back in.

* fix(ovs): render rich descriptions in valid containers

Review feedback from Copilot on #3879, all of it correct.

Every description surface was a Typography, and this theme's variantMapping
sends body1/body2 to <p> (smoot-design typography.ts) - so injecting <p>/<ul>
put block elements inside a <p>. That is invalid markup, browsers reparent it,
and it showed up as a real SSR hydration tree mismatch in the browser: React
reported <p> containing <p> and <ul>. The CSS flattening only changed
presentation, not validity.

All five now take component="div", typed with Pick<TypographyProps, "component">
following the pattern PodcastPage/EpisodeContentTabs.tsx already established for
its own sanitized descriptions: DescriptionText, MoreFromItemMeta,
PageDescription, EpisodeDescription, CardMetaValue. Verified in a browser - the
tree mismatch is gone; the only hydration warning left is Grammarly writing
data-gr-ext-installed onto <body>.

Two more findings from the same review:

- FeaturedVideo was a fourth description surface I missed entirely. It rendered
  into a literal styled.p with a two-line clamp. Now a div with the same inline
  flattening as the other previews. Anchors are kept here, unlike the card and
  episode rows: this description is a sibling of the title link rather than
  inside it, so a link is valid and clickable.

- the JSON-LD stripper only treated p/li/ul/ol/blockquote as boundaries, but the
  backend allowlist also keeps div, pre, caption, center, q and hr. So
  <div>First</div><div>Second</div> came out as "FirstSecond". All of them are
  boundaries now, with a regression case per tag.

Adds videoStructuredData.test.ts (16 cases): the per-tag boundaries above plus
the tag-reconstitution inputs behind the earlier CodeQL fix.

* fix(ovs): complete the container fix CI caught

Three typecheck errors on the previous commit, all mine.

Two files used Pick<TypographyProps, "component"> without importing the type.
Folded into the existing ol-components import rather than a second one, which
import/no-duplicates rejects.

The third was more than a missing import: VideoSeriesDetailPage renders
Styled.DescriptionText from its own VideoSeriesDetailPage.styled, not from
VideoDetailPage.styled. So the series page had a second, untyped DescriptionText
that also never received the rich-text rules - lists and links on that page were
unstyled, which the earlier commit missed entirely. It now takes the component
generic and the shared rules.

richTextDescription accordingly moves to shared.styled, which both page styled
modules and VideoPageHeader already import, instead of one page's styled module
importing another's.

Local typecheck had hidden all of this: it aborts in the api workspace on a
stale generated mitxonline client, so it never reached frontends/main. Checked
with `yarn workspace main exec tsc --noEmit` this time - no errors in these
files. lint-check and fmt-check clean.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
…TAIL rows (#3915)

* fix(sentry): cap request bodies at 1KB and scrub Postgres DETAIL rows

Two ways learner data reaches Sentry, neither gated by send_default_pii.

Request bodies. The SDK sets request.data unconditionally at
sentry_sdk/integrations/_wsgi_common.py:123; max_request_body_size,
checked at :61, is the only control, and left unset it defaults to
"medium" -- 10,000-byte bodies. Nobody chose that. Measured over the
last 30 days, the write endpoints that actually raise are the sensitive
ones: SCIM user PATCH, /api/v1/enrollments/, /api/checkout/result/,
/api/checkout/redeem_discount/, /api/profile/details/, and CMS page
edits. Set to "small" explicitly, so the choice is findable at the call
site instead of in a dependency's defaults.

Postgres DETAIL lines. A constraint violation carries a DETAIL line that
echoes the whole offending row, and psycopg puts it in str(exc) -- so it
ships inside the exception value, which no SDK privacy option covers.
Measured on mitxonline MITXONLINE-6PK: a SCIM PATCH IntegrityError
reproducing a learner email address three times per event, 46,764
occurrences since 2026-05-27. before_send now truncates at the DETAIL
marker across exception values, logentry, and the legacy top-level
message, keeping the primary error that names the failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVJKTGk9KHUTN2xfU59ujX

* fix(sentry): scrub DETAIL rows from the whole event, not three named fields

Copilot review, verified against sentry-sdk 2.55.0 source. The first pass
enumerated three paths -- exception values, logentry.message/.formatted,
and the legacy top-level message -- and missed every other field that can
carry the same string:

  breadcrumbs[].message   LoggingIntegration records each log record as a
                          breadcrumb (integrations/logging.py:311). This is
                          exactly MITXONLINE-6PK's shape: mechanism=logging,
                          logger=django_scim.views.
  logentry.params         record.args verbatim (:274), so
                          logger.error("...: %s", exc) carries it.
  frames[].vars           include_local_variables defaults to True
                          (consts.py:1028, utils.py:616), so a catch block
                          holding the exception in a local carries it.

Confirmed the old implementation leaked on all three shapes before
changing it; the new tests fail against it and pass against the walk.

Replaced with a recursive walk of the event instead of a longer path
list -- it covers these without enumerating them and does not go stale
when the SDK grows another such field. The walk only rewrites str leaves
and preserves everything else, with a test pinning that.

Copilot also suggested normalizing exception-valued params. Not needed:
client._prepare_event serializes the event before calling before_send
(client.py:650 vs :658), so every leaf is already a JSON primitive by
then and there are no live exception objects left to coerce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVJKTGk9KHUTN2xfU59ujX

* fix(sentry): scrub repr'd DETAIL lines and narrow the body-size comment

The SDK repr()s frame locals and logging params before before_send, so the
DETAIL line there carries a literal backslash-n and the old find() missed it.
Match both forms, and test through the real SDK. The body-size comment was
copied from mitxonline; it now describes what this app receives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYc2F3cFvSCfVzqeRMshGy

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
mitol-drf-lint 2026.8.28 added cross-file analysis and rules
ORM003-ORM009; the hook pins no version, so pre-commit.ci picked the new
release up when its cached env rebuilt. The checked-in baseline only
covered ORM002, leaving 41 pre-existing N+1 risks unsuppressed and every
build in the repo red.

Regenerate the baseline over all tracked serializers.py so existing debt
is grandfathered and recorded, while new violations still fail the hook.
Existing ORM002 entries are preserved; nothing is dropped.

Also ignore .drf_lint_cache.json, the cross-file index cache the new
version writes at the repo root.
* pass start date into enrollment handlers and make sure a course has actually started before automatically redirecting to it

* titles should also not be clickable by non-admins if the course hasn't started yet

* allow Toaster to also display success messages and show one when you successfully enroll in a course but are not redirected

* revert the enroll success toast

Adding a success Snackbar belongs in smoot-design with a design that every
surface can reuse, not bolted onto an unrelated bug fix. Reverted here and
tracked separately.

The start-date gate is unchanged: enrolling in a run that has not started
still enrolls without redirecting, and the card re-renders as enrolled
showing when the run starts.

Toaster, toastStore and the test harness are back to their state on main.
The comment rework and the SiblingRunsAccordion wording that rode along in
the same commit are kept, which is why this is a manual revert rather than
a git revert.
@odlbot
odlbot requested a review from a team as a code owner September 14, 2026 07:05
@github-actions

Copy link
Copy Markdown

OpenAPI Changes

26 changes: 0 error, 0 warning, 26 info

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

@odlbot
odlbot merged commit 78d678e into release Sep 14, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants