490 lines
20 KiB
Python
490 lines
20 KiB
Python
"""
|
|
Serializers for Instructor API v2.
|
|
|
|
These serializers handle data validation and business logic for instructor dashboard endpoints.
|
|
Following REST best practices, serializers encapsulate most of the data processing logic.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from django.conf import settings
|
|
from django.utils.html import escape
|
|
from django.utils.translation import gettext as _
|
|
from edx_when.api import is_enabled_for_course
|
|
from rest_framework import serializers
|
|
|
|
from common.djangoapps.course_modes.models import CourseMode
|
|
from common.djangoapps.student.models import CourseEnrollment
|
|
from common.djangoapps.student.roles import (
|
|
CourseFinanceAdminRole,
|
|
CourseInstructorRole,
|
|
CourseSalesAdminRole,
|
|
CourseStaffRole,
|
|
)
|
|
from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled
|
|
from lms.djangoapps.certificates.models import (
|
|
CertificateGenerationConfiguration
|
|
)
|
|
from lms.djangoapps.courseware.access import has_access
|
|
from lms.djangoapps.courseware.courses import get_studio_url
|
|
from lms.djangoapps.discussion.django_comment_client.utils import has_forum_access
|
|
from lms.djangoapps.instructor import permissions
|
|
from lms.djangoapps.instructor.views.instructor_dashboard import get_analytics_dashboard_message
|
|
from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
|
|
from xmodule.modulestore.django import modulestore
|
|
|
|
from .tools import get_student_from_identifier, parse_datetime, DashboardError
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class CourseInformationSerializerV2(serializers.Serializer):
|
|
"""
|
|
Serializer for comprehensive course information.
|
|
|
|
This serializer handles the business logic for gathering all course metadata,
|
|
enrollment statistics, permissions, and dashboard configuration.
|
|
"""
|
|
course_id = serializers.SerializerMethodField(help_text="Course run key")
|
|
display_name = serializers.SerializerMethodField(help_text="Course display name")
|
|
org = serializers.SerializerMethodField(help_text="Organization identifier")
|
|
course_number = serializers.SerializerMethodField(help_text="Course number")
|
|
course_run = serializers.SerializerMethodField(help_text="Course run identifier")
|
|
enrollment_start = serializers.SerializerMethodField(help_text="Enrollment start date (ISO 8601 with timezone)")
|
|
enrollment_end = serializers.SerializerMethodField(help_text="Enrollment end date (ISO 8601 with timezone)")
|
|
start = serializers.SerializerMethodField(help_text="Course start date (ISO 8601 with timezone)")
|
|
end = serializers.SerializerMethodField(help_text="Course end date (ISO 8601 with timezone)")
|
|
pacing = serializers.SerializerMethodField(help_text="Course pacing type (self or instructor)")
|
|
has_started = serializers.SerializerMethodField(help_text="Whether the course has started based on current time")
|
|
has_ended = serializers.SerializerMethodField(help_text="Whether the course has ended based on current time")
|
|
total_enrollment = serializers.SerializerMethodField(help_text="Total number of enrollments across all modes")
|
|
learner_count = serializers.SerializerMethodField(
|
|
help_text="Number of enrolled learners (excludes staff and admins)"
|
|
)
|
|
staff_count = serializers.SerializerMethodField(help_text="Number of enrolled staff and admins")
|
|
enrollment_counts = serializers.SerializerMethodField(help_text="Enrollment count breakdown by mode")
|
|
num_sections = serializers.SerializerMethodField(help_text="Number of sections/chapters in the course")
|
|
grade_cutoffs = serializers.SerializerMethodField(help_text="Formatted string of grade cutoffs")
|
|
course_errors = serializers.SerializerMethodField(help_text="List of course validation errors from modulestore")
|
|
studio_url = serializers.SerializerMethodField(help_text="URL to view/edit course in Studio")
|
|
permissions = serializers.SerializerMethodField(help_text="User permissions for instructor dashboard features")
|
|
tabs = serializers.SerializerMethodField(help_text="List of course tabs with configuration and display information")
|
|
disable_buttons = serializers.SerializerMethodField(
|
|
help_text="Whether to disable certain bulk action buttons due to large course size"
|
|
)
|
|
analytics_dashboard_message = serializers.SerializerMethodField(
|
|
help_text="Message about analytics dashboard availability"
|
|
)
|
|
|
|
def get_tabs(self, data):
|
|
"""Get serialized course tabs."""
|
|
request = data['request']
|
|
course = data['course']
|
|
course_key = course.id
|
|
mfe_base_url = settings.INSTRUCTOR_MICROFRONTEND_URL
|
|
|
|
if not mfe_base_url:
|
|
log.warning('INSTRUCTOR_MICROFRONTEND_URL is not set.')
|
|
mfe_base_url = ''
|
|
|
|
access = {
|
|
'admin': request.user.is_staff,
|
|
'instructor': bool(has_access(request.user, 'instructor', course)),
|
|
'finance_admin': CourseFinanceAdminRole(course_key).has_user(request.user),
|
|
'sales_admin': CourseSalesAdminRole(course_key).has_user(request.user),
|
|
'staff': bool(has_access(request.user, 'staff', course)),
|
|
'forum_admin': has_forum_access(request.user, course_key, FORUM_ROLE_ADMINISTRATOR),
|
|
'data_researcher': request.user.has_perm(permissions.CAN_RESEARCH, course_key),
|
|
}
|
|
tabs = []
|
|
|
|
# NOTE: The Instructor experience can be extended via FE plugins that insert tabs
|
|
# dynamically using explicit priority values. The sort_order field provides a stable
|
|
# ordering contract so plugins created via the FE can reliably position themselves
|
|
# relative to backend-defined tabs (e.g., "insert between Grading and Course Team").
|
|
# Without explicit sort_order values, there's no deterministic way to interleave
|
|
# backend tabs with plugin-inserted tabs, and tab order could shift based on
|
|
# load/config timing.
|
|
if access['staff']:
|
|
tabs.extend([
|
|
{
|
|
'tab_id': 'course_info',
|
|
'title': _('Course Info'),
|
|
'url': f'{mfe_base_url}/instructor/{str(course_key)}/course_info',
|
|
'sort_order': 10,
|
|
},
|
|
{
|
|
'tab_id': 'enrollments',
|
|
'title': _('Enrollments'),
|
|
'url': f'{mfe_base_url}/instructor/{str(course_key)}/enrollments',
|
|
'sort_order': 20,
|
|
},
|
|
{
|
|
"tab_id": "course_team",
|
|
"title": "Course Team",
|
|
"url": f'{mfe_base_url}/instructor/{str(course_key)}/course_team',
|
|
'sort_order': 30,
|
|
},
|
|
{
|
|
'tab_id': 'grading',
|
|
'title': _('Grading'),
|
|
'url': f'{mfe_base_url}/instructor/{str(course_key)}/grading',
|
|
'sort_order': 40,
|
|
},
|
|
{
|
|
'tab_id': 'cohorts',
|
|
'title': _('Cohorts'),
|
|
'url': f'{mfe_base_url}/instructor/{str(course_key)}/cohorts',
|
|
'sort_order': 90,
|
|
},
|
|
])
|
|
|
|
if access['staff'] and is_bulk_email_feature_enabled(course_key):
|
|
tabs.append({
|
|
'tab_id': 'bulk_email',
|
|
'title': _('Bulk Email'),
|
|
'url': f'{mfe_base_url}/instructor/{str(course_key)}/bulk_email',
|
|
'sort_order': 100,
|
|
})
|
|
|
|
if access['instructor'] and is_enabled_for_course(course_key):
|
|
tabs.append({
|
|
'tab_id': 'date_extensions',
|
|
'title': _('Date Extensions'),
|
|
'url': f'{mfe_base_url}/instructor/{str(course_key)}/date_extensions',
|
|
'sort_order': 50,
|
|
})
|
|
|
|
if access['data_researcher']:
|
|
tabs.append({
|
|
'tab_id': 'data_downloads',
|
|
'title': _('Data Downloads'),
|
|
'url': f'{mfe_base_url}/instructor/{str(course_key)}/data_downloads',
|
|
'sort_order': 60,
|
|
})
|
|
|
|
openassessment_blocks = modulestore().get_items(
|
|
course_key, qualifiers={'category': 'openassessment'}
|
|
)
|
|
# filter out orphaned openassessment blocks
|
|
openassessment_blocks = [
|
|
block for block in openassessment_blocks if block.parent is not None
|
|
]
|
|
if len(openassessment_blocks) > 0 and access['staff']:
|
|
tabs.append({
|
|
'tab_id': 'open_responses',
|
|
'title': _('Open Responses'),
|
|
'url': f'{mfe_base_url}/instructor/{str(course_key)}/open_responses',
|
|
'sort_order': 70,
|
|
})
|
|
|
|
# Note: This is hidden for all CCXs
|
|
certs_enabled = CertificateGenerationConfiguration.current().enabled and not hasattr(course_key, 'ccx')
|
|
certs_instructor_enabled = settings.FEATURES.get('ENABLE_CERTIFICATES_INSTRUCTOR_MANAGE', False)
|
|
|
|
if certs_enabled and access['admin'] or (access['instructor'] and certs_instructor_enabled):
|
|
tabs.append({
|
|
'tab_id': 'certificates',
|
|
'title': _('Certificates'),
|
|
'url': f'{mfe_base_url}/instructor/{str(course_key)}/certificates',
|
|
'sort_order': 80,
|
|
})
|
|
|
|
user_has_access = any([
|
|
access['admin'],
|
|
CourseStaffRole(course_key).has_user(request.user),
|
|
access['instructor'],
|
|
])
|
|
course_has_special_exams = course.enable_proctored_exams or course.enable_timed_exams
|
|
can_see_special_exams = course_has_special_exams and user_has_access and settings.FEATURES.get(
|
|
'ENABLE_SPECIAL_EXAMS', False)
|
|
|
|
if can_see_special_exams:
|
|
tabs.append({
|
|
'tab_id': 'special_exams',
|
|
'title': _('Special Exams'),
|
|
'url': f'{mfe_base_url}/instructor/{str(course_key)}/special_exams',
|
|
'sort_order': 110,
|
|
})
|
|
|
|
# We provide the tabs in a specific order based on how it was
|
|
# historically presented in the frontend. The frontend can use
|
|
# this info or choose to ignore the ordering.
|
|
tabs_order = [
|
|
'course_info',
|
|
'enrollments',
|
|
'course_team',
|
|
'grading',
|
|
'date_extensions',
|
|
'data_downloads',
|
|
'open_responses',
|
|
'certificates',
|
|
'cohorts',
|
|
'bulk_email',
|
|
'special_exams',
|
|
]
|
|
order_index = {tab: i for i, tab in enumerate(tabs_order)}
|
|
tabs = sorted(tabs, key=lambda x: order_index.get(x['tab_id'], float("inf")))
|
|
return tabs
|
|
|
|
def get_course_id(self, data):
|
|
"""Get course ID as string."""
|
|
return str(data['course'].id)
|
|
|
|
def get_display_name(self, data):
|
|
"""Get course display name."""
|
|
return data['course'].display_name
|
|
|
|
def get_org(self, data):
|
|
"""Get organization identifier."""
|
|
return data['course'].id.org
|
|
|
|
def get_course_number(self, data):
|
|
"""Get course number."""
|
|
return data['course'].id.course
|
|
|
|
def get_course_run(self, data):
|
|
"""Get course run identifier"""
|
|
course_id = data['course'].id
|
|
return course_id.run if course_id.run is not None else ''
|
|
|
|
def get_enrollment_start(self, data):
|
|
"""Get enrollment start date."""
|
|
return data['course'].enrollment_start
|
|
|
|
def get_enrollment_end(self, data):
|
|
"""Get enrollment end date."""
|
|
return data['course'].enrollment_end
|
|
|
|
def get_start(self, data):
|
|
"""Get course start date."""
|
|
return data['course'].start
|
|
|
|
def get_end(self, data):
|
|
"""Get course end date."""
|
|
return data['course'].end
|
|
|
|
def get_pacing(self, data):
|
|
"""Get course pacing type (self or instructor)."""
|
|
return 'self' if data['course'].self_paced else 'instructor'
|
|
|
|
def get_has_started(self, data):
|
|
"""Check if course has started."""
|
|
return data['course'].has_started()
|
|
|
|
def get_has_ended(self, data):
|
|
"""Check if course has ended."""
|
|
return data['course'].has_ended()
|
|
|
|
def get_total_enrollment(self, data):
|
|
"""Get total enrollment count."""
|
|
return self.get_enrollment_counts(data)['total']
|
|
|
|
def get_learner_count(self, data):
|
|
"""Get enrollment count excluding staff and admins."""
|
|
return CourseEnrollment.objects.num_enrolled_in_exclude_admins(data['course'].id)
|
|
|
|
def get_staff_count(self, data):
|
|
"""Get enrollment count for staff and admins only."""
|
|
return self.get_total_enrollment(data) - self.get_learner_count(data)
|
|
|
|
def get_enrollment_counts(self, data):
|
|
"""Get enrollment counts for all configured course modes."""
|
|
course_id = data['course'].id
|
|
counts = CourseEnrollment.objects.enrollment_counts(course_id)
|
|
configured_modes = CourseMode.modes_for_course(course_id)
|
|
result = {mode.slug: counts[mode.slug] for mode in configured_modes}
|
|
result['total'] = counts['total']
|
|
return result
|
|
|
|
def get_num_sections(self, data):
|
|
"""Get number of sections in the course."""
|
|
course = data['course']
|
|
return len(course.get_children()) if hasattr(course, 'get_children') else 0
|
|
|
|
def get_permissions(self, data):
|
|
"""Get user permissions for the course."""
|
|
user = data['user']
|
|
course_key = data['course'].id
|
|
return {
|
|
'admin': user.is_staff,
|
|
'instructor': CourseInstructorRole(course_key).has_user(user),
|
|
'finance_admin': CourseFinanceAdminRole(course_key).has_user(user),
|
|
'sales_admin': CourseSalesAdminRole(course_key).has_user(user),
|
|
'staff': CourseStaffRole(course_key).has_user(user),
|
|
'forum_admin': has_forum_access(user, course_key, FORUM_ROLE_ADMINISTRATOR),
|
|
'data_researcher': user.has_perm(permissions.CAN_RESEARCH, course_key),
|
|
}
|
|
|
|
def get_grade_cutoffs(self, data):
|
|
"""
|
|
Format grade cutoffs as a human-readable string.
|
|
|
|
Args:
|
|
data: Dictionary containing course object
|
|
|
|
Returns:
|
|
str: Formatted grade cutoffs (e.g., "A is 0.9, B is 0.8, C is 0.7")
|
|
"""
|
|
course = data['course']
|
|
if not hasattr(course, 'grading_policy') or not course.grading_policy:
|
|
return ""
|
|
|
|
grading_policy = course.grading_policy
|
|
if 'GRADER' not in grading_policy:
|
|
return ""
|
|
|
|
grade_cutoffs = grading_policy.get('GRADE_CUTOFFS', {})
|
|
if not grade_cutoffs:
|
|
return ""
|
|
|
|
# Sort by cutoff value descending
|
|
sorted_cutoffs = sorted(grade_cutoffs.items(), key=lambda x: x[1], reverse=True)
|
|
|
|
# Format as "A is 0.9, B is 0.8, ..."
|
|
formatted = ", ".join([f"{grade} is {cutoff}" for grade, cutoff in sorted_cutoffs])
|
|
return formatted
|
|
|
|
def get_course_errors(self, data):
|
|
"""Get course validation errors from modulestore."""
|
|
course = data['course']
|
|
try:
|
|
errors = modulestore().get_course_errors(course.id)
|
|
course_errors = [(escape(str(error)), '') for (error, _) in errors]
|
|
except (AttributeError, KeyError):
|
|
course_errors = []
|
|
return course_errors
|
|
|
|
def get_studio_url(self, data):
|
|
"""Get Studio URL for the course."""
|
|
return get_studio_url(data['course'], 'course')
|
|
|
|
def get_disable_buttons(self, data):
|
|
"""Check if buttons should be disabled for large courses."""
|
|
return not CourseEnrollment.objects.is_small_course(data['course'].id)
|
|
|
|
def get_analytics_dashboard_message(self, data):
|
|
"""Get analytics dashboard availability message."""
|
|
return get_analytics_dashboard_message(data['course'].id)
|
|
|
|
|
|
class InstructorTaskSerializer(serializers.Serializer):
|
|
"""Serializer for instructor task details."""
|
|
task_id = serializers.UUIDField()
|
|
task_type = serializers.CharField()
|
|
task_state = serializers.ChoiceField(choices=["PENDING", "PROGRESS", "SUCCESS", "FAILURE", "REVOKED"])
|
|
status = serializers.CharField()
|
|
created = serializers.DateTimeField()
|
|
duration_sec = serializers.CharField()
|
|
task_message = serializers.CharField()
|
|
requester = serializers.CharField()
|
|
task_input = serializers.CharField()
|
|
task_output = serializers.CharField(allow_null=True)
|
|
|
|
|
|
class InstructorTaskListSerializer(serializers.Serializer):
|
|
tasks = InstructorTaskSerializer(many=True)
|
|
|
|
|
|
class BlockDueDateSerializerV2(serializers.Serializer):
|
|
"""
|
|
Serializer for handling block due date updates for a specific student.
|
|
Fields:
|
|
block_id (str): The ID related to the block that needs the due date update.
|
|
due_datetime (str): The new due date and time for the block.
|
|
email_or_username (str): The email or username of the student whose access is being modified.
|
|
reason (str): Reason why updating this.
|
|
"""
|
|
block_id = serializers.CharField()
|
|
due_datetime = serializers.CharField()
|
|
email_or_username = serializers.CharField(
|
|
max_length=255,
|
|
help_text="Email or username of user to change access"
|
|
)
|
|
reason = serializers.CharField(required=False)
|
|
|
|
def validate_email_or_username(self, value):
|
|
"""
|
|
Validate that the email_or_username corresponds to an existing user.
|
|
"""
|
|
try:
|
|
user = get_student_from_identifier(value)
|
|
except Exception as exc:
|
|
raise serializers.ValidationError(
|
|
_('Invalid learner identifier: {0}').format(value)
|
|
) from exc
|
|
|
|
return user
|
|
|
|
def validate_due_datetime(self, value):
|
|
"""
|
|
Validate and parse the due_datetime string into a datetime object.
|
|
"""
|
|
try:
|
|
parsed_date = parse_datetime(value)
|
|
return parsed_date
|
|
except DashboardError as exc:
|
|
raise serializers.ValidationError(
|
|
_('The extension due date and time format is incorrect')
|
|
) from exc
|
|
|
|
|
|
class UnitExtensionSerializer(serializers.Serializer):
|
|
"""
|
|
Serializer for unit extension data.
|
|
|
|
This serializer formats the data returned by get_overrides_for_course
|
|
for the paginated list API endpoint.
|
|
"""
|
|
username = serializers.CharField(
|
|
help_text="Username of the learner who has the extension"
|
|
)
|
|
full_name = serializers.CharField(
|
|
help_text="Full name of the learner"
|
|
)
|
|
email = serializers.EmailField(
|
|
help_text="Email address of the learner"
|
|
)
|
|
unit_title = serializers.CharField(
|
|
help_text="Display name or URL of the unit"
|
|
)
|
|
unit_location = serializers.CharField(
|
|
help_text="Block location/ID of the unit"
|
|
)
|
|
extended_due_date = serializers.DateTimeField(
|
|
help_text="The extended due date for the learner"
|
|
)
|
|
|
|
|
|
class ORASerializer(serializers.Serializer):
|
|
"""Serializer for Open Response Assessments (ORAs) in a course."""
|
|
|
|
block_id = serializers.CharField(source="id")
|
|
unit_name = serializers.CharField(source="parent_name")
|
|
display_name = serializers.CharField(source="name")
|
|
|
|
# Metrics fields
|
|
total_responses = serializers.IntegerField(source="total")
|
|
training = serializers.IntegerField()
|
|
peer = serializers.IntegerField()
|
|
self = serializers.IntegerField()
|
|
waiting = serializers.IntegerField()
|
|
staff = serializers.IntegerField()
|
|
final_grade_received = serializers.IntegerField(source="done")
|
|
staff_ora_grading_url = serializers.URLField(allow_null=True)
|
|
|
|
|
|
class ORASummarySerializer(serializers.Serializer):
|
|
"""
|
|
Aggregated ORA statistics for a course
|
|
"""
|
|
total_units = serializers.IntegerField()
|
|
total_assessments = serializers.IntegerField()
|
|
total_responses = serializers.IntegerField()
|
|
training = serializers.IntegerField()
|
|
peer = serializers.IntegerField()
|
|
self = serializers.IntegerField()
|
|
waiting = serializers.IntegerField()
|
|
staff = serializers.IntegerField()
|
|
final_grade_received = serializers.IntegerField()
|