Beispiel #1
0
def get_course_grades_for_users(user_ids, course_id):
    """
    Returns course grades for the given users and course.

    Args:
        user_ids (list): List of user ids.
        course_id (CourseKeyString): Course id.

    Returns:
        dict: Dictionary containing user and grades information.
    """
    grades = PersistentCourseGrade.objects.filter(
        user_id__in=user_ids, course_id=course_id
    ).only('user_id', 'percent_grade', 'passed_timestamp')

    grades_dict = {
        grade.user_id: {
            'grade_percentage': f'{convert_float_point_to_percentage(grade.percent_grade)}%',
            'passed_timestamp': grade.passed_timestamp
        }
        for grade in grades
    }

    if len(user_ids) != len(grades):
        course_grade_factory = CourseGradeFactory()

        user_ids_without_grades = [user_id for user_id in user_ids if user_id not in grades_dict.keys()]
        users = User.objects.filter(id__in=user_ids_without_grades)

        for user in users:
            if grades_dict.get(user.id, None):
                continue

            course_grade = course_grade_factory.read(
                user=user, course_key=course_id
            )
            grades_dict[user.id] = {
                'grade_percentage': f'{convert_float_point_to_percentage(course_grade.percent)}%',
                'passed_timestamp': now() if course_grade.passed else None,
            }

    return grades_dict
Beispiel #2
0
def _listen_for_id_verification_status_changed(sender, user, **kwargs):  # pylint: disable=unused-argument
    """
    Catches a track change signal, determines user status,
    calls fire_ungenerated_certificate_task for passing grades
    """
    if not auto_certificate_generation_enabled():
        return

    user_enrollments = CourseEnrollment.enrollments_for_user(user=user)
    grade_factory = CourseGradeFactory()
    expected_verification_status, _ = SoftwareSecurePhotoVerification.user_status(user)
    for enrollment in user_enrollments:
        if grade_factory.read(user=user, course=enrollment.course_overview).passed:
            if fire_ungenerated_certificate_task(user, enrollment.course_id, expected_verification_status):
                message = (
                    u'Certificate generation task initiated for {user} : {course} via track change ' +
                    u'with verification status of {status}'
                )
                log.info(message.format(
                    user=user.id,
                    course=enrollment.course_id,
                    status=expected_verification_status
                ))
Beispiel #3
0
def _listen_for_id_verification_status_changed(sender, user, **kwargs):  # pylint: disable=unused-argument
    """
    Catches a track change signal, determines user status,
    calls fire_ungenerated_certificate_task for passing grades
    """
    if not auto_certificate_generation_enabled():
        return

    user_enrollments = CourseEnrollment.enrollments_for_user(user=user)
    grade_factory = CourseGradeFactory()
    expected_verification_status = IDVerificationService.user_status(user)
    for enrollment in user_enrollments:
        if grade_factory.read(user=user, course=enrollment.course_overview).passed:
            if fire_ungenerated_certificate_task(user, enrollment.course_id, expected_verification_status):
                message = (
                    u'Certificate generation task initiated for {user} : {course} via track change ' +
                    u'with verification status of {status}'
                )
                log.info(message.format(
                    user=user.id,
                    course=enrollment.course_id,
                    status=expected_verification_status['status']
                ))
