Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 0.151.0 #3026

Closed
wants to merge 2 commits into from
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
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
5 changes: 5 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Release Notes
=============

Version 0.151.0
---------------

- feat: ingest external course APIs (#2998)

Version 0.150.0 (Released June 24, 2024)
---------------

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
Loading