diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 7dcd7b925e..1bb3e115b6 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -270,7 +270,26 @@ class LoncapaProblem(object): # if answers include File objects, convert them to filenames. self.student_answers = convert_files_to_filenames(answers) + return self._grade_answers(answers) + def regrade_existing_answers(self): + ''' + Regrade student responses. Called by capa_module.regrade_problem. + ''' + return self._grade_answers(None) + + def _grade_answers(self, answers): + ''' + Internal grading call used for checking new student answers and also + regrading existing student answers. + + answers is a dict of all the entries from request.POST, but with the first part + of each key removed (the string before the first "_"). + + Thus, for example, input_ID123 -> ID123, and input_fromjs_ID123 -> fromjs_ID123 + + Calls the Response for each question in this problem, to do the actual grading. + ''' # old CorrectMap oldcmap = self.correct_map @@ -281,10 +300,11 @@ class LoncapaProblem(object): for responder in self.responders.values(): # File objects are passed only if responsetype explicitly allows for file # submissions - if 'filesubmission' in responder.allowed_inputfields: + # TODO: figure out where to get file submissions when regrading. + if 'filesubmission' in responder.allowed_inputfields and answers is not None: results = responder.evaluate_answers(answers, oldcmap) else: - results = responder.evaluate_answers(convert_files_to_filenames(answers), oldcmap) + results = responder.evaluate_answers(self.student_answers, oldcmap) newcmap.update(results) self.correct_map = newcmap # log.debug('%s: in grade_answers, answers=%s, cmap=%s' % (self,answers,newcmap)) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index fee80a34ff..7d7ca2c912 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -759,6 +759,8 @@ class CapaModule(CapaFields, XModule): try: correct_map = self.lcp.grade_answers(answers) + self.attempts = self.attempts + 1 + self.lcp.done = True self.set_state_from_lcp() except (StudentInputError, ResponseError, LoncapaProblemError) as inst: @@ -785,10 +787,6 @@ class CapaModule(CapaFields, XModule): return {'success': msg} raise - self.attempts = self.attempts + 1 - self.lcp.done = True - - self.set_state_from_lcp() self.publish_grade() # success = correct if ALL questions in this problem are correct @@ -814,6 +812,63 @@ class CapaModule(CapaFields, XModule): 'contents': html, } + def regrade_problem(self): + ''' Checks whether answers to a problem are correct, and + returns a map of correct/incorrect answers: + + {'success' : 'correct' | 'incorrect' | AJAX alert msg string, + 'contents' : html} + ''' + event_info = dict() + event_info['state'] = self.lcp.get_state() + event_info['problem_id'] = self.location.url() + + if not self.done: + event_info['failure'] = 'unanswered' + self.system.track_function('save_problem_regrade_fail', event_info) + raise NotFoundError('Problem must be answered before it can be graded again') + + try: + correct_map = self.lcp.regrade_existing_answers() + # regrading should have no effect on attempts, so don't + # need to increment here, or mark done. Just save. + self.set_state_from_lcp() + except StudentInputError as inst: + log.exception("StudentInputError in capa_module:problem_regrade") + return {'success': inst.message} + except Exception, err: + if self.system.DEBUG: + msg = "Error checking problem: " + str(err) + msg += '\nTraceback:\n' + traceback.format_exc() + return {'success': msg} + raise + + self.publish_grade() + + # success = correct if ALL questions in this problem are correct + success = 'correct' + for answer_id in correct_map: + if not correct_map.is_correct(answer_id): + success = 'incorrect' + + # NOTE: We are logging both full grading and queued-grading submissions. In the latter, + # 'success' will always be incorrect + event_info['correct_map'] = correct_map.get_dict() + event_info['success'] = success + event_info['attempts'] = self.attempts + self.system.track_function('save_problem_regrade', event_info) + + # TODO: figure out if psychometrics should be called on regrading requests + if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback + self.system.psychometrics_handler(self.get_instance_state()) + + # render problem into HTML + html = self.get_problem_html(encapsulate=False) + + return {'success': success, + 'contents': html, + } + def save_problem(self, get): ''' Save the passed in answers. diff --git a/lms/djangoapps/courseware/migrations/0010_add_courseware_coursetasklog.py b/lms/djangoapps/courseware/migrations/0010_add_courseware_coursetasklog.py new file mode 100644 index 0000000000..c24bcbd46e --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0010_add_courseware_coursetasklog.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding model 'CourseTaskLog' + db.create_table('courseware_coursetasklog', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('student', self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', null=True, to=orm['auth.User'])), + ('task_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('task_args', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('task_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('task_status', self.gf('django.db.models.fields.CharField')(max_length=50, null=True, db_index=True)), + ('requester', self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', to=orm['auth.User'])), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)), + ('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)), + )) + db.send_create_signal('courseware', ['CourseTaskLog']) + + def backwards(self, orm): + # Deleting model 'CourseTaskLog' + db.delete_table('courseware_coursetasklog') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'courseware.coursetasklog': { + 'Meta': {'object_name': 'CourseTaskLog'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'requester': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['auth.User']"}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'null': 'True', 'to': "orm['auth.User']"}), + 'task_args': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'task_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'task_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'task_status': ('django.db.models.fields.CharField', [], {'max_length': '50', 'null': 'True', 'db_index': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}) + }, + 'courseware.offlinecomputedgrade': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.offlinecomputedgradelog': { + 'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.studentmodulehistory': { + 'Meta': {'object_name': 'StudentModuleHistory'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}), + 'version': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'}) + }, + 'courseware.xmodulecontentfield': { + 'Meta': {'unique_together': "(('definition_id', 'field_name'),)", 'object_name': 'XModuleContentField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'definition_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulesettingsfield': { + 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleSettingsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'usage_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulestudentinfofield': { + 'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulestudentprefsfield': { + 'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + } + } + + complete_apps = ['courseware'] diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 53493b8e45..5e58dc2e96 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -17,6 +17,7 @@ from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver + class StudentModule(models.Model): """ Keeps student state for a particular module in a particular course. @@ -262,3 +263,20 @@ class OfflineComputedGradeLog(models.Model): def __unicode__(self): return "[OCGLog] %s: %s" % (self.course_id, self.created) + + +class CourseTaskLog(models.Model): + """ + Stores information about background tasks that have been submitted to + perform course-specific work. + Examples include grading and regrading. + """ + course_id = models.CharField(max_length=255, db_index=True) + student = models.ForeignKey(User, null=True, db_index=True, related_name='+') # optional: None = task applies to all students + task_name = models.CharField(max_length=50, db_index=True) + task_args = models.CharField(max_length=255, db_index=True) + task_id = models.CharField(max_length=255, db_index=True) # max_length from celery_taskmeta + task_status = models.CharField(max_length=50, null=True, db_index=True) # max_length from celery_taskmeta + requester = models.ForeignKey(User, db_index=True, related_name='+') + created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) + updated = models.DateTimeField(auto_now=True, db_index=True) diff --git a/lms/djangoapps/courseware/tasks.py b/lms/djangoapps/courseware/tasks.py new file mode 100644 index 0000000000..516997485e --- /dev/null +++ b/lms/djangoapps/courseware/tasks.py @@ -0,0 +1,352 @@ + +import json +import logging +from django.contrib.auth.models import User +from courseware.models import StudentModule, CourseTaskLog +from courseware.model_data import ModelDataCache +from courseware.module_render import get_module + +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError,\ + InvalidLocationError +import track.views + +from celery import task, current_task +from celery.utils.log import get_task_logger +from time import sleep +from django.core.handlers.wsgi import WSGIRequest + +logger = get_task_logger(__name__) + +# celery = Celery('tasks', broker='django://') + +log = logging.getLogger(__name__) + + +@task +def add(x, y): + return x + y + + +@task +def echo(value): + if value == 'ping': + result = 'pong' + else: + result = 'got: {0}'.format(value) + + return result + + +@task +def waitawhile(value): + for i in range(value): + sleep(1) # in seconds + logger.info('Waited {0} seconds...'.format(i)) + current_task.update_state(state='PROGRESS', + meta={'current': i, 'total': value}) + + result = 'Yeah!' + return result + + +class UpdateProblemModuleStateError(Exception): + pass + + +def _update_problem_module_state(request, course_id, problem_url, student, update_fcn, action_name, filter_fcn): + ''' + Performs generic update by visiting StudentModule instances with the update_fcn provided + + If student is None, performs update on modules for all students on the specified problem + ''' + module_state_key = problem_url + # TODO: store this in the task state, not as a separate return value. + # (Unless that's not what the task state is intended to mean. The task can successfully + # complete, as far as celery is concerned, but have an internal status of failed.) + succeeded = False + + # find the problem descriptor, if any: + try: + module_descriptor = modulestore().get_instance(course_id, module_state_key) + succeeded = True + except ItemNotFoundError: + msg = "Couldn't find problem with that urlname." + except InvalidLocationError: + msg = "Couldn't find problem with that urlname." + if module_descriptor is None: + msg = "Couldn't find problem with that urlname." +# if not succeeded: +# current_task.update_state( +# meta={'attempted': num_attempted, 'updated': num_updated, 'total': num_total}) +# The task should still succeed, but should have metadata indicating +# that the result of the successful task was a failure. (It's not +# the queue that failed, but the task put on the queue.) + + # find the module in question + succeeded = False + modules_to_update = StudentModule.objects.filter(course_id=course_id, + module_state_key=module_state_key) + + # give the option of regrading an individual student. If not specified, + # then regrades all students who have responded to a problem so far + if student is not None: + modules_to_update = modules_to_update.filter(student_id=student.id) + + if filter_fcn is not None: + modules_to_update = filter_fcn(modules_to_update) + + # perform the main loop + num_updated = 0 + num_attempted = 0 + num_total = len(modules_to_update) # TODO: make this more efficient. Count()? + for module_to_update in modules_to_update: + num_attempted += 1 +# try: + if update_fcn(request, module_to_update, module_descriptor): + num_updated += 1 +# if there's an error, just let it throw, and the task will +# be marked as FAILED, with a stack trace. +# except UpdateProblemModuleStateError as e: + # something bad happened, so exit right away +# return (succeeded, e.message) + # update task status: + current_task.update_state(state='PROGRESS', + meta={'attempted': num_attempted, 'updated': num_updated, 'total': num_total}) + + # done with looping through all modules, so just return final statistics: + if student is not None: + if num_attempted == 0: + msg = "Unable to find submission to be {action} for student '{student}' and problem '{problem}'." + elif num_updated == 0: + msg = "Problem failed to be {action} for student '{student}' and problem '{problem}'!" + else: + succeeded = True + msg = "Problem successfully {action} for student '{student}' and problem '{problem}'" + elif num_attempted == 0: + msg = "Unable to find any students with submissions to be {action} for problem '{problem}'." + elif num_updated == 0: + msg = "Problem failed to be {action} for any of {attempted} students for problem '{problem}'!" + elif num_updated == num_attempted: + succeeded = True + msg = "Problem successfully {action} for {attempted} students for problem '{problem}'!" + elif num_updated < num_attempted: + msg = "Problem {action} for {updated} of {attempted} students for problem '{problem}'!" + + msg = msg.format(action=action_name, updated=num_updated, attempted=num_attempted, student=student, problem=module_state_key) + # update status in task result object itself: + current_task.update_state(state='DONE', + meta={'attempted': num_attempted, 'updated': num_updated, 'total': num_total, + 'succeeded': succeeded, 'message': msg}) + + # and update status in course task table as well: + # TODO: figure out how this is legal. The actual task result + # status is updated by celery when this task completes, and is + # not +# course_task_log_entry = CourseTaskLog.objects.get(task_id=current_task.id) +# course_task_log_entry.task_status = ... + + # return (succeeded, msg) + return succeeded + + +def _update_problem_module_state_for_student(request, course_id, problem_url, student_identifier, + update_fcn, action_name, filter_fcn=None): + msg = '' + success = False + # try to uniquely id student by email address or username + try: + if "@" in student_identifier: + student_to_update = User.objects.get(email=student_identifier) + elif student_identifier is not None: + student_to_update = User.objects.get(username=student_identifier) + return _update_problem_module_state(request, course_id, problem_url, student_to_update, update_fcn, action_name, filter_fcn) + except User.DoesNotExist: + msg = "Couldn't find student with that email or username." + + return (success, msg) + + +def _update_problem_module_state_for_all_students(request, course_id, problem_url, update_fcn, action_name, filter_fcn=None): + return _update_problem_module_state(request, course_id, problem_url, None, update_fcn, action_name, filter_fcn) + + +def _regrade_problem_module_state(request, module_to_regrade, module_descriptor): + ''' + Takes an XModule descriptor and a corresponding StudentModule object, and + performs regrading on the student's problem submission. + + Throws exceptions if the regrading is fatal and should be aborted if in a loop. + ''' + # unpack the StudentModule: + course_id = module_to_regrade.course_id + student = module_to_regrade.student + module_state_key = module_to_regrade.module_state_key + + # reconstitute the problem's corresponding XModule: + model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, student, + module_descriptor) + # Note that the request is passed to get_module() to provide xqueue-related URL information + instance = get_module(student, request, module_state_key, model_data_cache, + course_id, grade_bucket_type='regrade') + + if instance is None: + # Either permissions just changed, or someone is trying to be clever + # and load something they shouldn't have access to. + msg = "No module {loc} for student {student}--access denied?".format(loc=module_state_key, + student=student) + log.debug(msg) + raise UpdateProblemModuleStateError(msg) + + if not hasattr(instance, 'regrade_problem'): + # if the first instance doesn't have a regrade method, we should + # probably assume that no other instances will either. + msg = "Specified problem does not support regrading." + raise UpdateProblemModuleStateError(msg) + + result = instance.regrade_problem() + if 'success' not in result: + # don't consider these fatal, but false means that the individual call didn't complete: + log.debug("error processing regrade call for problem {loc} and student {student}: " + "unexpected response {msg}".format(msg=result, loc=module_state_key, student=student)) + return False + elif result['success'] != 'correct' and result['success'] != 'incorrect': + log.debug("error processing regrade call for problem {loc} and student {student}: " + "{msg}".format(msg=result['success'], loc=module_state_key, student=student)) + return False + else: + track.views.server_track(request, + '{instructor} regrade problem {problem} for student {student} ' + 'in {course}'.format(student=student.id, + problem=module_to_regrade.module_state_key, + instructor=request.user, + course=course_id), + {}, + page='idashboard') + return True + + +def filter_problem_module_state_for_done(modules_to_update): + return modules_to_update.filter(state__contains='"done": true') + + +@task +def _regrade_problem_for_student(request, course_id, problem_url, student_identifier): + action_name = 'regraded' + update_fcn = _regrade_problem_module_state + filter_fcn = filter_problem_module_state_for_done + return _update_problem_module_state_for_student(request, course_id, problem_url, student_identifier, + update_fcn, action_name, filter_fcn) + + +def regrade_problem_for_student(request, course_id, problem_url, student_identifier): + # First submit task. Then put stuff into table with the resulting task_id. + result = _regrade_problem_for_student.apply_async(request, course_id, problem_url, student_identifier) + task_id = result.id + # TODO: for log, would want student_identifier to already be mapped to the student + tasklog_args = {'course_id': course_id, + 'task_name': 'regrade', + 'task_args': problem_url, + 'task_id': task_id, + 'task_status': result.state, + 'requester': request.user} + + CourseTaskLog.objects.create(**tasklog_args) + return result + + +@task +def _regrade_problem_for_all_students(request_environ, course_id, problem_url): +# request = dummy_request + request = WSGIRequest(request_environ) + action_name = 'regraded' + update_fcn = _regrade_problem_module_state + filter_fcn = filter_problem_module_state_for_done + return _update_problem_module_state_for_all_students(request, course_id, problem_url, + update_fcn, action_name, filter_fcn) + + +def regrade_problem_for_all_students(request, course_id, problem_url): + # Figure out (for now) how to serialize what we need of the request. The actual + # request will not successfully serialize with json or with pickle. + request_environ = {'HTTP_USER_AGENT': request.META['HTTP_USER_AGENT'], + 'REMOTE_ADDR': request.META['REMOTE_ADDR'], + 'SERVER_NAME': request.META['SERVER_NAME'], + 'REQUEST_METHOD': 'GET', +# 'HTTP_X_FORWARDED_PROTO': request.META['HTTP_X_FORWARDED_PROTO'], + } + + # Submit task. Then put stuff into table with the resulting task_id. + task_args = [request_environ, course_id, problem_url] + result = _regrade_problem_for_all_students.apply_async(task_args) + task_id = result.id + tasklog_args = {'course_id': course_id, + 'task_name': 'regrade', + 'task_args': problem_url, + 'task_id': task_id, + 'task_status': result.state, + 'requester': request.user} + course_task_log = CourseTaskLog.objects.create(**tasklog_args) + return course_task_log + + +def _reset_problem_attempts_module_state(request, module_to_reset, module_descriptor): + # modify the problem's state + # load the state json and change state + problem_state = json.loads(module_to_reset.state) + if 'attempts' in problem_state: + old_number_of_attempts = problem_state["attempts"] + if old_number_of_attempts > 0: + problem_state["attempts"] = 0 + # convert back to json and save + module_to_reset.state = json.dumps(problem_state) + module_to_reset.save() + # write out tracking info + track.views.server_track(request, + '{instructor} reset attempts from {old_attempts} to 0 for {student} ' + 'on problem {problem} in {course}'.format(old_attempts=old_number_of_attempts, + student=module_to_reset.student, + problem=module_to_reset.module_state_key, + instructor=request.user, + course=module_to_reset.course_id), + {}, + page='idashboard') + + # consider the reset to be successful, even if no update was performed. (It's just "optimized".) + return True + + +def _reset_problem_attempts_for_student(request, course_id, problem_url, student_identifier): + action_name = 'reset' + update_fcn = _reset_problem_attempts_module_state + return _update_problem_module_state_for_student(request, course_id, problem_url, student_identifier, + update_fcn, action_name) + + +def _reset_problem_attempts_for_all_students(request, course_id, problem_url): + action_name = 'reset' + update_fcn = _reset_problem_attempts_module_state + return _update_problem_module_state_for_all_students(request, course_id, problem_url, + update_fcn, action_name) + + +def _delete_problem_module_state(request, module_to_delete, module_descriptor): + ''' + delete the state + ''' + module_to_delete.delete() + return True + + +def _delete_problem_state_for_student(request, course_id, problem_url, student_ident): + action_name = 'deleted' + update_fcn = _delete_problem_module_state + return _update_problem_module_state_for_student(request, course_id, problem_url, + update_fcn, action_name) + + +def _delete_problem_state_for_all_students(request, course_id, problem_url): + action_name = 'deleted' + update_fcn = _delete_problem_module_state + return _update_problem_module_state_for_all_students(request, course_id, problem_url, + update_fcn, action_name) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index 63869fb48b..47b22edcb2 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -12,6 +12,7 @@ import requests from requests.status_codes import codes import urllib from collections import OrderedDict +from time import sleep from StringIO import StringIO @@ -24,6 +25,7 @@ from mitxmako.shortcuts import render_to_response from django.core.urlresolvers import reverse from courseware import grades +from courseware import tasks from courseware.access import (has_access, get_access_group_name, course_beta_test_group_name) from courseware.courses import get_course_with_access @@ -174,6 +176,13 @@ def instructor_dashboard(request, course_id): datatable['title'] = 'List of students enrolled in {0}'.format(course_id) track.views.server_track(request, 'list-students', {}, page='idashboard') + elif 'Test Celery' in action: + args = (10,) + result = tasks.waitawhile.apply_async(args, retry=False) + task_id = result.id + celery_ajax_url = reverse('celery_ajax_status', kwargs={'task_id': task_id}) + msg += '

