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) expected_verification_status = expected_verification_status['status'] 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 ))
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 send_composite_outcome(user_id, course_id, assignment_id, version): """ Calculate and transmit the score for a composite module (such as a vertical). A composite module may contain multiple problems, so we need to calculate the total points earned and possible for all child problems. This requires calculating the scores for the whole course, which is an expensive operation. Callers should be aware that the score calculation code accesses the latest scores from the database. This can lead to a race condition between a view that updates a user's score and the calculation of the grade. If the Celery task attempts to read the score from the database before the view exits (and its transaction is committed), it will see a stale value. Care should be taken that this task is not triggered until the view exits. The GradedAssignment model has a version_number field that is incremented whenever the score is updated. It is used by this method for two purposes. First, it allows the task to exit if it detects that it has been superseded by another task that will transmit the score for the same assignment. Second, it prevents a race condition where two tasks calculate different scores for a single assignment, and may potentially update the campus LMS in the wrong order. """ assignment = GradedAssignment.objects.get(id=assignment_id) if version != assignment.version_number: log.info( u"Score passback for GradedAssignment %s skipped. More recent score available.", assignment.id ) return course_key = CourseKey.from_string(course_id) mapped_usage_key = assignment.usage_key.map_into_course(course_key) user = User.objects.get(id=user_id) course = modulestore().get_course(course_key, depth=0) course_grade = CourseGradeFactory().read(user, course) earned, possible = course_grade.score_for_module(mapped_usage_key) if possible == 0: weighted_score = 0 else: weighted_score = float(earned) / float(possible) assignment = GradedAssignment.objects.get(id=assignment_id) if assignment.version_number == version: outcomes.send_score_update(assignment, weighted_score)
def add_cert(self, student, course_id, course=None, forced_grade=None, template_file=None, generate_pdf=True): """ Request a new certificate for a student. Arguments: student - User.object course_id - courseenrollment.course_id (CourseKey) forced_grade - a string indicating a grade parameter to pass with the certificate request. If this is given, grading will be skipped. generate_pdf - Boolean should a message be sent in queue to generate certificate PDF Will change the certificate status to 'generating' or `downloadable` in case of web view certificates. The course must not be a CCX. Certificate must be in the 'unavailable', 'error', 'deleted' or 'generating' state. If a student has a passing grade or is in the whitelist table for the course a request will be made for a new cert. If a student has allow_certificate set to False in the userprofile table the status will change to 'restricted' If a student does not have a passing grade the status will change to status.notpassing Returns the newly created certificate instance """ if hasattr(course_id, 'ccx'): LOGGER.warning( (u"Cannot create certificate generation task for user %s " u"in the course '%s'; " u"certificates are not allowed for CCX courses."), student.id, six.text_type(course_id)) return None valid_statuses = [ status.generating, status.unavailable, status.deleted, status.error, status.notpassing, status.downloadable, status.auditing, status.audit_passing, status.audit_notpassing, status.unverified, ] cert_status_dict = certificate_status_for_student(student, course_id) cert_status = cert_status_dict.get('status') download_url = cert_status_dict.get('download_url') cert = None if download_url: self._log_pdf_cert_generation_discontinued_warning( student.id, course_id, cert_status, download_url) return None if cert_status not in valid_statuses: LOGGER.warning( (u"Cannot create certificate generation task for user %s " u"in the course '%s'; " u"the certificate status '%s' is not one of %s."), student.id, six.text_type(course_id), cert_status, six.text_type(valid_statuses)) return None # The caller can optionally pass a course in to avoid # re-fetching it from Mongo. If they have not provided one, # get it from the modulestore. if course is None: course = modulestore().get_course(course_id, depth=0) profile = UserProfile.objects.get(user=student) profile_name = profile.name # Needed for access control in grading. self.request.user = student self.request.session = {} is_whitelisted = self.whitelist.filter(user=student, course_id=course_id, whitelist=True).exists() course_grade = CourseGradeFactory().read(student, course) enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user( student, course_id) mode_is_verified = enrollment_mode in GeneratedCertificate.VERIFIED_CERTS_MODES user_is_verified = IDVerificationService.user_is_verified(student) cert_mode = enrollment_mode is_eligible_for_certificate = CourseMode.is_eligible_for_certificate( enrollment_mode, cert_status) if is_whitelisted and not is_eligible_for_certificate: # check if audit certificates are enabled for audit mode is_eligible_for_certificate = enrollment_mode != CourseMode.AUDIT or \ not settings.FEATURES['DISABLE_AUDIT_CERTIFICATES'] unverified = False # For credit mode generate verified certificate if cert_mode in (CourseMode.CREDIT_MODE, CourseMode.MASTERS): cert_mode = CourseMode.VERIFIED if template_file is not None: template_pdf = template_file elif mode_is_verified and user_is_verified: template_pdf = "certificate-template-{id.org}-{id.course}-verified.pdf".format( id=course_id) elif mode_is_verified and not user_is_verified: template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format( id=course_id) if CourseMode.mode_for_course(course_id, CourseMode.HONOR): cert_mode = GeneratedCertificate.MODES.honor else: unverified = True else: # honor code and audit students template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format( id=course_id) LOGGER.info(( u"Certificate generated for student %s in the course: %s with template: %s. " u"given template: %s, " u"user is verified: %s, " u"mode is verified: %s," u"generate_pdf is: %s"), student.username, six.text_type(course_id), template_pdf, template_file, user_is_verified, mode_is_verified, generate_pdf) cert, created = GeneratedCertificate.objects.get_or_create( user=student, course_id=course_id) cert.mode = cert_mode cert.user = student cert.grade = course_grade.percent cert.course_id = course_id cert.name = profile_name cert.download_url = '' # Strip HTML from grade range label grade_contents = forced_grade or course_grade.letter_grade try: grade_contents = lxml.html.fromstring( grade_contents).text_content() passing = True except (TypeError, XMLSyntaxError, ParserError) as exc: LOGGER.info((u"Could not retrieve grade for student %s " u"in the course '%s' " u"because an exception occurred while parsing the " u"grade contents '%s' as HTML. " u"The exception was: '%s'"), student.id, six.text_type(course_id), grade_contents, six.text_type(exc)) # Log if the student is whitelisted if is_whitelisted: LOGGER.info(u"Student %s is whitelisted in '%s'", student.id, six.text_type(course_id)) passing = True else: passing = False # If this user's enrollment is not eligible to receive a # certificate, mark it as such for reporting and # analytics. Only do this if the certificate is new, or # already marked as ineligible -- we don't want to mark # existing audit certs as ineligible. cutoff = settings.AUDIT_CERT_CUTOFF_DATE if (cutoff and cert.created_date >= cutoff ) and not is_eligible_for_certificate: cert.status = status.audit_passing if passing else status.audit_notpassing cert.save() LOGGER.info( u"Student %s with enrollment mode %s is not eligible for a certificate.", student.id, enrollment_mode) return cert # If they are not passing, short-circuit and don't generate cert elif not passing: cert.status = status.notpassing cert.save() LOGGER.info( (u"Student %s does not have a grade for '%s', " u"so their certificate status has been set to '%s'. " u"No certificate generation task was sent to the XQueue."), student.id, six.text_type(course_id), cert.status) return cert # Check to see whether the student is on the the embargoed # country restricted list. If so, they should not receive a # certificate -- set their status to restricted and log it. if self.restricted.filter(user=student).exists(): cert.status = status.restricted cert.save() LOGGER.info( (u"Student %s is in the embargoed country restricted " u"list, so their certificate status has been set to '%s' " u"for the course '%s'. " u"No certificate generation task was sent to the XQueue."), student.id, cert.status, six.text_type(course_id)) return cert if unverified: cert.status = status.unverified cert.save() LOGGER.info( (u"User %s has a verified enrollment in course %s " u"but is missing ID verification. " u"Certificate status has been set to unverified"), student.id, six.text_type(course_id), ) return cert # Finally, generate the certificate and send it off. return self._generate_cert(cert, course, student, grade_contents, template_pdf, generate_pdf)
def get(self, request, *args, **kwargs): course_key_string = kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) student_id = kwargs.get('student_id') if not course_home_mfe_progress_tab_is_active(course_key): raise Http404 # Enable NR tracing for this view based on course monitoring_utils.set_custom_attribute('course_id', course_key_string) monitoring_utils.set_custom_attribute('user_id', request.user.id) monitoring_utils.set_custom_attribute('is_staff', request.user.is_staff) is_staff = bool(has_access(request.user, 'staff', course_key)) student = self._get_student_user(request, course_key, student_id, is_staff) username = get_enterprise_learner_generic_name(request) or student.username course = get_course_with_access(student, 'load', course_key, check_if_enrolled=False) course_overview = CourseOverview.get_from_id(course_key) enrollment = CourseEnrollment.get_enrollment(student, course_key) enrollment_mode = getattr(enrollment, 'mode', None) if not (enrollment and enrollment.is_active) and not is_staff: return Response('User not enrolled.', status=401) # The block structure is used for both the course_grade and has_scheduled content fields # So it is called upfront and reused for optimization purposes collected_block_structure = get_block_structure_manager(course_key).get_collected() course_grade = CourseGradeFactory().read(student, collected_block_structure=collected_block_structure) # Get has_scheduled_content data transformers = BlockStructureTransformers() transformers += [start_date.StartDateTransformer(), ContentTypeGateTransformer()] usage_key = collected_block_structure.root_block_usage_key course_blocks = get_course_blocks( student, usage_key, transformers=transformers, collected_block_structure=collected_block_structure, include_has_scheduled_content=True ) has_scheduled_content = course_blocks.get_xblock_field(usage_key, 'has_scheduled_content') # Get user_has_passing_grade data user_has_passing_grade = False if not student.is_anonymous: user_grade = course_grade.percent user_has_passing_grade = user_grade >= course.lowest_passing_grade descriptor = modulestore().get_course(course_key) grading_policy = descriptor.grading_policy verification_status = IDVerificationService.user_status(student) verification_link = None if verification_status['status'] is None or verification_status['status'] == 'expired': verification_link = IDVerificationService.get_verify_location(course_id=course_key) elif verification_status['status'] == 'must_reverify': verification_link = IDVerificationService.get_verify_location(course_id=course_key) verification_data = { 'link': verification_link, 'status': verification_status['status'], 'status_date': verification_status['status_date'], } access_expiration = get_access_expiration_data(request.user, course_overview) data = { 'access_expiration': access_expiration, 'certificate_data': get_cert_data(student, course, enrollment_mode, course_grade), 'completion_summary': get_course_blocks_completion_summary(course_key, student), 'course_grade': course_grade, 'end': course.end, 'enrollment_mode': enrollment_mode, 'grading_policy': grading_policy, 'has_scheduled_content': has_scheduled_content, 'section_scores': list(course_grade.chapter_grades.values()), 'studio_url': get_studio_url(course, 'settings/grading'), 'username': username, 'user_has_passing_grade': user_has_passing_grade, 'verification_data': verification_data, } context = self.get_serializer_context() context['staff_access'] = is_staff context['course_blocks'] = course_blocks context['course_key'] = course_key # course_overview and enrollment will be used by VerifiedModeSerializer context['course_overview'] = course_overview context['enrollment'] = enrollment serializer = self.get_serializer_class()(data, context=context) return Response(serializer.data)
def _cert_info(user, course_overview, cert_status): """ Implements the logic for cert_info -- split out for testing. Arguments: user (User): A user. course_overview (CourseOverview): A course. """ # simplify the status for the template using this lookup table template_state = { CertificateStatuses.generating: 'generating', CertificateStatuses.downloadable: 'downloadable', CertificateStatuses.notpassing: 'notpassing', CertificateStatuses.restricted: 'restricted', CertificateStatuses.auditing: 'auditing', CertificateStatuses.audit_passing: 'auditing', CertificateStatuses.audit_notpassing: 'auditing', CertificateStatuses.unverified: 'unverified', } certificate_earned_but_not_available_status = 'certificate_earned_but_not_available' default_status = 'processing' default_info = { 'status': default_status, 'show_survey_button': False, 'can_unenroll': True, } if cert_status is None: return default_info status = template_state.get(cert_status['status'], default_status) is_hidden_status = status in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing') if ( not certificates_viewable_for_course(course_overview) and (status in CertificateStatuses.PASSED_STATUSES) and course_overview.certificate_available_date ): status = certificate_earned_but_not_available_status if ( course_overview.certificates_display_behavior == 'early_no_info' and is_hidden_status ): return default_info status_dict = { 'status': status, 'mode': cert_status.get('mode', None), 'linked_in_url': None, 'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES, } if status != default_status and course_overview.end_of_course_survey_url is not None: status_dict.update({ 'show_survey_button': True, 'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)}) else: status_dict['show_survey_button'] = False if status == 'downloadable': # showing the certificate web view button if certificate is downloadable state and feature flags are enabled. if has_html_certificates_enabled(course_overview): if course_overview.has_any_active_web_certificate: status_dict.update({ 'show_cert_web_view': True, 'cert_web_view_url': get_certificate_url(course_id=course_overview.id, uuid=cert_status['uuid']) }) elif cert_status['download_url']: status_dict['download_url'] = cert_status['download_url'] else: # don't show download certificate button if we don't have an active certificate for course status_dict['status'] = 'unavailable' elif 'download_url' not in cert_status: log.warning( u"User %s has a downloadable cert for %s, but no download url", user.username, course_overview.id ) return default_info else: status_dict['download_url'] = cert_status['download_url'] # If enabled, show the LinkedIn "add to profile" button # Clicking this button sends the user to LinkedIn where they # can add the certificate information to their profile. linkedin_config = LinkedInAddToProfileConfiguration.current() # posting certificates to LinkedIn is not currently # supported in White Labels if linkedin_config.enabled and not theming_helpers.is_request_in_themed_site(): status_dict['linked_in_url'] = linkedin_config.add_to_profile_url( course_overview.id, course_overview.display_name, cert_status.get('mode'), cert_status['download_url'] ) if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}: cert_grade_percent = -1 persisted_grade_percent = -1 persisted_grade = CourseGradeFactory().read(user, course=course_overview, create_if_needed=False) if persisted_grade is not None: persisted_grade_percent = persisted_grade.percent if 'grade' in cert_status: cert_grade_percent = float(cert_status['grade']) if cert_grade_percent == -1 and persisted_grade_percent == -1: # Note: as of 11/20/2012, we know there are students in this state-- cs169.1x, # who need to be regraded (we weren't tracking 'notpassing' at first). # We can add a log.warning here once we think it shouldn't happen. return default_info grades_input = [cert_grade_percent, persisted_grade_percent] max_grade = ( None if all(grade is None for grade in grades_input) else max(filter(lambda x: x is not None, grades_input)) ) status_dict['grade'] = text_type(max_grade) return status_dict
def generate(cls, _xmodule_instance_args, _entry_id, course_id, _task_input, action_name): """ Generate a CSV containing all students' problem grades within a given `course_id`. """ def log_task_info(message): """ Updates the status on the celery task to the given message. Also logs the update. """ fmt = u'Task: {task_id}, InstructorTask ID: {entry_id}, Course: {course_id}, Input: {task_input}' task_info_string = fmt.format(task_id=task_id, entry_id=_entry_id, course_id=course_id, task_input=_task_input) TASK_LOG.info(u'%s, Task type: %s, %s, %s', task_info_string, action_name, message, task_progress.state) start_time = time() start_date = datetime.now(UTC) status_interval = 100 task_id = _xmodule_instance_args.get( 'task_id') if _xmodule_instance_args is not None else None enrolled_students = CourseEnrollment.objects.users_enrolled_in( course_id, include_inactive=True, verified_only=problem_grade_report_verified_only(course_id), ) task_progress = TaskProgress(action_name, enrolled_students.count(), start_time) # This struct encapsulates both the display names of each static item in the # header row as values as well as the django User field names of those items # as the keys. It is structured in this way to keep the values related. header_row = OrderedDict([('id', 'Student ID'), ('email', 'Email'), ('username', 'Username')]) course = get_course_by_id(course_id) log_task_info(u'Retrieving graded scorable blocks') graded_scorable_blocks = cls._graded_scorable_blocks_to_header(course) # Just generate the static fields for now. rows = [ list(header_row.values()) + ['Enrollment Status', 'Grade'] + _flatten(list(graded_scorable_blocks.values())) ] error_rows = [list(header_row.values()) + ['error_msg']] # Bulk fetch and cache enrollment states so we can efficiently determine # whether each user is currently enrolled in the course. log_task_info(u'Fetching enrollment status') CourseEnrollment.bulk_fetch_enrollment_states(enrolled_students, course_id) for student, course_grade, error in CourseGradeFactory().iter( enrolled_students, course): student_fields = [ getattr(student, field_name) for field_name in header_row ] task_progress.attempted += 1 if not course_grade: err_msg = text_type(error) # There was an error grading this student. if not err_msg: err_msg = u'Unknown error' error_rows.append(student_fields + [err_msg]) task_progress.failed += 1 continue enrollment_status = _user_enrollment_status(student, course_id) earned_possible_values = [] for block_location in graded_scorable_blocks: try: problem_score = course_grade.problem_scores[block_location] except KeyError: earned_possible_values.append( [u'Not Available', u'Not Available']) else: if problem_score.first_attempted: earned_possible_values.append( [problem_score.earned, problem_score.possible]) else: earned_possible_values.append( [u'Not Attempted', problem_score.possible]) rows.append(student_fields + [enrollment_status, course_grade.percent] + _flatten(earned_possible_values)) task_progress.succeeded += 1 if task_progress.attempted % status_interval == 0: step = u'Calculating Grades' task_progress.update_task_state(extra_meta={'step': step}) log_message = u'{0} {1}/{2}'.format(step, task_progress.attempted, task_progress.total) log_task_info(log_message) log_task_info('Uploading CSV to store') # Perform the upload if any students have been successfully graded if len(rows) > 1: upload_csv_to_report_store(rows, 'problem_grade_report', course_id, start_date) # If there are any error rows, write them out as well if len(error_rows) > 1: upload_csv_to_report_store(error_rows, 'problem_grade_report_err', course_id, start_date) return task_progress.update_task_state( extra_meta={'step': 'Uploading CSV'})
def get_extra_course_about_context(request, course): """ Get all the extra context for the course_about page Arguments: request (Request): Request object course (CourseOverview): Course Overview object to add data to the context Returns: dict: Returns an empty dict if it is the testing environment otherwise returns a dict with added context """ if is_testing_environment(): return {} user = request.user course_language_names = [] enrolled_course_group_course = None enroll_popup_message = cannot_enroll_message = '' multilingual_course = MultilingualCourse.objects.all( ).multilingual_course_with_course_id(course.id) if multilingual_course: course_group_courses = multilingual_course.multilingual_course_group.multilingual_courses course_language_codes = course_group_courses.open_multilingual_courses( ).language_codes_with_course_ids() course_language_names = get_language_names_from_codes( course_language_codes) enrolled_course_group_course = course_group_courses.all( ).enrolled_course(user) if enrolled_course_group_course: course_display_name = enrolled_course_group_course.course.display_name_with_default enroll_popup_message = Text( _('Warning: If you wish to change the language of this course, your progress in ' 'the following course(s) will be erased.{line_break}{course_name}' )).format(course_name=course_display_name, line_break=HTML('<br>')) course_enrollment_count = CourseEnrollment.objects.enrollment_counts( course.id).get('total') course_requirements = course_grade = certificate = None if user.is_authenticated: course_requirements = get_pre_requisite_courses_not_completed( user, [course.id]) course_grade = CourseGradeFactory().read(user, course_key=course.id) certificate = GeneratedCertificate.certificate_for_student( user, course.id) has_generated_cert_for_any_other_course_group_course = False if enrolled_course_group_course and enrolled_course_group_course.course != course: has_generated_cert_for_any_other_course_group_course = GeneratedCertificate.objects.filter( course_id=enrolled_course_group_course.course.id, user=user).exists() if has_generated_cert_for_any_other_course_group_course: cannot_enroll_message = _( 'You cannot enroll in this course version as you have already earned a ' 'certificate for another version of this course!') context = { 'course_languages': course_language_names, 'course_requirements': course_requirements, 'total_enrollments': course_enrollment_count, 'self_paced': course.self_paced, 'effort': course.effort, 'is_course_passed': course_grade and getattr(course_grade, 'passed', False), 'has_certificate': certificate, 'has_user_enrolled_in_course_group_courses': bool(enrolled_course_group_course), 'has_generated_cert_for_any_course_group_course': has_generated_cert_for_any_other_course_group_course, 'enroll_popup_message': enroll_popup_message, 'cannot_enroll_message': cannot_enroll_message, } return context
def get(self, request, *args, **kwargs): course_key_string = kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) # Enable NR tracing for this view based on course monitoring_utils.set_custom_metric('course_id', course_key_string) monitoring_utils.set_custom_metric('user_id', request.user.id) monitoring_utils.set_custom_metric('is_staff', request.user.is_staff) _, request.user = setup_masquerade( request, course_key, staff_access=has_access(request.user, 'staff', course_key), reset_masquerade_data=True ) user_timezone_locale = user_timezone_locale_prefs(request) user_timezone = user_timezone_locale['user_timezone'] transformers = BlockStructureTransformers() transformers += course_blocks_api.get_course_block_access_transformers(request.user) transformers += [ BlocksAPITransformer(None, None, depth=3), ] course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) enrollment_mode, _ = CourseEnrollment.enrollment_mode_for_user(request.user, course_key) course_grade = CourseGradeFactory().read(request.user, course) courseware_summary = course_grade.chapter_grades.values() verification_status = IDVerificationService.user_status(request.user) verification_link = None if verification_status['status'] is None or verification_status['status'] == 'expired': verification_link = IDVerificationService.get_verify_location('verify_student_verify_now', course_id=course_key) elif verification_status['status'] == 'must_reverify': verification_link = IDVerificationService.get_verify_location('verify_student_reverify', course_id=course_key) verification_data = { 'link': verification_link, 'status': verification_status['status'], 'status_date': verification_status['status_date'], } data = { 'certificate_data': get_cert_data(request.user, course, enrollment_mode, course_grade), 'courseware_summary': courseware_summary, 'credit_course_requirements': credit_course_requirements(course_key, request.user), 'credit_support_url': CREDIT_SUPPORT_URL, 'enrollment_mode': enrollment_mode, 'studio_url': get_studio_url(course, 'settings/grading'), 'user_timezone': user_timezone, 'verification_data': verification_data, } context = self.get_serializer_context() context['staff_access'] = bool(has_access(request.user, 'staff', course)) context['course_key'] = course_key serializer = self.get_serializer_class()(data, context=context) return Response(serializer.data)
def _has_passing_grade(user, course): """ Check if the user has a passing grade in this course run """ course_grade = CourseGradeFactory().read(user, course) return course_grade.passed
def _cert_info(user, enrollment, cert_status): """ Implements the logic for cert_info -- split out for testing. TODO: replace with a method that lives in the certificates app and combines this logic with lms.djangoapps.certificates.api.can_show_certificate_message and lms.djangoapps.courseware.views.get_cert_data Arguments: user (User): A user. enrollment (CourseEnrollment): A course enrollment. cert_status (dict): dictionary containing information about certificate status for the user Returns: dictionary containing: 'status': one of 'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'processing', 'unverified', 'unavailable', or 'certificate_earned_but_not_available' 'show_survey_button': bool 'can_unenroll': if status allows for unenrollment The dictionary may also contain: 'linked_in_url': url to add cert to LinkedIn profile 'survey_url': url, only if course_overview.end_of_course_survey_url is not None 'show_cert_web_view': bool if html web certs are enabled and there is an active web cert 'cert_web_view_url': url if html web certs are enabled and there is an active web cert 'download_url': url to download a cert 'grade': if status is in 'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', or 'unverified' """ # simplify the status for the template using this lookup table template_state = { CertificateStatuses.generating: 'generating', CertificateStatuses.downloadable: 'downloadable', CertificateStatuses.notpassing: 'notpassing', CertificateStatuses.restricted: 'restricted', CertificateStatuses.auditing: 'auditing', CertificateStatuses.audit_passing: 'auditing', CertificateStatuses.audit_notpassing: 'auditing', CertificateStatuses.unverified: 'unverified', } certificate_earned_but_not_available_status = 'certificate_earned_but_not_available' default_status = 'processing' default_info = { 'status': default_status, 'show_survey_button': False, 'can_unenroll': True, } if cert_status is None or enrollment is None: return default_info course_overview = enrollment.course_overview if enrollment else None status = template_state.get(cert_status['status'], default_status) is_hidden_status = status in ('processing', 'generating', 'notpassing', 'auditing') if _is_certificate_earned_but_not_available(course_overview, status): status = certificate_earned_but_not_available_status if ( course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.EARLY_NO_INFO and is_hidden_status ): return default_info if not CourseMode.is_eligible_for_certificate(enrollment.mode, status=status): return default_info if course_overview and access.is_beta_tester(user, course_overview.id): # Beta testers are not eligible for a course certificate return default_info status_dict = { 'status': status, 'mode': cert_status.get('mode', None), 'linked_in_url': None, 'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES, } if status != default_status and course_overview.end_of_course_survey_url is not None: status_dict.update({ 'show_survey_button': True, 'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)}) else: status_dict['show_survey_button'] = False if status == 'downloadable': # showing the certificate web view button if certificate is downloadable state and feature flags are enabled. if has_html_certificates_enabled(course_overview): if course_overview.has_any_active_web_certificate: status_dict.update({ 'show_cert_web_view': True, 'cert_web_view_url': get_certificate_url(course_id=course_overview.id, uuid=cert_status['uuid']) }) elif cert_status['download_url']: status_dict['download_url'] = cert_status['download_url'] else: # don't show download certificate button if we don't have an active certificate for course status_dict['status'] = 'unavailable' elif 'download_url' not in cert_status: log.warning( "User %s has a downloadable cert for %s, but no download url", user.username, course_overview.id ) return default_info else: status_dict['download_url'] = cert_status['download_url'] # If enabled, show the LinkedIn "add to profile" button # Clicking this button sends the user to LinkedIn where they # can add the certificate information to their profile. linkedin_config = LinkedInAddToProfileConfiguration.current() if linkedin_config.is_enabled(): status_dict['linked_in_url'] = linkedin_config.add_to_profile_url( course_overview.display_name, cert_status.get('mode'), cert_status['download_url'], ) if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}: cert_grade_percent = -1 persisted_grade_percent = -1 persisted_grade = CourseGradeFactory().read(user, course=course_overview, create_if_needed=False) if persisted_grade is not None: persisted_grade_percent = persisted_grade.percent if 'grade' in cert_status: cert_grade_percent = float(cert_status['grade']) if cert_grade_percent == -1 and persisted_grade_percent == -1: # Note: as of 11/20/2012, we know there are students in this state-- cs169.1x, # who need to be regraded (we weren't tracking 'notpassing' at first). # We can add a log.warning here once we think it shouldn't happen. return default_info grades_input = [cert_grade_percent, persisted_grade_percent] max_grade = ( None if all(grade is None for grade in grades_input) else max(filter(lambda x: x is not None, grades_input)) ) status_dict['grade'] = str(max_grade) # If the grade is passing, the status is one of these statuses, and request certificate # is enabled for a course then we need to provide the option to the learner if ( status_dict['status'] != CertificateStatuses.downloadable and (has_self_generated_certificates_enabled(course_overview.id) or auto_certificate_generation_enabled()) and persisted_grade and persisted_grade.passed ): status_dict['status'] = CertificateStatuses.requesting return status_dict
def _rows_for_users(self, context, users, header_row, graded_scorable_blocks): """ Returns a list of rows for the given users for this report. """ if six.text_type( context.course_id) == 'course-v1:MITx+CTL.SC0x+1T2019': context.update_status( 'ProblemGradeReport Investigation log: Start of row creation for users' ) success_rows, error_rows = [], [] for student, course_grade, error in CourseGradeFactory().iter( users, context.course): student_fields = [ getattr(student, field_name) for field_name in header_row ] context.task_progress.attempted += 1 if context.task_progress.attempted % 2 == 0 and \ six.text_type(context.course_id) == 'course-v1:MITx+CTL.SC0x+1T2019': context.update_status( 'ProblemGradeReport Investigation log: Processing user {}'. format(context.task_progress.attempted)) if not course_grade: err_msg = text_type(error) # There was an error grading this student. if not err_msg: err_msg = 'Unknown error' error_rows.append(student_fields + [err_msg]) context.task_progress.failed += 1 continue enrollment_status = _user_enrollment_status( student, context.course_id) earned_possible_values = [] for block_location in graded_scorable_blocks: try: problem_score = course_grade.problem_scores[block_location] except KeyError: earned_possible_values.append( ['Not Available', 'Not Available']) else: if problem_score.first_attempted: earned_possible_values.append( [problem_score.earned, problem_score.possible]) else: earned_possible_values.append( ['Not Attempted', problem_score.possible]) context.task_progress.succeeded += 1 success_rows.append(student_fields + [enrollment_status, course_grade.percent] + _flatten(earned_possible_values)) context.task_progress.update_task_state( extra_meta={ 'step': 'ProblemGradeReport Step 5: Calculating Grades' }) log_message = u'{0} {1}/{2}'.format( 'ProblemGradeReport Step 5: Calculating Grades', context.task_progress.attempted, context.task_progress.total) self.log_task_info(context, log_message) return success_rows, error_rows
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 = six.text_type(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 six.itervalues(inverted_programs): 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([ date for date in [available_date, earliest_course_run_date] if 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([ date for date in [earliest_course_run_date, program_available_date] if 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': six.text_type(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'])
def user_has_passing_grade(self): """ Returns a boolean on if the effective_user has a passing grade in the course """ course = get_course_by_id(self.course_key) user_grade = CourseGradeFactory().read(self.effective_user, course).percent return user_grade >= course.lowest_passing_grade
def generate(cls, _xmodule_instance_args, _entry_id, course_id, _task_input, action_name): """ Generate a CSV containing all students' problem grades within a given `course_id`. """ start_time = time() start_date = datetime.now(UTC) status_interval = 100 enrolled_students = CourseEnrollment.objects.users_enrolled_in( course_id, include_inactive=True) task_progress = TaskProgress(action_name, enrolled_students.count(), start_time) # This struct encapsulates both the display names of each static item in the # header row as values as well as the django User field names of those items # as the keys. It is structured in this way to keep the values related. header_row = OrderedDict([('id', 'Student ID'), ('email', 'Email'), ('username', 'Username')]) course = get_course_by_id(course_id) graded_scorable_blocks = cls._graded_scorable_blocks_to_header(course) # Just generate the static fields for now. rows = [ list(header_row.values()) + ['Enrollment Status', 'Grade'] + _flatten(list(graded_scorable_blocks.values())) ] error_rows = [list(header_row.values()) + ['error_msg']] current_step = {'step': 'Calculating Grades'} # Bulk fetch and cache enrollment states so we can efficiently determine # whether each user is currently enrolled in the course. CourseEnrollment.bulk_fetch_enrollment_states(enrolled_students, course_id) for student, course_grade, error in CourseGradeFactory().iter( enrolled_students, course): student_fields = [ getattr(student, field_name) for field_name in header_row ] task_progress.attempted += 1 if not course_grade: err_msg = text_type(error) # There was an error grading this student. if not err_msg: err_msg = u'Unknown error' error_rows.append(student_fields + [err_msg]) task_progress.failed += 1 continue enrollment_status = _user_enrollment_status(student, course_id) earned_possible_values = [] for block_location in graded_scorable_blocks: try: problem_score = course_grade.problem_scores[block_location] except KeyError: earned_possible_values.append( [u'Not Available', u'Not Available']) else: if problem_score.first_attempted: earned_possible_values.append( [problem_score.earned, problem_score.possible]) else: earned_possible_values.append( [u'Not Attempted', problem_score.possible]) rows.append(student_fields + [enrollment_status, course_grade.percent] + _flatten(earned_possible_values)) task_progress.succeeded += 1 if task_progress.attempted % status_interval == 0: task_progress.update_task_state(extra_meta=current_step) # Perform the upload if any students have been successfully graded if len(rows) > 1: upload_csv_to_report_store(rows, 'problem_grade_report', course_id, start_date) # If there are any error rows, write them out as well if len(error_rows) > 1: upload_csv_to_report_store(error_rows, 'problem_grade_report_err', course_id, start_date) return task_progress.update_task_state( extra_meta={'step': 'Uploading CSV'})
def _get_course_grade(user, course_key): """ Get the user's course grade in this course run """ return CourseGradeFactory().read(user, course_key=course_key)
def get_course_grade(self): """ Return CourseGrade for current user and course. """ return CourseGradeFactory().read(self.student_user, self.course)
def get(self, request, *args, **kwargs): course_key_string = kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) if not course_home_mfe_progress_tab_is_active(course_key): raise Http404 # Enable NR tracing for this view based on course monitoring_utils.set_custom_attribute('course_id', course_key_string) monitoring_utils.set_custom_attribute('user_id', request.user.id) monitoring_utils.set_custom_attribute('is_staff', request.user.is_staff) _, request.user = setup_masquerade(request, course_key, staff_access=has_access( request.user, 'staff', course_key), reset_masquerade_data=True) course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) enrollment_mode, _ = CourseEnrollment.enrollment_mode_for_user( request.user, course_key) course_grade = CourseGradeFactory().read(request.user, course)\ descriptor = modulestore().get_course(course_key) grading_policy = descriptor.grading_policy verification_status = IDVerificationService.user_status(request.user) verification_link = None if verification_status['status'] is None or verification_status[ 'status'] == 'expired': verification_link = IDVerificationService.get_verify_location( course_id=course_key) elif verification_status['status'] == 'must_reverify': verification_link = IDVerificationService.get_verify_location( course_id=course_key) verification_data = { 'link': verification_link, 'status': verification_status['status'], 'status_date': verification_status['status_date'], } data = { 'certificate_data': get_cert_data(request.user, course, enrollment_mode, course_grade), 'completion_summary': get_course_blocks_completion_summary(course_key, request.user), 'course_grade': course_grade, 'section_scores': course_grade.chapter_grades.values(), 'enrollment_mode': enrollment_mode, 'grading_policy': grading_policy, 'studio_url': get_studio_url(course, 'settings/grading'), 'verification_data': verification_data, } context = self.get_serializer_context() context['staff_access'] = bool( has_access(request.user, 'staff', course)) context['course_key'] = course_key serializer = self.get_serializer_class()(data, context=context) return Response(serializer.data)
def get(self, request, course_key): """ Returns a gradebook entry/entries (i.e. both course and subsection-level grade data) for all users enrolled in a course, or a single user enrolled in a course if a `username` parameter is provided. Args: request: A Django request object. course_key: The edx course opaque key of a course object. """ course = get_course_by_id(course_key, depth=None) # We fetch the entire course structure up-front, and use this when iterating # over users to determine their subsection grades. We purposely avoid fetching # the user-specific course structure for each user, because that is very expensive. course_data = CourseData(user=None, course=course) graded_subsections = list( grades_context.graded_subsections_for_course( course_data.collected_structure)) if request.GET.get('username'): with self._get_user_or_raise(request, course_key) as grade_user: course_grade = CourseGradeFactory().read(grade_user, course) entry = self._gradebook_entry(grade_user, course, graded_subsections, course_grade) serializer = StudentGradebookEntrySerializer(entry) return Response(serializer.data) else: q_objects = [] if request.GET.get('user_contains'): search_term = request.GET.get('user_contains') q_objects.append( Q(user__username__icontains=search_term) | Q(programcourseenrollment__program_enrollment__external_user_key__icontains =search_term) | Q(user__email__icontains=search_term)) if request.GET.get('username_contains'): q_objects.append( Q(user__username__icontains=request.GET.get( 'username_contains'))) if request.GET.get('cohort_id'): cohort = cohorts.get_cohort_by_id(course_key, request.GET.get('cohort_id')) if cohort: q_objects.append(Q(user__in=cohort.users.all())) else: q_objects.append(Q(user__in=[])) if request.GET.get('enrollment_mode'): q_objects.append(Q(mode=request.GET.get('enrollment_mode'))) entries = [] related_models = ['user'] users = self._paginate_users(course_key, q_objects, related_models) with bulk_gradebook_view_context(course_key, users): for user, course_grade, exc in CourseGradeFactory().iter( users, course_key=course_key, collected_block_structure=course_data. collected_structure): if not exc: entry = self._gradebook_entry(user, course, graded_subsections, course_grade) entries.append(entry) serializer = StudentGradebookEntrySerializer(entries, many=True) return self.get_paginated_response(serializer.data)
def _get_course_grade(user, course_key): """ Get the user's course grade in this course run. Note that this may be None. """ return CourseGradeFactory().read(user, course_key=course_key)
def get(self, request, course_key): """ Returns a gradebook entry/entries (i.e. both course and subsection-level grade data) for all users enrolled in a course, or a single user enrolled in a course if a `username` parameter is provided. Args: request: A Django request object. course_key: The edx course opaque key of a course object. """ course = get_course_by_id(course_key, depth=None) # We fetch the entire course structure up-front, and use this when iterating # over users to determine their subsection grades. We purposely avoid fetching # the user-specific course structure for each user, because that is very expensive. course_data = CourseData(user=None, course=course) graded_subsections = list(grades_context.graded_subsections_for_course(course_data.collected_structure)) if request.GET.get('username'): with self._get_user_or_raise(request, course_key) as grade_user: course_grade = CourseGradeFactory().read(grade_user, course) entry = self._gradebook_entry(grade_user, course, graded_subsections, course_grade) serializer = StudentGradebookEntrySerializer(entry) return Response(serializer.data) else: q_objects = [] annotations = {} if request.GET.get('user_contains'): search_term = request.GET.get('user_contains') q_objects.append( Q(user__username__icontains=search_term) | Q(programcourseenrollment__program_enrollment__external_user_key__icontains=search_term) | Q(user__email__icontains=search_term) ) if request.GET.get('username_contains'): q_objects.append(Q(user__username__icontains=request.GET.get('username_contains'))) if request.GET.get('cohort_id'): cohort = cohorts.get_cohort_by_id(course_key, request.GET.get('cohort_id')) if cohort: q_objects.append(Q(user__in=cohort.users.all())) else: q_objects.append(Q(user__in=[])) if request.GET.get('enrollment_mode'): q_objects.append(Q(mode=request.GET.get('enrollment_mode'))) if request.GET.get('assignment') and ( request.GET.get('assignment_grade_max') or request.GET.get('assignment_grade_min')): subqueryset = PersistentSubsectionGrade.objects.annotate( effective_grade_percentage=Case( When(override__isnull=False, then=( F('override__earned_graded_override') / F('override__possible_graded_override') ) * 100), default=(F('earned_graded') / F('possible_graded')) * 100 ) ) grade_conditions = { 'effective_grade_percentage__range': ( request.GET.get('assignment_grade_min', 0), request.GET.get('assignment_grade_max', 100) ) } annotations['selected_assignment_grade_in_range'] = Exists( subqueryset.filter( course_id=OuterRef('course'), user_id=OuterRef('user'), usage_key=UsageKey.from_string(request.GET.get('assignment')), **grade_conditions ) ) q_objects.append(Q(selected_assignment_grade_in_range=True)) if request.GET.get('course_grade_min') or request.GET.get('course_grade_max'): grade_conditions = {} q_object = Q() course_grade_min = request.GET.get('course_grade_min') if course_grade_min: course_grade_min = float(request.GET.get('course_grade_min')) / 100 grade_conditions['percent_grade__gte'] = course_grade_min if request.GET.get('course_grade_max'): course_grade_max = float(request.GET.get('course_grade_max')) / 100 grade_conditions['percent_grade__lte'] = course_grade_max if not course_grade_min or course_grade_min == 0: subquery_grade_absent = ~Exists( PersistentCourseGrade.objects.filter( course_id=OuterRef('course'), user_id=OuterRef('user_id'), ) ) annotations['course_grade_absent'] = subquery_grade_absent q_object |= Q(course_grade_absent=True) subquery_grade_in_range = Exists( PersistentCourseGrade.objects.filter( course_id=OuterRef('course'), user_id=OuterRef('user_id'), **grade_conditions ) ) annotations['course_grade_in_range'] = subquery_grade_in_range q_object |= Q(course_grade_in_range=True) q_objects.append(q_object) entries = [] related_models = ['user'] users = self._paginate_users(course_key, q_objects, related_models, annotations=annotations) users_counts = self._get_users_counts(course_key, q_objects, annotations=annotations) with bulk_gradebook_view_context(course_key, users): for user, course_grade, exc in CourseGradeFactory().iter( users, course_key=course_key, collected_block_structure=course_data.collected_structure ): if not exc: entry = self._gradebook_entry(user, course, graded_subsections, course_grade) entries.append(entry) serializer = StudentGradebookEntrySerializer(entries, many=True) return self.get_paginated_response(serializer.data, **users_counts)
def send_grade_if_interesting(user, course_run_key, mode, status, letter_grade, percent_grade, verbose=False): """ Checks if grade is interesting to Credentials and schedules a Celery task if so. """ if verbose: msg = "Starting send_grade_if_interesting with params: "\ "user [{username}], "\ "course_run_key [{key}], "\ "mode [{mode}], "\ "status [{status}], "\ "letter_grade [{letter_grade}], "\ "percent_grade [{percent_grade}], "\ "verbose [{verbose}]"\ .format( username=getattr(user, 'username', None), key=str(course_run_key), mode=mode, status=status, letter_grade=letter_grade, percent_grade=percent_grade, verbose=verbose ) logger.info(msg) # Avoid scheduling new tasks if certification is disabled. (Grades are a part of the records/cert story) if not CredentialsApiConfig.current().is_learner_issuance_enabled: if verbose: logger.info( "Skipping send grade: is_learner_issuance_enabled False") return # Avoid scheduling new tasks if learner records are disabled for this site. if not is_learner_records_enabled_for_org(course_run_key.org): if verbose: logger.info( "Skipping send grade: ENABLE_LEARNER_RECORDS False for org [{org}]" .format(org=course_run_key.org)) return # Grab mode/status if we don't have them in hand if mode is None or status is None: try: cert = GeneratedCertificate.objects.get(user=user, course_id=course_run_key) # pylint: disable=no-member mode = cert.mode status = cert.status except GeneratedCertificate.DoesNotExist: # We only care about grades for which there is a certificate. if verbose: logger.info( "Skipping send grade: no cert for user [{username}] & course_id [{course_id}]" .format(username=getattr(user, 'username', None), course_id=str(course_run_key))) return # Don't worry about whether it's available as well as awarded. Just awarded is good enough to record a verified # attempt at a course. We want even the grades that didn't pass the class because Credentials wants to know about # those too. if mode not in INTERESTING_MODES or status not in INTERESTING_STATUSES: if verbose: logger.info( "Skipping send grade: mode/status uninteresting for mode [{mode}] & status [{status}]" .format(mode=mode, status=status)) return # If the course isn't in any program, don't bother telling Credentials about it. When Credentials grows support # for course records as well as program records, we'll need to open this up. if not is_course_run_in_a_program(course_run_key): if verbose: logger.info( f"Skipping send grade: course run not in a program. [{str(course_run_key)}]" ) return # Grab grades if we don't have them in hand if letter_grade is None or percent_grade is None: grade = CourseGradeFactory().read(user, course_key=course_run_key, create_if_needed=False) if grade is None: if verbose: logger.info( "Skipping send grade: No grade found for user [{username}] & course_id [{course_id}]" .format(username=getattr(user, 'username', None), course_id=str(course_run_key))) return letter_grade = grade.letter_grade percent_grade = grade.percent send_grade_to_credentials.delay(user.username, str(course_run_key), True, letter_grade, percent_grade)
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'])
def user_has_passing_grade(self): """ Returns a boolean on if the effective_user has a passing grade in the course """ if not self.effective_user.is_anonymous: user_grade = CourseGradeFactory().read(self.effective_user, self.course).percent return user_grade >= self.course.lowest_passing_grade return False