""" ProgramEnrollment V1 API internal utilities. """ from datetime import datetime, timedelta from functools import wraps from django.core.exceptions import PermissionDenied from django.utils.functional import cached_property from opaque_keys.edx.keys import CourseKey from pytz import UTC from rest_framework import status from rest_framework.pagination import CursorPagination from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.helpers import get_resume_urls_for_enrollments from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.bulk_email.api import get_emails_enabled from lms.djangoapps.certificates.api import get_certificates_for_user_by_course_keys from lms.djangoapps.course_api.api import get_course_run_url, get_due_dates from lms.djangoapps.program_enrollments.api import fetch_program_enrollments from lms.djangoapps.program_enrollments.constants import ProgramEnrollmentStatuses from openedx.core.djangoapps.catalog.utils import course_run_keys_for_program, get_programs, is_course_run_in_program from openedx.core.lib.api.view_utils import verify_course_exists from .constants import CourseRunProgressStatuses class ProgramEnrollmentPagination(CursorPagination): """ Pagination class for views in the Program Enrollments app. """ ordering = 'id' page_size = 100 page_size_query_param = 'page_size' def get_paginated_response(self, data, status_code=200, **kwargs): # pylint: disable=arguments-differ """ Return a response given serialized page data, optional status_code (defaults to 200), and kwargs. Each key-value pair of kwargs is added to the response data. """ resp = super().get_paginated_response(data) for (key, value) in kwargs.items(): resp.data[key] = value resp.status_code = status_code return resp class UserProgramCourseEnrollmentPagination(CursorPagination): """ Pagination parameters for UserProgramCourseEnrollmentView. """ page_size = 10 max_page_size = 25 page_size_query_param = 'page_size' ordering = 'id' class ProgramSpecificViewMixin: """ A mixin for views that operate on or within a specific program. Requires `program_uuid` to be one of the kwargs to the view. """ @cached_property def program(self): """ The program specified by the `program_uuid` URL parameter. Returns: dict """ return get_programs(uuid=self.program_uuid) @property def program_uuid(self): """ The program specified by the `program_uuid` URL parameter. Returns: str """ return self.kwargs['program_uuid'] class UserProgramSpecificViewMixin(ProgramSpecificViewMixin): """ A mixin for views that operate on a specific program in the context of a user. Requires `program_uuid` to be one of the kwargs to the view. The property `target_user` returns the user that that we should operate with. """ @property def target_user(self): """ The user that this view's operations should operate in the context of. By default, this is the requesting user. This can be overriden in order to implement "user-parameterized" views, which, for example, a global staff member could use to see API responses in the context of a specific learner. This could be used to help implement masquerading. """ return self.request.user class ProgramCourseSpecificViewMixin(ProgramSpecificViewMixin): """ A mixin for views that operate on or within a specific course run in a program Requires `course_id` to be one of the kwargs to the view. """ @cached_property def course_key(self): """ The course key for the course run specified by the `course_id` URL parameter. """ return CourseKey.from_string(self.kwargs['course_id']) def verify_program_exists(view_func): """ Raises: An API error if the `program_uuid` kwarg in the wrapped function does not exist in the catalog programs cache. Expects to be used within a ProgramSpecificViewMixin subclass. """ @wraps(view_func) def wrapped_function(self, *args, **kwargs): """ Wraps the given view_function. """ if self.program is None: raise self.api_error( status_code=status.HTTP_404_NOT_FOUND, developer_message='no program exists with given key', error_code='program_does_not_exist' ) return view_func(self, *args, **kwargs) return wrapped_function def verify_course_exists_and_in_program(view_func): """ Raises: An api error if the course run specified by the `course_id` kwarg in the wrapped function is not part of the curriculum of the program specified by the `program_uuid` kwarg This decorator guarantees existance of the program and course, so wrapping alongside `verify_{program,course}_exists` is redundant. Expects to be used within a subclass of ProgramCourseSpecificViewMixin. """ @wraps(view_func) @verify_program_exists @verify_course_exists def wrapped_function(self, *args, **kwargs): """ Wraps view function """ if not is_course_run_in_program(self.course_key, self.program): raise self.api_error( status_code=status.HTTP_404_NOT_FOUND, developer_message="the program's curriculum does not contain the given course", error_code='course_not_in_program' ) return view_func(self, *args, **kwargs) return wrapped_function def verify_user_enrolled_in_program(view_func): """ Raised PermissionDenied if the `target_user` is not enrolled in the program. Expects to be used within a UserProgramViewMixin subclass. """ @wraps(view_func) def wrapped_function(self, *args, **kwargs): """ Wraps the given view_function. """ user_enrollment_qs = fetch_program_enrollments( program_uuid=self.program_uuid, users={self.target_user}, program_enrollment_statuses={ProgramEnrollmentStatuses.ENROLLED}, ) if not user_enrollment_qs.exists(): raise PermissionDenied return view_func(self, *args, **kwargs) return wrapped_function def get_enrollments_for_courses_in_program(user, program): """ Get a user's active enrollments for course runs with the given program. Note that this is distinct from the user's *program course enrollments*, which refers to courses that were enrollmed in *through* a program. In the case of this function, the course runs themselves must be part of the program, but the enrollments do not need to be associated with a program enrollment. Arguments: user (User) program (dict) Returns QuerySet[CourseEnrollment] """ course_keys = [ CourseKey.from_string(key) for key in course_run_keys_for_program(program) ] return CourseEnrollment.objects.filter( user=user, course_id__in=course_keys, mode__in=[CourseMode.VERIFIED, CourseMode.MASTERS, CourseMode.EXECUTIVE_EDUCATION], is_active=True, ) def get_enrollment_overviews(user, program, enrollments, request): """ Get a list of overviews for a user's course run enrollments within a program. Arguments: user (User) program (dict) enrollments (iterable[CourseEnrollment]) request (HttpRequest): Source HTTP request. Needed for URL generation. Returns list[dict] """ overviews_by_course_key = { enrollment.course.id: enrollment.course for enrollment in enrollments } course_keys = list(overviews_by_course_key.keys()) certficates_by_course_key = get_certificates_for_user_by_course_keys(user, course_keys) resume_urls_by_course_key = get_resume_urls_for_enrollments(user, enrollments) return [ get_single_enrollment_overview( user=user, program=program, course_overview=overviews_by_course_key[enrollment.course_id], certificate_info=certficates_by_course_key.get(enrollment.course_id, {}), relative_resume_url=resume_urls_by_course_key.get(enrollment.course_id), request=request, ) for enrollment in enrollments ] def get_single_enrollment_overview( user, program, course_overview, certificate_info, relative_resume_url, request, ): """ Get an overview of a user's enrollment in a course run within a program. Arguments: user (User) program (Program) course_overview (CourseOverview) certificate_info (dict): Info about a user's certificate in this course run. relative_resume_url (str): URL to resume course. Relative to LMS root. request (HttpRequest): Source HTTP request. Needed for URL generation. Returns: dict """ course_key = course_overview.id course_run_status = get_course_run_status(course_overview, certificate_info) due_dates = ( get_due_dates(request, course_key, user) if course_run_status == CourseRunProgressStatuses.IN_PROGRESS else [] ) result = { 'course_run_id': str(course_key), 'display_name': course_overview.display_name_with_default, 'course_run_status': course_run_status, 'course_run_url': get_course_run_url(request, course_key), 'start_date': course_overview.start, 'end_date': course_overview.end, 'due_dates': due_dates, } emails_enabled = get_emails_enabled(user, course_key) if emails_enabled is not None: result['emails_enabled'] = emails_enabled download_url = certificate_info.get('download_url') if download_url: result['certificate_download_url'] = request.build_absolute_uri( certificate_info['download_url'] ) if program['type'] == 'MicroMasters': result['micromasters_title'] = program['title'] if relative_resume_url: result['resume_course_run_url'] = request.build_absolute_uri(relative_resume_url) return result def get_enrollment_http_code(result_statuses, ok_statuses): """ Given a set of enrollment create/update statuses, return the appropriate HTTP status code. Arguments: result_statuses (sequence[str]): set of enrollment operation statuses (for example, 'enrolled', 'not-in-program', etc.) ok_statuses: sequence[str]: set of 'OK' (non-error) statuses """ result_status_set = set(result_statuses) ok_status_set = set(ok_statuses) if not result_status_set: return status.HTTP_204_NO_CONTENT if result_status_set.issubset(ok_status_set): return status.HTTP_200_OK elif result_status_set & ok_status_set: return status.HTTP_207_MULTI_STATUS else: return status.HTTP_422_UNPROCESSABLE_ENTITY def get_course_run_status(course_overview, certificate_info): """ Get the progress status of a course run, given the state of a user's certificate in the course. In the case of self-paced course runs, the run is considered completed when either the courserun has ended OR the user has earned a passing certificate 30 days ago or longer. Arguments: course_overview (CourseOverview): the overview for the course run certificate_info: A dict containing the following keys: ``is_passing``: whether the user has a passing certificate in the course run ``created``: the date the certificate was created Returns: status: one of ( CourseRunProgressStatuses.COMPLETE, CourseRunProgressStatuses.IN_PROGRESS, CourseRunProgressStatuses.UPCOMING, ) """ is_certificate_passing = certificate_info.get('is_passing', False) certificate_creation_date = certificate_info.get('created', datetime.max) if course_overview.pacing == 'instructor': if course_overview.has_ended(): return CourseRunProgressStatuses.COMPLETED elif course_overview.has_started(): return CourseRunProgressStatuses.IN_PROGRESS else: return CourseRunProgressStatuses.UPCOMING elif course_overview.pacing == 'self': thirty_days_ago = datetime.now(UTC) - timedelta(30) certificate_completed = is_certificate_passing and ( certificate_creation_date <= thirty_days_ago ) if course_overview.has_ended() or certificate_completed: return CourseRunProgressStatuses.COMPLETED elif course_overview.has_started(): return CourseRunProgressStatuses.IN_PROGRESS else: return CourseRunProgressStatuses.UPCOMING return None