diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 9cde878d21..c37b9fce16 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -4,7 +4,7 @@ Models for Student Information Replication Notes In our live deployment, we intend to run in a scenario where there is a pool of -Portal servers that hold the canoncial user information and that user +Portal servers that hold the canoncial user information and that user information is replicated to slave Course server pools. Each Course has a set of servers that serves only its content and has users that are relevant only to it. @@ -61,6 +61,7 @@ from xmodule.modulestore.django import modulestore log = logging.getLogger(__name__) + class UserProfile(models.Model): """This is where we store all the user demographic fields. We have a separate table for this rather than extending the built-in Django auth_user. @@ -175,6 +176,7 @@ class PendingEmailChange(models.Model): new_email = models.CharField(blank=True, max_length=255, db_index=True) activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True) + class CourseEnrollment(models.Model): user = models.ForeignKey(User) course_id = models.CharField(max_length=255, db_index=True) @@ -184,6 +186,10 @@ class CourseEnrollment(models.Model): class Meta: unique_together = (('user', 'course_id'), ) + def __unicode__(self): + return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created) + + @receiver(post_save, sender=CourseEnrollment) def assign_default_role(sender, instance, **kwargs): if instance.user.is_staff: @@ -273,6 +279,7 @@ def add_user_to_default_group(user, group): utg.users.add(User.objects.get(username=user)) utg.save() + @receiver(post_save, sender=User) def update_user_information(sender, instance, created, **kwargs): try: @@ -283,6 +290,7 @@ def update_user_information(sender, instance, created, **kwargs): log.error(unicode(e)) log.error("update user info to discussion failed for user with id: " + str(instance.id)) + ########################## REPLICATION SIGNALS ################################# # @receiver(post_save, sender=User) def replicate_user_save(sender, **kwargs): @@ -292,6 +300,7 @@ def replicate_user_save(sender, **kwargs): for course_db_name in db_names_to_replicate_to(user_obj.id): replicate_user(user_obj, course_db_name) + # @receiver(post_save, sender=CourseEnrollment) def replicate_enrollment_save(sender, **kwargs): """This is called when a Student enrolls in a course. It has to do the @@ -317,12 +326,14 @@ def replicate_enrollment_save(sender, **kwargs): log.debug("Replicating user profile because of new enrollment") user_profile = UserProfile.objects.get(user_id=enrollment_obj.user_id) replicate_model(UserProfile.save, user_profile, enrollment_obj.user_id) - + + # @receiver(post_delete, sender=CourseEnrollment) def replicate_enrollment_delete(sender, **kwargs): enrollment_obj = kwargs['instance'] return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id) - + + # @receiver(post_save, sender=UserProfile) def replicate_userprofile_save(sender, **kwargs): """We just updated the UserProfile (say an update to the name), so push that @@ -330,12 +341,13 @@ def replicate_userprofile_save(sender, **kwargs): user_profile_obj = kwargs['instance'] return replicate_model(UserProfile.save, user_profile_obj, user_profile_obj.user_id) - + ######### Replication functions ######### USER_FIELDS_TO_COPY = ["id", "username", "first_name", "last_name", "email", "password", "is_staff", "is_active", "is_superuser", "last_login", "date_joined"] + def replicate_user(portal_user, course_db_name): """Replicate a User to the correct Course DB. This is more complicated than it should be because Askbot extends the auth_user table and adds its own @@ -359,9 +371,10 @@ def replicate_user(portal_user, course_db_name): course_user.save(using=course_db_name) unmark(course_user) + def replicate_model(model_method, instance, user_id): """ - model_method is the model action that we want replicated. For instance, + model_method is the model action that we want replicated. For instance, UserProfile.save """ if not should_replicate(instance): @@ -376,8 +389,10 @@ def replicate_model(model_method, instance, user_id): model_method(instance, using=db_name) unmark(instance) + ######### Replication Helpers ######### + def is_valid_course_id(course_id): """Right now, the only database that's not a course database is 'default'. I had nicer checking in here originally -- it would scan the courses that @@ -387,26 +402,30 @@ def is_valid_course_id(course_id): """ return course_id != 'default' + def is_portal(): """Are we in the portal pool? Only Portal servers are allowed to replicate their changes. For now, only Portal servers see multiple DBs, so we use that to decide.""" return len(settings.DATABASES) > 1 + def db_names_to_replicate_to(user_id): """Return a list of DB names that this user_id is enrolled in.""" return [c.course_id for c in CourseEnrollment.objects.filter(user_id=user_id) if is_valid_course_id(c.course_id)] + def marked_handled(instance): """Have we marked this instance as being handled to avoid infinite loops caused by saving models in post_save hooks for the same models?""" return hasattr(instance, '_do_not_copy_to_course_db') and instance._do_not_copy_to_course_db + def mark_handled(instance): """You have to mark your instance with this function or else we'll go into - an infinite loop since we're putting listeners on Model saves/deletes and + an infinite loop since we're putting listeners on Model saves/deletes and the act of replication requires us to call the same model method. We create a _replicated attribute to differentiate the first save of this @@ -415,16 +434,18 @@ def mark_handled(instance): """ instance._do_not_copy_to_course_db = True + def unmark(instance): - """If we don't unmark a model after we do replication, then consecutive + """If we don't unmark a model after we do replication, then consecutive save() calls won't be properly replicated.""" instance._do_not_copy_to_course_db = False + def should_replicate(instance): """Should this instance be replicated? We need to be a Portal server and the instance has to not have been marked_handled.""" if marked_handled(instance): - # Basically, avoid an infinite loop. You should + # Basically, avoid an infinite loop. You should log.debug("{0} should not be replicated because it's been marked" .format(instance)) return False diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 27adf485f0..486aba12f6 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -75,8 +75,11 @@ def index(request, extra_context={}, user=None): entry.summary = soup.getText() # The course selection work is done in courseware.courses. + domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False + if not domain: + domain = request.META.get('HTTP_HOST') universities = get_courses_by_university(None, - domain=request.META.get('HTTP_HOST')) + domain=domain) context = {'universities': universities, 'entries': entries} context.update(extra_context) return render_to_response('index.html', context) diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 17380bff18..066d83ed3e 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -1,6 +1,7 @@ import re import json import logging +import time from django.conf import settings from functools import wraps @@ -75,7 +76,7 @@ def grade_histogram(module_id): grades = list(cursor.fetchall()) grades.sort(key=lambda x: x[0]) # Add ORDER BY to sql query? - if len(grades) == 1 and grades[0][0] is None: + if len(grades) >= 1 and grades[0][0] is None: return [] return grades @@ -117,6 +118,14 @@ def add_histogram(get_html, module, user): data_dir = "" source_file = module.metadata.get('source_file','') # source used to generate the problem XML, eg latex or word + # useful to indicate to staff if problem has been released or not + # TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here + now = time.gmtime() + is_released = "unknown" + mstart = getattr(module.descriptor,'start') + if mstart is not None: + is_released = "Yes!" if (now > mstart) else "Not yet" + staff_context = {'definition': module.definition.get('data'), 'metadata': json.dumps(module.metadata, indent=4), 'location': module.location, @@ -130,7 +139,9 @@ def add_histogram(get_html, module, user): 'xqa_server' : settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu/xqa'), 'histogram': json.dumps(histogram), 'render_histogram': render_histogram, - 'module_content': get_html()} + 'module_content': get_html(), + 'is_released': is_released, + } return render_to_string("staff_problem_info.html", staff_context) return _get_html diff --git a/common/lib/xmodule/xmodule/errortracker.py b/common/lib/xmodule/xmodule/errortracker.py index 8ac2903149..6accc8b8a7 100644 --- a/common/lib/xmodule/xmodule/errortracker.py +++ b/common/lib/xmodule/xmodule/errortracker.py @@ -35,6 +35,11 @@ def make_error_tracker(): if in_exception_handler(): exc_str = exc_info_to_str(sys.exc_info()) + # don't display irrelevant gunicorn sync error + if (('python2.7/site-packages/gunicorn/workers/sync.py' in exc_str) and + ('[Errno 11] Resource temporarily unavailable' in exc_str)): + exc_str = '' + errors.append((msg, exc_str)) return ErrorLog(error_tracker, errors) diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 070342f453..547df36471 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -12,6 +12,9 @@ import sys log = logging.getLogger(__name__) +edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, + remove_comments=True, remove_blank_text=True) + def name_to_pathname(name): """ Convert a location name for use in a path: replace ':' with '/'. @@ -150,7 +153,7 @@ class XmlDescriptor(XModuleDescriptor): Returns an lxml Element """ - return etree.parse(file_object).getroot() + return etree.parse(file_object, parser=edx_xml_parser).getroot() @classmethod def load_file(cls, filepath, fs, location): diff --git a/doc/development.md b/doc/development.md index fcf0b7a40f..b4ac52d202 100644 --- a/doc/development.md +++ b/doc/development.md @@ -94,3 +94,8 @@ course content can be setup to trigger an automatic reload when changes are push The mitx server will then do "git reset --hard HEAD; git clean -f -d; git pull origin" in that directory. After the pull, it will reload the modulestore for that course. + +Note that the gitreload-based workflow is not meant for deployments on AWS (or elsewhere) which use collectstatic, since collectstatic is not run by a gitreload event. + +Also, the gitreload feature needs MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True in the django settings. + diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 91c769f90a..2612efedb9 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -30,7 +30,7 @@ def has_access(user, obj, action): Things this module understands: - start dates for modules - DISABLE_START_DATES - - different access for staff, course staff, and students. + - different access for instructor, staff, course staff, and students. user: a Django user object. May be anonymous. @@ -70,6 +70,20 @@ def has_access(user, obj, action): raise TypeError("Unknown object type in has_access(): '{0}'" .format(type(obj))) +def get_access_group_name(obj,action): + ''' + Returns group name for user group which has "action" access to the given object. + + Used in managing access lists. + ''' + + if isinstance(obj, CourseDescriptor): + return _get_access_group_name_course_desc(obj, action) + + # Passing an unknown object here is a coding error, so rather than + # returning a default, complain. + raise TypeError("Unknown object type in get_access_group_name(): '{0}'" + .format(type(obj))) # ================ Implementation helpers ================================ @@ -138,11 +152,19 @@ def _has_access_course_desc(user, course, action): 'load': can_load, 'enroll': can_enroll, 'see_exists': see_exists, - 'staff': lambda: _has_staff_access_to_descriptor(user, course) + 'staff': lambda: _has_staff_access_to_descriptor(user, course), + 'instructor': lambda: _has_instructor_access_to_descriptor(user, course), } return _dispatch(checkers, action, user, course) +def _get_access_group_name_course_desc(course, action): + ''' + Return name of group which gives staff access to course. Only understands action = 'staff' + ''' + if not action=='staff': + return [] + return _course_staff_group_name(course.location) def _has_access_error_desc(user, descriptor, action): """ @@ -292,6 +314,17 @@ def _course_staff_group_name(location): """ return 'staff_%s' % Location(location).course + +def _course_instructor_group_name(location): + """ + Get the name of the instructor group for a location. Right now, that's instructor_COURSE. + A course instructor has all staff privileges, but also can manage list of course staff (add, remove, list). + + location: something that can passed to Location. + """ + return 'instructor_%s' % Location(location).course + + def _has_global_staff_access(user): if user.is_staff: debug("Allow: user.is_staff") @@ -301,17 +334,28 @@ def _has_global_staff_access(user): return False -def _has_staff_access_to_location(user, location): - ''' - Returns True if the given user has staff access to a location. For now this - is equivalent to having staff access to the course location.course. +def _has_instructor_access_to_location(user, location): + return _has_access_to_location(user, location, 'instructor') - This means that user is in the staff_* group, or is an overall admin. + +def _has_staff_access_to_location(user, location): + return _has_access_to_location(user, location, 'staff') + + +def _has_access_to_location(user, location, access_level): + ''' + Returns True if the given user has access_level (= staff or + instructor) access to a location. For now this is equivalent to + having staff / instructor access to the course location.course. + + This means that user is in the staff_* group or instructor_* group, or is an overall admin. TODO (vshnayder): this needs to be changed to allow per-course_id permissions, not per-course (e.g. staff in 2012 is different from 2013, but maybe some people always have access) course is a string: the course field of the location being accessed. + location = location + access_level = string, either "staff" or "instructor" ''' if user is None or (not user.is_authenticated()): debug("Deny: no user or anon user") @@ -322,24 +366,46 @@ def _has_staff_access_to_location(user, location): # If not global staff, is the user in the Auth group for this class? user_groups = [g.name for g in user.groups.all()] - staff_group = _course_staff_group_name(location) - if staff_group in user_groups: - debug("Allow: user in group %s", staff_group) - return True - debug("Deny: user not in group %s", staff_group) + + if access_level == 'staff': + staff_group = _course_staff_group_name(location) + if staff_group in user_groups: + debug("Allow: user in group %s", staff_group) + return True + debug("Deny: user not in group %s", staff_group) + + if access_level == 'instructor' or access_level == 'staff': # instructors get staff privileges + instructor_group = _course_instructor_group_name(location) + if instructor_group in user_groups: + debug("Allow: user in group %s", instructor_group) + return True + debug("Deny: user not in group %s", instructor_group) + + else: + log.debug("Error in access._has_access_to_location access_level=%s unknown" % access_level) + return False + def _has_staff_access_to_course_id(user, course_id): """Helper method that takes a course_id instead of a course name""" loc = CourseDescriptor.id_to_location(course_id) return _has_staff_access_to_location(user, loc) +def _has_instructor_access_to_descriptor(user, descriptor): + """Helper method that checks whether the user has staff access to + the course of the location. + + descriptor: something that has a location attribute + """ + return _has_instructor_access_to_location(user, descriptor.location) + def _has_staff_access_to_descriptor(user, descriptor): """Helper method that checks whether the user has staff access to the course of the location. - location: something that can be passed to Location + descriptor: something that has a location attribute """ return _has_staff_access_to_location(user, descriptor.location) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 7f28f3ca5c..f32da532df 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -24,7 +24,7 @@ def yield_module_descendents(module): stack.extend( next_module.get_display_items() ) yield next_module -def grade(student, request, course, student_module_cache=None): +def grade(student, request, course, student_module_cache=None, keep_raw_scores=False): """ This grades a student as quickly as possible. It retuns the output from the course grader, augmented with the final letter @@ -38,11 +38,13 @@ def grade(student, request, course, student_module_cache=None): up the grade. (For display) - grade_breakdown : A breakdown of the major components that make up the final grade. (For display) + - keep_raw_scores : if True, then value for key 'raw_scores' contains scores for every graded module More information on the format is in the docstring for CourseGrader. """ grading_context = course.grading_context + raw_scores = [] if student_module_cache == None: student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors']) @@ -83,7 +85,7 @@ def grade(student, request, course, student_module_cache=None): if correct is None and total is None: continue - if settings.GENERATE_PROFILE_SCORES: + if settings.GENERATE_PROFILE_SCORES: # for debugging! if total > 1: correct = random.randrange(max(total - 2, 1), total + 1) else: @@ -97,6 +99,8 @@ def grade(student, request, course, student_module_cache=None): scores.append(Score(correct, total, graded, module.metadata.get('display_name'))) section_total, graded_total = graders.aggregate_scores(scores, section_name) + if keep_raw_scores: + raw_scores += scores else: section_total = Score(0.0, 1.0, False, section_name) graded_total = Score(0.0, 1.0, True, section_name) @@ -117,7 +121,10 @@ def grade(student, request, course, student_module_cache=None): letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent']) grade_summary['grade'] = letter_grade - + grade_summary['totaled_scores'] = totaled_scores # make this available, eg for instructor download & debugging + if keep_raw_scores: + grade_summary['raw_scores'] = raw_scores # way to get all RAW scores out to instructor + # so grader can be double-checked return grade_summary def grade_for_percentage(grade_cutoffs, percentage): diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 71ec687cf6..aa3444b193 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -361,96 +361,3 @@ def progress(request, course_id, student_id=None): -# ======== Instructor views ============================================================================= - -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -def gradebook(request, course_id): - """ - Show the gradebook for this course: - - only displayed to course staff - - shows students who are enrolled. - """ - course = get_course_with_access(request.user, course_id, 'staff') - - enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username') - - # TODO (vshnayder): implement pagination. - enrolled_students = enrolled_students[:1000] # HACK! - - student_info = [{'username': student.username, - 'id': student.id, - 'email': student.email, - 'grade_summary': grades.grade(student, request, course), - 'realname': UserProfile.objects.get(user=student).name - } - for student in enrolled_students] - - return render_to_response('courseware/gradebook.html', {'students': student_info, - 'course': course, - 'course_id': course_id, - # Checked above - 'staff_access': True,}) - - -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -def grade_summary(request, course_id): - """Display the grade summary for a course.""" - course = get_course_with_access(request.user, course_id, 'staff') - - # For now, just a static page - context = {'course': course, - 'staff_access': True,} - return render_to_response('courseware/grade_summary.html', context) - - -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -def instructor_dashboard(request, course_id): - """Display the instructor dashboard for a course.""" - course = get_course_with_access(request.user, course_id, 'staff') - - # For now, just a static page - context = {'course': course, - 'staff_access': True,} - - return render_to_response('courseware/instructor_dashboard.html', context) - -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -def enroll_students(request, course_id): - ''' Allows a staff member to enroll students in a course. - - This is a short-term hack for Berkeley courses launching fall - 2012. In the long term, we would like functionality like this, but - we would like both the instructor and the student to agree. Right - now, this allows any instructor to add students to their course, - which we do not want. - - It is poorly written and poorly tested, but it's designed to be - stripped out. - ''' - - course = get_course_with_access(request.user, course_id, 'staff') - existing_students = [ce.user.email for ce in CourseEnrollment.objects.filter(course_id = course_id)] - - if 'new_students' in request.POST: - new_students = request.POST['new_students'].split('\n') - else: - new_students = [] - new_students = [s.strip() for s in new_students] - - added_students = [] - rejected_students = [] - - for student in new_students: - try: - nce = CourseEnrollment(user=User.objects.get(email = student), course_id = course_id) - nce.save() - added_students.append(student) - except: - rejected_students.append(student) - - return render_to_response("enroll_students.html", {'course':course_id, - 'existing_students': existing_students, - 'added_students': added_students, - 'rejected_students': rejected_students, - 'debug':new_students}) diff --git a/lms/djangoapps/instructor/__init__.py b/lms/djangoapps/instructor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/instructor/tests.py b/lms/djangoapps/instructor/tests.py new file mode 100644 index 0000000000..e948771d6d --- /dev/null +++ b/lms/djangoapps/instructor/tests.py @@ -0,0 +1,84 @@ +""" +Unit tests for instructor dashboard + +Based on (and depends on) unit tests for courseware. + +Notes for running by hand: + +django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/instructor +""" + +import courseware.tests.tests as ct + +from nose import SkipTest +from mock import patch, Mock +from override_settings import override_settings + +# Need access to internal func to put users in the right group +from courseware.access import _course_staff_group_name +from django.contrib.auth.models import User, Group +from django.conf import settings +from django.core.urlresolvers import reverse + +import xmodule.modulestore.django + +from xmodule.modulestore.django import modulestore + + +@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE) +class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader): + ''' + Check for download of csv + ''' + + def setUp(self): + xmodule.modulestore.django._MODULESTORES = {} + courses = modulestore().get_courses() + + def find_course(name): + """Assumes the course is present""" + return [c for c in courses if c.location.course==name][0] + + self.full = find_course("full") + self.toy = find_course("toy") + + # Create two accounts + self.student = 'view@test.com' + self.instructor = 'view2@test.com' + self.password = 'foo' + self.create_account('u1', self.student, self.password) + self.create_account('u2', self.instructor, self.password) + self.activate_user(self.student) + self.activate_user(self.instructor) + + group_name = _course_staff_group_name(self.toy.location) + g = Group.objects.create(name=group_name) + g.user_set.add(ct.user(self.instructor)) + + self.logout() + self.login(self.instructor, self.password) + self.enroll(self.toy) + + + def test_download_grades_csv(self): + print "running test_download_grades_csv" + course = self.toy + url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) + msg = "url = %s\n" % url + response = self.client.post(url, {'action': 'Download CSV of all student grades for this course', + }) + msg += "instructor dashboard download csv grades: response = '%s'\n" % response + + self.assertEqual(response['Content-Type'],'text/csv',msg) + + cdisp = response['Content-Disposition'].replace('TT_2012','2012') # jenkins course_id is TT_2012_Fall instead of 2012_Fall? + msg += "cdisp = '%s'\n" % cdisp + self.assertEqual(cdisp,'attachment; filename=grades_edX/toy/2012_Fall.csv',msg) + + body = response.content.replace('\r','') + msg += "body = '%s'\n" % body + + expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm","Final" +"2","u2","Fred Weasley","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0.0","0.0" +''' + self.assertEqual(body, expected_body, msg) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py new file mode 100644 index 0000000000..92b2401216 --- /dev/null +++ b/lms/djangoapps/instructor/views.py @@ -0,0 +1,355 @@ +# ======== Instructor views ============================================================================= + +import csv +import itertools +import json +import logging +import os +import urllib + +import track.views + +from functools import partial +from collections import defaultdict + +from django.conf import settings +from django.core.context_processors import csrf +from django.core.urlresolvers import reverse +from django.contrib.auth.models import User, Group +from django.contrib.auth.decorators import login_required +from django.http import Http404, HttpResponse +from django.shortcuts import redirect +from mitxmako.shortcuts import render_to_response, render_to_string +#from django.views.decorators.csrf import ensure_csrf_cookie +from django_future.csrf import ensure_csrf_cookie +from django.views.decorators.cache import cache_control + +from courseware import grades +from courseware.access import has_access, get_access_group_name +from courseware.courses import (get_course_with_access, get_courses_by_university) +from student.models import UserProfile + +from student.models import UserTestGroup, CourseEnrollment +from util.cache import cache, cache_if_anonymous +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem +from xmodule.modulestore.search import path_to_location + +log = logging.getLogger("mitx.courseware") + +template_imports = {'urllib': urllib} + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def instructor_dashboard(request, course_id): + """Display the instructor dashboard for a course.""" + course = get_course_with_access(request.user, course_id, 'staff') + + instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists + + msg = '' + # msg += ('POST=%s' % dict(request.POST)).replace('<','<') + + def escape(s): + """escape HTML special characters in string""" + return str(s).replace('<', '<').replace('>', '>') + + # assemble some course statistics for output to instructor + datatable = {'header': ['Statistic', 'Value'], + 'title': 'Course Statistics At A Glance', + } + data = [['# Enrolled', CourseEnrollment.objects.filter(course_id=course_id).count()]] + data += compute_course_stats(course).items() + if request.user.is_staff: + data.append(['metadata', escape(str(course.metadata))]) + datatable['data'] = data + + def return_csv(fn, datatable): + response = HttpResponse(mimetype='text/csv') + response['Content-Disposition'] = 'attachment; filename=%s' % fn + writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL) + writer.writerow(datatable['header']) + for datarow in datatable['data']: + writer.writerow(datarow) + return response + + def get_staff_group(course): + staffgrp = get_access_group_name(course, 'staff') + try: + group = Group.objects.get(name=staffgrp) + except Group.DoesNotExist: + group = Group(name=staffgrp) # create the group + group.save() + return group + + # process actions from form POST + action = request.POST.get('action', '') + + if 'GIT pull' in action: + data_dir = course.metadata['data_dir'] + log.debug('git pull %s' % (data_dir)) + gdir = settings.DATA_DIR / data_dir + if not os.path.exists(gdir): + msg += "====> ERROR in gitreload - no such directory %s" % gdir + else: + cmd = "cd %s; git reset --hard HEAD; git clean -f -d; git pull origin; chmod g+w course.xml" % gdir + msg += "git pull on %s:

