Skip to content

Commit ff820e0

Browse files
authored
adding import_course command and course run sync (#5429)
1 parent 121c850 commit ff820e0

File tree

3 files changed

+154
-2
lines changed

3 files changed

+154
-2
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ jobs:
5353
python-version: "3.9.1"
5454

5555
- id: cache
56-
uses: actions/cache@v1
56+
uses: actions/cache@v4
5757
with:
5858
path: ~/.cache/pip
5959
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/test_reqirements.txt') }}
@@ -118,7 +118,7 @@ jobs:
118118
id: yarn-cache-dir-path
119119
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
120120

121-
- uses: actions/cache@v1
121+
- uses: actions/cache@v4
122122
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
123123
with:
124124
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
"Imports" a course run from MIT Learn.
3+
4+
You can specify a course by its readable ID, e.g. `MITx+14.100x`.
5+
6+
./manage.py import_course_runs_for_course --course_id MITx+14.100x
7+
"""
8+
9+
from django.core.management import BaseCommand
10+
11+
from courses.mit_learn_api import sync_mit_learn_courseruns_for_course, \
12+
fetch_course_from_mit_learn
13+
from courses.models import Course
14+
15+
16+
class Command(BaseCommand):
17+
"""
18+
Creates/Updates a course with course runs using details from MIT Learn.
19+
You can specify a course by its readable ID, e.g. `MITxT+14.100x`.
20+
Usage: ./manage.py import_course_runs_for_course --course_id MITxT+14.100x
21+
"""
22+
23+
help = "Creates missing course runs and updates existing using details from MIT Learn."
24+
25+
def add_arguments(self, parser) -> None:
26+
parser.add_argument(
27+
"--course_id",
28+
type=str,
29+
nargs="?",
30+
help="This should be the course key, e.g. MITxT+14.100x",
31+
)
32+
33+
def handle(self, *args, **kwargs): # pylint: disable=unused-argument # noqa: C901, PLR0915
34+
course_id = kwargs["course_id"]
35+
if course_id is None:
36+
self.stdout.write(
37+
self.style.ERROR("You must provide a course_id to import all the associated course runs.")
38+
)
39+
self.stdout.write(
40+
self.style.SUCCESS(f"Fetching course run data from MIT Learn API for course {course_id}")
41+
)
42+
raw_course = fetch_course_from_mit_learn(course_id)
43+
44+
if raw_course is None:
45+
self.stdout.write(
46+
self.style.ERROR(f"Course {course_id} not found in MIT Learn API.")
47+
)
48+
49+
try:
50+
course = Course.objects.get(edx_key=course_id)
51+
except Course.DoesNotExist:
52+
self.stdout.write(
53+
self.style.ERROR(f"Course {course_id} does not exist.")
54+
)
55+
return None
56+
57+
self.stdout.write(
58+
self.style.SUCCESS(
59+
f"Updating course runs for {course_id}: {course.title}"
60+
)
61+
)
62+
63+
course_runs_created = sync_mit_learn_courseruns_for_course(course, raw_courseruns=raw_course["runs"])
64+
self.stdout.write(self.style.SUCCESS(f"{course_runs_created} course runs created"))
65+
66+
return None

courses/mit_learn_api.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from urllib.parse import urlencode
2+
3+
import requests
4+
import re
5+
from typing import Any, Dict, List, Optional
6+
7+
from courses.models import Course, CourseRun
8+
from django.utils.dateparse import parse_datetime
9+
10+
11+
class MITLearnAPIError(Exception):
12+
"""Custom exception for MIT Learn API errors."""
13+
pass
14+
15+
LEARN_API_COURSES_LIST_URL = "https://api.learn.mit.edu/api/v1/courses/"
16+
17+
def fetch_course_from_mit_learn(course_id) -> Dict[str, Any]:
18+
"""
19+
Fetch course run data from the MIT Learn API.
20+
21+
Returns:
22+
Dict[str, Any]: course run data dict.
23+
24+
Raises:
25+
MITLearnAPIError: If the API call fails or returns an error.
26+
"""
27+
28+
headers = {
29+
"Accept": "application/json",
30+
}
31+
try:
32+
params = {"readable_id": course_id}
33+
encoded_params = urlencode(params)
34+
url = f"{LEARN_API_COURSES_LIST_URL}?{encoded_params}"
35+
response = requests.get(url, headers=headers, timeout=20)
36+
except requests.exceptions.RequestException as e:
37+
raise MITLearnAPIError(f"Network/API error: {e}")
38+
try:
39+
data = response.json()
40+
except ValueError:
41+
raise MITLearnAPIError("Invalid JSON response from MIT Learn API.")
42+
for course in data["results"]:
43+
if course["readable_id"] == course_id:
44+
return course
45+
46+
return {}
47+
48+
49+
50+
51+
52+
53+
54+
55+
def sync_mit_learn_courseruns_for_course(course, raw_courseruns: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
56+
"""
57+
Process and normalize raw course data from MIT Learn API, but only for courses that already exist in the database.
58+
59+
Args:
60+
raw_courses (List[Dict[str, Any]]): Raw course data from the API.
61+
62+
Returns:
63+
List[Dict[str, Any]]: List of dicts with course and course run info for existing courses only.
64+
"""
65+
66+
num_created = 0
67+
for raw_courserun in raw_courseruns:
68+
print("Syncing course run:", raw_courserun.get("run_id"))
69+
run_defaults = {
70+
"title": raw_courserun.get("title", ""),
71+
"enrollment_start": parse_datetime(raw_courserun.get("enrollment_start")) if raw_courserun.get("enrollment_start") else None,
72+
"enrollment_end": parse_datetime(raw_courserun.get("enrollment_end")) if raw_courserun.get("enrollment_end") else None,
73+
"start_date": parse_datetime(raw_courserun.get("start_date")) if raw_courserun.get("start_date") else None,
74+
"end_date": parse_datetime(raw_courserun.get("end_date"))if raw_courserun.get("end_date") else None,
75+
"upgrade_deadline": parse_datetime(raw_courserun.get("upgrade_deadline")) if raw_courserun.get("upgrade_deadline") else None,
76+
"courseware_backend": raw_courserun.get("courseware_backend", ""),
77+
"enrollment_url": raw_courserun.get("enrollment_url", ""),
78+
}
79+
course_run, created = CourseRun.objects.update_or_create(
80+
course=course, edx_course_key=raw_courserun["run_id"], defaults=run_defaults
81+
)
82+
if created:
83+
num_created += 1
84+
print(f"Created course run: {course_run.edx_course_key} for course {course.title}")
85+
return num_created
86+

0 commit comments

Comments
 (0)