def get_progress_data(self, course_enrollment): """ TODO: Add this to metrics, then we'll need to store per-user progress data For initial implementation, we get the TODO: We will cache course grades, so we'll refactor this method to use the cache, so we'll likely change the call to LearnerCourseGrades """ cert = GeneratedCertificate.objects.filter( user=course_enrollment.user, course_id=course_enrollment.course_id, ) if cert: course_completed = cert[0].created_date else: course_completed = False # Default values if we can't retrieve progress data progress_percent = 0.0 course_progress_details = None try: obj = LearnerCourseGradeMetrics.objects.most_recent_for_learner_course( user=course_enrollment.user, course_id=str(course_enrollment.course_id)) if obj: progress_percent = obj.progress_percent course_progress_details = obj.progress_details except Exception as e: # pylint: disable=broad-except # TODO: Use more specific database-related exception error_data = dict( msg='Exception trying to get learner course metrics', username=course_enrollment.user.username, course_id=str(course_enrollment.course_id), exception=str(e) ) log_error( error_data=error_data, error_type=PipelineError.UNSPECIFIED_DATA, ) # Empty list initially, then will fill after we implement capturing # learner specific progress course_progress_history = [] data = dict( course_completed=course_completed, course_progress=progress_percent, course_progress_details=course_progress_details, course_progress_history=course_progress_history, ) return data
def bulk_calculate_course_progress_data(course_id, date_for=None): """Calculates the average progress for a set of course enrollments How it works 1. collects progress percent for each course enrollment 1.1. If up to date enrollment metrics record already exists, use that 2. calculate and return the average of these enrollments TODO: Update to filter on active users Questions: - What makes a learner an active learner? """ progress_percentages = [] if not date_for: date_for = datetime.utcnow().replace(tzinfo=utc).date() site = get_site_for_course(course_id) if not site: raise UnlinkedCourseError( 'No site found for course "{}"'.format(course_id)) course_sm = get_student_modules_for_course_in_site(site=site, course_id=course_id) for ce in course_enrollments_for_course(course_id): metrics = collect_metrics_for_enrollment(site=site, course_enrollment=ce, course_sm=course_sm, date_for=date_for) if metrics: progress_percentages.append(metrics.progress_percent) else: # Log this for troubleshooting error_data = dict( msg=('Unable to create or retrieve enrollment metrics ' + 'for user {} and course {}'.format( ce.user.username, str(ce.course_id)))) log_error(error_data=error_data, error_type=PipelineError.COURSE_DATA, user=ce.user, course_id=str(course_id)) return dict( average_progress=calculate_average_progress(progress_percentages), )
def get_average_progress_deprecated(course_id, date_for, course_enrollments): """Collects and aggregates raw course grades data """ progress = [] for ce in course_enrollments: try: course_progress = figures.metrics.LearnerCourseGrades.course_progress( ce) figures.pipeline.loaders.save_learner_course_grades( site=figures.sites.get_site_for_course(course_id), date_for=date_for, course_enrollment=ce, course_progress_details=course_progress[ 'course_progress_details']) # TODO: Use more specific database-related exception except Exception as e: # pylint: disable=broad-except error_data = dict( msg='Unable to get course blocks', username=ce.user.username, course_id=str(ce.course_id), exception=str(e), ) log_error( error_data=error_data, error_type=PipelineError.GRADES_DATA, user=ce.user, course_id=ce.course_id, ) course_progress = dict(progress_percent=0.0, course_progress_details=None) if course_progress: progress.append(course_progress) if progress: progress_percent = [rec['progress_percent'] for rec in progress] average_progress = float(sum(progress_percent)) / float( len(progress_percent)) average_progress = float( Decimal(average_progress).quantize(Decimal('.00'))) else: average_progress = 0.0 return average_progress
def get_average_progress(course_id, date_for, course_enrollments): """Collects and aggregates raw course grades data """ progress = [] for ce in course_enrollments: try: course_progress = figures.metrics.LearnerCourseGrades.course_progress( ce) figures.pipeline.loaders.save_learner_course_grades( date_for=date_for, course_enrollment=ce, course_progress_details=course_progress[ 'course_progress_details']) except Exception as e: error_data = dict( msg='Unable to get course blocks', username=ce.user.username, course_id=str(ce.course_id), exception=str(e), ) log_error( error_data=error_data, error_type=PipelineError.GRADES_DATA, user=ce.user, course_id=ce.course_id, ) course_progress = dict(progress_percent=0.0, course_progress_details=None) if course_progress: progress.append(course_progress) if len(progress): progress_percent = [rec['progress_percent'] for rec in progress] average_progress = float(sum(progress_percent)) / float( len(progress_percent)) else: average_progress = 0.0 return average_progress
def _enrollment_metrics_needs_update(most_recent_lcgm, most_recent_sm): """Returns True if we need to update our learner progress, False otherwise See the #Logic section in this module's docstring If we need to check that the records match the same user and course, we can do something like: ``` class RecordMismatchError(Exception): pass def rec_matches_user_and_course(lcgm, sm): return lcgm.user == sm.student and lcgm.course_id == sm.course_id ``` And in this function add the check when we have both records: ``` if not rec_matches_user_and_course(most_recent_lcgm, most_recent_sm): rec_msg = '{}(user_id={}, course_id="{}"' msg1 = rec_msg.format('lcgm', most_recent_lcgm.user.id, most_recent_lcgm.course_id) msg2 = rec_msg.format('sm', most_recent_sm.student.id, most_recent_sm.course_id) raise RecordMismatchError(msg1 + ':' + msg2) ``` """ # First assume we need to update the enrollment metrics record needs_update = True if not most_recent_lcgm and not most_recent_sm: # Learner has not started coursework needs_update = False elif most_recent_lcgm and most_recent_sm: # Learner has past course activity needs_update = most_recent_lcgm.date_for < most_recent_sm.modified.date( ) elif not most_recent_lcgm and most_recent_sm: # No LCGM recs, so Learner started on course after last collection # This could also happen # If this is the irst time collection is run for the learner+course # if an unhandled error prevents LCGM from saving # if the learner's LCGM recs were deleted needs_update = True elif most_recent_lcgm and not most_recent_sm: # This shouldn't happen. We log this state as an error # using 'COURSE_DATA' for the pipeline error type. Although we should # revisit logging and error tracking in Figures to define a clear # approach that has clear an intuitive contexts for the logging event # # We neede to decide: # # 1. Is this always an error state? Could this condition happen naturally? # # 2. Which error type should be created and what is the most applicable # context # Candidates # - Enrollment (learner+course) context # - Data state context - Why would we have an LCGM # # So we hold off updating PipelineError error choises initially until # we can think carefully on how we tag pipeline errors # error_data = dict( msg='LearnerCourseGradeMetrics record exists without StudentModule', ) log_error(error_data=error_data, error_type=PipelineError.COURSE_DATA, user=most_recent_lcgm.user, course_id=str(most_recent_lcgm.course_id)) needs_update = False return needs_update
def test_logging_to_model_with_kwargs(self, dict_args): assert PipelineError.objects.count() == 0 logger.log_error(self.error_data, **dict_args)
def test_logging_to_model(self): assert PipelineError.objects.count() == 0 logger.log_error(self.error_data)
def test_logging_to_logger(self): assert PipelineError.objects.count() == 0 env_tokens = {'LOG_PIPELINE_ERRORS_TO_DB': False} with mock.patch('figures.settings.env_tokens', env_tokens): logger.log_error(self.error_data) assert PipelineError.objects.count() == 0
def test_logging_to_logger(self): assert PipelineError.objects.count() == 0 features = {'FIGURES_LOG_PIPELINE_ERRORS_TO_DB': False} with mock.patch('figures.helpers.settings.FEATURES', features): logger.log_error(self.error_data) assert PipelineError.objects.count() == 0