feat: Learner Dashboard get enrollments (#30808)
* feat: fill out enrollment serializer * test: add basic integration tests for enrollments * feat: get info for user account activation * test: test integrating user account activation * feat: get course enrollments * feat: get course email settings * feat: add ecommerce info * feat: add resume urls * refactor: move learner home to separate app * refactor: remove course limit Co-authored-by: nsprenkle <nsprenkle@2u.com>
This commit is contained in:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -35,6 +35,7 @@ common/djangoapps/enrollment/ @edx/platform-discovery
|
||||
lms/djangoapps/commerce/ @edx/rev-team
|
||||
lms/djangoapps/experiments/ @edx/rev-team
|
||||
lms/djangoapps/learner_dashboard/ @edx/platform-discovery
|
||||
lms/djangoapps/learner_home/ @edx/content-aurora
|
||||
openedx/features/content_type_gating/ @edx/rev-team
|
||||
openedx/features/course_duration_limits/ @edx/rev-team
|
||||
openedx/features/discounts/ @edx/rev-team
|
||||
|
||||
2
.github/workflows/pylint-checks.yml
vendored
2
.github/workflows/pylint-checks.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- module-name: lms-1
|
||||
path: "lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/ lms/djangoapps/save_for_later/"
|
||||
- module-name: lms-2
|
||||
path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py"
|
||||
path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py"
|
||||
- module-name: openedx-1
|
||||
path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/"
|
||||
- module-name: openedx-2
|
||||
|
||||
1
.github/workflows/unit-test-shards.json
vendored
1
.github/workflows/unit-test-shards.json
vendored
@@ -53,6 +53,7 @@
|
||||
"paths": [
|
||||
"lms/djangoapps/instructor_task/",
|
||||
"lms/djangoapps/learner_dashboard/",
|
||||
"lms/djangoapps/learner_home/",
|
||||
"lms/djangoapps/lms_initialization/",
|
||||
"lms/djangoapps/lms_xblock/",
|
||||
"lms/djangoapps/lti_provider/",
|
||||
|
||||
@@ -268,6 +268,7 @@ def complete_course_mode_info(course_id, enrollment, modes=None):
|
||||
if modes['verified'].expiration_datetime:
|
||||
today = datetime.datetime.now(UTC).date()
|
||||
mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days
|
||||
mode_info['expiration_datetime'] = modes['verified'].expiration_datetime.date()
|
||||
|
||||
return mode_info
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
=================
|
||||
Learner dashboard
|
||||
=================
|
||||
|
||||
This djangoapp houses 2 related dashboards owned and developed, currently by 2 separate teams:
|
||||
|
||||
1. Courses Dashboard
|
||||
2. Programs Dashboard
|
||||
|
||||
Courses Dashboard
|
||||
=================
|
||||
|
||||
The Courses section of the Learner Dashboard is a backend supporting a new MFE experience of the learner dashboard.
|
||||
|
||||
This aims to replace the existing dashboard at::
|
||||
/common/djangoapps/student/views/dashboard.py
|
||||
|
||||
Programs Dashboard
|
||||
==================
|
||||
|
||||
See :ref:`programs`.rst doc.
|
||||
@@ -1,37 +0,0 @@
|
||||
"""
|
||||
Views for the learner dashboard.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
from common.djangoapps.edxmako.shortcuts import marketing_link
|
||||
from common.djangoapps.util.json_request import JsonResponse
|
||||
from lms.djangoapps.learner_dashboard.serializers import LearnerDashboardSerializer
|
||||
|
||||
|
||||
def get_platform_settings():
|
||||
"""Get settings used for platform level connections: emails, url routes, etc."""
|
||||
|
||||
return {
|
||||
"supportEmail": settings.DEFAULT_FEEDBACK_EMAIL,
|
||||
"billingEmail": settings.PAYMENT_SUPPORT_EMAIL,
|
||||
"courseSearchUrl": marketing_link("COURSES"),
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def dashboard_view(request): # pylint: disable=unused-argument
|
||||
"""List of courses a user is enrolled in or entitled to"""
|
||||
learner_dash_data = {
|
||||
"emailConfirmation": None,
|
||||
"enterpriseDashboards": None,
|
||||
"platformSettings": get_platform_settings(),
|
||||
"enrollments": [],
|
||||
"unfulfilledEntitlements": [],
|
||||
"suggestedCourses": [],
|
||||
}
|
||||
|
||||
response_data = LearnerDashboardSerializer(learner_dash_data).data
|
||||
return JsonResponse(response_data)
|
||||
@@ -1,193 +0,0 @@
|
||||
"""
|
||||
Serializers for the Learner Dashboard
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class PlatformSettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for platform-level info, emails, and URLs"""
|
||||
|
||||
supportEmail = serializers.EmailField()
|
||||
billingEmail = serializers.EmailField()
|
||||
courseSearchUrl = serializers.URLField()
|
||||
|
||||
|
||||
class CourseProviderSerializer(serializers.Serializer):
|
||||
"""Info about a course provider (institution/business)"""
|
||||
|
||||
name = serializers.CharField()
|
||||
website = serializers.URLField()
|
||||
email = serializers.EmailField()
|
||||
|
||||
|
||||
class CourseSerializer(serializers.Serializer):
|
||||
"""Serializer for course header info"""
|
||||
|
||||
bannerImgSrc = serializers.URLField()
|
||||
courseName = serializers.CharField()
|
||||
|
||||
|
||||
class CourseRunSerializer(serializers.Serializer):
|
||||
"""Serializer for course run info"""
|
||||
|
||||
isPending = serializers.BooleanField()
|
||||
isStarted = serializers.BooleanField()
|
||||
isFinished = serializers.BooleanField()
|
||||
isArchived = serializers.BooleanField()
|
||||
courseNumber = serializers.CharField()
|
||||
accessExpirationDate = serializers.DateTimeField()
|
||||
minPassingGrade = serializers.DecimalField(max_digits=5, decimal_places=2)
|
||||
endDate = serializers.DateTimeField()
|
||||
homeUrl = serializers.URLField()
|
||||
marketingUrl = serializers.URLField()
|
||||
progressUrl = serializers.URLField()
|
||||
unenrollUrl = serializers.URLField()
|
||||
upgradeUrl = serializers.URLField()
|
||||
|
||||
|
||||
class EnrollmentSerializer(serializers.Serializer):
|
||||
"""Info about this particular enrollment"""
|
||||
|
||||
isAudit = serializers.BooleanField()
|
||||
isVerified = serializers.BooleanField()
|
||||
canUpgrade = serializers.BooleanField()
|
||||
isAuditAccessExpired = serializers.BooleanField()
|
||||
isEmailEnabled = serializers.BooleanField()
|
||||
lastEnrolled = serializers.DateTimeField()
|
||||
isEnrolled = serializers.BooleanField()
|
||||
|
||||
|
||||
class GradeDataSerializer(serializers.Serializer):
|
||||
"""Info about grades for this enrollment"""
|
||||
|
||||
isPassing = serializers.BooleanField()
|
||||
|
||||
|
||||
class CertificateSerializer(serializers.Serializer):
|
||||
"""Certificate availability info"""
|
||||
|
||||
availableDate = serializers.DateTimeField(allow_null=True)
|
||||
isRestricted = serializers.BooleanField()
|
||||
isAvailable = serializers.BooleanField()
|
||||
isEarned = serializers.BooleanField()
|
||||
isDownloadable = serializers.BooleanField()
|
||||
certPreviewUrl = serializers.URLField(allow_null=True)
|
||||
certDownloadUrl = serializers.URLField(allow_null=True)
|
||||
honorCertDownloadUrl = serializers.URLField(allow_null=True)
|
||||
|
||||
|
||||
class AvailableEntitlementSessionSerializer(serializers.Serializer):
|
||||
"""An available entitlement session"""
|
||||
|
||||
startDate = serializers.DateTimeField()
|
||||
endDate = serializers.DateTimeField()
|
||||
courseNumber = serializers.CharField()
|
||||
|
||||
|
||||
class EntitlementSerializer(serializers.Serializer):
|
||||
"""Entitlements info"""
|
||||
|
||||
availableSessions = serializers.ListField(
|
||||
child=AvailableEntitlementSessionSerializer(), allow_empty=True
|
||||
)
|
||||
isRefundable = serializers.BooleanField()
|
||||
isFulfilled = serializers.BooleanField()
|
||||
canViewCourse = serializers.BooleanField()
|
||||
changeDeadline = serializers.DateTimeField()
|
||||
isExpired = serializers.BooleanField()
|
||||
expirationDate = serializers.DateTimeField()
|
||||
|
||||
|
||||
class RelatedProgramSerializer(serializers.Serializer):
|
||||
"""Related programs information"""
|
||||
|
||||
provider = serializers.CharField()
|
||||
programUrl = serializers.URLField()
|
||||
bannerUrl = serializers.URLField()
|
||||
logoUrl = serializers.URLField()
|
||||
title = serializers.CharField()
|
||||
# Note - this should probably be a choice, eventually
|
||||
programType = serializers.CharField()
|
||||
programTypeUrl = serializers.URLField()
|
||||
numberOfCourses = serializers.IntegerField()
|
||||
estimatedNumberOfWeeks = serializers.IntegerField()
|
||||
|
||||
|
||||
class ProgramsSerializer(serializers.Serializer):
|
||||
"""Programs information"""
|
||||
|
||||
relatedPrograms = serializers.ListField(
|
||||
child=RelatedProgramSerializer(), allow_empty=True
|
||||
)
|
||||
|
||||
|
||||
class LearnerEnrollmentSerializer(serializers.Serializer):
|
||||
"""Info for displaying an enrollment on the learner dashboard"""
|
||||
|
||||
courseProvider = CourseProviderSerializer(allow_null=True)
|
||||
course = CourseSerializer()
|
||||
courseRun = CourseRunSerializer()
|
||||
enrollment = EnrollmentSerializer()
|
||||
gradeData = GradeDataSerializer()
|
||||
certificate = CertificateSerializer()
|
||||
entitlements = EntitlementSerializer()
|
||||
programs = ProgramsSerializer()
|
||||
|
||||
|
||||
class UnfulfilledEntitlementSerializer(serializers.Serializer):
|
||||
"""Serializer for an unfulfilled entitlement"""
|
||||
|
||||
courseProvider = CourseProviderSerializer(allow_null=True)
|
||||
course = CourseSerializer()
|
||||
entitlements = EntitlementSerializer()
|
||||
programs = ProgramsSerializer()
|
||||
|
||||
|
||||
class SuggestedCourseSerializer(serializers.Serializer):
|
||||
"""Serializer for a suggested course"""
|
||||
|
||||
bannerUrl = serializers.URLField()
|
||||
logoUrl = serializers.URLField()
|
||||
title = serializers.CharField()
|
||||
courseUrl = serializers.URLField()
|
||||
|
||||
|
||||
class EmailConfirmationSerializer(serializers.Serializer):
|
||||
"""Serializer for email confirmation banner resources"""
|
||||
|
||||
isNeeded = serializers.BooleanField()
|
||||
sendEmailUrl = serializers.URLField()
|
||||
|
||||
|
||||
class EnterpriseDashboardSerializer(serializers.Serializer):
|
||||
"""Serializer for individual enterprise dashboard data"""
|
||||
|
||||
label = serializers.CharField()
|
||||
url = serializers.URLField()
|
||||
|
||||
|
||||
class EnterpriseDashboardsSerializer(serializers.Serializer):
|
||||
"""Listing of available enterprise dashboards"""
|
||||
|
||||
availableDashboards = serializers.ListField(
|
||||
child=EnterpriseDashboardSerializer(), allow_empty=True
|
||||
)
|
||||
mostRecentDashboard = EnterpriseDashboardSerializer()
|
||||
|
||||
|
||||
class LearnerDashboardSerializer(serializers.Serializer):
|
||||
"""Serializer for all info required to render the Learner Dashboard"""
|
||||
|
||||
emailConfirmation = EmailConfirmationSerializer()
|
||||
enterpriseDashboards = EnterpriseDashboardsSerializer()
|
||||
platformSettings = PlatformSettingsSerializer()
|
||||
enrollments = serializers.ListField(
|
||||
child=LearnerEnrollmentSerializer(), allow_empty=True
|
||||
)
|
||||
unfulfilledEntitlements = serializers.ListField(
|
||||
child=UnfulfilledEntitlementSerializer(), allow_empty=True
|
||||
)
|
||||
suggestedCourses = serializers.ListField(
|
||||
child=SuggestedCourseSerializer(), allow_empty=True
|
||||
)
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Test for learner views and related functions"""
|
||||
|
||||
import json
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from lms.djangoapps.learner_dashboard.learner_views import get_platform_settings
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import (
|
||||
TEST_DATA_SPLIT_MODULESTORE,
|
||||
SharedModuleStoreTestCase,
|
||||
)
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
class TestGetPlatformSettings(TestCase):
|
||||
"""Tests for get_platform_settings"""
|
||||
|
||||
MOCK_SETTINGS = {
|
||||
"DEFAULT_FEEDBACK_EMAIL": f"{uuid4()}@example.com",
|
||||
"PAYMENT_SUPPORT_EMAIL": f"{uuid4()}@example.com",
|
||||
}
|
||||
|
||||
@patch.multiple("django.conf.settings", **MOCK_SETTINGS)
|
||||
@patch("lms.djangoapps.learner_dashboard.learner_views.marketing_link")
|
||||
def test_happy_path(self, mock_marketing_link):
|
||||
# Given email/search info exists
|
||||
mock_marketing_link.return_value = mock_search_url = f"/{uuid4()}"
|
||||
|
||||
# When I request those settings
|
||||
return_data = get_platform_settings()
|
||||
|
||||
# Then I return them in the appropriate format
|
||||
self.assertDictEqual(
|
||||
return_data,
|
||||
{
|
||||
"supportEmail": self.MOCK_SETTINGS["DEFAULT_FEEDBACK_EMAIL"],
|
||||
"billingEmail": self.MOCK_SETTINGS["PAYMENT_SUPPORT_EMAIL"],
|
||||
"courseSearchUrl": mock_search_url,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestDashboardView(SharedModuleStoreTestCase, APITestCase):
|
||||
"""Tests for the dashboard view"""
|
||||
|
||||
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
# Get view URL
|
||||
cls.view_url = reverse("dashboard_view")
|
||||
|
||||
# Set up a course
|
||||
cls.course = CourseFactory.create()
|
||||
cls.course_key = cls.course.location.course_key
|
||||
|
||||
# Set up a user
|
||||
cls.username = "alan"
|
||||
cls.password = "enigma"
|
||||
cls.user = UserFactory(username=cls.username, password=cls.password)
|
||||
|
||||
def log_in(self):
|
||||
"""Log in as a test user"""
|
||||
self.client.login(username=self.username, password=self.password)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.log_in()
|
||||
|
||||
def test_response_structure(self):
|
||||
"""Basic test for correct response structure"""
|
||||
|
||||
# Given I am logged in
|
||||
self.log_in()
|
||||
|
||||
# When I request the dashboard
|
||||
response = self.client.get(self.view_url)
|
||||
|
||||
# Then I get the expected success response
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = json.loads(response.content)
|
||||
expected_keys = set(
|
||||
[
|
||||
"emailConfirmation",
|
||||
"enterpriseDashboards",
|
||||
"platformSettings",
|
||||
"enrollments",
|
||||
"unfulfilledEntitlements",
|
||||
"suggestedCourses",
|
||||
]
|
||||
)
|
||||
|
||||
assert expected_keys == response_data.keys()
|
||||
@@ -2,15 +2,9 @@
|
||||
|
||||
from django.urls import path, re_path
|
||||
|
||||
from lms.djangoapps.learner_dashboard import learner_views, programs, program_views
|
||||
from lms.djangoapps.learner_dashboard import programs, program_views
|
||||
|
||||
# Learner Dashboard Routing
|
||||
urlpatterns = [
|
||||
path('learner/', learner_views.dashboard_view, name='dashboard_view')
|
||||
]
|
||||
|
||||
# Program Dashboard Routing
|
||||
urlpatterns += [
|
||||
path('programs/', program_views.program_listing, name='program_listing_view'),
|
||||
re_path(r'^programs/(?P<program_uuid>[0-9a-f-]+)/$', program_views.program_details, name='program_details_view'),
|
||||
re_path(r'^programs/(?P<program_uuid>[0-9a-f-]+)/discussion/$', program_views.ProgramDiscussionIframeView.as_view(),
|
||||
|
||||
8
lms/djangoapps/learner_home/README.md
Normal file
8
lms/djangoapps/learner_home/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
=================
|
||||
Learner Home
|
||||
=================
|
||||
|
||||
This is the new dashboard for learner courses, built as a backend supporting a new MFE experience of the "student dashboard".
|
||||
|
||||
This aims to replace the existing dashboard at::
|
||||
/common/djangoapps/student/views/dashboard.py
|
||||
0
lms/djangoapps/learner_home/__init__.py
Normal file
0
lms/djangoapps/learner_home/__init__.py
Normal file
33
lms/djangoapps/learner_home/docs/001-remove-course-limit.rst
Normal file
33
lms/djangoapps/learner_home/docs/001-remove-course-limit.rst
Normal file
@@ -0,0 +1,33 @@
|
||||
Remove course_limit
|
||||
--------------
|
||||
|
||||
Status
|
||||
======
|
||||
|
||||
Approved
|
||||
|
||||
Context
|
||||
=======
|
||||
|
||||
In the old student dashboard we had a built-in limit for the number of courses to show on the page (DASHBOARD_COURSE_LIMIT, 250). Previously, the user could manually show all courses by clicking a link on the page, shown only if they were enrolled in more courses than the course limit.
|
||||
|
||||
For the new learner dashboard, we need the ability to sort/filter. Without a currently built in way to paginate, we needed a way to either add or remove the issue of partial filters/sorting.
|
||||
|
||||
Decisions
|
||||
=========
|
||||
|
||||
To avoid the potential for filtering/sorting being incomplete, for the new dashboard we have decided to remove the dashboard course limit. This means all users will see all of their courses on their homepage by default. This will make local sorting and filtering accurate.
|
||||
|
||||
Consequences
|
||||
============
|
||||
|
||||
After taking a, hopefully, representative sample of users (by usage over a certain time period) we identified that ~0.2% of users meet or exceed the current dashboard limit, so we expect the impact to be small.
|
||||
|
||||
Possible consequences are longer page load time and increased backend system utilization.
|
||||
|
||||
|
||||
Alternatives
|
||||
============
|
||||
|
||||
1. We could design a pagination system for querying course enrollments. Unclear the technical lift here.
|
||||
2. We could only show up to a set limit and either restrict or, as before, have a manual trigger to request the full list of courses.
|
||||
322
lms/djangoapps/learner_home/serializers.py
Normal file
322
lms/djangoapps/learner_home/serializers.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Serializers for the Learner Dashboard
|
||||
"""
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from openedx.features.course_experience import course_home_url
|
||||
|
||||
|
||||
class PlatformSettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for platform-level info, emails, and URLs"""
|
||||
|
||||
supportEmail = serializers.EmailField()
|
||||
billingEmail = serializers.EmailField()
|
||||
courseSearchUrl = serializers.URLField()
|
||||
|
||||
|
||||
class CourseProviderSerializer(serializers.Serializer):
|
||||
"""Info about a course provider (institution/business)"""
|
||||
|
||||
name = serializers.CharField()
|
||||
|
||||
|
||||
class CourseSerializer(serializers.Serializer):
|
||||
"""Course header information, derived from a CourseOverview"""
|
||||
|
||||
bannerImgSrc = serializers.URLField(source="banner_image_url")
|
||||
courseName = serializers.CharField(source="display_name_with_default")
|
||||
courseNumber = serializers.CharField(source="display_number_with_default")
|
||||
|
||||
|
||||
class CourseRunSerializer(serializers.Serializer):
|
||||
"""
|
||||
Information about a course run.
|
||||
Derived from the CourseEnrollment with required context:
|
||||
- "resume_course_urls" (dict) with a matching course_id key
|
||||
- "ecommerce_payment_page" (url) root to the ecommerce page
|
||||
- "course_mode_info" (dict) keyed by course ID, with sub info:
|
||||
- "verified_sku" (uid, optional) if the course has an upgrade identifier
|
||||
- "days_for_upsell" (int, optional) days before audit student loses access
|
||||
"""
|
||||
|
||||
requires_context = True
|
||||
|
||||
isStarted = serializers.SerializerMethodField()
|
||||
isArchived = serializers.SerializerMethodField()
|
||||
courseId = serializers.CharField(source="course_id")
|
||||
minPassingGrade = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, source="course_overview.lowest_passing_grade"
|
||||
)
|
||||
endDate = serializers.DateTimeField(source="course_overview.end")
|
||||
homeUrl = serializers.SerializerMethodField()
|
||||
marketingUrl = serializers.URLField(
|
||||
source="course_overview.marketing_url", allow_null=True
|
||||
)
|
||||
progressUrl = serializers.SerializerMethodField()
|
||||
unenrollUrl = serializers.SerializerMethodField()
|
||||
upgradeUrl = serializers.SerializerMethodField()
|
||||
resumeUrl = serializers.SerializerMethodField()
|
||||
|
||||
def get_isStarted(self, instance):
|
||||
return instance.course_overview.has_started()
|
||||
|
||||
def get_isArchived(self, instance):
|
||||
return instance.course_overview.has_ended()
|
||||
|
||||
def get_homeUrl(self, instance):
|
||||
return course_home_url(instance.course_id)
|
||||
|
||||
def get_progressUrl(self, instance):
|
||||
return reverse("progress", kwargs={"course_id": instance.course_id})
|
||||
|
||||
def get_unenrollUrl(self, instance):
|
||||
return reverse("course_run_refund_status", args=[instance.course_id])
|
||||
|
||||
def get_upgradeUrl(self, instance):
|
||||
"""If the enrollment mode has a verified upgrade through ecommerce, return the link"""
|
||||
ecommerce_payment_page = self.context.get("ecommerce_payment_page")
|
||||
verified_sku = (
|
||||
self.context.get("course_mode_info", {})
|
||||
.get(instance.course_id, {})
|
||||
.get("verified_sku")
|
||||
)
|
||||
|
||||
if ecommerce_payment_page and verified_sku:
|
||||
return f"{ecommerce_payment_page}?sku={verified_sku}"
|
||||
|
||||
def get_resumeUrl(self, instance):
|
||||
return self.context.get("resume_course_urls", {}).get(instance.course_id)
|
||||
|
||||
|
||||
class EnrollmentSerializer(serializers.Serializer):
|
||||
"""
|
||||
Info about this particular enrollment.
|
||||
Derived from a CourseEnrollment with added context:
|
||||
- "use_ecommerce_payment_flow" (bool): whether or not we use an ecommerce flow to
|
||||
upsell.
|
||||
- "course_mode_info" (dict): keyed by course ID with the following values:
|
||||
- "expiration_datetime" (int): when the verified mode will expire.
|
||||
- "show_upsell" (bool): whether or not we offer an upsell for this course.
|
||||
- "verified_sku" (uuid): ID for the verified mode for upgrade.
|
||||
- "show_courseware_link": keyed by course ID with added metadata.
|
||||
- "show_email_settings_for" (dict): keyed by course ID with a boolean whether we
|
||||
show email settings.
|
||||
"""
|
||||
|
||||
accessExpirationDate = serializers.SerializerMethodField()
|
||||
isAudit = serializers.SerializerMethodField()
|
||||
hasStarted = serializers.SerializerMethodField()
|
||||
hasFinished = serializers.SerializerMethodField()
|
||||
isVerified = serializers.SerializerMethodField()
|
||||
canUpgrade = serializers.SerializerMethodField()
|
||||
isAuditAccessExpired = serializers.SerializerMethodField()
|
||||
isEmailEnabled = serializers.SerializerMethodField()
|
||||
hasOptedOutOfEmail = serializers.SerializerMethodField()
|
||||
lastEnrolled = serializers.DateTimeField(source="created")
|
||||
isEnrolled = serializers.BooleanField(source="is_active")
|
||||
|
||||
def get_accessExpirationDate(self, instance):
|
||||
return (
|
||||
self.context.get("course_mode_info", {})
|
||||
.get(instance.course_id)
|
||||
.get("expiration_datetime")
|
||||
)
|
||||
|
||||
def get_isAudit(self, enrollment):
|
||||
return enrollment.mode in CourseMode.AUDIT_MODES
|
||||
|
||||
def get_hasStarted(self, enrollment):
|
||||
"""Determined based on whether there's a 'resume' link on the course"""
|
||||
resume_button_url = self.context.get("resume_course_urls", {}).get(
|
||||
enrollment.course_id
|
||||
)
|
||||
return resume_button_url is not None
|
||||
|
||||
def get_hasFinished(self, enrollment):
|
||||
# TODO - AU-796
|
||||
return False
|
||||
|
||||
def get_isVerified(self, enrollment):
|
||||
return enrollment.is_verified_enrollment()
|
||||
|
||||
def get_canUpgrade(self, enrollment):
|
||||
"""Determine if a user can upgrade this enrollment to verified track"""
|
||||
use_ecommerce_payment_flow = self.context.get(
|
||||
"use_ecommerce_payment_flow", False
|
||||
)
|
||||
course_mode_info = self.context.get("course_mode_info", {}).get(
|
||||
enrollment.course_id, {}
|
||||
)
|
||||
return bool(
|
||||
use_ecommerce_payment_flow
|
||||
and course_mode_info.get("show_upsell", False)
|
||||
and course_mode_info.get("verified_sku", False)
|
||||
)
|
||||
|
||||
def get_isAuditAccessExpired(self, enrollment):
|
||||
show_courseware_link = self.context.get("show_courseware_link", {}).get(
|
||||
enrollment.course.id, {}
|
||||
)
|
||||
return show_courseware_link.get("error_code") == "audit_expired"
|
||||
|
||||
def get_isEmailEnabled(self, enrollment):
|
||||
return enrollment.course_id in self.context.get("show_email_settings_for", [])
|
||||
|
||||
def get_hasOptedOutOfEmail(self, enrollment):
|
||||
return enrollment.course_id in self.context.get("course_optouts", [])
|
||||
|
||||
|
||||
class GradeDataSerializer(serializers.Serializer):
|
||||
"""Info about grades for this enrollment"""
|
||||
|
||||
isPassing = serializers.BooleanField()
|
||||
|
||||
|
||||
class CertificateSerializer(serializers.Serializer):
|
||||
"""Certificate availability info"""
|
||||
|
||||
availableDate = serializers.DateTimeField(allow_null=True)
|
||||
isRestricted = serializers.BooleanField()
|
||||
isAvailable = serializers.BooleanField()
|
||||
isEarned = serializers.BooleanField()
|
||||
isDownloadable = serializers.BooleanField()
|
||||
certPreviewUrl = serializers.URLField(allow_null=True)
|
||||
certDownloadUrl = serializers.URLField(allow_null=True)
|
||||
honorCertDownloadUrl = serializers.URLField(allow_null=True)
|
||||
|
||||
|
||||
class AvailableEntitlementSessionSerializer(serializers.Serializer):
|
||||
"""An available entitlement session"""
|
||||
|
||||
startDate = serializers.DateTimeField()
|
||||
endDate = serializers.DateTimeField()
|
||||
courseId = serializers.CharField()
|
||||
|
||||
|
||||
class EntitlementSerializer(serializers.Serializer):
|
||||
"""Entitlements info"""
|
||||
|
||||
availableSessions = serializers.ListField(
|
||||
child=AvailableEntitlementSessionSerializer(), allow_empty=True
|
||||
)
|
||||
isRefundable = serializers.BooleanField()
|
||||
isFulfilled = serializers.BooleanField()
|
||||
canViewCourse = serializers.BooleanField()
|
||||
changeDeadline = serializers.DateTimeField()
|
||||
isExpired = serializers.BooleanField()
|
||||
expirationDate = serializers.DateTimeField()
|
||||
|
||||
|
||||
class RelatedProgramSerializer(serializers.Serializer):
|
||||
"""Related programs information"""
|
||||
|
||||
bannerUrl = serializers.URLField()
|
||||
estimatedNumberOfWeeks = serializers.IntegerField()
|
||||
logoUrl = serializers.URLField()
|
||||
numberOfCourses = serializers.IntegerField()
|
||||
programType = serializers.CharField()
|
||||
programUrl = serializers.URLField()
|
||||
provider = serializers.CharField()
|
||||
title = serializers.CharField()
|
||||
|
||||
|
||||
class ProgramsSerializer(serializers.Serializer):
|
||||
"""Programs information"""
|
||||
|
||||
relatedPrograms = serializers.ListField(
|
||||
child=RelatedProgramSerializer(), allow_empty=True
|
||||
)
|
||||
|
||||
|
||||
class LearnerEnrollmentSerializer(serializers.Serializer):
|
||||
"""
|
||||
Info for displaying an enrollment on the learner dashboard.
|
||||
Derived from a CourseEnrollment with added context.
|
||||
"""
|
||||
|
||||
course = CourseSerializer()
|
||||
courseRun = CourseRunSerializer(source="*")
|
||||
enrollment = EnrollmentSerializer(source="*")
|
||||
|
||||
# TODO - remove "allow_null" as each of these are implemented, temp for testing.
|
||||
courseProvider = CourseProviderSerializer(allow_null=True)
|
||||
gradeData = GradeDataSerializer(allow_null=True)
|
||||
certificate = CertificateSerializer(allow_null=True)
|
||||
entitlements = EntitlementSerializer(allow_null=True)
|
||||
programs = ProgramsSerializer(allow_null=True)
|
||||
|
||||
|
||||
class UnfulfilledEntitlementSerializer(serializers.Serializer):
|
||||
"""Serializer for an unfulfilled entitlement"""
|
||||
|
||||
courseProvider = CourseProviderSerializer(allow_null=True)
|
||||
course = CourseSerializer()
|
||||
entitlements = EntitlementSerializer()
|
||||
programs = ProgramsSerializer()
|
||||
|
||||
|
||||
class SuggestedCourseSerializer(serializers.Serializer):
|
||||
"""Serializer for a suggested course"""
|
||||
|
||||
bannerUrl = serializers.URLField()
|
||||
logoUrl = serializers.URLField()
|
||||
title = serializers.CharField()
|
||||
courseUrl = serializers.URLField()
|
||||
|
||||
|
||||
class EmailConfirmationSerializer(serializers.Serializer):
|
||||
"""Serializer for email confirmation banner resources"""
|
||||
|
||||
isNeeded = serializers.BooleanField()
|
||||
sendEmailUrl = serializers.URLField()
|
||||
|
||||
|
||||
class EnterpriseDashboardSerializer(serializers.Serializer):
|
||||
"""Serializer for individual enterprise dashboard data"""
|
||||
|
||||
label = serializers.CharField()
|
||||
url = serializers.URLField()
|
||||
|
||||
|
||||
class EnterpriseDashboardsSerializer(serializers.Serializer):
|
||||
"""Listing of available enterprise dashboards"""
|
||||
|
||||
availableDashboards = serializers.ListField(
|
||||
child=EnterpriseDashboardSerializer(), allow_empty=True
|
||||
)
|
||||
mostRecentDashboard = EnterpriseDashboardSerializer()
|
||||
|
||||
|
||||
class LearnerDashboardSerializer(serializers.Serializer):
|
||||
"""Serializer for all info required to render the Learner Dashboard"""
|
||||
|
||||
requires_context = True
|
||||
|
||||
emailConfirmation = EmailConfirmationSerializer()
|
||||
enterpriseDashboards = EnterpriseDashboardsSerializer()
|
||||
platformSettings = PlatformSettingsSerializer()
|
||||
courses = serializers.SerializerMethodField()
|
||||
suggestedCourses = serializers.ListField(
|
||||
child=SuggestedCourseSerializer(), allow_empty=True
|
||||
)
|
||||
|
||||
def get_courses(self, instance):
|
||||
"""
|
||||
Get a list of course cards by serializing enrollments and entitlements into
|
||||
a single list.
|
||||
"""
|
||||
courses = []
|
||||
|
||||
for enrollment in instance.get("enrollments", []):
|
||||
courses.append(
|
||||
LearnerEnrollmentSerializer(enrollment, context=self.context).data
|
||||
)
|
||||
for entitlement in instance.get("unfulfilledEntitlements", []):
|
||||
courses.append(
|
||||
UnfulfilledEntitlementSerializer(entitlement, context=self.context).data
|
||||
)
|
||||
|
||||
return courses
|
||||
@@ -1,13 +1,19 @@
|
||||
"""Tests for serializers for the Learner Dashboard"""
|
||||
|
||||
import datetime
|
||||
from random import choice, getrandbits, randint
|
||||
from time import time
|
||||
from random import randint
|
||||
from unittest import TestCase
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from lms.djangoapps.learner_dashboard.serializers import (
|
||||
import ddt
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
|
||||
from common.djangoapps.student.tests.factories import (
|
||||
CourseEnrollmentFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from lms.djangoapps.learner_home.serializers import (
|
||||
CertificateSerializer,
|
||||
CourseProviderSerializer,
|
||||
CourseRunSerializer,
|
||||
@@ -24,49 +30,41 @@ from lms.djangoapps.learner_dashboard.serializers import (
|
||||
SuggestedCourseSerializer,
|
||||
UnfulfilledEntitlementSerializer,
|
||||
)
|
||||
from lms.djangoapps.learner_home.test_utils import (
|
||||
datetime_to_django_format,
|
||||
random_bool,
|
||||
random_date,
|
||||
random_url,
|
||||
)
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
def random_bool():
|
||||
"""Test util for generating a random boolean"""
|
||||
return bool(getrandbits(1))
|
||||
class LearnerDashboardBaseTest(SharedModuleStoreTestCase):
|
||||
"""Base class for common setup"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.user = UserFactory()
|
||||
|
||||
def random_date(allow_null=False):
|
||||
"""Test util for generating a random date, optionally blank"""
|
||||
def create_test_enrollment(self):
|
||||
"""Create a test user, course, and enrollment. Return the enrollment."""
|
||||
course = CourseFactory(self_paced=True)
|
||||
CourseModeFactory(
|
||||
course_id=course.id,
|
||||
mode_slug=CourseMode.AUDIT,
|
||||
)
|
||||
|
||||
# If null allowed, return null half the time
|
||||
if allow_null and random_bool():
|
||||
return None
|
||||
test_enrollment = CourseEnrollmentFactory(
|
||||
course_id=course.id, mode=CourseMode.AUDIT
|
||||
)
|
||||
|
||||
d = randint(1, int(time()))
|
||||
return datetime.datetime.fromtimestamp(d)
|
||||
# Add extra info to exercise serialization
|
||||
test_enrollment.course_overview.marketing_url = random_url()
|
||||
test_enrollment.course_overview.end = random_date()
|
||||
|
||||
|
||||
def random_url(allow_null=False):
|
||||
"""Test util for generating a random URL, optionally blank"""
|
||||
|
||||
# If null allowed, return null half the time
|
||||
if allow_null and random_bool():
|
||||
return None
|
||||
|
||||
random_uuid = uuid4()
|
||||
return choice([f"{random_uuid}.example.com", f"example.com/{random_uuid}"])
|
||||
|
||||
|
||||
def random_grade():
|
||||
"""Return a random grade (0-100) with 2 decimal places of padding"""
|
||||
return randint(0, 10000) / 100
|
||||
|
||||
|
||||
def decimal_to_grade_format(decimal):
|
||||
"""Util for matching serialized grade format, pads a decimal to 2 places"""
|
||||
return "{:.2f}".format(decimal)
|
||||
|
||||
|
||||
def datetime_to_django_format(datetime_obj):
|
||||
"""Util for matching serialized Django datetime format for comparison"""
|
||||
if datetime_obj:
|
||||
return datetime_obj.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return test_enrollment
|
||||
|
||||
|
||||
class TestPlatformSettingsSerializer(TestCase):
|
||||
@@ -101,8 +99,6 @@ class TestCourseProviderSerializer(TestCase):
|
||||
"""Util to generate test provider info"""
|
||||
return {
|
||||
"name": f"{uuid4()}",
|
||||
"website": f"{uuid4()}.example.com",
|
||||
"email": f"{uuid4()}@example.com",
|
||||
}
|
||||
|
||||
def test_happy_path(self):
|
||||
@@ -111,110 +107,138 @@ class TestCourseProviderSerializer(TestCase):
|
||||
|
||||
assert output_data == {
|
||||
"name": input_data["name"],
|
||||
"website": input_data["website"],
|
||||
"email": input_data["email"],
|
||||
}
|
||||
|
||||
|
||||
class TestCourseSerializer(TestCase):
|
||||
class TestCourseSerializer(LearnerDashboardBaseTest):
|
||||
"""Tests for the CourseSerializer"""
|
||||
|
||||
@classmethod
|
||||
def generate_test_course_info(cls):
|
||||
"""Util to generate test course info"""
|
||||
return {
|
||||
"bannerImgSrc": f"example.com/assets/{uuid4()}",
|
||||
"courseName": f"{uuid4()}",
|
||||
}
|
||||
|
||||
def test_happy_path(self):
|
||||
input_data = self.generate_test_course_info()
|
||||
test_enrollment = self.create_test_enrollment()
|
||||
|
||||
input_data = test_enrollment.course_overview
|
||||
output_data = CourseSerializer(input_data).data
|
||||
|
||||
assert output_data == {
|
||||
"bannerImgSrc": input_data["bannerImgSrc"],
|
||||
"courseName": input_data["courseName"],
|
||||
"bannerImgSrc": test_enrollment.course_overview.banner_image_url,
|
||||
"courseName": test_enrollment.course_overview.display_name_with_default,
|
||||
"courseNumber": test_enrollment.course_overview.display_number_with_default,
|
||||
}
|
||||
|
||||
|
||||
class TestCourseRunSerializer(TestCase):
|
||||
class TestCourseRunSerializer(LearnerDashboardBaseTest):
|
||||
"""Tests for the CourseRunSerializer"""
|
||||
|
||||
@classmethod
|
||||
def generate_test_course_run_info(cls):
|
||||
"""Util to generate test course run info"""
|
||||
return {
|
||||
"isPending": random_bool(),
|
||||
"isStarted": random_bool(),
|
||||
"isFinished": random_bool(),
|
||||
"isArchived": random_bool(),
|
||||
"courseNumber": f"{uuid4()}-101",
|
||||
"accessExpirationDate": random_date(),
|
||||
"minPassingGrade": random_grade(),
|
||||
"endDate": random_date(),
|
||||
"homeUrl": f"{uuid4()}.example.com",
|
||||
"marketingUrl": f"{uuid4()}.example.com",
|
||||
"progressUrl": f"{uuid4()}.example.com",
|
||||
"unenrollUrl": f"{uuid4()}.example.com",
|
||||
"upgradeUrl": f"{uuid4()}.example.com",
|
||||
def test_with_data(self):
|
||||
input_data = self.create_test_enrollment()
|
||||
|
||||
input_context = {
|
||||
"resume_course_urls": {input_data.course.id: random_url()},
|
||||
"ecommerce_payment_page": random_url(),
|
||||
"course_mode_info": {
|
||||
input_data.course.id: {
|
||||
"verified_sku": str(uuid4()),
|
||||
"days_for_upsell": randint(0, 14),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def test_happy_path(self):
|
||||
input_data = self.generate_test_course_run_info()
|
||||
output_data = CourseRunSerializer(input_data).data
|
||||
serializer = CourseRunSerializer(input_data, context=input_context)
|
||||
output = serializer.data
|
||||
|
||||
assert output_data == {
|
||||
"isPending": input_data["isPending"],
|
||||
"isStarted": input_data["isStarted"],
|
||||
"isFinished": input_data["isFinished"],
|
||||
"isArchived": input_data["isArchived"],
|
||||
"courseNumber": input_data["courseNumber"],
|
||||
"accessExpirationDate": datetime_to_django_format(
|
||||
input_data["accessExpirationDate"]
|
||||
),
|
||||
"minPassingGrade": decimal_to_grade_format(input_data["minPassingGrade"]),
|
||||
"endDate": datetime_to_django_format(input_data["endDate"]),
|
||||
"homeUrl": input_data["homeUrl"],
|
||||
"marketingUrl": input_data["marketingUrl"],
|
||||
"progressUrl": input_data["progressUrl"],
|
||||
"unenrollUrl": input_data["unenrollUrl"],
|
||||
"upgradeUrl": input_data["upgradeUrl"],
|
||||
}
|
||||
# Serializaiton set up so all fields will have values to make testing easy
|
||||
for key in output:
|
||||
assert output[key] is not None
|
||||
|
||||
|
||||
class TestEnrollmentSerializer(TestCase):
|
||||
@ddt.ddt
|
||||
class TestEnrollmentSerializer(LearnerDashboardBaseTest):
|
||||
"""Tests for the EnrollmentSerializer"""
|
||||
|
||||
@classmethod
|
||||
def generate_test_enrollment_info(cls):
|
||||
"""Util to generate test enrollment info"""
|
||||
def create_test_context(self, course):
|
||||
"""Get a test context object"""
|
||||
return {
|
||||
"isAudit": random_bool(),
|
||||
"isVerified": random_bool(),
|
||||
"canUpgrade": random_bool(),
|
||||
"isAuditAccessExpired": random_bool(),
|
||||
"isEmailEnabled": random_bool(),
|
||||
"lastEnrolled": random_date(),
|
||||
"isEnrolled": random_bool(),
|
||||
"course_mode_info": {
|
||||
course.id: {
|
||||
"expiration_datetime": random_date(),
|
||||
"show_upsell": True,
|
||||
}
|
||||
},
|
||||
"course_optouts": [],
|
||||
"show_email_settings_for": [course.id],
|
||||
"show_courseware_link": {course.id: {"has_access": True}},
|
||||
"resume_course_urls": {course.id: "some_url"},
|
||||
"use_ecommerce_payment_flow": True,
|
||||
}
|
||||
|
||||
def test_happy_path(self):
|
||||
input_data = self.generate_test_enrollment_info()
|
||||
output_data = EnrollmentSerializer(input_data).data
|
||||
def test_with_data(self):
|
||||
input_data = self.create_test_enrollment()
|
||||
input_context = self.create_test_context(input_data.course)
|
||||
|
||||
self.assertDictEqual(
|
||||
output_data,
|
||||
serializer = EnrollmentSerializer(input_data, context=input_context)
|
||||
output = serializer.data
|
||||
|
||||
# Serializaiton set up so all fields will have values to make testing easy
|
||||
for key in output:
|
||||
assert output[key] is not None
|
||||
|
||||
def test_audit_access_expired(self):
|
||||
input_data = self.create_test_enrollment()
|
||||
input_context = self.create_test_context(input_data.course)
|
||||
|
||||
# Example audit expired context
|
||||
input_context.update(
|
||||
{
|
||||
"isAudit": input_data["isAudit"],
|
||||
"isVerified": input_data["isVerified"],
|
||||
"canUpgrade": input_data["canUpgrade"],
|
||||
"isAuditAccessExpired": input_data["isAuditAccessExpired"],
|
||||
"isEmailEnabled": input_data["isEmailEnabled"],
|
||||
"lastEnrolled": datetime_to_django_format(input_data["lastEnrolled"]),
|
||||
"isEnrolled": input_data["isEnrolled"],
|
||||
},
|
||||
"show_courseware_link": {
|
||||
input_data.course.id: {"error_code": "audit_expired"}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
serializer = EnrollmentSerializer(input_data, context=input_context)
|
||||
output = serializer.data
|
||||
|
||||
assert output["isAuditAccessExpired"] is True
|
||||
|
||||
def test_user_can_upgrade(self):
|
||||
input_data = self.create_test_enrollment()
|
||||
input_context = self.create_test_context(input_data.course)
|
||||
|
||||
# Example audit expired context
|
||||
input_context.update(
|
||||
{
|
||||
"course_mode_info": {
|
||||
input_data.course.id: {"show_upsell": True, "verified_sku": uuid4()}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
output = EnrollmentSerializer(input_data, context=input_context).data
|
||||
assert output["canUpgrade"] is True
|
||||
|
||||
@ddt.data(None, "some_url")
|
||||
def test_has_started(self, resume_url):
|
||||
# Given the presence or lack of a resume_course_url
|
||||
input_data = self.create_test_enrollment()
|
||||
input_context = self.create_test_context(input_data.course)
|
||||
|
||||
input_context.update(
|
||||
{
|
||||
"resume_course_urls": {
|
||||
input_data.course.id: resume_url,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# When I get "hasStarted"
|
||||
output = EnrollmentSerializer(input_data, context=input_context).data
|
||||
|
||||
# If I have a resume URL, "hasStarted" should be True, otherwise False
|
||||
if resume_url:
|
||||
self.assertTrue(output["hasStarted"])
|
||||
else:
|
||||
self.assertFalse(output["hasStarted"])
|
||||
|
||||
|
||||
class TestGradeDataSerializer(TestCase):
|
||||
"""Tests for the GradeDataSerializer"""
|
||||
@@ -277,7 +301,7 @@ class TestEntitlementSerializer(TestCase):
|
||||
return {
|
||||
"startDate": random_date(),
|
||||
"endDate": random_date(),
|
||||
"courseNumber": f"{uuid4()}-101",
|
||||
"courseId": f"{uuid4()}",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -328,15 +352,14 @@ class TestProgramsSerializer(TestCase):
|
||||
def generate_test_related_program(cls):
|
||||
"""Generate a program with random test data"""
|
||||
return {
|
||||
"provider": f"{uuid4()} Inc.",
|
||||
"programUrl": random_url(),
|
||||
"bannerUrl": random_url(),
|
||||
"logoUrl": random_url(),
|
||||
"title": f"{uuid4()}",
|
||||
"programType": f"{uuid4()}",
|
||||
"programTypeUrl": random_url(),
|
||||
"numberOfCourses": randint(0, 100),
|
||||
"estimatedNumberOfWeeks": randint(0, 45),
|
||||
"logoUrl": random_url(),
|
||||
"numberOfCourses": randint(0, 100),
|
||||
"programType": f"{uuid4()}",
|
||||
"programUrl": random_url(),
|
||||
"provider": f"{uuid4()} Inc.",
|
||||
"title": f"{uuid4()}",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -357,15 +380,14 @@ class TestProgramsSerializer(TestCase):
|
||||
for i, related_program in enumerate(related_programs):
|
||||
input_program = input_data["relatedPrograms"][i]
|
||||
assert related_program == {
|
||||
"provider": input_program["provider"],
|
||||
"programUrl": input_program["programUrl"],
|
||||
"bannerUrl": input_program["bannerUrl"],
|
||||
"logoUrl": input_program["logoUrl"],
|
||||
"title": input_program["title"],
|
||||
"programType": input_program["programType"],
|
||||
"programTypeUrl": input_program["programTypeUrl"],
|
||||
"numberOfCourses": input_program["numberOfCourses"],
|
||||
"estimatedNumberOfWeeks": input_program["estimatedNumberOfWeeks"],
|
||||
"logoUrl": input_program["logoUrl"],
|
||||
"numberOfCourses": input_program["numberOfCourses"],
|
||||
"programType": input_program["programType"],
|
||||
"programUrl": input_program["programUrl"],
|
||||
"provider": input_program["provider"],
|
||||
"title": input_program["title"],
|
||||
}
|
||||
|
||||
self.assertDictEqual(output_data, {})
|
||||
@@ -377,27 +399,29 @@ class TestProgramsSerializer(TestCase):
|
||||
assert output_data == {"relatedPrograms": []}
|
||||
|
||||
|
||||
class TestLearnerEnrollmentsSerializer(TestCase):
|
||||
class TestLearnerEnrollmentsSerializer(LearnerDashboardBaseTest):
|
||||
"""High-level tests for LearnerEnrollmentsSerializer"""
|
||||
|
||||
@classmethod
|
||||
def generate_test_enrollments_data(cls):
|
||||
return {
|
||||
"courseProvider": TestCourseProviderSerializer.generate_test_provider_info(),
|
||||
"course": TestCourseSerializer.generate_test_course_info(),
|
||||
"courseRun": TestCourseRunSerializer.generate_test_course_run_info(),
|
||||
"enrollment": TestEnrollmentSerializer.generate_test_enrollment_info(),
|
||||
"gradeData": TestGradeDataSerializer.generate_test_grade_data(),
|
||||
"certificate": TestCertificateSerializer.generate_test_certificate_info(),
|
||||
"entitlements": TestEntitlementSerializer.generate_test_entitlement_info(),
|
||||
"programs": TestProgramsSerializer.generate_test_programs_info(),
|
||||
}
|
||||
|
||||
def test_happy_path(self):
|
||||
"""Test that nothing breaks and the output fields look correct"""
|
||||
input_data = self.generate_test_enrollments_data()
|
||||
|
||||
output_data = LearnerEnrollmentSerializer(input_data).data
|
||||
enrollment = self.create_test_enrollment()
|
||||
|
||||
input_data = enrollment
|
||||
input_context = {
|
||||
"resume_course_urls": {enrollment.course.id: random_url()},
|
||||
"ecommerce_payment_page": random_url(),
|
||||
"course_mode_info": {
|
||||
enrollment.course.id: {
|
||||
"verified_sku": str(uuid4()),
|
||||
"days_for_upsell": randint(0, 14),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
output_data = LearnerEnrollmentSerializer(
|
||||
input_data, context=input_context
|
||||
).data
|
||||
|
||||
expected_keys = [
|
||||
"courseProvider",
|
||||
@@ -412,14 +436,16 @@ class TestLearnerEnrollmentsSerializer(TestCase):
|
||||
assert output_data.keys() == set(expected_keys)
|
||||
|
||||
|
||||
class TestUnfulfilledEntitlementSerializer(TestCase):
|
||||
class TestUnfulfilledEntitlementSerializer(LearnerDashboardBaseTest):
|
||||
"""High-level tests for UnfulfilledEntitlementSerializer"""
|
||||
|
||||
@classmethod
|
||||
def generate_test_entitlements_data(cls):
|
||||
mock_enrollment = cls.create_test_enrollment(cls)
|
||||
|
||||
return {
|
||||
"courseProvider": TestCourseProviderSerializer.generate_test_provider_info(),
|
||||
"course": TestCourseSerializer.generate_test_course_info(),
|
||||
"course": mock_enrollment.course,
|
||||
"entitlements": TestEntitlementSerializer.generate_test_entitlement_info(),
|
||||
"programs": TestProgramsSerializer.generate_test_programs_info(),
|
||||
}
|
||||
@@ -585,7 +611,7 @@ class TestEnterpriseDashboardsSerializer(TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestLearnerDashboardSerializer(TestCase):
|
||||
class TestLearnerDashboardSerializer(LearnerDashboardBaseTest):
|
||||
"""High-level tests for Learner Dashboard serialization"""
|
||||
|
||||
# Show full diff for serialization issues
|
||||
@@ -610,29 +636,66 @@ class TestLearnerDashboardSerializer(TestCase):
|
||||
"emailConfirmation": None,
|
||||
"enterpriseDashboards": None,
|
||||
"platformSettings": None,
|
||||
"enrollments": [],
|
||||
"unfulfilledEntitlements": [],
|
||||
"courses": [],
|
||||
"suggestedCourses": [],
|
||||
},
|
||||
)
|
||||
|
||||
def test_enrollments(self):
|
||||
"""Test that enrollments-related info is linked and serialized correctly"""
|
||||
|
||||
enrollments = [self.create_test_enrollment()]
|
||||
|
||||
resume_course_urls = {
|
||||
enrollment.course.id: random_url() for enrollment in enrollments
|
||||
}
|
||||
course_mode_info = {
|
||||
enrollment.course.id: {
|
||||
"verified_sku": str(uuid4()),
|
||||
"days_for_upsell": randint(0, 14),
|
||||
}
|
||||
for enrollment in enrollments
|
||||
if enrollment.mode == "audit"
|
||||
}
|
||||
|
||||
input_data = {
|
||||
"emailConfirmation": None,
|
||||
"enterpriseDashboards": None,
|
||||
"platformSettings": None,
|
||||
"enrollments": enrollments,
|
||||
"unfulfilledEntitlements": [],
|
||||
"suggestedCourses": [],
|
||||
}
|
||||
|
||||
input_context = {
|
||||
"resume_course_urls": resume_course_urls,
|
||||
"ecommerce_payment_page": random_url(),
|
||||
"course_mode_info": course_mode_info,
|
||||
}
|
||||
|
||||
output_data = LearnerDashboardSerializer(input_data, context=input_context).data
|
||||
|
||||
# Right now just make sure nothing broke
|
||||
courses = output_data.pop("courses")
|
||||
assert courses is not None
|
||||
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_dashboard.serializers.SuggestedCourseSerializer.to_representation"
|
||||
"lms.djangoapps.learner_home.serializers.SuggestedCourseSerializer.to_representation"
|
||||
)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_dashboard.serializers.UnfulfilledEntitlementSerializer.to_representation"
|
||||
"lms.djangoapps.learner_home.serializers.UnfulfilledEntitlementSerializer.data"
|
||||
)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_dashboard.serializers.LearnerEnrollmentSerializer.to_representation"
|
||||
"lms.djangoapps.learner_home.serializers.LearnerEnrollmentSerializer.data"
|
||||
)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_dashboard.serializers.PlatformSettingsSerializer.to_representation"
|
||||
"lms.djangoapps.learner_home.serializers.PlatformSettingsSerializer.to_representation"
|
||||
)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_dashboard.serializers.EnterpriseDashboardsSerializer.to_representation"
|
||||
"lms.djangoapps.learner_home.serializers.EnterpriseDashboardsSerializer.to_representation"
|
||||
)
|
||||
@mock.patch(
|
||||
"lms.djangoapps.learner_dashboard.serializers.EmailConfirmationSerializer.to_representation"
|
||||
"lms.djangoapps.learner_home.serializers.EmailConfirmationSerializer.to_representation"
|
||||
)
|
||||
def test_linkage(
|
||||
self,
|
||||
@@ -674,8 +737,10 @@ class TestLearnerDashboardSerializer(TestCase):
|
||||
"emailConfirmation": mock_email_confirmation_serializer,
|
||||
"enterpriseDashboards": mock_enterprise_dashboards_serializer,
|
||||
"platformSettings": mock_platform_settings_serializer,
|
||||
"enrollments": [mock_learner_enrollment_serializer],
|
||||
"unfulfilledEntitlements": [mock_entitlements_serializer],
|
||||
"courses": [
|
||||
mock_learner_enrollment_serializer,
|
||||
mock_entitlements_serializer,
|
||||
],
|
||||
"suggestedCourses": [mock_suggestions_serializer],
|
||||
},
|
||||
)
|
||||
50
lms/djangoapps/learner_home/test_utils.py
Normal file
50
lms/djangoapps/learner_home/test_utils.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Various utilities used for testing/test data.
|
||||
"""
|
||||
import datetime
|
||||
from random import choice, getrandbits, randint
|
||||
from time import time
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def random_bool():
|
||||
"""Test util for generating a random boolean"""
|
||||
return bool(getrandbits(1))
|
||||
|
||||
|
||||
def random_date(allow_null=False):
|
||||
"""Test util for generating a random date, optionally blank"""
|
||||
|
||||
# If null allowed, return null half the time
|
||||
if allow_null and random_bool():
|
||||
return None
|
||||
|
||||
d = randint(1, int(time()))
|
||||
return datetime.datetime.fromtimestamp(d, tz=datetime.timezone.utc)
|
||||
|
||||
|
||||
def random_url(allow_null=False):
|
||||
"""Test util for generating a random URL, optionally blank"""
|
||||
|
||||
# If null allowed, return null half the time
|
||||
if allow_null and random_bool():
|
||||
return None
|
||||
|
||||
random_uuid = uuid4()
|
||||
return choice([f"{random_uuid}.example.com", f"example.com/{random_uuid}"])
|
||||
|
||||
|
||||
def random_grade():
|
||||
"""Return a random grade (0-100) with 2 decimal places of padding"""
|
||||
return randint(0, 10000) / 100
|
||||
|
||||
|
||||
def decimal_to_grade_format(decimal):
|
||||
"""Util for matching serialized grade format, pads a decimal to 2 places"""
|
||||
return "{:.2f}".format(decimal)
|
||||
|
||||
|
||||
def datetime_to_django_format(datetime_obj):
|
||||
"""Util for matching serialized Django datetime format for comparison"""
|
||||
if datetime_obj:
|
||||
return datetime_obj.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
283
lms/djangoapps/learner_home/test_views.py
Normal file
283
lms/djangoapps/learner_home/test_views.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Test for learner views and related functions"""
|
||||
|
||||
import json
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import ddt
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
|
||||
from common.djangoapps.student.tests.factories import (
|
||||
CourseEnrollmentFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from lms.djangoapps.bulk_email.models import Optout
|
||||
from lms.djangoapps.learner_home.views import (
|
||||
get_email_settings_info,
|
||||
get_enrollments,
|
||||
get_platform_settings,
|
||||
get_user_account_confirmation_info,
|
||||
)
|
||||
from lms.djangoapps.learner_home.test_serializers import random_url
|
||||
from xmodule.modulestore.tests.django_utils import (
|
||||
TEST_DATA_SPLIT_MODULESTORE,
|
||||
SharedModuleStoreTestCase,
|
||||
)
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
class TestGetPlatformSettings(TestCase):
|
||||
"""Tests for get_platform_settings"""
|
||||
|
||||
MOCK_SETTINGS = {
|
||||
"DEFAULT_FEEDBACK_EMAIL": f"{uuid4()}@example.com",
|
||||
"PAYMENT_SUPPORT_EMAIL": f"{uuid4()}@example.com",
|
||||
}
|
||||
|
||||
@patch.multiple("django.conf.settings", **MOCK_SETTINGS)
|
||||
@patch("lms.djangoapps.learner_home.views.marketing_link")
|
||||
def test_happy_path(self, mock_marketing_link):
|
||||
# Given email/search info exists
|
||||
mock_marketing_link.return_value = mock_search_url = f"/{uuid4()}"
|
||||
|
||||
# When I request those settings
|
||||
return_data = get_platform_settings()
|
||||
|
||||
# Then I return them in the appropriate format
|
||||
self.assertDictEqual(
|
||||
return_data,
|
||||
{
|
||||
"supportEmail": self.MOCK_SETTINGS["DEFAULT_FEEDBACK_EMAIL"],
|
||||
"billingEmail": self.MOCK_SETTINGS["PAYMENT_SUPPORT_EMAIL"],
|
||||
"courseSearchUrl": mock_search_url,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestGetUserAccountConfirmationInfo(SharedModuleStoreTestCase):
|
||||
"""Tests for get_user_account_confirmation_info"""
|
||||
|
||||
MOCK_SETTINGS = {
|
||||
"ACTIVATION_EMAIL_SUPPORT_LINK": "activation.example.com",
|
||||
"SUPPORT_SITE_LINK": "support.example.com",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def mock_response(cls):
|
||||
return {
|
||||
"isNeeded": False,
|
||||
"sendEmailUrl": random_url(),
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
|
||||
@patch.multiple("django.conf.settings", **MOCK_SETTINGS)
|
||||
@ddt.data(True, False)
|
||||
def test_is_needed(self, user_is_active):
|
||||
"""Email confirmation is needed when the user is not active"""
|
||||
self.user.is_active = user_is_active
|
||||
|
||||
user_account_confirmation_info = get_user_account_confirmation_info(self.user)
|
||||
|
||||
assert user_account_confirmation_info["isNeeded"] == (not user_is_active)
|
||||
|
||||
@patch(
|
||||
"django.conf.settings.ACTIVATION_EMAIL_SUPPORT_LINK",
|
||||
MOCK_SETTINGS["ACTIVATION_EMAIL_SUPPORT_LINK"],
|
||||
)
|
||||
def test_email_url_support_link(self):
|
||||
# Given an ACTIVATION_EMAIL_SUPPORT_LINK is supplied
|
||||
# When I get user account confirmation info
|
||||
user_account_confirmation_info = get_user_account_confirmation_info(self.user)
|
||||
|
||||
# Then that link should be returned as the sendEmailUrl
|
||||
self.assertEqual(
|
||||
user_account_confirmation_info["sendEmailUrl"],
|
||||
self.MOCK_SETTINGS["ACTIVATION_EMAIL_SUPPORT_LINK"],
|
||||
)
|
||||
|
||||
@patch("lms.djangoapps.learner_home.views.configuration_helpers")
|
||||
@patch("django.conf.settings.SUPPORT_SITE_LINK", MOCK_SETTINGS["SUPPORT_SITE_LINK"])
|
||||
def test_email_url_support_fallback_link(self, mock_config_helpers):
|
||||
# Given an ACTIVATION_EMAIL_SUPPORT_LINK is NOT supplied
|
||||
mock_config_helpers.get_value.return_value = None
|
||||
|
||||
# When I get user account confirmation info
|
||||
user_account_confirmation_info = get_user_account_confirmation_info(self.user)
|
||||
|
||||
# Then sendEmailUrl falls back to SUPPORT_SITE_LINK
|
||||
self.assertEqual(
|
||||
user_account_confirmation_info["sendEmailUrl"],
|
||||
self.MOCK_SETTINGS["SUPPORT_SITE_LINK"],
|
||||
)
|
||||
|
||||
|
||||
class TestGetEnrollments(SharedModuleStoreTestCase):
|
||||
"""Tests for get_enrollments"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
|
||||
def create_test_enrollment(self, course_mode=CourseMode.AUDIT):
|
||||
"""Create a course and enrollment for the test user. Returns a CourseEnrollment"""
|
||||
course = CourseFactory(self_paced=True)
|
||||
|
||||
CourseModeFactory(
|
||||
course_id=course.id,
|
||||
mode_slug=course_mode,
|
||||
)
|
||||
|
||||
return CourseEnrollmentFactory(
|
||||
course_id=course.id, mode=course_mode, user_id=self.user.id
|
||||
)
|
||||
|
||||
def test_basic(self):
|
||||
# Given a set of enrollments
|
||||
test_enrollments = [self.create_test_enrollment() for i in range(3)]
|
||||
|
||||
# When I request my enrollments
|
||||
returned_enrollments, course_mode_info = get_enrollments(self.user, None, None)
|
||||
|
||||
# Then I return those enrollments and course mode info
|
||||
assert len(returned_enrollments) == len(test_enrollments)
|
||||
assert len(course_mode_info.keys()) == len(test_enrollments)
|
||||
|
||||
# ... with enrollments and course info
|
||||
for enrollment in test_enrollments:
|
||||
assert enrollment.course_id in course_mode_info
|
||||
assert enrollment in returned_enrollments
|
||||
|
||||
def test_empty(self):
|
||||
# Given a user has no enrollments
|
||||
# When they request enrollments
|
||||
returned_enrollments, course_mode_info = get_enrollments(self.user, None, None)
|
||||
|
||||
# Then I return an empty list and dict
|
||||
self.assertEqual(returned_enrollments, [])
|
||||
self.assertEqual(course_mode_info, {})
|
||||
|
||||
|
||||
class TestGetEmailSettingsInfo(SharedModuleStoreTestCase):
|
||||
"""Tests for get_email_settings_info"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
|
||||
@patch("lms.djangoapps.learner_home.views.is_bulk_email_feature_enabled")
|
||||
def test_get_email_settings(self, mock_is_bulk_email_enabled):
|
||||
# Given 3 courses where bulk email is enabled for 2 and user has opted out of one
|
||||
courses = [CourseFactory.create() for _ in range(3)]
|
||||
enrollments = [
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=course.id)
|
||||
for course in courses
|
||||
]
|
||||
optouts = {Optout.objects.create(user=self.user, course_id=courses[1].id)}
|
||||
mock_is_bulk_email_enabled.side_effect = (True, True, False)
|
||||
|
||||
# When I get email settings
|
||||
show_email_settings_for, course_optouts = get_email_settings_info(
|
||||
self.user, enrollments
|
||||
)
|
||||
|
||||
# Then the email settings show for courses where bulk email is enabled
|
||||
self.assertSetEqual(
|
||||
{course.id for course in courses[0:2]}, show_email_settings_for
|
||||
)
|
||||
|
||||
# ... and course optouts are returned
|
||||
self.assertSetEqual(
|
||||
{optout.course_id for optout in optouts},
|
||||
set(course_optouts),
|
||||
)
|
||||
|
||||
|
||||
class TestDashboardView(SharedModuleStoreTestCase, APITestCase):
|
||||
"""Tests for the dashboard view"""
|
||||
|
||||
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
# Get view URL
|
||||
cls.view_url = reverse("learner_home:dashboard_view")
|
||||
|
||||
# Set up a course
|
||||
cls.course = CourseFactory.create()
|
||||
cls.course_key = cls.course.location.course_key
|
||||
|
||||
# Set up a user
|
||||
cls.username = "alan"
|
||||
cls.password = "enigma"
|
||||
cls.user = UserFactory(username=cls.username, password=cls.password)
|
||||
|
||||
def log_in(self):
|
||||
"""Log in as a test user"""
|
||||
self.client.login(username=self.username, password=self.password)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.log_in()
|
||||
|
||||
def test_response_structure(self):
|
||||
"""Basic test for correct response structure"""
|
||||
|
||||
# Given I am logged in
|
||||
self.log_in()
|
||||
|
||||
# When I request the dashboard
|
||||
response = self.client.get(self.view_url)
|
||||
|
||||
# Then I get the expected success response
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = json.loads(response.content)
|
||||
expected_keys = set(
|
||||
[
|
||||
"emailConfirmation",
|
||||
"enterpriseDashboards",
|
||||
"platformSettings",
|
||||
"courses",
|
||||
"suggestedCourses",
|
||||
]
|
||||
)
|
||||
|
||||
assert expected_keys == response_data.keys()
|
||||
|
||||
@patch("lms.djangoapps.learner_home.views.get_user_account_confirmation_info")
|
||||
def test_email_confirmation(self, mock_user_conf_info):
|
||||
"""Test that email confirmation info passes through correctly"""
|
||||
|
||||
# Given I am logged in
|
||||
self.log_in()
|
||||
|
||||
# (and we have tons of mocks to avoid integration tests)
|
||||
mock_user_conf_info_response = (
|
||||
TestGetUserAccountConfirmationInfo.mock_response()
|
||||
)
|
||||
mock_user_conf_info.return_value = mock_user_conf_info_response
|
||||
|
||||
# When I request the dashboard
|
||||
response = self.client.get(self.view_url)
|
||||
|
||||
# Then I get the expected success response
|
||||
assert response.status_code == 200
|
||||
response_data = json.loads(response.content)
|
||||
|
||||
self.assertDictEqual(
|
||||
response_data["emailConfirmation"],
|
||||
{
|
||||
"isNeeded": mock_user_conf_info_response["isNeeded"],
|
||||
"sendEmailUrl": mock_user_conf_info_response["sendEmailUrl"],
|
||||
},
|
||||
)
|
||||
10
lms/djangoapps/learner_home/urls.py
Normal file
10
lms/djangoapps/learner_home/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Learner home URL routing configuration"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from lms.djangoapps.learner_home import views
|
||||
|
||||
app_name = "learner_home"
|
||||
|
||||
# Learner Dashboard Routing
|
||||
urlpatterns = [path("home/", views.dashboard_view, name="dashboard_view")]
|
||||
178
lms/djangoapps/learner_home/views.py
Normal file
178
lms/djangoapps/learner_home/views.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Views for the learner dashboard.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_GET
|
||||
from edx_django_utils import monitoring as monitoring_utils
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.edxmako.shortcuts import marketing_link
|
||||
from common.djangoapps.student.helpers import get_resume_urls_for_enrollments
|
||||
from common.djangoapps.student.views.dashboard import (
|
||||
complete_course_mode_info,
|
||||
get_course_enrollments,
|
||||
get_org_black_and_whitelist_for_site,
|
||||
)
|
||||
from common.djangoapps.util.json_request import JsonResponse
|
||||
from lms.djangoapps.bulk_email.models import Optout
|
||||
from lms.djangoapps.bulk_email.models_api import is_bulk_email_feature_enabled
|
||||
from lms.djangoapps.commerce.utils import EcommerceService
|
||||
from lms.djangoapps.learner_home.serializers import LearnerDashboardSerializer
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
|
||||
|
||||
def get_platform_settings():
|
||||
"""Get settings used for platform level connections: emails, url routes, etc."""
|
||||
|
||||
return {
|
||||
"supportEmail": settings.DEFAULT_FEEDBACK_EMAIL,
|
||||
"billingEmail": settings.PAYMENT_SUPPORT_EMAIL,
|
||||
"courseSearchUrl": marketing_link("COURSES"),
|
||||
}
|
||||
|
||||
|
||||
def get_user_account_confirmation_info(user):
|
||||
"""Determine if a user needs to verify their account and related URL info"""
|
||||
|
||||
activation_email_support_link = (
|
||||
configuration_helpers.get_value(
|
||||
"ACTIVATION_EMAIL_SUPPORT_LINK", settings.ACTIVATION_EMAIL_SUPPORT_LINK
|
||||
)
|
||||
or settings.SUPPORT_SITE_LINK
|
||||
)
|
||||
|
||||
email_confirmation = {
|
||||
"isNeeded": not user.is_active,
|
||||
"sendEmailUrl": activation_email_support_link,
|
||||
}
|
||||
|
||||
return email_confirmation
|
||||
|
||||
|
||||
def get_enrollments(user, org_allow_list, org_block_list, course_limit=None):
|
||||
"""Get enrollments and enrollment course modes for user"""
|
||||
|
||||
course_enrollments = list(
|
||||
get_course_enrollments(user, org_allow_list, org_block_list, course_limit)
|
||||
)
|
||||
|
||||
# Sort the enrollments by enrollment date
|
||||
course_enrollments.sort(key=lambda x: x.created, reverse=True)
|
||||
|
||||
# Record how many courses there are so that we can get a better
|
||||
# understanding of usage patterns on prod.
|
||||
monitoring_utils.accumulate("num_courses", len(course_enrollments))
|
||||
|
||||
# Retrieve the course modes for each course
|
||||
enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments]
|
||||
__, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(
|
||||
enrolled_course_ids
|
||||
)
|
||||
course_modes_by_course = {
|
||||
course_id: {mode.slug: mode for mode in modes}
|
||||
for course_id, modes in unexpired_course_modes.items()
|
||||
}
|
||||
|
||||
# Construct a dictionary of course mode information
|
||||
# used to render the course list. We re-use the course modes dict
|
||||
# we loaded earlier to avoid hitting the database.
|
||||
course_mode_info = {
|
||||
enrollment.course_id: complete_course_mode_info(
|
||||
enrollment.course_id,
|
||||
enrollment,
|
||||
modes=course_modes_by_course[enrollment.course_id],
|
||||
)
|
||||
for enrollment in course_enrollments
|
||||
}
|
||||
|
||||
return course_enrollments, course_mode_info
|
||||
|
||||
|
||||
def get_email_settings_info(user, course_enrollments):
|
||||
"""
|
||||
Given a user and enrollments, determine which courses allow bulk email (show_email_settings_for)
|
||||
and which the learner has opted out from (optouts)
|
||||
"""
|
||||
course_optouts = Optout.objects.filter(user=user).values_list(
|
||||
"course_id", flat=True
|
||||
)
|
||||
|
||||
# only show email settings for course where bulk email is turned on
|
||||
show_email_settings_for = frozenset(
|
||||
enrollment.course_id
|
||||
for enrollment in course_enrollments
|
||||
if (is_bulk_email_feature_enabled(enrollment.course_id))
|
||||
)
|
||||
|
||||
return show_email_settings_for, course_optouts
|
||||
|
||||
|
||||
def get_ecommerce_payment_page(user):
|
||||
"""Determine the ecommerce payment page URL if enabled for this user"""
|
||||
ecommerce_service = EcommerceService()
|
||||
return (
|
||||
ecommerce_service.payment_page_url()
|
||||
if ecommerce_service.is_enabled(user)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def dashboard_view(request): # pylint: disable=unused-argument
|
||||
"""List of courses a user is enrolled in or entitled to"""
|
||||
|
||||
# Get user, determine if user needs to confirm email account
|
||||
user = request.user
|
||||
email_confirmation = get_user_account_confirmation_info(user)
|
||||
|
||||
# Get the org whitelist or the org blacklist for the current site
|
||||
site_org_whitelist, site_org_blacklist = get_org_black_and_whitelist_for_site()
|
||||
|
||||
# TODO - Get entitlements (moving before enrollments because we use this to filter the enrollments)
|
||||
course_entitlements = []
|
||||
|
||||
# Get enrollments
|
||||
course_enrollments, course_mode_info = get_enrollments(
|
||||
user, site_org_whitelist, site_org_blacklist
|
||||
)
|
||||
|
||||
# Get email opt-outs for student
|
||||
show_email_settings_for, course_optouts = get_email_settings_info(
|
||||
user, course_enrollments
|
||||
)
|
||||
|
||||
# TODO - Get verification status by course (do we still need this?)
|
||||
|
||||
# TODO - Determine view access for courses (for showing courseware link or not)
|
||||
|
||||
# TODO - Get related programs
|
||||
|
||||
# TODO - Get user verification status
|
||||
|
||||
# e-commerce info
|
||||
ecommerce_payment_page = get_ecommerce_payment_page(user)
|
||||
|
||||
# Gather urls for course card resume buttons.
|
||||
resume_button_urls = get_resume_urls_for_enrollments(user, course_enrollments)
|
||||
|
||||
learner_dash_data = {
|
||||
"emailConfirmation": email_confirmation,
|
||||
"enterpriseDashboards": None,
|
||||
"platformSettings": get_platform_settings(),
|
||||
"enrollments": course_enrollments,
|
||||
"unfulfilledEntitlements": [],
|
||||
"suggestedCourses": [],
|
||||
}
|
||||
|
||||
context = {
|
||||
"ecommerce_payment_page": ecommerce_payment_page,
|
||||
"course_mode_info": course_mode_info,
|
||||
"course_optouts": course_optouts,
|
||||
"resume_course_urls": resume_button_urls,
|
||||
"show_email_settings_for": show_email_settings_for,
|
||||
}
|
||||
|
||||
response_data = LearnerDashboardSerializer(learner_dash_data, context=context).data
|
||||
return JsonResponse(response_data)
|
||||
@@ -195,6 +195,9 @@ urlpatterns = [
|
||||
path('dashboard/', include('lms.djangoapps.learner_dashboard.urls')),
|
||||
path('api/dashboard/', include('lms.djangoapps.learner_dashboard.api.urls', namespace='dashboard_api')),
|
||||
|
||||
# Learner Home
|
||||
path('learner/', include('lms.djangoapps.learner_home.urls', namespace='learner_home')),
|
||||
|
||||
path(
|
||||
'api/experiments/',
|
||||
include(
|
||||
|
||||
Reference in New Issue
Block a user