" % data_dir + msg += "

%s

" % escape(os.popen(cmd).read()) + track.views.server_track(request, 'git pull %s' % data_dir, {}, page='idashboard') + + if 'Reload course' in action: + log.debug('reloading %s (%s)' % (course_id, course)) + try: + data_dir = course.metadata['data_dir'] + modulestore().try_load_course(data_dir) + msg += "

Course reloaded from %s

" % data_dir + track.views.server_track(request, 'reload %s' % data_dir, {}, page='idashboard') + course_errors = modulestore().get_item_errors(course.location) + msg += '' + except Exception as err: + msg += '

Error: %s

' % escape(err) + + elif action == 'Dump list of enrolled students': + log.debug(action) + datatable = get_student_grade_summary_data(request, course, course_id, get_grades=False) + datatable['title'] = 'List of students enrolled in %s' % course_id + track.views.server_track(request, 'list-students', {}, page='idashboard') + + elif 'Dump Grades' in action: + log.debug(action) + datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True) + datatable['title'] = 'Summary Grades of students enrolled in %s' % course_id + track.views.server_track(request, 'dump-grades', {}, page='idashboard') + + elif 'Dump all RAW grades' in action: + log.debug(action) + datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True, + get_raw_scores=True) + datatable['title'] = 'Raw Grades of students enrolled in %s' % course_id + track.views.server_track(request, 'dump-grades-raw', {}, page='idashboard') + + elif 'Download CSV of all student grades' in action: + track.views.server_track(request, 'dump-grades-csv', {}, page='idashboard') + return return_csv('grades_%s.csv' % course_id, + get_student_grade_summary_data(request, course, course_id)) + + elif 'Download CSV of all RAW grades' in action: + track.views.server_track(request, 'dump-grades-csv-raw', {}, page='idashboard') + return return_csv('grades_%s_raw.csv' % course_id, + get_student_grade_summary_data(request, course, course_id, get_raw_scores=True)) + + elif 'List course staff' in action: + group = get_staff_group(course) + msg += 'Staff group = %s' % group.name + log.debug('staffgrp=%s' % group.name) + uset = group.user_set.all() + datatable = {'header': ['Username', 'Full name']} + datatable['data'] = [[x.username, x.profile.name] for x in uset] + datatable['title'] = 'List of Staff in course %s' % course_id + track.views.server_track(request, 'list-staff', {}, page='idashboard') + + elif action == 'Add course staff': + uname = request.POST['staffuser'] + try: + user = User.objects.get(username=uname) + except User.DoesNotExist: + msg += 'Error: unknown username "%s"' % uname + user = None + if user is not None: + group = get_staff_group(course) + msg += 'Added %s to staff group = %s' % (user, group.name) + log.debug('staffgrp=%s' % group.name) + user.groups.add(group) + track.views.server_track(request, 'add-staff %s' % user, {}, page='idashboard') + + elif action == 'Remove course staff': + uname = request.POST['staffuser'] + try: + user = User.objects.get(username=uname) + except User.DoesNotExist: + msg += 'Error: unknown username "%s"' % uname + user = None + if user is not None: + group = get_staff_group(course) + msg += 'Removed %s from staff group = %s' % (user, group.name) + log.debug('staffgrp=%s' % group.name) + user.groups.remove(group) + track.views.server_track(request, 'remove-staff %s' % user, {}, page='idashboard') + + # For now, mostly a static page + context = {'course': course, + 'staff_access': True, + 'admin_access': request.user.is_staff, + 'instructor_access': instructor_access, + 'datatable': datatable, + 'msg': msg, + } + + return render_to_response('courseware/instructor_dashboard.html', context) + + +def get_student_grade_summary_data(request, course, course_id, get_grades=True, get_raw_scores=False): + ''' + Return data arrays with student identity and grades for specified course. + + course = CourseDescriptor + course_id = course ID + + Note: both are passed in, only because instructor_dashboard already has them already. + + returns datatable = dict(header=header, data=data) + where + + header = list of strings labeling the data fields + data = list (one per student) of lists of data corresponding to the fields + + If get_raw_scores=True, then instead of grade summaries, the raw grades for all graded modules are returned. + + ''' + enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username') + + header = ['ID', 'Username', 'Full Name', 'edX email', 'External email'] + if get_grades: + # just to construct the header + gradeset = grades.grade(enrolled_students[0], request, course, keep_raw_scores=get_raw_scores) + # log.debug('student %s gradeset %s' % (enrolled_students[0], gradeset)) + if get_raw_scores: + header += [score.section for score in gradeset['raw_scores']] + else: + header += [x['label'] for x in gradeset['section_breakdown']] + + datatable = {'header': header} + data = [] + + for student in enrolled_students: + datarow = [ student.id, student.username, student.profile.name, student.email ] + try: + datarow.append(student.externalauthmap.external_email) + except: # ExternalAuthMap.DoesNotExist + datarow.append('') + + if get_grades: + gradeset = grades.grade(student, request, course, keep_raw_scores=get_raw_scores) + # log.debug('student=%s, gradeset=%s' % (student,gradeset)) + if get_raw_scores: + datarow += [score.earned for score in gradeset['raw_scores']] + else: + datarow += [x['percent'] for x in gradeset['section_breakdown']] + + data.append(datarow) + datatable['data'] = data + return datatable + + +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def gradebook(request, course_id): + """ + Show the gradebook for this course: + - only displayed to course staff + - shows students who are enrolled. + """ + course = get_course_with_access(request.user, course_id, 'staff') + + enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username') + + # TODO (vshnayder): implement pagination. + enrolled_students = enrolled_students[:1000] # HACK! + + student_info = [{'username': student.username, + 'id': student.id, + 'email': student.email, + 'grade_summary': grades.grade(student, request, course), + 'realname': student.profile.name, + } + for student in enrolled_students] + + return render_to_response('courseware/gradebook.html', {'students': student_info, + 'course': course, + 'course_id': course_id, + # Checked above + 'staff_access': True, }) + + +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def grade_summary(request, course_id): + """Display the grade summary for a course.""" + course = get_course_with_access(request.user, course_id, 'staff') + + # For now, just a static page + context = {'course': course, + 'staff_access': True, } + return render_to_response('courseware/grade_summary.html', context) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def enroll_students(request, course_id): + ''' Allows a staff member to enroll students in a course. + + This is a short-term hack for Berkeley courses launching fall + 2012. In the long term, we would like functionality like this, but + we would like both the instructor and the student to agree. Right + now, this allows any instructor to add students to their course, + which we do not want. + + It is poorly written and poorly tested, but it's designed to be + stripped out. + ''' + + course = get_course_with_access(request.user, course_id, 'staff') + existing_students = [ce.user.email for ce in CourseEnrollment.objects.filter(course_id=course_id)] + + if 'new_students' in request.POST: + new_students = request.POST['new_students'].split('\n') + else: + new_students = [] + new_students = [s.strip() for s in new_students] + + added_students = [] + rejected_students = [] + + for student in new_students: + try: + nce = CourseEnrollment(user=User.objects.get(email=student), course_id=course_id) + nce.save() + added_students.append(student) + except: + rejected_students.append(student) + + return render_to_response("enroll_students.html", {'course': course_id, + 'existing_students': existing_students, + 'added_students': added_students, + 'rejected_students': rejected_students, + 'debug': new_students}) + +#----------------------------------------------------------------------------- + + +def compute_course_stats(course): + ''' + Compute course statistics, including number of problems, videos, html. + + course is a CourseDescriptor from the xmodule system. + ''' + + # walk the course by using get_children() until we come to the leaves; count the + # number of different leaf types + + counts = defaultdict(int) + + def walk(module): + children = module.get_children() + category = module.__class__.__name__ # HtmlDescriptor, CapaDescriptor, ... + counts[category] += 1 + for c in children: + walk(c) + + walk(course) + stats = dict(counts) # number of each kind of module + return stats diff --git a/lms/djangoapps/lms_migration/management/commands/create_user.py b/lms/djangoapps/lms_migration/management/commands/create_user.py index 333608d467..7d39accc44 100644 --- a/lms/djangoapps/lms_migration/management/commands/create_user.py +++ b/lms/djangoapps/lms_migration/management/commands/create_user.py @@ -67,7 +67,10 @@ class Command(BaseCommand): password = GenPasswd(12) # get name from kerberos - kname = os.popen("finger %s | grep 'name:'" % email).read().strip().split('name: ')[1].strip() + try: + kname = os.popen("finger %s | grep 'name:'" % email).read().strip().split('name: ')[1].strip() + except: + kname = '' name = raw_input('Full name: [%s] ' % kname).strip() if name=='': name = kname diff --git a/lms/envs/common.py b/lms/envs/common.py index 5c8a8bf3d6..089c4618e8 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -64,6 +64,9 @@ MITX_FEATURES = { # university to use for branding purposes 'SUBDOMAIN_BRANDING': False, + 'FORCE_UNIVERSITY_DOMAIN': False, # set this to the university domain to use, as an override to HTTP_HOST + # set to None to do no university selection + 'ENABLE_TEXTBOOK' : True, 'ENABLE_DISCUSSION' : False, 'ENABLE_DISCUSSION_SERVICE': True, @@ -604,6 +607,7 @@ INSTALLED_APPS = ( 'track', 'util', 'certificates', + 'instructor', #For the wiki 'wiki', # The new django-wiki from benjaoming diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 974b8c9fd6..0427938b70 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -17,6 +17,7 @@ MITX_FEATURES['DISABLE_START_DATES'] = True MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up MITX_FEATURES['SUBDOMAIN_BRANDING'] = True +MITX_FEATURES['FORCE_UNIVERSITY_DOMAIN'] = None # show all university courses if in dev (ie don't use HTTP_HOST) WIKI_ENABLED = True diff --git a/lms/envs/dev_ike.py b/lms/envs/dev_ike.py index 297b179fae..0be9146fd4 100644 --- a/lms/envs/dev_ike.py +++ b/lms/envs/dev_ike.py @@ -18,6 +18,7 @@ MITX_FEATURES['ENABLE_DISCUSSION'] = False MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = True # require that user be in the staff_* group to be able to enroll MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False MITX_FEATURES['SUBDOMAIN_BRANDING'] = False +MITX_FEATURES['FORCE_UNIVERSITY_DOMAIN'] = None # show all university courses if in dev (ie don't use HTTP_HOST) MITX_FEATURES['DISABLE_START_DATES'] = True # MITX_FEATURES['USE_DJANGO_PIPELINE']=False # don't recompile scss @@ -28,6 +29,9 @@ if ('edxvm' in myhost) or ('ocw' in myhost): MITX_FEATURES['USE_XQA_SERVER'] = 'https://qisx.mit.edu/xqa' # needs to be ssl or browser blocks it MITX_FEATURES['USE_DJANGO_PIPELINE']=False # don't recompile scss +if ('ocw' in myhost): + MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = False + if ('domU' in myhost): EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' MITX_FEATURES['REROUTE_ACTIVATION_EMAIL'] = 'ichuang@mitx.mit.edu' # nonempty string = address for all activation emails diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 9508624f9b..29397e5c41 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -8,17 +8,99 @@ <%include file="/courseware/course_navigation.html" args="active_page='instructor'" /> + +