Beispiel #4
0
class ProgramProgressMeter(object):
    """Utility for gauging a user's progress towards program completion.

    Arguments:
        user (User): The user for which to find programs.

    Keyword Arguments:
        enrollments (list): List of the user's enrollments.
        uuid (str): UUID identifying a specific program. If provided, the meter
            will only inspect this one program, not all programs the user may be
            engaged with.
    """
    def __init__(self, site, user, enrollments=None, uuid=None):
        self.site = site
        self.user = user

        self.enrollments = enrollments or list(
            CourseEnrollment.enrollments_for_user(self.user))
        self.enrollments.sort(key=lambda e: e.created, reverse=True)

        self.enrolled_run_modes = {}
        self.course_run_ids = []
        for enrollment in self.enrollments:
            # enrollment.course_id is really a CourseKey (╯ಠ_ಠ)╯︵ ┻━┻
            enrollment_id = unicode(enrollment.course_id)
            mode = enrollment.mode
            if mode == CourseMode.NO_ID_PROFESSIONAL_MODE:
                mode = CourseMode.PROFESSIONAL
            self.enrolled_run_modes[enrollment_id] = mode
            # We can't use dict.keys() for this because the course run ids need to be ordered
            self.course_run_ids.append(enrollment_id)

        self.entitlements = list(
            CourseEntitlement.unexpired_entitlements_for_user(self.user))
        self.course_uuids = [
            str(entitlement.course_uuid) for entitlement in self.entitlements
        ]

        self.course_grade_factory = CourseGradeFactory()

        if uuid:
            self.programs = [get_programs(self.site, uuid=uuid)]
        else:
            self.programs = attach_program_detail_url(get_programs(self.site))

    def invert_programs(self):
        """Intersect programs and enrollments.

        Builds a dictionary of program dict lists keyed by course run ID and by course UUID.
        The resulting dictionary is suitable in applications where programs must be
        filtered by the course runs or courses they contain (e.g., the student dashboard).

        Returns:
            defaultdict, programs keyed by course run ID
        """
        inverted_programs = defaultdict(list)

        for program in self.programs:
            for course in program['courses']:
                course_uuid = course['uuid']
                if course_uuid in self.course_uuids:
                    program_list = inverted_programs[course_uuid]
                    if program not in program_list:
                        program_list.append(program)
                    continue
                for course_run in course['course_runs']:
                    course_run_id = course_run['key']
                    if course_run_id in self.course_run_ids:
                        program_list = inverted_programs[course_run_id]
                        if program not in program_list:
                            program_list.append(program)

        # Sort programs by title for consistent presentation.
        for program_list in inverted_programs.itervalues():
            program_list.sort(key=lambda p: p['title'])

        return inverted_programs

    @cached_property
    def engaged_programs(self):
        """Derive a list of programs in which the given user is engaged.

        Returns:
            list of program dicts, ordered by most recent enrollment
        """
        inverted_programs = self.invert_programs()

        programs = []
        # Remember that these course run ids are derived from a list of
        # enrollments sorted from most recent to least recent. Iterating
        # over the values in inverted_programs alone won't yield a program
        # ordering consistent with the user's enrollments.
        for course_run_id in self.course_run_ids:
            for program in inverted_programs[course_run_id]:
                # Dicts aren't a hashable type, so we can't use a set. Sets also
                # aren't ordered, which is important here.
                if program not in programs:
                    programs.append(program)

        for course_uuid in self.course_uuids:
            for program in inverted_programs[course_uuid]:
                if program not in programs:
                    programs.append(program)

        return programs

    def _is_course_in_progress(self, now, course):
        """Check if course qualifies as in progress as part of the program.

        A course is considered to be in progress if a user is enrolled in a run
        of the correct mode or a run of the correct mode is still available for enrollment.

        Arguments:
            now (datetime): datetime for now
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is in progress.
        """
        enrolled_runs = [
            run for run in course['course_runs']
            if run['key'] in self.course_run_ids
        ]

        # Check if the user is enrolled in a required run and mode/seat.
        runs_with_required_mode = [
            run for run in enrolled_runs
            if run['type'] == self.enrolled_run_modes[run['key']]
        ]

        if runs_with_required_mode:
            not_failed_runs = [
                run for run in runs_with_required_mode
                if run not in self.failed_course_runs
            ]
            if not_failed_runs:
                return True

        # Check if seats required for course completion are still available.
        upgrade_deadlines = []
        for run in enrolled_runs:
            for seat in run['seats']:
                if seat['type'] == run['type'] and run[
                        'type'] != self.enrolled_run_modes[run['key']]:
                    upgrade_deadlines.append(seat['upgrade_deadline'])

        # An upgrade deadline of None means the course is always upgradeable.
        return any(not deadline or deadline and parse(deadline) > now
                   for deadline in upgrade_deadlines)

    def progress(self, programs=None, count_only=True):
        """Gauge a user's progress towards program completion.

        Keyword Arguments:
            programs (list): Specific list of programs to check the user's progress
                against. If left unspecified, self.engaged_programs will be used.

            count_only (bool): Whether or not to return counts of completed, in
                progress, and unstarted courses instead of serialized representations
                of the courses.

        Returns:
            list of dict, each containing information about a user's progress
                towards completing a program.
        """
        now = datetime.datetime.now(utc)

        progress = []
        programs = programs or self.engaged_programs
        for program in programs:
            program_copy = deepcopy(program)
            completed, in_progress, not_started = [], [], []

            for course in program_copy['courses']:
                if self._is_course_complete(course):
                    completed.append(course)
                elif self._is_course_enrolled(course):
                    course_in_progress = self._is_course_in_progress(
                        now, course)
                    if course_in_progress:
                        in_progress.append(course)
                    else:
                        course['expired'] = not course_in_progress
                        not_started.append(course)
                else:
                    not_started.append(course)

            grades = {}
            for run in self.course_run_ids:
                grade = self.course_grade_factory.read(
                    self.user, course_key=CourseKey.from_string(run))
                grades[run] = grade.percent

            progress.append({
                'uuid': program_copy['uuid'],
                'completed': len(completed) if count_only else completed,
                'in_progress': len(in_progress) if count_only else in_progress,
                'not_started': len(not_started) if count_only else not_started,
                'grades': grades,
            })

        return progress

    @property
    def completed_programs(self):
        """Identify programs completed by the student.

        Returns:
            list of UUIDs, each identifying a completed program.
        """
        return [
            program['uuid'] for program in self.programs
            if self._is_program_complete(program)
        ]

    def _is_program_complete(self, program):
        """Check if a user has completed a program.

        A program is completed if the user has completed all nested courses.

        Arguments:
            program (dict): Representing the program whose completion to assess.

        Returns:
            bool, indicating whether the program is complete.
        """
        return all(self._is_course_complete(course) for course in program['courses']) \
            and len(program['courses']) > 0

    def _is_course_complete(self, course):
        """Check if a user has completed a course.

        A course is completed if the user has earned a certificate for any of
        the nested course runs.

        Arguments:
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is complete.
        """
        def reshape(course_run):
            """
            Modify the structure of a course run dict to facilitate comparison
            with course run certificates.
            """
            return {
                'course_run_id':
                course_run['key'],
                # A course run's type is assumed to indicate which mode must be
                # completed in order for the run to count towards program completion.
                # This supports the same flexible program construction allowed by the
                # old programs service (e.g., completion of an old honor-only run may
                # count towards completion of a course in a program). This may change
                # in the future to make use of the more rigid set of "applicable seat
                # types" associated with each program type in the catalog.

                # Runs of type 'credit' are counted as 'verified' since verified
                # certificates are earned when credit runs are completed. LEARNER-1274
                # tracks a cleaner way to do this using the discovery service's
                # applicable_seat_types field.
                'type':
                'verified'
                if course_run['type'] == 'credit' else course_run['type'],
            }

        return any(
            reshape(course_run) in self.completed_course_runs
            for course_run in course['course_runs'])

    @cached_property
    def completed_course_runs(self):
        """
        Determine which course runs have been completed by the user.

        Returns:
            list of dicts, each representing a course run certificate
        """
        return self.course_runs_with_state['completed']

    @cached_property
    def failed_course_runs(self):
        """
        Determine which course runs have been failed by the user.

        Returns:
            list of dicts, each a course run ID
        """
        return [
            run['course_run_id']
            for run in self.course_runs_with_state['failed']
        ]

    @cached_property
    def course_runs_with_state(self):
        """
        Determine which course runs have been completed and failed by the user.

        Returns:
            dict with a list of completed and failed runs
        """
        course_run_certificates = certificate_api.get_certificates_for_user(
            self.user.username)

        completed_runs, failed_runs = [], []
        for certificate in course_run_certificates:
            certificate_type = certificate['type']

            # Treat "no-id-professional" certificates as "professional" certificates
            if certificate_type == CourseMode.NO_ID_PROFESSIONAL_MODE:
                certificate_type = CourseMode.PROFESSIONAL

            course_data = {
                'course_run_id': unicode(certificate['course_key']),
                'type': certificate_type,
            }

            if certificate_api.is_passing_status(certificate['status']):
                completed_runs.append(course_data)
            else:
                failed_runs.append(course_data)

        return {'completed': completed_runs, 'failed': failed_runs}

    def _is_course_enrolled(self, course):
        """Check if a user is enrolled in a course.

        A user is considered to be enrolled in a course if
        they're enrolled in any of the nested course runs.

        Arguments:
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is in progress.
        """
        return any(course_run['key'] in self.course_run_ids
                   for course_run in course['course_runs'])
