Skip to content

Commit

Permalink
feat: ingest external course APIs (#2998)
Browse files Browse the repository at this point in the history
* feat: ingest external course APIs

* save work

* save work

* add migration

* add admin updates

* save work

* create internal objects

* feat: helper funcs

* organize

* remova extra change

* fixes

* refactor

* refactor

* more fixes and refactoring due to bad data

* refactor & fmt

* update app.json

* add EMERITUS_API_KEY in ci.yml

* more fixes

* add tests

* add tests + refactor

* add timeout setting

* add more tests

* add docs

* add feat flag

* review changes in generate_course_readable_id

* review changes in generate_course_readable_id

* review changes

* review changes

* review changes

* review changes

* review changes

* review changes

* refactor

* add celery beat scheduled task to sync emeritus course runs

* fix tests

* refactor tests

* review changes

* refactor

* add count details

* add counts log

* fix tests

* filter stats

* add external id admin seacrh for course and course run
  • Loading branch information
asadali145 authored Jun 25, 2024
1 parent b354094 commit 1118b17
Show file tree
Hide file tree
Showing 16 changed files with 1,542 additions and 5 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ jobs:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres # pragma: allowlist secret
WEBPACK_DISABLE_LOADER_STATS: "True"
ELASTICSEARCH_URL: localhost:9200
EMERITUS_API_KEY: fake_emeritus_api_key # pragma: allowlist secret
MAILGUN_KEY: fake_mailgun_key
MAILGUN_SENDER_DOMAIN: other.fake.site
MITOL_DIGITAL_CREDENTIALS_VERIFY_SERVICE_BASE_URL: http://localhost:5000
Expand Down
20 changes: 20 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@
"description": "'hours' value for the 'generate-course-certificate' scheduled task (defaults to midnight)",
"required": false
},
"CRON_EMERITUS_COURSERUN_SYNC_DAYS": {
"description": "'day_of_week' value for 'sync-emeritus-course-runs' scheduled task (default will run once a day).",
"required": false
},
"CRON_EMERITUS_COURSERUN_SYNC_HOURS": {
"description": "'hours' value for the 'sync-emeritus-course-runs' scheduled task (defaults to midnight)",
"required": false
},
"CSRF_TRUSTED_ORIGINS": {
"description": "Comma separated string of trusted domains that should be CSRF exempt",
"required": false
Expand Down Expand Up @@ -214,6 +222,18 @@
"description": "Timeout (in seconds) for requests made via the edX API client",
"required": false
},
"EMERITUS_API_BASE_URL": {
"description": "Base API URL for Emeritus API",
"required": false
},
"EMERITUS_API_KEY": {
"description": "The API Key for Emeritus API",
"required": true
},
"EMERITUS_API_TIMEOUT": {
"description": "API request timeout for Emeritus APIs in seconds",
"required": false
},
"ENROLLMENT_CHANGE_SHEET_ID": {
"description": "ID of the Google Sheet that contains the enrollment change request worksheets (refunds, transfers, etc)",
"required": false
Expand Down
11 changes: 8 additions & 3 deletions courses/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ class CourseAdmin(admin.ModelAdmin):
"""Admin for Course"""

model = Course
search_fields = ["title", "readable_id", "platform__name"]
search_fields = ["title", "readable_id", "platform__name", "external_course_id"]
list_display = ("id", "title", "get_program", "position_in_program", "platform")
list_filter = ["live", "program", "platform"]
list_filter = ["live", "platform", "program"]
formfield_overrides = {
models.CharField: {"widget": TextInput(attrs={"size": "80"})}
}
Expand All @@ -73,7 +73,12 @@ class CourseRunAdmin(TimestampedModelAdmin):
"""Admin for CourseRun"""

model = CourseRun
search_fields = ["title", "courseware_id"]
search_fields = [
"title",
"courseware_id",
"external_course_run_id",
"course__external_course_id",
]
list_display = (
"id",
"title",
Expand Down
18 changes: 18 additions & 0 deletions courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,21 @@ def defer_enrollment(
except EdxEnrollmentCreateError: # noqa: TRY302
raise
return from_enrollment, first_or_none(to_enrollments)


def generate_course_readable_id(course_tag):
"""
Generates course readable ID using the course tag.
Args:
course_tag (str): Course tag of course
Returns:
str: Course readable id
"""
if not course_tag:
raise ValidationError(
"course_tag is required to generate a valid readable ID for a course." # noqa: EM101
)

return f"course-v1:xPRO+{course_tag}"
22 changes: 22 additions & 0 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
deactivate_program_enrollment,
deactivate_run_enrollment,
defer_enrollment,
generate_course_readable_id,
get_user_enrollments,
)
from courses.constants import (
Expand Down Expand Up @@ -537,3 +538,24 @@ def test_defer_enrollment_validation(mocker, user):
force=True,
)
assert patched_create_enrollments.call_count == 2


@pytest.mark.parametrize(
("course_tag", "expected_readable_id", "raises_validation_error"),
[
("DBIP", "course-v1:xPRO+DBIP", False),
("DBIP.ES", "course-v1:xPRO+DBIP.ES", False),
("DBIP.SEPO", "course-v1:xPRO+DBIP.SEPO", False),
("", "", True),
(None, None, True),
],
)
def test_generate_course_readable_id(
course_tag, expected_readable_id, raises_validation_error
):
"""Test that `generate_course_readable_id` returns expected course readable_id for course tags."""
if raises_validation_error:
with pytest.raises(ValidationError):
generate_course_readable_id(course_tag)
else:
assert generate_course_readable_id(course_tag) == expected_readable_id
129 changes: 129 additions & 0 deletions courses/management/commands/sync_external_course_runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Management command to sync external course runs"""

from django.core.management.base import BaseCommand

from courses.sync_external_courses.emeritus_api import (
EmeritusKeyMap,
fetch_emeritus_courses,
update_emeritus_course_runs,
)
from mitxpro import settings


class Command(BaseCommand):
"""Sync external course runs"""

help = "Management command to sync external course runs from the vendor APIs."

def add_arguments(self, parser):
parser.add_argument(
"--vendor-name",
type=str,
help="The name of the vendor i.e. `Emeritus`",
required=True,
)
super().add_arguments(parser)

def handle(self, *args, **options): # noqa: ARG002
"""Handle command execution"""
if not settings.FEATURES.get("ENABLE_EXTERNAL_COURSE_SYNC", False):
self.stdout.write(
self.style.ERROR(
"External Course Sync is disabled. You can enable it by turning on the feature flag "
"`ENABLE_EXTERNAL_COURSE_SYNC`"
)
)
return

vendor_name = options["vendor_name"]
if vendor_name.lower() == EmeritusKeyMap.PLATFORM_NAME.value.lower():
self.stdout.write(f"Starting course sync for {vendor_name}.")
emeritus_course_runs = fetch_emeritus_courses()
stats = update_emeritus_course_runs(emeritus_course_runs)
self.stdout.write(
self.style.SUCCESS(
f"Number of Courses Created {len(stats['courses_created'])}."
)
)
self.stdout.write(
self.style.SUCCESS(
f"External Course Codes: {stats['courses_created'] if stats['courses_created'] else None}.\n"
)
)
self.stdout.write(
self.style.SUCCESS(
f"Number of existing Courses {len(stats['existing_courses'])}."
)
)
self.stdout.write(
self.style.SUCCESS(
f"External Course Codes: {stats['existing_courses'] if stats['existing_courses'] else None}.\n"
)
)
self.stdout.write(
self.style.SUCCESS(
f"Number of Course Runs Created {len(stats['course_runs_created'])}."
)
)
self.stdout.write(
self.style.SUCCESS(
f"External Course Run Codes: {stats['course_runs_created'] if stats['course_runs_created'] else None}.\n"
)
)
self.stdout.write(
self.style.SUCCESS(
f"Number of Course Runs Updated {len(stats['course_runs_updated'])}."
)
)
self.stdout.write(
self.style.SUCCESS(
f"External Course Run Codes: {stats['course_runs_updated'] if stats['course_runs_updated'] else None}.\n"
)
)
self.stdout.write(
self.style.SUCCESS(
f"Number of Courses Pages Created {len(stats['course_pages_created'])}."
)
)
self.stdout.write(
self.style.SUCCESS(
f"External Course Codes: {stats['course_pages_created'] if stats['course_pages_created'] else None}.\n"
)
)
self.stdout.write(
self.style.SUCCESS(
f"Number of Courses Pages Updated {len(stats['course_pages_updated'])}."
)
)
self.stdout.write(
self.style.SUCCESS(
f"External Course Codes: {stats['course_pages_updated'] if stats['course_pages_updated'] else None}.\n"
)
)
self.stdout.write(
self.style.SUCCESS(
f"Number of Course Runs Skipped due to bad data {len(stats['course_runs_skipped'])}."
)
)
self.stdout.write(
self.style.SUCCESS(
f"External Course Codes: {stats['course_runs_skipped'] if stats['course_runs_skipped'] else None}.\n"
)
)
self.stdout.write(
self.style.SUCCESS(
f"Number of Expired Course Runs {len(stats['course_runs_expired'])}."
)
)
self.stdout.write(
self.style.SUCCESS(
f"External Course Codes: {stats['course_runs_expired'] if stats['course_runs_expired'] else None}.\n"
)
)
self.stdout.write(
self.style.SUCCESS(
f"External course sync successful for {vendor_name}."
)
)
else:
self.stdout.write(self.style.ERROR(f"Unknown vendor name {vendor_name}."))
Empty file.
Loading

0 comments on commit 1118b17

Please sign in to comment.