Files
edx-platform/lms/djangoapps/learner_home/serializers.py
Deborah Kaplan 8c923bea24 feat!: removes deprecated v1 certificate behavior (#35562)
* feat!: removes deprecated v1 certificate behavior

this removes the long-deprecated v1  certificate behavior. This removes
the old-style date selection behavior  (ie., not a choice between
*Immediately upon passing*, *End date of course*, *A date after the course
end date*), which is no longer reliably maintained or supported in
Studio or Credentials.

FIXES: #35399
2024-10-02 12:06:57 -04:00

629 lines
23 KiB
Python

"""
Serializers for Learner Home
"""
from datetime import date, timedelta
from urllib.parse import urlencode, urljoin
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from opaque_keys.edx.keys import CourseKey
from rest_framework import serializers
from openedx_filters.learning.filters import CourseEnrollmentAPIRenderStarted, CourseRunAPIRenderStarted
from common.djangoapps.course_modes.models import CourseMode
from openedx.features.course_experience import course_home_url
from xmodule.data import CertificatesDisplayBehaviors
from lms.djangoapps.learner_home.utils import course_progress_url
class LiteralField(serializers.Field):
"""
Custom Field for use with fields that will always intentionally serialize to the same static value.
"""
def __init__(self, literal_value):
super().__init__()
self.literal_value = literal_value
def to_representation(self, _):
return self.literal_value
def get_attribute(self, _):
return self.literal_value
class PlatformSettingsSerializer(serializers.Serializer):
"""Serializer for platform-level info, emails, and URLs"""
supportEmail = serializers.EmailField()
billingEmail = serializers.EmailField()
courseSearchUrl = serializers.URLField()
class SocialMediaSiteSettingsSerializer(serializers.Serializer):
"""Social media sharing config for a particular website"""
isEnabled = serializers.BooleanField(source="is_enabled")
socialBrand = serializers.CharField(source="brand")
utmParams = serializers.CharField(source="utm_params")
class SocialShareSettingsSerializer(serializers.Serializer):
"""Serializer for social media sharing config"""
facebook = SocialMediaSiteSettingsSerializer()
twitter = SocialMediaSiteSettingsSerializer()
class CourseProviderSerializer(serializers.Serializer):
"""Info about a course provider (institution/business) from a CourseOverview"""
name = serializers.CharField(source="display_org_with_default")
class CourseSerializer(serializers.Serializer):
"""Course header information, derived from a CourseOverview"""
requires_context = True
bannerImgSrc = serializers.URLField(source="image_urls.small")
courseName = serializers.CharField(source="display_name_with_default")
courseNumber = serializers.CharField(source="display_number_with_default")
socialShareUrl = serializers.SerializerMethodField()
def get_socialShareUrl(self, instance):
return self.context.get("course_share_urls", {}).get(instance.id)
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"
)
startDate = serializers.DateTimeField(source="course_overview.start")
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 course_progress_url(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:
query_params = {
'sku': verified_sku,
'course_run_key': str(instance.course_id)
}
encoded_params = urlencode(query_params)
upgrade_url = f"{ecommerce_payment_page}?{encoded_params}"
return upgrade_url
def get_resumeUrl(self, instance):
return self.context.get("resume_course_urls", {}).get(instance.course_id)
def to_representation(self, instance):
"""Serialize the courserun instance to be able to update the values before the API finishes rendering."""
serialized_courserun = super().to_representation(instance)
serialized_courserun = CourseRunAPIRenderStarted().run_filter(
serialized_courserun=serialized_courserun,
)
return serialized_courserun
class CoursewareAccessSerializer(serializers.Serializer):
"""
Info determining whether a user should be able to view course material.
Mirrors logic in "show_courseware_links_for" from old dashboard.py
"""
requires_context = True
hasUnmetPrerequisites = serializers.SerializerMethodField()
isTooEarly = serializers.SerializerMethodField()
isStaff = serializers.SerializerMethodField()
def _get_course_access_checks(self, enrollment):
"""Internal helper to unpack access object for this particular enrollment"""
return self.context.get("course_access_checks", {}).get(
enrollment.course_id, {}
)
def get_hasUnmetPrerequisites(self, enrollment):
"""Whether or not a course has unmet prerequisites"""
return self._get_course_access_checks(enrollment).get(
"has_unmet_prerequisites", False
)
def get_isTooEarly(self, enrollment):
"""Determine if the course is open to a learner (course has started or user has early beta access)"""
return self._get_course_access_checks(enrollment).get(
"is_too_early_to_view", False
)
def get_isStaff(self, enrollment):
"""Determine whether a user has staff access to this course"""
return self._get_course_access_checks(enrollment).get(
"user_has_staff_access", False
)
class EnrollmentSerializer(serializers.Serializer):
"""
Info about this particular enrollment.
Derived from a CourseEnrollment with added context:
- "audit_access_deadlines" (dict): when audit access expires for user.
- "ecommerce_payment_page" (url): ecommerce page, used to determine if we can upgrade.
- "course_mode_info" (dict): keyed by course ID with the following values:
- "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.
"""
requires_context = True
accessExpirationDate = serializers.SerializerMethodField()
isAudit = serializers.SerializerMethodField()
hasStarted = serializers.SerializerMethodField()
coursewareAccess = CoursewareAccessSerializer(source="*")
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")
mode = serializers.CharField()
def get_accessExpirationDate(self, instance):
return self.context.get("audit_access_deadlines", {}).get(instance.course_id)
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 bool(resume_button_url)
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 = bool(self.context.get("ecommerce_payment_page"))
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):
"""Mirrors logic in "check_course_expired" but using pre-fetched expiration date"""
expiration_date = self.context.get("audit_access_deadlines", {}).get(
enrollment.course_id
)
return bool(expiration_date) and timezone.now() > expiration_date
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", [])
def to_representation(self, instance):
"""Serialize the enrollment instance to be able to update the values before the API finishes rendering."""
serialized_enrollment = super().to_representation(instance)
course_key, serialized_enrollment = CourseEnrollmentAPIRenderStarted().run_filter(
course_key=instance.course_id,
serialized_enrollment=serialized_enrollment,
)
return serialized_enrollment
class GradeDataSerializer(serializers.Serializer):
"""Info about grades for this enrollment"""
requires_context = True
isPassing = serializers.SerializerMethodField()
def get_isPassing(self, enrollment):
return self.context.get("grade_statuses", {}).get(enrollment.course_id, False)
class CertificateSerializer(serializers.Serializer):
"""Certificate availability info"""
requires_context = True
availableDate = serializers.SerializerMethodField()
isRestricted = serializers.SerializerMethodField()
isEarned = serializers.SerializerMethodField()
isDownloadable = serializers.SerializerMethodField()
certPreviewUrl = serializers.SerializerMethodField()
def get_cert_info(self, enrollment):
"""Utility to grab certificate info for this enrollment or empty object"""
return self.context.get("cert_statuses", {}).get(enrollment.course.id, {})
def get_availableDate(self, enrollment):
"""Available date changes based off of Certificate display behavior"""
course_overview = enrollment.course_overview
available_date = None
if (
course_overview.certificates_display_behavior
== CertificatesDisplayBehaviors.END_WITH_DATE
and course_overview.certificate_available_date
):
available_date = course_overview.certificate_available_date
elif (
course_overview.certificates_display_behavior
== CertificatesDisplayBehaviors.END
and course_overview.end
):
available_date = course_overview.end
return serializers.DateTimeField().to_representation(available_date)
def get_isRestricted(self, enrollment):
"""Cert is considered restricted based on certificate status"""
return self.get_cert_info(enrollment).get("status") == "restricted"
def get_isEarned(self, enrollment):
"""Cert is considered earned based on certificate status"""
is_earned_states = ("downloadable", "certificate_earned_but_not_available")
return self.get_cert_info(enrollment).get("status") in is_earned_states
def get_isDownloadable(self, enrollment):
"""Cert is considered downloadable based on certificate status"""
return self.get_cert_info(enrollment).get("status") == "downloadable"
def get_certPreviewUrl(self, enrollment):
"""Cert preview URL comes from certificate info"""
cert_info = self.get_cert_info(enrollment)
if not cert_info.get("show_cert_web_view", False):
return None
else:
return cert_info.get("cert_web_view_url")
class AvailableEntitlementSessionSerializer(serializers.Serializer):
"""An available entitlement session"""
startDate = serializers.DateTimeField(source="start")
endDate = serializers.DateTimeField(source="end")
courseId = serializers.CharField(source="key")
class EntitlementSerializer(serializers.Serializer):
"""Entitlement info"""
requires_context = True
availableSessions = serializers.SerializerMethodField()
uuid = serializers.UUIDField()
isRefundable = serializers.BooleanField(source="is_entitlement_refundable")
isFulfilled = serializers.SerializerMethodField()
changeDeadline = serializers.SerializerMethodField()
isExpired = serializers.SerializerMethodField()
expirationDate = serializers.SerializerMethodField()
# DRF doesn't convert None to False so we must do this rather than a booleanfield:
# https://github.com/encode/django-rest-framework/issues/2299
def get_isFulfilled(self, instance):
return bool(instance.enrollment_course_run)
def get_isExpired(self, instance):
return bool(instance.expired_at)
def get_availableSessions(self, instance):
availableSessions = self.context["course_entitlement_available_sessions"].get(
str(instance.uuid)
)
return AvailableEntitlementSessionSerializer(availableSessions, many=True).data
def get_expirationDate(self, instance):
if instance.expired_at is not None:
return instance.expired_at
else:
return date.today() + timedelta(days=instance.get_days_until_expiration())
def get_changeDeadline(self, instance):
return self.get_expirationDate(instance)
class RelatedProgramSerializer(serializers.Serializer):
"""Related programs information"""
bannerImgSrc = serializers.URLField(source="banner_image.small.url", default=None)
logoImgSrc = serializers.SerializerMethodField()
numberOfCourses = serializers.SerializerMethodField()
programType = serializers.CharField(source="type")
programUrl = serializers.SerializerMethodField()
provider = serializers.SerializerMethodField()
title = serializers.CharField()
def get_numberOfCourses(self, instance):
return len(instance["courses"])
def get_logoImgSrc(self, instance):
return (
instance["authoring_organizations"][0].get("logo_image_url")
if instance.get("authoring_organizations")
else None
)
def get_provider(self, instance):
return (
instance["authoring_organizations"][0].get("name")
if instance.get("authoring_organizations")
else None
)
def get_programUrl(self, instance):
return urljoin(
settings.LMS_ROOT_URL,
instance.get(
"detail_url",
reverse(
"program_details_view", kwargs={"program_uuid": instance["uuid"]}
),
),
)
class ProgramsSerializer(serializers.Serializer):
"""Programs information"""
relatedPrograms = serializers.ListField(
child=RelatedProgramSerializer(), allow_empty=True
)
class CreditSerializer(serializers.Serializer):
"""Credit status information"""
providerStatusUrl = serializers.URLField(source="provider_status_url")
providerName = serializers.CharField(source="provider_name")
providerId = serializers.CharField(source="provider_id")
error = serializers.BooleanField()
purchased = serializers.BooleanField()
requestStatus = serializers.CharField(source="request_status")
class LearnerEnrollmentSerializer(serializers.Serializer):
"""
Info for displaying an enrollment on the learner dashboard.
Derived from a CourseEnrollment with added context.
"""
requires_context = True
course = CourseSerializer()
courseProvider = CourseProviderSerializer(source="course_overview")
courseRun = CourseRunSerializer(source="*")
enrollment = EnrollmentSerializer(source="*")
certificate = CertificateSerializer(source="*")
entitlement = serializers.SerializerMethodField()
gradeData = GradeDataSerializer(source="*")
programs = serializers.SerializerMethodField()
credit = serializers.SerializerMethodField()
def get_entitlement(self, instance):
"""
If this enrollment is the fulfillment of an entitlement, include information about the entitlement
"""
entitlement = self.context["fulfilled_entitlements"].get(
str(instance.course_id)
)
if entitlement:
return EntitlementSerializer(entitlement, context=self.context).data
else:
return {}
def get_programs(self, instance):
"""
If this enrollment is part of a program, include information about the program and related programs
"""
programs = self.context["programs"].get(str(instance.course_id), [])
return ProgramsSerializer(
{"relatedPrograms": programs}, context=self.context
).data
def get_credit(self, instance):
"""Pull credit statuses from context"""
credit_status = self.context["credit_statuses"].get(instance.course_id)
# If user or course is ineligible for credit, return empty
if not credit_status:
return {}
else:
return CreditSerializer(credit_status).data
class UnfulfilledEntitlementSerializer(serializers.Serializer):
"""
Serializer for an unfulfilled entitlement.
This should have the same keys as the LearnerEnrollmentSerializer.
We are flattening the two lists into one "course card" list and so should be the same shape.
"""
requires_context = True
# This is the static constant data returned as the 'enrollment' key for all unfulfilled enrollments.
STATIC_ENTITLEMENT_ENROLLMENT_DATA = {
"accessExpirationDate": None,
"isAudit": False,
"hasStarted": False,
"coursewareAccess": {
"hasUnmetPrerequisites": False,
"isTooEarly": False,
"isStaff": False,
},
"isVerified": False,
"canUpgrade": False,
"isAuditAccessExpired": False,
"isEmailEnabled": False,
"hasOptedOutOfEmail": False,
"lastEnrolled": None,
"isEnrolled": False,
"mode": None,
}
# These fields contain all real data and will be serialized
entitlement = EntitlementSerializer(source="*")
course = serializers.SerializerMethodField()
courseProvider = serializers.SerializerMethodField()
programs = serializers.SerializerMethodField()
# These fields are literal values that do not change
courseRun = LiteralField(None)
gradeData = LiteralField(None)
certificate = LiteralField(None)
enrollment = LiteralField(STATIC_ENTITLEMENT_ENROLLMENT_DATA)
credit = LiteralField({})
def _get_course_overview(self, instance):
"""Look up course provider from CourseOverview matching the pseudo session"""
pseudo_session = self.context["unfulfilled_entitlement_pseudo_sessions"].get(
str(instance.uuid)
)
if pseudo_session:
course_key = CourseKey.from_string(pseudo_session["key"])
return self.context.get("pseudo_session_course_overviews").get(course_key)
def get_course(self, instance):
"""Serialize course info from a course overview"""
course_overview = self._get_course_overview(instance)
return CourseSerializer(course_overview, context=self.context).data
def get_courseProvider(self, instance):
"""Serialize course provider info from a course overview"""
course_overview = self._get_course_overview(instance)
return CourseProviderSerializer(course_overview, allow_null=True).data
def get_programs(self, instance):
"""
If this entitlement is part of a program, include information about the program and related programs
"""
programs = self.context["programs"].get(str(instance.course_uuid), [])
return ProgramsSerializer(
{"relatedPrograms": programs}, context=self.context
).data
class SuggestedCourseSerializer(serializers.Serializer):
"""Serializer for a suggested course from recommendation engine"""
bannerImgSrc = serializers.URLField(source="logo_image_url")
logoImgSrc = serializers.URLField(allow_null=True)
courseName = serializers.CharField(source="title")
courseUrl = serializers.URLField(source="marketing_url")
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(source="name")
url = serializers.SerializerMethodField()
uuid = serializers.UUIDField()
authOrgId = serializers.CharField(source="auth_org_id", allow_null=True)
isLearnerPortalEnabled = serializers.BooleanField(source="enable_learner_portal")
def get_url(self, instance):
return urljoin(
settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL,
instance["slug"],
)
class LearnerDashboardSerializer(serializers.Serializer):
"""Serializer for all info required to render the Learner Dashboard"""
requires_context = True
emailConfirmation = EmailConfirmationSerializer()
enterpriseDashboard = EnterpriseDashboardSerializer(allow_null=True)
platformSettings = PlatformSettingsSerializer()
courses = serializers.SerializerMethodField()
socialShareSettings = SocialShareSettingsSerializer()
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