Beispiel #5
0
class ProgramProgressMeter(object):
    """Utility for gauging a user's progress towards program completion.

    Arguments:
        user (User): The user for which to find programs.

    Keyword Arguments:
        enrollments (list): List of the user's enrollments.
        uuid (str): UUID identifying a specific program. If provided, the meter
            will only inspect this one program, not all programs the user may be
            engaged with.
    """
    def __init__(self,
                 site,
                 user,
                 enrollments=None,
                 uuid=None,
                 mobile_only=False):
        self.site = site
        self.user = user
        self.mobile_only = mobile_only

        self.enrollments = enrollments or list(
            CourseEnrollment.enrollments_for_user(self.user))
        self.enrollments.sort(key=lambda e: e.created, reverse=True)

        self.enrolled_run_modes = {}
        self.course_run_ids = []
        for enrollment in self.enrollments:
            # enrollment.course_id is really a CourseKey (╯ಠ_ಠ)╯︵ ┻━┻
            enrollment_id = unicode(enrollment.course_id)
            mode = enrollment.mode
            if mode == CourseMode.NO_ID_PROFESSIONAL_MODE:
                mode = CourseMode.PROFESSIONAL
            self.enrolled_run_modes[enrollment_id] = mode
            # We can't use dict.keys() for this because the course run ids need to be ordered
            self.course_run_ids.append(enrollment_id)

        self.entitlements = list(
            CourseEntitlement.unexpired_entitlements_for_user(self.user))
        self.course_uuids = [
            str(entitlement.course_uuid) for entitlement in self.entitlements
        ]

        self.course_grade_factory = CourseGradeFactory()

        if uuid:
            self.programs = [get_programs(self.site, uuid=uuid)]
        else:
            self.programs = attach_program_detail_url(get_programs(self.site),
                                                      self.mobile_only)

    def invert_programs(self):
        """Intersect programs and enrollments.

        Builds a dictionary of program dict lists keyed by course run ID and by course UUID.
        The resulting dictionary is suitable in applications where programs must be
        filtered by the course runs or courses they contain (e.g., the student dashboard).

        Returns:
            defaultdict, programs keyed by course run ID
        """
        inverted_programs = defaultdict(list)

        for program in self.programs:
            for course in program['courses']:
                course_uuid = course['uuid']
                if course_uuid in self.course_uuids:
                    program_list = inverted_programs[course_uuid]
                    if program not in program_list:
                        program_list.append(program)
                for course_run in course['course_runs']:
                    course_run_id = course_run['key']
                    if course_run_id in self.course_run_ids:
                        program_list = inverted_programs[course_run_id]
                        if program not in program_list:
                            program_list.append(program)

        # Sort programs by title for consistent presentation.
        for program_list in inverted_programs.itervalues():
            program_list.sort(key=lambda p: p['title'])

        return inverted_programs

    @cached_property
    def engaged_programs(self):
        """Derive a list of programs in which the given user is engaged.

        Returns:
            list of program dicts, ordered by most recent enrollment
        """
        inverted_programs = self.invert_programs()

        programs = []
        # Remember that these course run ids are derived from a list of
        # enrollments sorted from most recent to least recent. Iterating
        # over the values in inverted_programs alone won't yield a program
        # ordering consistent with the user's enrollments.
        for course_run_id in self.course_run_ids:
            for program in inverted_programs[course_run_id]:
                # Dicts aren't a hashable type, so we can't use a set. Sets also
                # aren't ordered, which is important here.
                if program not in programs:
                    programs.append(program)

        for course_uuid in self.course_uuids:
            for program in inverted_programs[course_uuid]:
                if program not in programs:
                    programs.append(program)

        return programs

    def _is_course_in_progress(self, now, course):
        """Check if course qualifies as in progress as part of the program.

        A course is considered to be in progress if a user is enrolled in a run
        of the correct mode or a run of the correct mode is still available for enrollment.

        Arguments:
            now (datetime): datetime for now
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is in progress.
        """
        enrolled_runs = [
            run for run in course['course_runs']
            if run['key'] in self.course_run_ids
        ]

        # Check if the user is enrolled in a required run and mode/seat.
        runs_with_required_mode = [
            run for run in enrolled_runs
            if run['type'] == self.enrolled_run_modes[run['key']]
        ]

        if runs_with_required_mode:
            not_failed_runs = [
                run for run in runs_with_required_mode
                if run not in self.failed_course_runs
            ]
            if not_failed_runs:
                return True

        # Check if seats required for course completion are still available.
        upgrade_deadlines = []
        for run in enrolled_runs:
            for seat in run['seats']:
                if seat['type'] == run['type'] and run[
                        'type'] != self.enrolled_run_modes[run['key']]:
                    upgrade_deadlines.append(seat['upgrade_deadline'])

        # An upgrade deadline of None means the course is always upgradeable.
        return any(not deadline or deadline and parse(deadline) > now
                   for deadline in upgrade_deadlines)

    def progress(self, programs=None, count_only=True):
        """Gauge a user's progress towards program completion.

        Keyword Arguments:
            programs (list): Specific list of programs to check the user's progress
                against. If left unspecified, self.engaged_programs will be used.

            count_only (bool): Whether or not to return counts of completed, in
                progress, and unstarted courses instead of serialized representations
                of the courses.

        Returns:
            list of dict, each containing information about a user's progress
                towards completing a program.
        """
        now = datetime.datetime.now(utc)

        progress = []
        programs = programs or self.engaged_programs
        for program in programs:
            program_copy = deepcopy(program)
            completed, in_progress, not_started = [], [], []

            for course in program_copy['courses']:
                active_entitlement = CourseEntitlement.get_entitlement_if_active(
                    user=self.user, course_uuid=course['uuid'])
                if self._is_course_complete(course):
                    completed.append(course)
                elif self._is_course_enrolled(course) or active_entitlement:
                    # Show all currently enrolled courses and active entitlements as in progress
                    if active_entitlement:
                        course[
                            'course_runs'] = get_fulfillable_course_runs_for_entitlement(
                                active_entitlement, course['course_runs'])
                        course[
                            'user_entitlement'] = active_entitlement.to_dict()
                        course['enroll_url'] = reverse(
                            'entitlements_api:v1:enrollments',
                            args=[str(active_entitlement.uuid)])
                        in_progress.append(course)
                    else:
                        course_in_progress = self._is_course_in_progress(
                            now, course)
                        if course_in_progress:
                            in_progress.append(course)
                        else:
                            course['expired'] = not course_in_progress
                            not_started.append(course)
                else:
                    not_started.append(course)

            grades = {}
            for run in self.course_run_ids:
                grade = self.course_grade_factory.read(
                    self.user, course_key=CourseKey.from_string(run))
                grades[run] = grade.percent

            progress.append({
                'uuid': program_copy['uuid'],
                'completed': len(completed) if count_only else completed,
                'in_progress': len(in_progress) if count_only else in_progress,
                'not_started': len(not_started) if count_only else not_started,
                'grades': grades,
            })

        return progress

    @property
    def completed_programs_with_available_dates(self):
        """
        Calculate the available date for completed programs based on course runs.

        Returns a dict of {uuid_string: available_datetime}
        """
        # Query for all user certs up front, for performance reasons (rather than querying per course run).
        user_certificates = GeneratedCertificate.eligible_certificates.filter(
            user=self.user)
        certificates_by_run = {
            cert.course_id: cert
            for cert in user_certificates
        }

        completed = {}
        for program in self.programs:
            available_date = self._available_date_for_program(
                program, certificates_by_run)
            if available_date:
                completed[program['uuid']] = available_date
        return completed

    def _available_date_for_program(self, program_data, certificates):
        """
        Calculate the available date for the program based on the courses within it.

        Arguments:
            program_data (dict): nested courses and course runs
            certificates (dict): course run key -> certificate mapping

        Returns a datetime object or None if the program is not complete.
        """
        program_available_date = None
        for course in program_data['courses']:
            earliest_course_run_date = None

            for course_run in course['course_runs']:
                key = CourseKey.from_string(course_run['key'])

                # Get a certificate if one exists
                certificate = certificates.get(key)
                if certificate is None:
                    continue

                # Modes must match (see _is_course_complete() comments for why)
                course_run_mode = self._course_run_mode_translation(
                    course_run['type'])
                certificate_mode = self._certificate_mode_translation(
                    certificate.mode)
                modes_match = course_run_mode == certificate_mode

                # Grab the available date and keep it if it's the earliest one for this catalog course.
                if modes_match and certificate_api.is_passing_status(
                        certificate.status):
                    course_overview = CourseOverview.get_from_id(key)
                    available_date = available_date_for_certificate(
                        course_overview, certificate)
                    earliest_course_run_date = min(
                        filter(None,
                               [available_date, earliest_course_run_date]))

            # If we're missing a cert for a course, the program isn't completed and we should just bail now
            if earliest_course_run_date is None:
                return None

            # Keep the catalog course date if it's the latest one
            program_available_date = max(
                filter(None,
                       [earliest_course_run_date, program_available_date]))

        return program_available_date

    def _course_run_mode_translation(self, course_run_mode):
        """
        Returns a canonical mode for a course run (whose data is coming from the program cache).
        This mode must match the certificate mode to be counted as complete.
        """
        mappings = {
            # Runs of type 'credit' are counted as 'verified' since verified
            # certificates are earned when credit runs are completed. LEARNER-1274
            # tracks a cleaner way to do this using the discovery service's
            # applicable_seat_types field.
            CourseMode.CREDIT_MODE:
            CourseMode.VERIFIED,
        }
        return mappings.get(course_run_mode, course_run_mode)

    def _certificate_mode_translation(self, certificate_mode):
        """
        Returns a canonical mode for a certificate (whose data is coming from the database).
        This mode must match the course run mode to be counted as complete.
        """
        mappings = {
            # Treat "no-id-professional" certificates as "professional" certificates
            CourseMode.NO_ID_PROFESSIONAL_MODE:
            CourseMode.PROFESSIONAL,
        }
        return mappings.get(certificate_mode, certificate_mode)

    def _is_course_complete(self, course):
        """Check if a user has completed a course.

        A course is completed if the user has earned a certificate for any of
        the nested course runs.

        Arguments:
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is complete.
        """
        def reshape(course_run):
            """
            Modify the structure of a course run dict to facilitate comparison
            with course run certificates.
            """
            return {
                'course_run_id': course_run['key'],
                # A course run's type is assumed to indicate which mode must be
                # completed in order for the run to count towards program completion.
                # This supports the same flexible program construction allowed by the
                # old programs service (e.g., completion of an old honor-only run may
                # count towards completion of a course in a program). This may change
                # in the future to make use of the more rigid set of "applicable seat
                # types" associated with each program type in the catalog.
                'type': self._course_run_mode_translation(course_run['type']),
            }

        return any(
            reshape(course_run) in self.completed_course_runs
            for course_run in course['course_runs'])

    @cached_property
    def completed_course_runs(self):
        """
        Determine which course runs have been completed by the user.

        Returns:
            list of dicts, each representing a course run certificate
        """
        return self.course_runs_with_state['completed']

    @cached_property
    def failed_course_runs(self):
        """
        Determine which course runs have been failed by the user.

        Returns:
            list of dicts, each a course run ID
        """
        return [
            run['course_run_id']
            for run in self.course_runs_with_state['failed']
        ]

    @cached_property
    def course_runs_with_state(self):
        """
        Determine which course runs have been completed and failed by the user.

        Returns:
            dict with a list of completed and failed runs
        """
        course_run_certificates = certificate_api.get_certificates_for_user(
            self.user.username)

        completed_runs, failed_runs = [], []
        for certificate in course_run_certificates:
            course_data = {
                'course_run_id': unicode(certificate['course_key']),
                'type':
                self._certificate_mode_translation(certificate['type']),
            }

            if certificate_api.is_passing_status(certificate['status']):
                completed_runs.append(course_data)
            else:
                failed_runs.append(course_data)

        return {'completed': completed_runs, 'failed': failed_runs}

    def _is_course_enrolled(self, course):
        """Check if a user is enrolled in a course.

        A user is considered to be enrolled in a course if
        they're enrolled in any of the nested course runs.

        Arguments:
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is in progress.
        """
        return any(course_run['key'] in self.course_run_ids
                   for course_run in course['course_runs'])