Celery Status for task ${task}:

Status end.

'.format(task=task_id, url=celery_ajax_url) + elif 'Dump Grades' in action: log.debug(action) datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True, use_offline=use_offline) @@ -205,6 +214,13 @@ def instructor_dashboard(request, course_id): track.views.server_track(request, action, {}, page='idashboard') msg += dump_grading_context(course) + elif "Regrade ALL students' problem submissions" in action: + problem_url = request.POST.get('problem_to_regrade', '') + try: + result = tasks.regrade_problem_for_all_students(request, course_id, problem_url) + except Exception as e: + log.error("Encountered exception from regrade: {msg}", msg=e.message()) + elif "Reset student's attempts" in action or "Delete student state for problem" in action: # get the form data unique_student_identifier = request.POST.get('unique_student_identifier', '') @@ -1181,3 +1197,101 @@ def dump_grading_context(course): msg += "length=%d\n" % len(gc['all_descriptors']) msg = '
%s
' % msg.replace('<', '<') return msg + + +def old1testcelery(request): + """ + A Simple view that checks if the application can talk to the celery workers + """ + args = ('ping',) + result = tasks.echo.apply_async(args, retry=False) + value = result.get(timeout=0.5) + output = { + 'task_id': result.id, + 'value': value + } + return HttpResponse(json.dumps(output, indent=4)) + + +def old2testcelery(request): + """ + A Simple view that checks if the application can talk to the celery workers + """ + args = (10,) + result = tasks.waitawhile.apply_async(args, retry=False) + while not result.ready(): + sleep(0.5) # in seconds + if result.state == "PROGRESS": + if hasattr(result, 'result') and 'current' in result.result: + log.info("still waiting... progress at {0} of {1}".format(result.result['current'], result.result['total'])) + else: + log.info("still making progress... ") + if result.successful(): + value = result.result + output = { + 'task_id': result.id, + 'value': value + } + return HttpResponse(json.dumps(output, indent=4)) + + +def testcelery(request): + """ + A Simple view that checks if the application can talk to the celery workers + """ + args = (10,) + result = tasks.waitawhile.apply_async(args, retry=False) + task_id = result.id + # return the task_id to a template which will set up an ajax call to + # check the progress of the task. + return testcelery_status(request, task_id) +# return mitxmako.shortcuts.render_to_response('celery_ajax.html', { +# 'element_id': 'celery_task' +# 'id': self.task_id, +# 'ajax_url': reverse('testcelery_ajax'), +# }) + + +def testcelery_status(request, task_id): + result = tasks.waitawhile.AsyncResult(task_id) + while not result.ready(): + sleep(0.5) # in seconds + if result.state == "PROGRESS": + if hasattr(result, 'result') and 'current' in result.result: + log.info("still waiting... progress at {0} of {1}".format(result.result['current'], result.result['total'])) + else: + log.info("still making progress... ") + if result.successful(): + value = result.result + output = { + 'task_id': result.id, + 'value': value + } + return HttpResponse(json.dumps(output, indent=4)) + + +def celery_task_status(request, task_id): + # TODO: determine if we need to know the name of the original task, + # or if this could be any task... Sample code seems to indicate that + # we could just include the AsyncResult class directly, i.e.: + # from celery.result import AsyncResult. + result = tasks.waitawhile.AsyncResult(task_id) + + output = { + 'task_id': result.id, + 'state': result.state + } + + if result.state == "PROGRESS": + if hasattr(result, 'result') and 'current' in result.result: + log.info("still waiting... progress at {0} of {1}".format(result.result['current'], result.result['total'])) + output['current'] = result.result['current'] + output['total'] = result.result['total'] + else: + log.info("still making progress... ") + + if result.successful(): + value = result.result + output['value'] = value + + return HttpResponse(json.dumps(output, indent=4)) diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 561c6b9507..f60f591f2f 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -194,6 +194,12 @@ function goto( mode)
%endif +

Course-specific grade adjustment

+ +

to regrade a problem for all students, input the urlname of that problem

+

+ +

Student-specific grade inspection and adjustment

edX email address or their username:

@@ -234,6 +240,7 @@ function goto( mode) ##----------------------------------------------------------------------------- %if modeflag.get('Admin'): + %if instructor_access:

@@ -331,6 +338,10 @@ function goto( mode) ##----------------------------------------------------------------------------- %if modeflag.get('Data'): +

+ +

+