Instructor Dashboard

+
+ +

Gradebook

Grade summary +

+ + +

+ + + +

+ + + +%if instructor_access: +


+

+ +

+ + +


+ %endif + +%if admin_access: +

+ + +%endif + +

+ +
+
+

+


+

${datatable['title']}

+ + + %for hname in datatable['header']: + + %endfor + + %for row in datatable['data']: + + %for value in row: + + %endfor + + %endfor +
${hname}
${value}
+

+ +%if msg: +

${msg}

+%endif +
diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 47194aa6fd..6d9d1a3a30 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -32,6 +32,7 @@ ${module_content}

Staff Debug

+is_released = ${is_released} location = ${location | h} github = ${edit_link | h} %if source_file: diff --git a/lms/urls.py b/lms/urls.py index 278239751b..26aa10a3f4 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -153,14 +153,14 @@ if settings.COURSEWARE_ENABLED: # For the instructor url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor$', - 'courseware.views.instructor_dashboard', name="instructor_dashboard"), + 'instructor.views.instructor_dashboard', name="instructor_dashboard"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/gradebook$', - 'courseware.views.gradebook', name='gradebook'), + 'instructor.views.gradebook', name='gradebook'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/grade_summary$', - 'courseware.views.grade_summary', name='grade_summary'), + 'instructor.views.grade_summary', name='grade_summary'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/enroll_students$', - 'courseware.views.enroll_students', name='enroll_students'), + 'instructor.views.enroll_students', name='enroll_students'), ) # discussion forums live within courseware, so courseware must be enabled first