Beispiel #6
0
class ProgramProgressMeter(object):
    """Utility for gauging a user's progress towards program completion.

    Arguments:
        user (User): The user for which to find programs.

    Keyword Arguments:
        enrollments (list): List of the user's enrollments.
        uuid (str): UUID identifying a specific program. If provided, the meter
            will only inspect this one program, not all programs the user may be
            engaged with.
    """
    def __init__(self, site, user, enrollments=None, uuid=None):
        self.site = site
        self.user = user

        self.enrollments = enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
        self.enrollments.sort(key=lambda e: e.created, reverse=True)

        self.enrolled_run_modes = {}
        self.course_run_ids = []
        for enrollment in self.enrollments:
            # enrollment.course_id is really a CourseKey (╯ಠ_ಠ)╯︵ ┻━┻
            enrollment_id = unicode(enrollment.course_id)
            mode = enrollment.mode
            if mode == CourseMode.NO_ID_PROFESSIONAL_MODE:
                mode = CourseMode.PROFESSIONAL
            self.enrolled_run_modes[enrollment_id] = mode
            # We can't use dict.keys() for this because the course run ids need to be ordered
            self.course_run_ids.append(enrollment_id)

        self.course_grade_factory = CourseGradeFactory()

        if uuid:
            self.programs = [get_programs(self.site, uuid=uuid)]
        else:
            self.programs = attach_program_detail_url(get_programs(self.site))

    def invert_programs(self):
        """Intersect programs and enrollments.

        Builds a dictionary of program dict lists keyed by course run ID. The
        resulting dictionary is suitable in applications where programs must be
        filtered by the course runs they contain (e.g., the student dashboard).

        Returns:
            defaultdict, programs keyed by course run ID
        """
        inverted_programs = defaultdict(list)

        for program in self.programs:
            for course in program['courses']:
                for course_run in course['course_runs']:
                    course_run_id = course_run['key']
                    if course_run_id in self.course_run_ids:
                        program_list = inverted_programs[course_run_id]
                        if program not in program_list:
                            program_list.append(program)

        # Sort programs by title for consistent presentation.
        for program_list in inverted_programs.itervalues():
            program_list.sort(key=lambda p: p['title'])

        return inverted_programs

    @cached_property
    def engaged_programs(self):
        """Derive a list of programs in which the given user is engaged.

        Returns:
            list of program dicts, ordered by most recent enrollment
        """
        inverted_programs = self.invert_programs()

        programs = []
        # Remember that these course run ids are derived from a list of
        # enrollments sorted from most recent to least recent. Iterating
        # over the values in inverted_programs alone won't yield a program
        # ordering consistent with the user's enrollments.
        for course_run_id in self.course_run_ids:
            for program in inverted_programs[course_run_id]:
                # Dicts aren't a hashable type, so we can't use a set. Sets also
                # aren't ordered, which is important here.
                if program not in programs:
                    programs.append(program)

        return programs

    def _is_course_in_progress(self, now, course):
        """Check if course qualifies as in progress as part of the program.

        A course is considered to be in progress if a user is enrolled in a run
        of the correct mode or a run of the correct mode is still available for enrollment.

        Arguments:
            now (datetime): datetime for now
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is in progress.
        """
        enrolled_runs = [run for run in course['course_runs'] if run['key'] in self.course_run_ids]

        # Check if the user is enrolled in a required run and mode/seat.
        runs_with_required_mode = [
            run for run in enrolled_runs
            if run['type'] == self.enrolled_run_modes[run['key']]
        ]

        if runs_with_required_mode:
            not_failed_runs = [run for run in runs_with_required_mode if run not in self.failed_course_runs]
            if not_failed_runs:
                return True

        # Check if seats required for course completion are still available.
        upgrade_deadlines = []
        for run in enrolled_runs:
            for seat in run['seats']:
                if seat['type'] == run['type'] and run['type'] != self.enrolled_run_modes[run['key']]:
                    upgrade_deadlines.append(seat['upgrade_deadline'])

        # An upgrade deadline of None means the course is always upgradeable.
        return any(not deadline or deadline and parse(deadline) > now for deadline in upgrade_deadlines)

    def progress(self, programs=None, count_only=True):
        """Gauge a user's progress towards program completion.

        Keyword Arguments:
            programs (list): Specific list of programs to check the user's progress
                against. If left unspecified, self.engaged_programs will be used.

            count_only (bool): Whether or not to return counts of completed, in
                progress, and unstarted courses instead of serialized representations
                of the courses.

        Returns:
            list of dict, each containing information about a user's progress
                towards completing a program.
        """
        now = datetime.datetime.now(utc)

        progress = []
        programs = programs or self.engaged_programs
        for program in programs:
            program_copy = deepcopy(program)
            completed, in_progress, not_started = [], [], []

            for course in program_copy['courses']:
                if self._is_course_complete(course):
                    completed.append(course)
                elif self._is_course_enrolled(course):
                    course_in_progress = self._is_course_in_progress(now, course)
                    if course_in_progress:
                        in_progress.append(course)
                    else:
                        course['expired'] = not course_in_progress
                        not_started.append(course)
                else:
                    not_started.append(course)

            grades = {}
            for run in self.course_run_ids:
                grade = self.course_grade_factory.read(self.user, course_key=CourseKey.from_string(run))
                grades[run] = grade.percent

            progress.append({
                'uuid': program_copy['uuid'],
                'completed': len(completed) if count_only else completed,
                'in_progress': len(in_progress) if count_only else in_progress,
                'not_started': len(not_started) if count_only else not_started,
                'grades': grades,
            })

        return progress

    @property
    def completed_programs(self):
        """Identify programs completed by the student.

        Returns:
            list of UUIDs, each identifying a completed program.
        """
        return [program['uuid'] for program in self.programs if self._is_program_complete(program)]

    def _is_program_complete(self, program):
        """Check if a user has completed a program.

        A program is completed if the user has completed all nested courses.

        Arguments:
            program (dict): Representing the program whose completion to assess.

        Returns:
            bool, indicating whether the program is complete.
        """
        return all(self._is_course_complete(course) for course in program['courses']) \
            and len(program['courses']) > 0

    def _is_course_complete(self, course):
        """Check if a user has completed a course.

        A course is completed if the user has earned a certificate for any of
        the nested course runs.

        Arguments:
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is complete.
        """

        def reshape(course_run):
            """
            Modify the structure of a course run dict to facilitate comparison
            with course run certificates.
            """
            return {
                'course_run_id': course_run['key'],
                # A course run's type is assumed to indicate which mode must be
                # completed in order for the run to count towards program completion.
                # This supports the same flexible program construction allowed by the
                # old programs service (e.g., completion of an old honor-only run may
                # count towards completion of a course in a program). This may change
                # in the future to make use of the more rigid set of "applicable seat
                # types" associated with each program type in the catalog.

                # Runs of type 'credit' are counted as 'verified' since verified
                # certificates are earned when credit runs are completed. LEARNER-1274
                # tracks a cleaner way to do this using the discovery service's
                # applicable_seat_types field.
                'type': 'verified' if course_run['type'] == 'credit' else course_run['type'],
            }

        return any(reshape(course_run) in self.completed_course_runs for course_run in course['course_runs'])

    @cached_property
    def completed_course_runs(self):
        """
        Determine which course runs have been completed by the user.

        Returns:
            list of dicts, each representing a course run certificate
        """
        return self.course_runs_with_state['completed']

    @cached_property
    def failed_course_runs(self):
        """
        Determine which course runs have been failed by the user.

        Returns:
            list of dicts, each a course run ID
        """
        return [run['course_run_id'] for run in self.course_runs_with_state['failed']]

    @cached_property
    def course_runs_with_state(self):
        """
        Determine which course runs have been completed and failed by the user.

        Returns:
            dict with a list of completed and failed runs
        """
        course_run_certificates = certificate_api.get_certificates_for_user(self.user.username)

        completed_runs, failed_runs = [], []
        for certificate in course_run_certificates:
            certificate_type = certificate['type']

            # Treat "no-id-professional" certificates as "professional" certificates
            if certificate_type == CourseMode.NO_ID_PROFESSIONAL_MODE:
                certificate_type = CourseMode.PROFESSIONAL

            course_data = {
                'course_run_id': unicode(certificate['course_key']),
                'type': certificate_type,
            }

            if certificate_api.is_passing_status(certificate['status']):
                completed_runs.append(course_data)
            else:
                failed_runs.append(course_data)

        return {'completed': completed_runs, 'failed': failed_runs}

    def _is_course_enrolled(self, course):
        """Check if a user is enrolled in a course.

        A user is considered to be enrolled in a course if
        they're enrolled in any of the nested course runs.

        Arguments:
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is in progress.
        """
        return any(course_run['key'] in self.course_run_ids for course_run in course['course_runs'])
