Skip to content

Commit a38e78e

Browse files
blarghmateyclaude
andauthored
Add MIT Learn Cohort 2 dbt models and Dagster webhook delivery assets (#2277)
* feat: add MIT Learn Cohort 2 dbt staging/integration models and webhook delivery Adds the dbt transformation layer and Dagster webhook delivery assets for the four MIT Learn REST API sources added in PR #2276. Depends on the raw Iceberg tables from that PR being loaded successfully before dbt can run. dbt models added: - stg__mit_climate__api__articles - stg__mitpe__api__courses - stg__oll__google_sheets__courses - stg__edxorg__discovery__api__programs - integrations__learn__mit_climate_articles - integrations__learn__mitpe_courses - integrations__learn__oll_courses - integrations__learn__mit_edx_programs Webhook delivery assets (learning_resources code location): - mit_climate_webhook — fetches from integrations__learn__mit_climate_articles - mitpe_webhook — fetches from integrations__learn__mitpe_courses - oll_webhook — fetches from integrations__learn__oll_courses - mit_edx_programs_webhook — fetches from integrations__learn__mit_edx_programs MITLearnApiClient gains a unified notify_learning_resources() method used by all four webhook assets. Schedules at staggered daily UTC times (06:00-06:45). Fix vs original PR #2263: - Remove integrations__learn__mitpe_programs model (no /feeds/programs/ endpoint) - mitpe_webhook now delivers courses only (programs mixed in courses feed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove mitpe programs dbt model (no separate programs API endpoint) MIT PE does not have a /feeds/programs/ endpoint — programs are mixed in with courses in the /feeds/courses/ feed. Remove the programs staging and integration models added in error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address PR #2277 review feedback integrations__learn__oll_courses.sql: - Fix column reference: description → course_description (staging alias) - Deduplicate to one row per readable_id by selecting the most recent course-semester offering (ROW_NUMBER over year desc, semester desc), since the OLL spreadsheet has one row per course-semester offering and the integration contract requires unique readable_id per course learn_api.py: - Make notify_learning_resources() endpoint configurable via parameter (defaults to /api/v1/webhooks/learning_resources/) - Clarify docstring: all Cohort 2 sources use a single consolidated endpoint; per-source routing is handled by MIT Learn based on etl_source/resource_type mitpe.py: - Fix misleading docstring: MIT PE delivers courses only (resource_type='course'); remove claim that resource_type distinguishes programs, since the dbt integration model hard-codes resource_type='course' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(dbt): add missing edxorg discovery API source declaration stg__edxorg__discovery__api__programs references source('ol_warehouse_raw_data', 'raw__edxorg__discovery__api__programs') but no dbt source YAML declared the table, causing compilation failure. Added _edxorg__discovery__sources.yml with loader: dlt and column docs mirroring the pattern from the other three cohort-2 source files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(oll): align source YAML and staging SQL with actual CSV schema The OLL pipeline reads learning_resources/data/oll_metadata.csv from the mit-learn repo, not a Google Sheet. The actual CSV has no Semester, Year, Run ID, or Subjects columns, so the staging model failed with column-not-found errors. Changes: - _oll__sources.yml: fix description (mit-learn CSV, PK is readable_id); remove phantom columns (semester, year, run_id, subjects); add actual columns (readable_id, ed_x_subject_1/2/3). - stg__oll__google_sheets__courses.sql: replace run_id ref with readable_id; build course_topics_raw by pipe-joining ed_x_subject_1/2/3 via concat_ws; emit course_semester/course_year as null so integration-layer dedup ordering degrades gracefully. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mit_climate): rename resource_category to resource_type in webhook payload Address PR review comment: MIT Learn expects 'resource_type' not 'resource_category' in the LearningResource webhook shape. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(learn-cohort2): use inline schema scalar in dlt source ymls The oll and edxorg__discovery source files used a YAML folded scalar (schema: >) which appends a trailing newline to the rendered schema name, so the source would resolve to "..._raw\n" at dbt runtime (table-not-found), a failure dbt parse and CI do not catch because the models are never materialized in CI. Switch to the inline single-quoted form used by the other 13 staging source files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(mit_climate): lowercase resource_type 'article' in webhook payload Sentry review flagged the MIT Climate delivery asset emitting resource_type='Article' (capitalized), inconsistent with every other Cohort 2 asset ('course', 'program') and the lowercase enum MIT Learn's webhook handler expects. Lowercase it so articles are categorized rather than rejected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(mit_climate): deliver articles as resource_type "document" MIT Learn's data model has no "article" resource_type — the legacy Celery ETL loads MIT Climate articles via load_documents() as LearningResourceType.document with resource_category "Article" (learning_resources/etl/pipelines.py). Emitting resource_type "article" over the webhook meant MIT Learn's generic learning_resources handler had no loader for it and would skip the batch. Match the legacy contract: resource_type "document" + resource_category "Article", so the webhook path ingests Climate articles through the same load_documents route as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f950e50 commit a38e78e

21 files changed

Lines changed: 1103 additions & 11 deletions
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""
2+
MIT Climate Portal webhook delivery asset.
3+
4+
Reads pre-transformed article records from the
5+
``integrations__learn__mit_climate_articles`` Iceberg table (produced by dbt
6+
from the mit_climate dlt pipeline) and delivers them to MIT Learn via a
7+
single signed webhook POST.
8+
9+
Data flow:
10+
raw__mit_climate__api__articles (Iceberg, via dlt)
11+
→ integrations__learn__mit_climate_articles (dbt)
12+
→ MIT Learn webhook (this asset)
13+
14+
Scheduling: daily at 06:00 UTC. Configured in definitions.py.
15+
"""
16+
17+
import logging
18+
from typing import Any, cast
19+
20+
import httpx2 as httpx
21+
import polars as pl
22+
from dagster import (
23+
AssetExecutionContext,
24+
AssetKey,
25+
MetadataValue,
26+
RetryPolicy,
27+
asset,
28+
)
29+
from ol_orchestrate.lib.constants import DAGSTER_ENV
30+
from ol_orchestrate.lib.glue_helper import get_dbt_model_as_dataframe
31+
from ol_orchestrate.resources.api_client_factory import ApiClientFactory
32+
from ol_orchestrate.resources.learn_api import MITLearnApiClient
33+
34+
log = logging.getLogger(__name__)
35+
36+
_GLUE_DB = (
37+
f"ol_warehouse_{DAGSTER_ENV}_integrations"
38+
if DAGSTER_ENV in ("qa", "production")
39+
else "ol_warehouse_production_integrations"
40+
)
41+
_TABLE = "integrations__learn__mit_climate_articles"
42+
43+
44+
def _row_to_resource(row: dict[str, Any]) -> dict[str, Any]:
45+
"""Map an integrations table row to the MIT Learn LearningResource shape."""
46+
image_url = row.get("image_url")
47+
image_alt = row.get("image_alt") or ""
48+
topics_raw = row.get("topics") or ""
49+
topics = [{"name": t.strip()} for t in topics_raw.split(",") if t.strip()]
50+
51+
return {
52+
"readable_id": row["readable_id"],
53+
"title": row["title"],
54+
"url": row.get("url"),
55+
"description": row.get("description"),
56+
"image": {"url": image_url, "alt": image_alt} if image_url else None,
57+
"topics": topics,
58+
"published": True,
59+
"etl_source": "mit_climate",
60+
"offered_by": {"code": "climate"},
61+
"platform": "climate",
62+
# MIT Learn has no "article" resource_type; the legacy Celery ETL loads
63+
# Climate articles as documents (load_documents) with an "Article"
64+
# resource_category, so match that contract for the webhook path too.
65+
"resource_type": "document",
66+
"resource_category": "Article",
67+
"created_on": row.get("last_modified"),
68+
}
69+
70+
71+
@asset(
72+
key=AssetKey(["mit_learn_delivery", "mit_climate_webhook"]),
73+
group_name="mit_learn_delivery",
74+
description=(
75+
"Read MIT Climate Portal articles from the "
76+
"integrations__learn__mit_climate_articles "
77+
"Iceberg table and POST as a signed webhook batch to MIT Learn."
78+
),
79+
deps=[
80+
AssetKey(["integrations", "learn", "integrations__learn__mit_climate_articles"])
81+
],
82+
retry_policy=RetryPolicy(max_retries=3, delay=5.0),
83+
)
84+
def mit_climate_webhook(
85+
context: AssetExecutionContext,
86+
learn_api: ApiClientFactory,
87+
) -> dict[str, Any]:
88+
"""Deliver MIT Climate articles to MIT Learn via signed webhook."""
89+
context.log.info("Reading %s from Glue database %s", _TABLE, _GLUE_DB)
90+
df: pl.DataFrame = get_dbt_model_as_dataframe(
91+
database_name=_GLUE_DB,
92+
table_name=_TABLE,
93+
).collect()
94+
context.log.info("Loaded %d MIT Climate articles from Iceberg", len(df))
95+
96+
resources = [_row_to_resource(row) for row in df.iter_rows(named=True)]
97+
98+
context.log.info(
99+
"Delivering %d MIT Climate articles to MIT Learn webhook", len(resources)
100+
)
101+
try:
102+
response = cast(MITLearnApiClient, learn_api.client).notify_learning_resources(
103+
resources
104+
)
105+
except httpx.HTTPStatusError as exc:
106+
msg = (
107+
f"MIT Climate webhook failed with status {exc.response.status_code}: {exc}"
108+
)
109+
context.log.exception(msg)
110+
raise RuntimeError(msg) from exc
111+
112+
context.add_output_metadata(
113+
{
114+
"resource_count": len(resources),
115+
"webhook_status": "success",
116+
"response": MetadataValue.json(response),
117+
}
118+
)
119+
return {"resource_count": len(resources), "webhook_status": "success"}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""
2+
MIT edX (MITx on edX.org) programs webhook delivery asset.
3+
4+
Reads pre-transformed program records from the
5+
``integrations__learn__mit_edx_programs`` Iceberg table (produced by dbt from
6+
the mit_edx_programs dlt pipeline) and delivers them to MIT Learn via a single
7+
signed webhook POST.
8+
9+
MicroMasters programs are excluded — the dlt pipeline filters them at ingest
10+
time and they are handled separately by the Cohort 1 Trino-pull path via
11+
integrations__learn__micromasters_programs.
12+
13+
Data flow:
14+
raw__edxorg__discovery__api__programs (Iceberg, via dlt)
15+
→ integrations__learn__mit_edx_programs (dbt)
16+
→ MIT Learn webhook (this asset)
17+
18+
Scheduling: daily at 06:45 UTC. Configured in definitions.py.
19+
"""
20+
21+
import json
22+
import logging
23+
from typing import Any, cast
24+
25+
import httpx2 as httpx
26+
import polars as pl
27+
from dagster import (
28+
AssetExecutionContext,
29+
AssetKey,
30+
MetadataValue,
31+
RetryPolicy,
32+
asset,
33+
)
34+
from ol_orchestrate.lib.constants import DAGSTER_ENV
35+
from ol_orchestrate.lib.glue_helper import get_dbt_model_as_dataframe
36+
from ol_orchestrate.resources.api_client_factory import ApiClientFactory
37+
from ol_orchestrate.resources.learn_api import MITLearnApiClient
38+
39+
log = logging.getLogger(__name__)
40+
41+
_GLUE_DB = (
42+
f"ol_warehouse_{DAGSTER_ENV}_integrations"
43+
if DAGSTER_ENV in ("qa", "production")
44+
else "ol_warehouse_production_integrations"
45+
)
46+
_TABLE = "integrations__learn__mit_edx_programs"
47+
48+
49+
def _parse_topics(topics_json: str | None) -> list[dict[str, str]]:
50+
"""Parse the subjects JSON array into MIT Learn topic dicts."""
51+
if not topics_json:
52+
return []
53+
try:
54+
subjects = json.loads(topics_json)
55+
return [{"name": s["name"]} for s in subjects if s.get("name")]
56+
except (json.JSONDecodeError, TypeError):
57+
log.warning("Could not parse topics_json: %r", topics_json)
58+
return []
59+
60+
61+
def _parse_courses(courses_json: str | None) -> list[dict[str, str]]:
62+
"""Parse the courses JSON array into MIT Learn course readable_id dicts."""
63+
if not courses_json:
64+
return []
65+
try:
66+
courses = json.loads(courses_json)
67+
return [{"readable_id": c["key"]} for c in courses if c.get("key")]
68+
except (json.JSONDecodeError, TypeError):
69+
log.warning("Could not parse courses_json: %r", courses_json)
70+
return []
71+
72+
73+
def _row_to_resource(row: dict[str, Any]) -> dict[str, Any]:
74+
"""Map an integrations table row to the MIT Learn LearningResource shape."""
75+
image_url = row.get("image_url")
76+
77+
return {
78+
"readable_id": row["readable_id"],
79+
"title": row["title"],
80+
"url": row.get("url"),
81+
"description": row.get("description"),
82+
"image": {"url": image_url, "alt": row.get("title", "")} if image_url else None,
83+
"topics": _parse_topics(row.get("topics_json")),
84+
"published": True,
85+
"etl_source": "mit_edx",
86+
"offered_by": {"code": "mitx"},
87+
"platform": "edxorg",
88+
"resource_type": "program",
89+
"courses": _parse_courses(row.get("courses_json")),
90+
}
91+
92+
93+
@asset(
94+
key=AssetKey(["mit_learn_delivery", "mit_edx_programs_webhook"]),
95+
group_name="mit_learn_delivery",
96+
description=(
97+
"Read active MIT-authored edX.org programs from the "
98+
"integrations__learn__mit_edx_programs Iceberg table and POST as a "
99+
"signed webhook batch to MIT Learn. Excludes MicroMasters (handled via "
100+
"the Cohort 1 Trino-pull path)."
101+
),
102+
deps=[AssetKey(["integrations", "learn", "integrations__learn__mit_edx_programs"])],
103+
retry_policy=RetryPolicy(max_retries=3, delay=10.0),
104+
)
105+
def mit_edx_programs_webhook(
106+
context: AssetExecutionContext,
107+
learn_api: ApiClientFactory,
108+
) -> dict[str, Any]:
109+
"""Deliver MIT edX programs to MIT Learn via signed webhook."""
110+
context.log.info("Reading %s from Glue database %s", _TABLE, _GLUE_DB)
111+
df: pl.DataFrame = get_dbt_model_as_dataframe(
112+
database_name=_GLUE_DB,
113+
table_name=_TABLE,
114+
).collect()
115+
context.log.info("Loaded %d MIT edX programs from Iceberg", len(df))
116+
117+
resources = [_row_to_resource(row) for row in df.iter_rows(named=True)]
118+
119+
context.log.info(
120+
"Delivering %d MIT edX programs to MIT Learn webhook", len(resources)
121+
)
122+
try:
123+
response = cast(MITLearnApiClient, learn_api.client).notify_learning_resources(
124+
resources
125+
)
126+
except httpx.HTTPStatusError as exc:
127+
msg = (
128+
f"MIT edX programs webhook failed with status "
129+
f"{exc.response.status_code}: {exc}"
130+
)
131+
context.log.exception(msg)
132+
raise RuntimeError(msg) from exc
133+
134+
context.add_output_metadata(
135+
{
136+
"delivered_count": len(resources),
137+
"webhook_status": "success",
138+
"response": MetadataValue.json(response),
139+
}
140+
)
141+
return {"delivered_count": len(resources), "webhook_status": "success"}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
MIT Professional Education (MIT PE) webhook delivery asset.
3+
4+
Reads pre-transformed course records from the
5+
``integrations__learn__mitpe_courses`` Iceberg table (produced by dbt from the
6+
mitpe dlt pipeline) and delivers them to MIT Learn via a signed webhook POST.
7+
8+
Note: MIT PE does not expose a separate programs API endpoint. All records
9+
from the /feeds/courses/ feed are delivered as courses (resource_type='course').
10+
11+
Data flow:
12+
raw__mitpe__api__courses (Iceberg, via dlt)
13+
→ integrations__learn__mitpe_courses (dbt)
14+
→ MIT Learn webhook (this asset)
15+
16+
Scheduling: daily at 06:15 UTC. Configured in definitions.py.
17+
"""
18+
19+
import logging
20+
from typing import Any, cast
21+
22+
import httpx2 as httpx
23+
import polars as pl
24+
from dagster import (
25+
AssetExecutionContext,
26+
AssetKey,
27+
MetadataValue,
28+
RetryPolicy,
29+
asset,
30+
)
31+
from ol_orchestrate.lib.constants import DAGSTER_ENV
32+
from ol_orchestrate.lib.glue_helper import get_dbt_model_as_dataframe
33+
from ol_orchestrate.resources.api_client_factory import ApiClientFactory
34+
from ol_orchestrate.resources.learn_api import MITLearnApiClient
35+
36+
log = logging.getLogger(__name__)
37+
38+
_GLUE_DB = (
39+
f"ol_warehouse_{DAGSTER_ENV}_integrations"
40+
if DAGSTER_ENV in ("qa", "production")
41+
else "ol_warehouse_production_integrations"
42+
)
43+
_COURSES_TABLE = "integrations__learn__mitpe_courses"
44+
45+
46+
def _row_to_resource(row: dict[str, Any]) -> dict[str, Any]:
47+
"""Map an integrations table row to the MIT Learn LearningResource shape."""
48+
image_url = row.get("image_url")
49+
image_alt = row.get("image_alt") or ""
50+
topics_raw = row.get("topics") or ""
51+
topics = [{"name": t.strip()} for t in topics_raw.split(",") if t.strip()]
52+
53+
return {
54+
"readable_id": row["readable_id"],
55+
"title": row["title"],
56+
"url": row.get("url"),
57+
"description": row.get("description"),
58+
"image": {"url": image_url, "alt": image_alt} if image_url else None,
59+
"topics": topics,
60+
"published": True,
61+
"professional": True,
62+
"etl_source": "mitpe",
63+
"offered_by": {"code": "mitpe"},
64+
"platform": "mitpe",
65+
"resource_type": row.get("resource_type", "course"),
66+
}
67+
68+
69+
@asset(
70+
key=AssetKey(["mit_learn_delivery", "mitpe_webhook"]),
71+
group_name="mit_learn_delivery",
72+
description=(
73+
"Read MIT Professional Education courses from the "
74+
"integrations__learn__mitpe_courses Iceberg table and POST as a signed "
75+
"webhook batch to MIT Learn."
76+
),
77+
deps=[
78+
AssetKey(["integrations", "learn", "integrations__learn__mitpe_courses"]),
79+
],
80+
retry_policy=RetryPolicy(max_retries=3, delay=5.0),
81+
)
82+
def mitpe_webhook(
83+
context: AssetExecutionContext,
84+
learn_api: ApiClientFactory,
85+
) -> dict[str, Any]:
86+
"""Deliver MIT PE courses to MIT Learn via signed webhook."""
87+
context.log.info("Reading %s from Glue database %s", _COURSES_TABLE, _GLUE_DB)
88+
89+
courses_df: pl.DataFrame = get_dbt_model_as_dataframe(
90+
database_name=_GLUE_DB, table_name=_COURSES_TABLE
91+
).collect()
92+
context.log.info("Loaded %d MIT PE courses from Iceberg", len(courses_df))
93+
94+
resources = [_row_to_resource(row) for row in courses_df.iter_rows(named=True)]
95+
96+
context.log.info(
97+
"Delivering %d MIT PE courses to MIT Learn webhook", len(resources)
98+
)
99+
try:
100+
response = cast(MITLearnApiClient, learn_api.client).notify_learning_resources(
101+
resources
102+
)
103+
except httpx.HTTPStatusError as exc:
104+
msg = f"MIT PE webhook failed with status {exc.response.status_code}: {exc}"
105+
context.log.exception(msg)
106+
raise RuntimeError(msg) from exc
107+
108+
context.add_output_metadata(
109+
{
110+
"delivered_count": len(resources),
111+
"webhook_status": "success",
112+
"response": MetadataValue.json(response),
113+
}
114+
)
115+
return {"delivered_count": len(resources), "webhook_status": "success"}

0 commit comments

Comments
 (0)