Files
edx-platform/cms/djangoapps/contentstore/api/views/course_validation.py
Muhammad Labeeb 8ad4d42e3b feat!: Remove proctortrack references; add requires_escalation_email and show_review_rules options (#37576)
BREAKING CHANGE: All references to the hardcoded 'proctortrack' string have
been removed from the codebase,  as well as the `studio.show_review_rules`
waffle flag. These were used to determine whether an escalation email is
required and whether review rules should be shown. These decisions are now made
based on the value of 'requires_escalation_email' (default False) and 'show_review_rules'
(default True) config items in the PROCTORING_BACKENDS entry.

Additionally:
* The proctoring info api will now return the list of providers which require an escalation
   email so that frontend-app-learning does not need to use a hardcoded check agaist
   the provider name 'proctortrack'.
* Removed translation commands, mock variables and user facing strings that contained
  'proctortrack'.
* Updated all test cases that were using proctortrack to use fake providers names.

Part of: https://github.com/openedx/edx-platform/issues/36329
2025-11-25 09:37:32 -05:00

349 lines
14 KiB
Python

# lint-amnesty, pylint: disable=missing-module-docstring
import logging
import dateutil
from pytz import UTC
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from cms.djangoapps.contentstore.course_info_model import get_course_updates
from cms.djangoapps.contentstore.views.certificates import CertificateManager
from common.djangoapps.util.proctoring import requires_escalation_email
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from xmodule.course_metadata_utils import DEFAULT_GRADING_POLICY # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from .utils import course_author_access_required, get_bool_param
log = logging.getLogger(__name__)
@view_auth_classes()
class CourseValidationView(DeveloperErrorViewMixin, GenericAPIView):
"""
**Use Case**
**Example Requests**
GET /api/courses/v1/validation/{course_id}/
**GET Parameters**
A GET request may include the following parameters.
* all
* dates
* assignments
* grades
* certificates
* updates
* proctoring
* graded_only (boolean) - whether to included graded subsections only in the assignments information.
* validate_oras (boolean) - whether to check the dates in ORA problems in addition to assignment due dates.
**GET Response Values**
The HTTP 200 response has the following values.
* is_self_paced - whether the course is self-paced.
* dates
* has_start_date - whether the start date is set on the course.
* has_end_date - whether the end date is set on the course.
* assignments
* total_number - total number of assignments in the course.
* total_visible - number of assignments visible to learners in the course.
* assignments_with_dates_before_start - assignments with due dates before the start date.
* assignments_with_dates_after_end - assignments with due dates after the end date.
* grades
* sum_of_weights - sum of weights for all assignments in the course (valid ones should equal 1).
* certificates
* is_activated - whether the certificate is activated for the course.
* has_certificate - whether the course has a certificate.
* updates
* has_update - whether at least one course update exists.
* proctoring
* needs_proctoring_escalation_email - whether the course requires a proctoring escalation email
* has_proctoring_escalation_email - whether the course has a proctoring escalation email
"""
# TODO: ARCH-91
# This view is excluded from Swagger doc generation because it
# does not specify a serializer class.
swagger_schema = None
@course_author_access_required
def get(self, request, course_key):
"""
Returns validation information for the given course.
"""
all_requested = get_bool_param(request, 'all', False)
store = modulestore()
with store.bulk_operations(course_key):
course = store.get_course(course_key, depth=self._required_course_depth(request, all_requested))
response = dict(
is_self_paced=course.self_paced,
)
if get_bool_param(request, 'dates', all_requested):
response.update(
dates=self._dates_validation(course)
)
if get_bool_param(request, 'assignments', all_requested):
response.update(
assignments=self._assignments_validation(course, request)
)
if get_bool_param(request, 'grades', all_requested):
response.update(
grades=self._grades_validation(course)
)
if get_bool_param(request, 'certificates', all_requested):
response.update(
certificates=self._certificates_validation(course)
)
if get_bool_param(request, 'updates', all_requested):
response.update(
updates=self._updates_validation(course, request)
)
if get_bool_param(request, 'proctoring', all_requested):
response.update(
proctoring=self._proctoring_validation(course)
)
return Response(response)
def _required_course_depth(self, request, all_requested):
if get_bool_param(request, 'assignments', all_requested):
return 2
else:
return 0
def _dates_validation(self, course):
return dict(
has_start_date=self._has_start_date(course),
has_end_date=course.end is not None,
)
def _assignments_validation(self, course, request): # lint-amnesty, pylint: disable=missing-function-docstring
assignments, visible_assignments = self._get_assignments(course)
assignments_with_dates = [
a for a in visible_assignments if a.due
]
assignments_with_dates_before_start = (
[
{'id': str(a.location), 'display_name': a.display_name}
for a in assignments_with_dates
if a.due < course.start
]
if self._has_start_date(course)
else []
)
assignments_with_dates_after_end = (
[
{'id': str(a.location), 'display_name': a.display_name}
for a in assignments_with_dates
if a.due > course.end
]
if course.end
else []
)
if get_bool_param(request, 'graded_only', False):
assignments_with_dates = [
a
for a in visible_assignments
if a.due and a.graded
]
assignments_with_dates_before_start = (
[
{'id': str(a.location), 'display_name': a.display_name}
for a in assignments_with_dates
if a.due < course.start
]
if self._has_start_date(course)
else []
)
assignments_with_dates_after_end = (
[
{'id': str(a.location), 'display_name': a.display_name}
for a in assignments_with_dates
if a.due > course.end
]
if course.end
else []
)
assignments_with_ora_dates_before_start = []
assignments_with_ora_dates_after_end = []
if get_bool_param(request, 'validate_oras', False):
# Iterate over all ORAs to find any with dates outside
# acceptable range
for ora in self._get_open_responses(
course,
get_bool_param(request, 'graded_only', False)
):
if course.start and self._has_date_before_start(ora, course.start):
parent_unit = modulestore().get_item(ora.parent)
parent_assignment = modulestore().get_item(parent_unit.parent)
assignments_with_ora_dates_before_start.append({
'id': str(parent_assignment.location),
'display_name': parent_assignment.display_name
})
if course.end and self._has_date_after_end(ora, course.end):
parent_unit = modulestore().get_item(ora.parent)
parent_assignment = modulestore().get_item(parent_unit.parent)
assignments_with_ora_dates_after_end.append({
'id': str(parent_assignment.location),
'display_name': parent_assignment.display_name
})
return dict(
total_number=len(assignments),
total_visible=len(visible_assignments),
assignments_with_dates_before_start=assignments_with_dates_before_start,
assignments_with_dates_after_end=assignments_with_dates_after_end,
assignments_with_ora_dates_before_start=assignments_with_ora_dates_before_start,
assignments_with_ora_dates_after_end=assignments_with_ora_dates_after_end,
)
def _grades_validation(self, course):
has_grading_policy = self._has_grading_policy(course)
sum_of_weights = course.grader.sum_of_weights
return dict(
has_grading_policy=has_grading_policy,
sum_of_weights=sum_of_weights,
)
def _certificates_validation(self, course):
is_activated, certificates = CertificateManager.is_activated(course)
certificates_enabled = CertificateManager.is_enabled(course)
return dict(
is_activated=is_activated,
has_certificate=certificates_enabled and len(certificates) > 0,
is_enabled=certificates_enabled,
)
def _updates_validation(self, course, request):
updates_usage_key = course.id.make_usage_key('course_info', 'updates')
updates = get_course_updates(updates_usage_key, provided_id=None, user_id=request.user.id)
return dict(
has_update=len(updates) > 0,
)
def _get_assignments(self, course): # lint-amnesty, pylint: disable=missing-function-docstring
store = modulestore()
sections = [store.get_item(section_usage_key) for section_usage_key in course.children]
assignments = [
store.get_item(assignment_usage_key)
for section in sections
for assignment_usage_key in section.children
]
visible_sections = [
s for s in sections
if not s.visible_to_staff_only and not s.hide_from_toc
]
assignments_in_visible_sections = [
store.get_item(assignment_usage_key)
for visible_section in visible_sections
for assignment_usage_key in visible_section.children
]
visible_assignments = [
a for a in assignments_in_visible_sections
if not a.visible_to_staff_only
]
return assignments, visible_assignments
def _get_open_responses(self, course, graded_only):
oras = modulestore().get_items(course.id, qualifiers={'category': 'openassessment'})
return oras if not graded_only else [ora for ora in oras if ora.graded]
def _has_date_before_start(self, ora, start): # lint-amnesty, pylint: disable=missing-function-docstring
if ora.submission_start:
if dateutil.parser.parse(ora.submission_start).replace(tzinfo=UTC) < start:
return True
if ora.submission_due:
if dateutil.parser.parse(ora.submission_due).replace(tzinfo=UTC) < start:
return True
for assessment in ora.rubric_assessments:
if assessment['start']:
if dateutil.parser.parse(assessment['start']).replace(tzinfo=UTC) < start:
return True
if assessment['due']:
if dateutil.parser.parse(assessment['due']).replace(tzinfo=UTC) < start:
return True
return False
def _has_date_after_end(self, ora, end): # lint-amnesty, pylint: disable=missing-function-docstring
if ora.submission_start:
if dateutil.parser.parse(ora.submission_start).replace(tzinfo=UTC) > end:
return True
if ora.submission_due:
if dateutil.parser.parse(ora.submission_due).replace(tzinfo=UTC) > end:
return True
for assessment in ora.rubric_assessments:
if assessment['start']:
if dateutil.parser.parse(assessment['start']).replace(tzinfo=UTC) > end:
return True
if assessment['due']:
if dateutil.parser.parse(assessment['due']).replace(tzinfo=UTC) > end:
return True
return False
def _has_start_date(self, course):
return not course.start_date_is_still_default
def _has_grading_policy(self, course): # lint-amnesty, pylint: disable=missing-function-docstring
grading_policy_formatted = {}
default_grading_policy_formatted = {}
for grader, assignment_type, weight in course.grader.subgraders:
grading_policy_formatted[assignment_type] = {
'type': assignment_type,
'short_label': grader.short_label,
'min_count': grader.min_count,
'drop_count': grader.drop_count,
'weight': weight,
}
# the default grading policy Lab assignment type does not have a short-label,
# but courses with the default grading policy do return a short-label for Lab
# assignments, so we ignore the Lab short-label
if 'Lab' in grading_policy_formatted:
grading_policy_formatted['Lab'].pop('short_label')
for assignment in DEFAULT_GRADING_POLICY['GRADER']:
default_assignment_grading_policy_formatted = {
'type': assignment['type'],
'min_count': assignment['min_count'],
'drop_count': assignment['drop_count'],
'weight': assignment['weight'],
}
# the default grading policy Lab assignment type does not have a short-label, so only
# add short_label to dictionary when the assignment has one
if 'short_label' in assignment:
default_assignment_grading_policy_formatted['short_label'] = assignment['short_label']
default_grading_policy_formatted[assignment['type']] = default_assignment_grading_policy_formatted
# check for equality
if len(grading_policy_formatted) != len(default_grading_policy_formatted):
return True
else:
for assignment_type in grading_policy_formatted:
if (assignment_type not in default_grading_policy_formatted or
grading_policy_formatted[assignment_type] != default_grading_policy_formatted[assignment_type]):
return True
return False
def _proctoring_validation(self, course):
# A proctoring escalation email is required if 'required_escalation_email' is set on the proctoring backend
return dict(
needs_proctoring_escalation_email=requires_escalation_email(course.proctoring_provider),
has_proctoring_escalation_email=bool(course.proctoring_escalation_email)
)