Beispiel #7
0
class ProgramProgressMeter(object):
    """Utility for gauging a user's progress towards program completion.

    Arguments:
        user (User): The user for which to find programs.

    Keyword Arguments:
        enrollments (list): List of the user's enrollments.
        uuid (str): UUID identifying a specific program. If provided, the meter
            will only inspect this one program, not all programs the user may be
            engaged with.
    """
    def __init__(self, site, user, enrollments=None, uuid=None, mobile_only=False):
        self.site = site
        self.user = user
        self.mobile_only = mobile_only

        self.enrollments = enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
        self.enrollments.sort(key=lambda e: e.created, reverse=True)

        self.enrolled_run_modes = {}
        self.course_run_ids = []
        for enrollment in self.enrollments:
            # enrollment.course_id is really a CourseKey (╯ಠ_ಠ)╯︵ ┻━┻
            enrollment_id = unicode(enrollment.course_id)
            mode = enrollment.mode
            if mode == CourseMode.NO_ID_PROFESSIONAL_MODE:
                mode = CourseMode.PROFESSIONAL
            self.enrolled_run_modes[enrollment_id] = mode
            # We can't use dict.keys() for this because the course run ids need to be ordered
            self.course_run_ids.append(enrollment_id)

        self.entitlements = list(CourseEntitlement.unexpired_entitlements_for_user(self.user))
        self.course_uuids = [str(entitlement.course_uuid) for entitlement in self.entitlements]

        self.course_grade_factory = CourseGradeFactory()

        if uuid:
            self.programs = [get_programs(uuid=uuid)]
        else:
            self.programs = attach_program_detail_url(get_programs(self.site), self.mobile_only)

    def invert_programs(self):
        """Intersect programs and enrollments.

        Builds a dictionary of program dict lists keyed by course run ID and by course UUID.
        The resulting dictionary is suitable in applications where programs must be
        filtered by the course runs or courses they contain (e.g., the student dashboard).

        Returns:
            defaultdict, programs keyed by course run ID
        """
        inverted_programs = defaultdict(list)

        for program in self.programs:
            for course in program['courses']:
                course_uuid = course['uuid']
                if course_uuid in self.course_uuids:
                    program_list = inverted_programs[course_uuid]
                    if program not in program_list:
                        program_list.append(program)
                for course_run in course['course_runs']:
                    course_run_id = course_run['key']
                    if course_run_id in self.course_run_ids:
                        program_list = inverted_programs[course_run_id]
                        if program not in program_list:
                            program_list.append(program)

        # Sort programs by title for consistent presentation.
        for program_list in inverted_programs.itervalues():
            program_list.sort(key=lambda p: p['title'])

        return inverted_programs

    @cached_property
    def engaged_programs(self):
        """Derive a list of programs in which the given user is engaged.

        Returns:
            list of program dicts, ordered by most recent enrollment
        """
        inverted_programs = self.invert_programs()

        programs = []
        # Remember that these course run ids are derived from a list of
        # enrollments sorted from most recent to least recent. Iterating
        # over the values in inverted_programs alone won't yield a program
        # ordering consistent with the user's enrollments.
        for course_run_id in self.course_run_ids:
            for program in inverted_programs[course_run_id]:
                # Dicts aren't a hashable type, so we can't use a set. Sets also
                # aren't ordered, which is important here.
                if program not in programs:
                    programs.append(program)

        for course_uuid in self.course_uuids:
            for program in inverted_programs[course_uuid]:
                if program not in programs:
                    programs.append(program)

        return programs

    def _is_course_in_progress(self, now, course):
        """Check if course qualifies as in progress as part of the program.

        A course is considered to be in progress if a user is enrolled in a run
        of the correct mode or a run of the correct mode is still available for enrollment.

        Arguments:
            now (datetime): datetime for now
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is in progress.
        """
        enrolled_runs = [run for run in course['course_runs'] if run['key'] in self.course_run_ids]

        # Check if the user is enrolled in a required run and mode/seat.
        runs_with_required_mode = [
            run for run in enrolled_runs
            if run['type'] == self.enrolled_run_modes[run['key']]
        ]

        if runs_with_required_mode:
            not_failed_runs = [run for run in runs_with_required_mode if run not in self.failed_course_runs]
            if not_failed_runs:
                return True

        # Check if seats required for course completion are still available.
        upgrade_deadlines = []
        for run in enrolled_runs:
            for seat in run['seats']:
                if seat['type'] == run['type'] and run['type'] != self.enrolled_run_modes[run['key']]:
                    upgrade_deadlines.append(seat['upgrade_deadline'])

        # An upgrade deadline of None means the course is always upgradeable.
        return any(not deadline or deadline and parse(deadline) > now for deadline in upgrade_deadlines)

    def progress(self, programs=None, count_only=True):
        """Gauge a user's progress towards program completion.

        Keyword Arguments:
            programs (list): Specific list of programs to check the user's progress
                against. If left unspecified, self.engaged_programs will be used.

            count_only (bool): Whether or not to return counts of completed, in
                progress, and unstarted courses instead of serialized representations
                of the courses.

        Returns:
            list of dict, each containing information about a user's progress
                towards completing a program.
        """
        now = datetime.datetime.now(utc)

        progress = []
        programs = programs or self.engaged_programs
        for program in programs:
            program_copy = deepcopy(program)
            completed, in_progress, not_started = [], [], []

            for course in program_copy['courses']:
                active_entitlement = CourseEntitlement.get_entitlement_if_active(
                    user=self.user,
                    course_uuid=course['uuid']
                )
                if self._is_course_complete(course):
                    completed.append(course)
                elif self._is_course_enrolled(course) or active_entitlement:
                    # Show all currently enrolled courses and active entitlements as in progress
                    if active_entitlement:
                        course['course_runs'] = get_fulfillable_course_runs_for_entitlement(
                            active_entitlement,
                            course['course_runs']
                        )
                        course['user_entitlement'] = active_entitlement.to_dict()
                        course['enroll_url'] = reverse(
                            'entitlements_api:v1:enrollments',
                            args=[str(active_entitlement.uuid)]
                        )
                        in_progress.append(course)
                    else:
                        course_in_progress = self._is_course_in_progress(now, course)
                        if course_in_progress:
                            in_progress.append(course)
                        else:
                            course['expired'] = not course_in_progress
                            not_started.append(course)
                else:
                    not_started.append(course)

            grades = {}
            for run in self.course_run_ids:
                grade = self.course_grade_factory.read(self.user, course_key=CourseKey.from_string(run))
                grades[run] = grade.percent

            progress.append({
                'uuid': program_copy['uuid'],
                'completed': len(completed) if count_only else completed,
                'in_progress': len(in_progress) if count_only else in_progress,
                'not_started': len(not_started) if count_only else not_started,
                'grades': grades,
            })

        return progress

    @property
    def completed_programs_with_available_dates(self):
        """
        Calculate the available date for completed programs based on course runs.

        Returns a dict of {uuid_string: available_datetime}
        """
        # Query for all user certs up front, for performance reasons (rather than querying per course run).
        user_certificates = GeneratedCertificate.eligible_available_certificates.filter(user=self.user)
        certificates_by_run = {cert.course_id: cert for cert in user_certificates}

        completed = {}
        for program in self.programs:
            available_date = self._available_date_for_program(program, certificates_by_run)
            if available_date:
                completed[program['uuid']] = available_date
        return completed

    def _available_date_for_program(self, program_data, certificates):
        """
        Calculate the available date for the program based on the courses within it.

        Arguments:
            program_data (dict): nested courses and course runs
            certificates (dict): course run key -> certificate mapping

        Returns a datetime object or None if the program is not complete.
        """
        program_available_date = None
        for course in program_data['courses']:
            earliest_course_run_date = None

            for course_run in course['course_runs']:
                key = CourseKey.from_string(course_run['key'])

                # Get a certificate if one exists
                certificate = certificates.get(key)
                if certificate is None:
                    continue

                # Modes must match (see _is_course_complete() comments for why)
                course_run_mode = self._course_run_mode_translation(course_run['type'])
                certificate_mode = self._certificate_mode_translation(certificate.mode)
                modes_match = course_run_mode == certificate_mode

                # Grab the available date and keep it if it's the earliest one for this catalog course.
                if modes_match and certificate_api.is_passing_status(certificate.status):
                    course_overview = CourseOverview.get_from_id(key)
                    available_date = available_date_for_certificate(course_overview, certificate)
                    earliest_course_run_date = min(filter(None, [available_date, earliest_course_run_date]))

            # If we're missing a cert for a course, the program isn't completed and we should just bail now
            if earliest_course_run_date is None:
                return None

            # Keep the catalog course date if it's the latest one
            program_available_date = max(filter(None, [earliest_course_run_date, program_available_date]))

        return program_available_date

    def _course_run_mode_translation(self, course_run_mode):
        """
        Returns a canonical mode for a course run (whose data is coming from the program cache).
        This mode must match the certificate mode to be counted as complete.
        """
        mappings = {
            # Runs of type 'credit' are counted as 'verified' since verified
            # certificates are earned when credit runs are completed. LEARNER-1274
            # tracks a cleaner way to do this using the discovery service's
            # applicable_seat_types field.
            CourseMode.CREDIT_MODE: CourseMode.VERIFIED,
        }
        return mappings.get(course_run_mode, course_run_mode)

    def _certificate_mode_translation(self, certificate_mode):
        """
        Returns a canonical mode for a certificate (whose data is coming from the database).
        This mode must match the course run mode to be counted as complete.
        """
        mappings = {
            # Treat "no-id-professional" certificates as "professional" certificates
            CourseMode.NO_ID_PROFESSIONAL_MODE: CourseMode.PROFESSIONAL,
        }
        return mappings.get(certificate_mode, certificate_mode)

    def _is_course_complete(self, course):
        """Check if a user has completed a course.

        A course is completed if the user has earned a certificate for any of
        the nested course runs.

        Arguments:
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is complete.
        """

        def reshape(course_run):
            """
            Modify the structure of a course run dict to facilitate comparison
            with course run certificates.
            """
            return {
                'course_run_id': course_run['key'],
                # A course run's type is assumed to indicate which mode must be
                # completed in order for the run to count towards program completion.
                # This supports the same flexible program construction allowed by the
                # old programs service (e.g., completion of an old honor-only run may
                # count towards completion of a course in a program). This may change
                # in the future to make use of the more rigid set of "applicable seat
                # types" associated with each program type in the catalog.
                'type': self._course_run_mode_translation(course_run['type']),
            }

        return any(reshape(course_run) in self.completed_course_runs for course_run in course['course_runs'])

    @cached_property
    def completed_course_runs(self):
        """
        Determine which course runs have been completed by the user.

        Returns:
            list of dicts, each representing a course run certificate
        """
        return self.course_runs_with_state['completed']

    @cached_property
    def failed_course_runs(self):
        """
        Determine which course runs have been failed by the user.

        Returns:
            list of dicts, each a course run ID
        """
        return [run['course_run_id'] for run in self.course_runs_with_state['failed']]

    @cached_property
    def course_runs_with_state(self):
        """
        Determine which course runs have been completed and failed by the user.

        Returns:
            dict with a list of completed and failed runs
        """
        course_run_certificates = certificate_api.get_certificates_for_user(self.user.username)

        completed_runs, failed_runs = [], []
        for certificate in course_run_certificates:
            course_data = {
                'course_run_id': unicode(certificate['course_key']),
                'type': self._certificate_mode_translation(certificate['type']),
            }

            if certificate_api.is_passing_status(certificate['status']):
                completed_runs.append(course_data)
            else:
                failed_runs.append(course_data)

        return {'completed': completed_runs, 'failed': failed_runs}

    def _is_course_enrolled(self, course):
        """Check if a user is enrolled in a course.

        A user is considered to be enrolled in a course if
        they're enrolled in any of the nested course runs.

        Arguments:
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is in progress.
        """
        return any(course_run['key'] in self.course_run_ids for course_run in course['course_runs'])