Commit 32a98e4f by Calen Pennington

Merge pull request #383 from MITx/feature/victor/course-grade-detail

Feature/victor/course grade detail
parents 63e545cb 1879e6ab
##
## A script to create some dummy users
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User
from student.models import UserProfile, CourseEnrollment
from student.views import _do_create_account, get_random_post_override
def create(n, course_id):
"""Create n users, enrolling them in course_id if it's not None"""
for i in range(n):
(user, user_profile, _) = _do_create_account(get_random_post_override())
if course_id is not None:
CourseEnrollment.objects.create(user=user, course_id=course_id)
class Command(BaseCommand):
help = """Create N new users, with random parameters.
Usage: create_random_users.py N [course_id_to_enroll_in].
Examples:
create_random_users.py 1
create_random_users.py 10 MITx/6.002x/2012_Fall
create_random_users.py 100 HarvardX/CS50x/2012
"""
def handle(self, *args, **options):
if len(args) < 1 or len(args) > 2:
print Command.help
return
n = int(args[0])
course_id = args[1] if len(args) == 2 else None
create(n, course_id)
...@@ -278,6 +278,58 @@ def change_setting(request): ...@@ -278,6 +278,58 @@ def change_setting(request):
return HttpResponse(json.dumps({'success': True, return HttpResponse(json.dumps({'success': True,
'location': up.location, })) 'location': up.location, }))
def _do_create_account(post_vars):
"""
Given cleaned post variables, create the User and UserProfile objects, as well as the
registration for this user.
Returns a tuple (User, UserProfile, Registration).
Note: this function is also used for creating test users.
"""
user = User(username=post_vars['username'],
email=post_vars['email'],
is_active=False)
user.set_password(post_vars['password'])
registration = Registration()
# TODO: Rearrange so that if part of the process fails, the whole process fails.
# Right now, we can have e.g. no registration e-mail sent out and a zombie account
try:
user.save()
except IntegrityError:
# Figure out the cause of the integrity error
if len(User.objects.filter(username=post_vars['username'])) > 0:
js['value'] = "An account with this username already exists."
js['field'] = 'username'
return HttpResponse(json.dumps(js))
if len(User.objects.filter(email=post_vars['email'])) > 0:
js['value'] = "An account with this e-mail already exists."
js['field'] = 'email'
return HttpResponse(json.dumps(js))
raise
registration.register(user)
profile = UserProfile(user=user)
profile.name = post_vars['name']
profile.level_of_education = post_vars.get('level_of_education')
profile.gender = post_vars.get('gender')
profile.mailing_address = post_vars.get('mailing_address')
profile.goals = post_vars.get('goals')
try:
profile.year_of_birth = int(post_vars['year_of_birth'])
except (ValueError, KeyError):
profile.year_of_birth = None # If they give us garbage, just ignore it instead
# of asking them to put an integer.
try:
profile.save()
except Exception:
log.exception("UserProfile creation failed for user {0}.".format(user.id))
return (user, profile, registration)
@ensure_csrf_cookie @ensure_csrf_cookie
def create_account(request, post_override=None): def create_account(request, post_override=None):
...@@ -349,50 +401,11 @@ def create_account(request, post_override=None): ...@@ -349,50 +401,11 @@ def create_account(request, post_override=None):
js['field'] = 'username' js['field'] = 'username'
return HttpResponse(json.dumps(js)) return HttpResponse(json.dumps(js))
u = User(username=post_vars['username'], # Ok, looks like everything is legit. Create the account.
email=post_vars['email'], (user, profile, registration) = _do_create_account(post_vars)
is_active=False)
u.set_password(post_vars['password'])
r = Registration()
# TODO: Rearrange so that if part of the process fails, the whole process fails.
# Right now, we can have e.g. no registration e-mail sent out and a zombie account
try:
u.save()
except IntegrityError:
# Figure out the cause of the integrity error
if len(User.objects.filter(username=post_vars['username'])) > 0:
js['value'] = "An account with this username already exists."
js['field'] = 'username'
return HttpResponse(json.dumps(js))
if len(User.objects.filter(email=post_vars['email'])) > 0:
js['value'] = "An account with this e-mail already exists."
js['field'] = 'email'
return HttpResponse(json.dumps(js))
raise
r.register(u)
up = UserProfile(user=u)
up.name = post_vars['name']
up.level_of_education = post_vars.get('level_of_education')
up.gender = post_vars.get('gender')
up.mailing_address = post_vars.get('mailing_address')
up.goals = post_vars.get('goals')
try:
up.year_of_birth = int(post_vars['year_of_birth'])
except (ValueError, KeyError):
up.year_of_birth = None # If they give us garbage, just ignore it instead
# of asking them to put an integer.
try:
up.save()
except Exception:
log.exception("UserProfile creation failed for user {0}.".format(u.id))
d = {'name': post_vars['name'], d = {'name': post_vars['name'],
'key': r.activation_key, 'key': registration.activation_key,
} }
# composes activation email # composes activation email
...@@ -404,10 +417,11 @@ def create_account(request, post_override=None): ...@@ -404,10 +417,11 @@ def create_account(request, post_override=None):
try: try:
if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'): if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
dest_addr = settings.MITX_FEATURES['REROUTE_ACTIVATION_EMAIL'] dest_addr = settings.MITX_FEATURES['REROUTE_ACTIVATION_EMAIL']
message = "Activation for %s (%s): %s\n" % (u, u.email, up.name) + '-' * 80 + '\n\n' + message message = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) +
'-' * 80 + '\n\n' + message)
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [dest_addr], fail_silently=False) send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [dest_addr], fail_silently=False)
elif not settings.GENERATE_RANDOM_USER_CREDENTIALS: elif not settings.GENERATE_RANDOM_USER_CREDENTIALS:
res = u.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
except: except:
log.exception(sys.exc_info()) log.exception(sys.exc_info())
js['value'] = 'Could not send activation e-mail.' js['value'] = 'Could not send activation e-mail.'
...@@ -437,24 +451,30 @@ def create_account(request, post_override=None): ...@@ -437,24 +451,30 @@ def create_account(request, post_override=None):
return HttpResponse(json.dumps(js), mimetype="application/json") return HttpResponse(json.dumps(js), mimetype="application/json")
def create_random_account(create_account_function): def get_random_post_override():
"""
Return a dictionary suitable for passing to post_vars of _do_create_account or post_override
of create_account, with random user info.
"""
def id_generator(size=6, chars=string.ascii_uppercase + string.ascii_lowercase + string.digits): def id_generator(size=6, chars=string.ascii_uppercase + string.ascii_lowercase + string.digits):
return ''.join(random.choice(chars) for x in range(size)) return ''.join(random.choice(chars) for x in range(size))
def inner_create_random_account(request): return {'username': "random_" + id_generator(),
post_override = {'username': "random_" + id_generator(), 'email': id_generator(size=10, chars=string.ascii_lowercase) + "_dummy_test@mitx.mit.edu",
'email': id_generator(size=10, chars=string.ascii_lowercase) + "_dummy_test@mitx.mit.edu", 'password': id_generator(),
'password': id_generator(), 'name': (id_generator(size=5, chars=string.ascii_lowercase) + " " +
'location': id_generator(size=5, chars=string.ascii_uppercase), id_generator(size=7, chars=string.ascii_lowercase)),
'name': id_generator(size=5, chars=string.ascii_lowercase) + " " + id_generator(size=7, chars=string.ascii_lowercase), 'honor_code': u'true',
'honor_code': u'true', 'terms_of_service': u'true', }
'terms_of_service': u'true', }
return create_account_function(request, post_override=post_override) def create_random_account(create_account_function):
def inner_create_random_account(request):
return create_account_function(request, post_override=get_random_post_override())
return inner_create_random_account return inner_create_random_account
# TODO (vshnayder): do we need GENERATE_RANDOM_USER_CREDENTIALS for anything?
if settings.GENERATE_RANDOM_USER_CREDENTIALS: if settings.GENERATE_RANDOM_USER_CREDENTIALS:
create_account = create_random_account(create_account) create_account = create_random_account(create_account)
...@@ -520,7 +540,7 @@ def reactivation_email(request): ...@@ -520,7 +540,7 @@ def reactivation_email(request):
subject = ''.join(subject.splitlines()) subject = ''.join(subject.splitlines())
message = render_to_string('reactivation_email.txt', d) message = render_to_string('reactivation_email.txt', d)
res = u.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
return HttpResponse(json.dumps({'success': True})) return HttpResponse(json.dumps({'success': True}))
......
...@@ -11,18 +11,19 @@ DEFAULT = "_DEFAULT_GROUP" ...@@ -11,18 +11,19 @@ DEFAULT = "_DEFAULT_GROUP"
def group_from_value(groups, v): def group_from_value(groups, v):
''' Given group: (('a',0.3),('b',0.4),('c',0.3)) And random value """
Given group: (('a', 0.3), ('b', 0.4), ('c', 0.3)) and random value v
in [0,1], return the associated group (in the above case, return in [0,1], return the associated group (in the above case, return
'a' if v<0.3, 'b' if 0.3<=v<0.7, and 'c' if v>0.7 'a' if v < 0.3, 'b' if 0.3 <= v < 0.7, and 'c' if v > 0.7
''' """
sum = 0 sum = 0
for (g, p) in groups: for (g, p) in groups:
sum = sum + p sum = sum + p
if sum > v: if sum > v:
return g return g
# Round off errors might cause us to run to the end of the list # Round off errors might cause us to run to the end of the list.
# If the do, return the last element # If the do, return the last element.
return g return g
......
...@@ -145,6 +145,8 @@ def has_staff_access_to_course(user, course): ...@@ -145,6 +145,8 @@ def has_staff_access_to_course(user, course):
''' '''
Returns True if the given user has staff access to the course. Returns True if the given user has staff access to the course.
This means that user is in the staff_* group, or is an overall admin. This means that user is in the staff_* 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 the course field of the location being accessed. course is the course field of the location being accessed.
''' '''
...@@ -156,13 +158,18 @@ def has_staff_access_to_course(user, course): ...@@ -156,13 +158,18 @@ def has_staff_access_to_course(user, course):
# note this is the Auth group, not UserTestGroup # note this is the Auth group, not UserTestGroup
user_groups = [x[1] for x in user.groups.values_list()] user_groups = [x[1] for x in user.groups.values_list()]
staff_group = course_staff_group_name(course) staff_group = course_staff_group_name(course)
log.debug('course %s, staff_group %s, user %s, groups %s' % (
course, staff_group, user, user_groups))
if staff_group in user_groups: if staff_group in user_groups:
return True return True
return False return False
def has_access_to_course(user,course): 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_course(user, loc.course)
def has_access_to_course(user, course):
'''course is the .course element of a location'''
if course.metadata.get('ispublic'): if course.metadata.get('ispublic'):
return True return True
return has_staff_access_to_course(user,course) return has_staff_access_to_course(user,course)
......
# Compute grades using real division, with no integer truncation
from __future__ import division
import random import random
import logging import logging
...@@ -13,33 +16,33 @@ log = logging.getLogger("mitx.courseware") ...@@ -13,33 +16,33 @@ log = logging.getLogger("mitx.courseware")
def yield_module_descendents(module): def yield_module_descendents(module):
stack = module.get_display_items() stack = module.get_display_items()
while len(stack) > 0: while len(stack) > 0:
next_module = stack.pop() next_module = stack.pop()
stack.extend( next_module.get_display_items() ) stack.extend( next_module.get_display_items() )
yield next_module yield next_module
def grade(student, request, course, student_module_cache=None): def grade(student, request, course, student_module_cache=None):
""" """
This grades a student as quickly as possible. It retuns the This grades a student as quickly as possible. It retuns the
output from the course grader, augmented with the final letter output from the course grader, augmented with the final letter
grade. The keys in the output are: grade. The keys in the output are:
- grade : A final letter grade. - grade : A final letter grade.
- percent : The final percent for the class (rounded up). - percent : The final percent for the class (rounded up).
- section_breakdown : A breakdown of each section that makes - section_breakdown : A breakdown of each section that makes
up the grade. (For display) up the grade. (For display)
- grade_breakdown : A breakdown of the major components that - grade_breakdown : A breakdown of the major components that
make up the final grade. (For display) make up the final grade. (For display)
More information on the format is in the docstring for CourseGrader. More information on the format is in the docstring for CourseGrader.
""" """
grading_context = course.grading_context grading_context = course.grading_context
if student_module_cache == None: if student_module_cache == None:
student_module_cache = StudentModuleCache(student, grading_context['all_descriptors']) student_module_cache = StudentModuleCache(student, grading_context['all_descriptors'])
totaled_scores = {} totaled_scores = {}
# This next complicated loop is just to collect the totaled_scores, which is # This next complicated loop is just to collect the totaled_scores, which is
# passed to the grader # passed to the grader
...@@ -48,91 +51,91 @@ def grade(student, request, course, student_module_cache=None): ...@@ -48,91 +51,91 @@ def grade(student, request, course, student_module_cache=None):
for section in sections: for section in sections:
section_descriptor = section['section_descriptor'] section_descriptor = section['section_descriptor']
section_name = section_descriptor.metadata.get('display_name') section_name = section_descriptor.metadata.get('display_name')
should_grade_section = False should_grade_section = False
# If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0% # If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0%
for moduledescriptor in section['xmoduledescriptors']: for moduledescriptor in section['xmoduledescriptors']:
if student_module_cache.lookup(moduledescriptor.category, moduledescriptor.location.url() ): if student_module_cache.lookup(moduledescriptor.category, moduledescriptor.location.url() ):
should_grade_section = True should_grade_section = True
break break
if should_grade_section: if should_grade_section:
scores = [] scores = []
# TODO: We need the request to pass into here. If we could forgo that, our arguments # TODO: We need the request to pass into here. If we could forgo that, our arguments
# would be simpler # would be simpler
section_module = get_module(student, request, section_descriptor.location, student_module_cache) section_module = get_module(student, request, section_descriptor.location, student_module_cache)
# TODO: We may be able to speed this up by only getting a list of children IDs from section_module # TODO: We may be able to speed this up by only getting a list of children IDs from section_module
# Then, we may not need to instatiate any problems if they are already in the database # Then, we may not need to instatiate any problems if they are already in the database
for module in yield_module_descendents(section_module): for module in yield_module_descendents(section_module):
(correct, total) = get_score(student, module, student_module_cache) (correct, total) = get_score(student, module, student_module_cache)
if correct is None and total is None: if correct is None and total is None:
continue continue
if settings.GENERATE_PROFILE_SCORES: if settings.GENERATE_PROFILE_SCORES:
if total > 1: if total > 1:
correct = random.randrange(max(total - 2, 1), total + 1) correct = random.randrange(max(total - 2, 1), total + 1)
else: else:
correct = total correct = total
graded = module.metadata.get("graded", False) graded = module.metadata.get("graded", False)
if not total > 0: if not total > 0:
#We simply cannot grade a problem that is 12/0, because we might need it as a percentage #We simply cannot grade a problem that is 12/0, because we might need it as a percentage
graded = False graded = False
scores.append(Score(correct, total, graded, module.metadata.get('display_name'))) scores.append(Score(correct, total, graded, module.metadata.get('display_name')))
section_total, graded_total = graders.aggregate_scores(scores, section_name) section_total, graded_total = graders.aggregate_scores(scores, section_name)
else: else:
section_total = Score(0.0, 1.0, False, section_name) section_total = Score(0.0, 1.0, False, section_name)
graded_total = Score(0.0, 1.0, True, section_name) graded_total = Score(0.0, 1.0, True, section_name)
#Add the graded total to totaled_scores #Add the graded total to totaled_scores
if graded_total.possible > 0: if graded_total.possible > 0:
format_scores.append(graded_total) format_scores.append(graded_total)
else: else:
log.exception("Unable to grade a section with a total possible score of zero. " + str(section_descriptor.location)) log.exception("Unable to grade a section with a total possible score of zero. " + str(section_descriptor.location))
totaled_scores[section_format] = format_scores totaled_scores[section_format] = format_scores
grade_summary = course.grader.grade(totaled_scores) grade_summary = course.grader.grade(totaled_scores)
# We round the grade here, to make sure that the grade is an whole percentage and # We round the grade here, to make sure that the grade is an whole percentage and
# doesn't get displayed differently than it gets grades # doesn't get displayed differently than it gets grades
grade_summary['percent'] = round(grade_summary['percent'] * 100 + 0.05) / 100 grade_summary['percent'] = round(grade_summary['percent'] * 100 + 0.05) / 100
letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent']) letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent'])
grade_summary['grade'] = letter_grade grade_summary['grade'] = letter_grade
return grade_summary return grade_summary
def grade_for_percentage(grade_cutoffs, percentage): def grade_for_percentage(grade_cutoffs, percentage):
""" """
Returns a letter grade 'A' 'B' 'C' or None. Returns a letter grade 'A' 'B' 'C' or None.
Arguments Arguments
- grade_cutoffs is a dictionary mapping a grade to the lowest - grade_cutoffs is a dictionary mapping a grade to the lowest
possible percentage to earn that grade. possible percentage to earn that grade.
- percentage is the final percent across all problems in a course - percentage is the final percent across all problems in a course
""" """
letter_grade = None letter_grade = None
for possible_grade in ['A', 'B', 'C']: for possible_grade in ['A', 'B', 'C']:
if percentage >= grade_cutoffs[possible_grade]: if percentage >= grade_cutoffs[possible_grade]:
letter_grade = possible_grade letter_grade = possible_grade
break break
return letter_grade return letter_grade
def progress_summary(student, course, grader, student_module_cache): def progress_summary(student, course, grader, student_module_cache):
""" """
This pulls a summary of all problems in the course. This pulls a summary of all problems in the course.
Returns Returns
- courseware_summary is a summary of all sections with problems in the course. - courseware_summary is a summary of all sections with problems in the course.
It is organized as an array of chapters, each containing an array of sections, It is organized as an array of chapters, each containing an array of sections,
each containing an array of scores. This contains information for graded and each containing an array of scores. This contains information for graded and
ungraded problems, and is good for displaying a course summary with due dates, ungraded problems, and is good for displaying a course summary with due dates,
etc. etc.
Arguments: Arguments:
...@@ -152,7 +155,7 @@ def progress_summary(student, course, grader, student_module_cache): ...@@ -152,7 +155,7 @@ def progress_summary(student, course, grader, student_module_cache):
if correct is None and total is None: if correct is None and total is None:
continue continue
scores.append(Score(correct, total, graded, scores.append(Score(correct, total, graded,
module.metadata.get('display_name'))) module.metadata.get('display_name')))
section_total, graded_total = graders.aggregate_scores( section_total, graded_total = graders.aggregate_scores(
...@@ -179,7 +182,7 @@ def progress_summary(student, course, grader, student_module_cache): ...@@ -179,7 +182,7 @@ def progress_summary(student, course, grader, student_module_cache):
def get_score(user, problem, student_module_cache): def get_score(user, problem, student_module_cache):
""" """
Return the score for a user on a problem Return the score for a user on a problem, as a tuple (correct, total).
user: a Student object user: a Student object
problem: an XModule problem: an XModule
...@@ -188,7 +191,7 @@ def get_score(user, problem, student_module_cache): ...@@ -188,7 +191,7 @@ def get_score(user, problem, student_module_cache):
if not (problem.descriptor.stores_state and problem.descriptor.has_score): if not (problem.descriptor.stores_state and problem.descriptor.has_score):
# These are not problems, and do not have a score # These are not problems, and do not have a score
return (None, None) return (None, None)
correct = 0.0 correct = 0.0
# If the ID is not in the cache, add the item # If the ID is not in the cache, add the item
......
...@@ -13,8 +13,9 @@ from django.core.urlresolvers import reverse ...@@ -13,8 +13,9 @@ from django.core.urlresolvers import reverse
from mock import patch, Mock from mock import patch, Mock
from override_settings import override_settings from override_settings import override_settings
from django.contrib.auth.models import User from django.contrib.auth.models import User, Group
from student.models import Registration from student.models import Registration
from courseware.courses import course_staff_group_name
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
import xmodule.modulestore.django import xmodule.modulestore.django
...@@ -88,6 +89,13 @@ class ActivateLoginTestCase(TestCase): ...@@ -88,6 +89,13 @@ class ActivateLoginTestCase(TestCase):
self.assertTrue(data['success']) self.assertTrue(data['success'])
return resp return resp
def logout(self):
'''Logout, check that it worked.'''
resp = self.client.get(reverse('logout'), {})
# should redirect
self.assertEqual(resp.status_code, 302)
return resp
def _create_account(self, username, email, pw): def _create_account(self, username, email, pw):
'''Try to create an account. No error checking''' '''Try to create an account. No error checking'''
resp = self.client.post('/create_account', { resp = self.client.post('/create_account', {
...@@ -131,12 +139,16 @@ class ActivateLoginTestCase(TestCase): ...@@ -131,12 +139,16 @@ class ActivateLoginTestCase(TestCase):
'''The setup function does all the work''' '''The setup function does all the work'''
pass pass
def test_logout(self):
'''Setup function does login'''
self.logout()
class PageLoader(ActivateLoginTestCase): class PageLoader(ActivateLoginTestCase):
''' Base class that adds a function to load all pages in a modulestore ''' ''' Base class that adds a function to load all pages in a modulestore '''
def enroll(self, course): def enroll(self, course):
"""Enroll the currently logged-in user, and check that it worked."""
resp = self.client.post('/change_enrollment', { resp = self.client.post('/change_enrollment', {
'enrollment_action': 'enroll', 'enrollment_action': 'enroll',
'course_id': course.id, 'course_id': course.id,
...@@ -193,7 +205,99 @@ class TestCoursesLoadTestCase(PageLoader): ...@@ -193,7 +205,99 @@ class TestCoursesLoadTestCase(PageLoader):
self.check_pages_load('full', TEST_DATA_DIR, modulestore()) self.check_pages_load('full', TEST_DATA_DIR, modulestore())
# ========= TODO: check ajax interaction here too? @override_settings(MODULESTORE=TEST_DATA_MODULESTORE)
class TestInstructorAuth(PageLoader):
"""Check that authentication works properly"""
# NOTE: setUpClass() runs before override_settings takes effect, so
# can't do imports there without manually hacking settings.
def setUp(self):
xmodule.modulestore.django._MODULESTORES = {}
modulestore().collection.drop()
import_from_xml(modulestore(), TEST_DATA_DIR, ['toy'])
import_from_xml(modulestore(), TEST_DATA_DIR, ['full'])
courses = modulestore().get_courses()
# get the two courses sorted out
courses.sort(key=lambda c: c.location.course)
[self.full, self.toy] = courses
# 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)
def check_for_get_code(self, code, url):
resp = self.client.get(url)
# HACK: workaround the bug that returns 200 instead of 404.
# TODO (vshnayder): once we're returning 404s, get rid of this if.
if code != 404:
self.assertEqual(resp.status_code, code)
else:
# look for "page not found" instead of the status code
self.assertTrue(resp.content.lower().find('page not found') != -1)
def test_instructor_page(self):
"Make sure only instructors can load it"
# First, try with an enrolled student
self.login(self.student, self.password)
# shouldn't work before enroll
self.check_for_get_code(302, reverse('courseware', kwargs={'course_id': self.toy.id}))
self.enroll(self.toy)
self.enroll(self.full)
# should work now
self.check_for_get_code(200, reverse('courseware', kwargs={'course_id': self.toy.id}))
def instructor_urls(course):
"list of urls that only instructors/staff should be able to see"
urls = [reverse(name, kwargs={'course_id': course.id}) for name in (
'instructor_dashboard',
'gradebook',
'grade_summary',)]
urls.append(reverse('student_profile', kwargs={'course_id': course.id,
'student_id': user(self.student).id}))
return urls
# shouldn't be able to get to the instructor pages
for url in instructor_urls(self.toy) + instructor_urls(self.full):
print 'checking for 404 on {}'.format(url)
self.check_for_get_code(404, url)
# Make the instructor staff in the toy course
group_name = course_staff_group_name(self.toy)
g = Group.objects.create(name=group_name)
g.user_set.add(user(self.instructor))
self.logout()
self.login(self.instructor, self.password)
# Now should be able to get to the toy course, but not the full course
for url in instructor_urls(self.toy):
print 'checking for 200 on {}'.format(url)
self.check_for_get_code(200, url)
for url in instructor_urls(self.full):
print 'checking for 404 on {}'.format(url)
self.check_for_get_code(404, url)
# now also make the instructor staff
u = user(self.instructor)
u.is_staff = True
u.save()
# and now should be able to load both
for url in instructor_urls(self.toy) + instructor_urls(self.full):
print 'checking for 200 on {}'.format(url)
self.check_for_get_code(200, url)
@override_settings(MODULESTORE=REAL_DATA_MODULESTORE) @override_settings(MODULESTORE=REAL_DATA_MODULESTORE)
......
...@@ -27,7 +27,8 @@ from xmodule.course_module import CourseDescriptor ...@@ -27,7 +27,8 @@ from xmodule.course_module import CourseDescriptor
from util.cache import cache, cache_if_anonymous from util.cache import cache, cache_if_anonymous
from student.models import UserTestGroup, CourseEnrollment from student.models import UserTestGroup, CourseEnrollment
from courseware import grades from courseware import grades
from courseware.courses import check_course, get_courses_by_university from courseware.courses import (check_course, get_courses_by_university,
has_staff_access_to_course_id)
log = logging.getLogger("mitx.courseware") log = logging.getLogger("mitx.courseware")
...@@ -35,6 +36,9 @@ log = logging.getLogger("mitx.courseware") ...@@ -35,6 +36,9 @@ log = logging.getLogger("mitx.courseware")
template_imports = {'urllib': urllib} template_imports = {'urllib': urllib}
def user_groups(user): def user_groups(user):
"""
TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately.
"""
if not user.is_authenticated(): if not user.is_authenticated():
return [] return []
...@@ -64,64 +68,6 @@ def courses(request): ...@@ -64,64 +68,6 @@ def courses(request):
universities = get_courses_by_university(request.user) universities = get_courses_by_university(request.user)
return render_to_response("courses.html", {'universities': universities}) return render_to_response("courses.html", {'universities': universities})
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def gradebook(request, course_id):
if 'course_admin' not in user_groups(request.user):
raise Http404
course = check_course(course_id)
student_objects = User.objects.all()[:100]
student_info = []
#TODO: Only select students who are in the course
for student in student_objects:
student_info.append({
'username': student.username,
'id': student.id,
'email': student.email,
'grade_summary': grades.grade(student, request, course),
'realname': UserProfile.objects.get(user=student).name
})
return render_to_response('gradebook.html', {'students': student_info, 'course': course})
@login_required
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def profile(request, course_id, student_id=None):
''' User profile. Show username, location, etc, as well as grades .
We need to allow the user to change some of these settings .'''
course = check_course(course_id)
if student_id is None:
student = request.user
else:
if 'course_admin' not in user_groups(request.user):
raise Http404
student = User.objects.get(id=int(student_id))
user_info = UserProfile.objects.get(user=student)
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, course)
course_module = get_module(request.user, request, course.location, student_module_cache)
courseware_summary = grades.progress_summary(student, course_module, course.grader, student_module_cache)
grade_summary = grades.grade(request.user, request, course, student_module_cache)
context = {'name': user_info.name,
'username': student.username,
'location': user_info.location,
'language': user_info.language,
'email': student.email,
'course': course,
'csrf': csrf(request)['csrf_token'],
'courseware_summary' : courseware_summary,
'grade_summary' : grade_summary
}
context.update()
return render_to_response('profile.html', context)
def render_accordion(request, course, chapter, section): def render_accordion(request, course, chapter, section):
''' Draws navigation bar. Takes current position in accordion as ''' Draws navigation bar. Takes current position in accordion as
...@@ -296,3 +242,104 @@ def university_profile(request, org_id): ...@@ -296,3 +242,104 @@ def university_profile(request, org_id):
template_file = "university_profile/{0}.html".format(org_id).lower() template_file = "university_profile/{0}.html".format(org_id).lower()
return render_to_response(template_file, context) return render_to_response(template_file, context)
@login_required
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def profile(request, course_id, student_id=None):
""" User profile. Show username, location, etc, as well as grades .
We need to allow the user to change some of these settings.
Course staff are allowed to see the profiles of students in their class.
"""
course = check_course(course_id)
if student_id is None or student_id == request.user.id:
# always allowed to see your own profile
student = request.user
else:
# Requesting access to a different student's profile
if not has_staff_access_to_course_id(request.user, course_id):
raise Http404
student = User.objects.get(id=int(student_id))
user_info = UserProfile.objects.get(user=student)
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, course)
course_module = get_module(request.user, request, course.location, student_module_cache)
courseware_summary = grades.progress_summary(student, course_module, course.grader, student_module_cache)
grade_summary = grades.grade(request.user, request, course, student_module_cache)
context = {'name': user_info.name,
'username': student.username,
'location': user_info.location,
'language': user_info.language,
'email': student.email,
'course': course,
'csrf': csrf(request)['csrf_token'],
'courseware_summary' : courseware_summary,
'grade_summary' : grade_summary
}
context.update()
return render_to_response('profile.html', context)
# ======== 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.
"""
if not has_staff_access_to_course_id(request.user, course_id):
raise Http404
course = check_course(course_id)
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('gradebook.html', {'students': student_info,
'course': course, 'course_id': course_id})
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def grade_summary(request, course_id):
"""Display the grade summary for a course."""
if not has_staff_access_to_course_id(request.user, course_id):
raise Http404
course = check_course(course_id)
# For now, just a static page
context = {'course': course }
return render_to_response('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."""
if not has_staff_access_to_course_id(request.user, course_id):
raise Http404
course = check_course(course_id)
# For now, just a static page
context = {'course': course }
return render_to_response('instructor_dashboard.html', context)
...@@ -25,6 +25,7 @@ SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead ...@@ -25,6 +25,7 @@ SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
# Nose Test Runner # Nose Test Runner
INSTALLED_APPS += ('django_nose',) INSTALLED_APPS += ('django_nose',)
NOSE_ARGS = ['--cover-erase', '--with-xunit', '--with-xcoverage', '--cover-html', NOSE_ARGS = ['--cover-erase', '--with-xunit', '--with-xcoverage', '--cover-html',
# '-v', '--pdb', # When really stuck, uncomment to start debugger on error
'--cover-inclusive', '--cover-html-dir', '--cover-inclusive', '--cover-html-dir',
os.environ.get('NOSE_COVER_HTML_DIR', 'cover_html')] os.environ.get('NOSE_COVER_HTML_DIR', 'cover_html')]
for app in os.listdir(PROJECT_ROOT / 'djangoapps'): for app in os.listdir(PROJECT_ROOT / 'djangoapps'):
......
...@@ -7,6 +7,7 @@ def url_class(url): ...@@ -7,6 +7,7 @@ def url_class(url):
return "" return ""
%> %>
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%! from courseware.courses import has_staff_access_to_course_id %>
<nav class="${active_page} course-material"> <nav class="${active_page} course-material">
<div class="inner-wrapper"> <div class="inner-wrapper">
...@@ -16,10 +17,10 @@ def url_class(url): ...@@ -16,10 +17,10 @@ def url_class(url):
% if user.is_authenticated(): % if user.is_authenticated():
% if settings.MITX_FEATURES.get('ENABLE_TEXTBOOK'): % if settings.MITX_FEATURES.get('ENABLE_TEXTBOOK'):
<li class="book"><a href="${reverse('book', args=[course.id])}" class="${url_class('book')}">Textbook</a></li> <li class="book"><a href="${reverse('book', args=[course.id])}" class="${url_class('book')}">Textbook</a></li>
% endif % endif
% if settings.MITX_FEATURES.get('ENABLE_DISCUSSION'): % if settings.MITX_FEATURES.get('ENABLE_DISCUSSION'):
<li class="discussion"><a href="${reverse('questions')}">Discussion</a></li> <li class="discussion"><a href="${reverse('questions')}">Discussion</a></li>
% endif % endif
% endif % endif
% if settings.WIKI_ENABLED: % if settings.WIKI_ENABLED:
<li class="wiki"><a href="${reverse('wiki_root', args=[course.id])}" class="${url_class('wiki')}">Wiki</a></li> <li class="wiki"><a href="${reverse('wiki_root', args=[course.id])}" class="${url_class('wiki')}">Wiki</a></li>
...@@ -27,6 +28,10 @@ def url_class(url): ...@@ -27,6 +28,10 @@ def url_class(url):
% if user.is_authenticated(): % if user.is_authenticated():
<li class="profile"><a href="${reverse('profile', args=[course.id])}" class="${url_class('profile')}">Profile</a></li> <li class="profile"><a href="${reverse('profile', args=[course.id])}" class="${url_class('profile')}">Profile</a></li>
% endif % endif
% if has_staff_access_to_course_id(user, course.id):
<li class="instructor"><a href="${reverse('instructor_dashboard', args=[course.id])}" class="${url_class('instructor')}">Instructor</a></li>
% endif
</ol> </ol>
</div> </div>
</nav> </nav>
\ No newline at end of file
<%inherit file="main.html" />
<%! from django.core.urlresolvers import reverse %>
<%namespace name='static' file='static_content.html'/>
<%include file="course_navigation.html" args="active_page=''" />
<section class="container">
<div class="gradebook-summary-wrapper">
<section class="gradebook-summary-content">
<h1>Grade summary</h1>
<p>Not implemented yet</p>
</section>
</div>
</section>
<%inherit file="main.html" /> <%inherit file="main.html" />
<%! from django.core.urlresolvers import reverse %>
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%block name="js_extra"> <%block name="js_extra">
...@@ -9,7 +10,7 @@ ...@@ -9,7 +10,7 @@
<%block name="headextra"> <%block name="headextra">
<%static:css group='course'/> <%static:css group='course'/>
<style type="text/css"> <style type="text/css">
.grade_A {color:green;} .grade_A {color:green;}
.grade_B {color:Chocolate;} .grade_B {color:Chocolate;}
...@@ -17,7 +18,7 @@ ...@@ -17,7 +18,7 @@
.grade_F {color:DimGray;} .grade_F {color:DimGray;}
.grade_None {color:LightGray;} .grade_None {color:LightGray;}
</style> </style>
</%block> </%block>
<%include file="course_navigation.html" args="active_page=''" /> <%include file="course_navigation.html" args="active_page=''" />
...@@ -26,14 +27,14 @@ ...@@ -26,14 +27,14 @@
<div class="gradebook-wrapper"> <div class="gradebook-wrapper">
<section class="gradebook-content"> <section class="gradebook-content">
<h1>Gradebook</h1> <h1>Gradebook</h1>
%if len(students) > 0: %if len(students) > 0:
<table> <table>
<% <%
templateSummary = students[0]['grade_summary'] templateSummary = students[0]['grade_summary']
%> %>
<tr> <!-- Header Row --> <tr> <!-- Header Row -->
<th>Student</th> <th>Student</th>
%for section in templateSummary['section_breakdown']: %for section in templateSummary['section_breakdown']:
...@@ -41,25 +42,28 @@ ...@@ -41,25 +42,28 @@
%endfor %endfor
<th>Total</th> <th>Total</th>
</tr> </tr>
<%def name="percent_data(percentage)"> <%def name="percent_data(fraction)">
<% <%
letter_grade = 'None' letter_grade = 'None'
if percentage > 0: if fraction > 0:
letter_grade = 'F' letter_grade = 'F'
for grade in ['A', 'B', 'C']: for grade in ['A', 'B', 'C']:
if percentage >= course.grade_cutoffs[grade]: if fraction >= course.grade_cutoffs[grade]:
letter_grade = grade letter_grade = grade
break break
data_class = "grade_" + letter_grade data_class = "grade_" + letter_grade
%> %>
<td class="${data_class}" data-percent="${percentage}">${ "{0:.0%}".format( percentage ) }</td> <td class="${data_class}" data-percent="${fraction}">${ "{0:.0f}".format( 100 * fraction ) }</td>
</%def> </%def>
%for student in students: %for student in students:
<tr> <tr>
<td><a href="/profile/${student['id']}/">${student['username']}</a></td> <td><a href="${reverse('student_profile',
kwargs={'course_id' : course_id,
'student_id': student['id']})}">
${student['username']}</a></td>
%for section in student['grade_summary']['section_breakdown']: %for section in student['grade_summary']['section_breakdown']:
${percent_data( section['percent'] )} ${percent_data( section['percent'] )}
%endfor %endfor
......
<%inherit file="main.html" />
<%! from django.core.urlresolvers import reverse %>
<%namespace name='static' file='static_content.html'/>
<%block name="headextra">
<%static:css group='course'/>
</%block>
<%include file="course_navigation.html" args="active_page='instructor'" />
<section class="container">
<div class="instructor-dashboard-wrapper">
<section class="instructor-dashboard-content">
<h1>Instructor Dashboard</h1>
<p>
<a href="${reverse('gradebook', kwargs={'course_id': course.id})}">Gradebook</a>
<p>
<a href="${reverse('grade_summary', kwargs={'course_id': course.id})}">Grade summary</a>
</section>
</div>
</section>
...@@ -85,7 +85,6 @@ urlpatterns = ('', ...@@ -85,7 +85,6 @@ urlpatterns = ('',
(r'^pressrelease$', 'django.views.generic.simple.redirect_to', {'url': '/press/uc-berkeley-joins-edx'}), (r'^pressrelease$', 'django.views.generic.simple.redirect_to', {'url': '/press/uc-berkeley-joins-edx'}),
(r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}), (r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}),
# TODO: These urls no longer work. They need to be updated before they are re-enabled # TODO: These urls no longer work. They need to be updated before they are re-enabled
...@@ -136,12 +135,18 @@ if settings.COURSEWARE_ENABLED: ...@@ -136,12 +135,18 @@ if settings.COURSEWARE_ENABLED:
'courseware.views.index', name="courseware_section"), 'courseware.views.index', name="courseware_section"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/profile$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/profile$',
'courseware.views.profile', name="profile"), 'courseware.views.profile', name="profile"),
# Takes optional student_id for instructor use--shows profile as that student sees it.
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/profile/(?P<student_id>[^/]*)/$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/profile/(?P<student_id>[^/]*)/$',
'courseware.views.profile'), 'courseware.views.profile', name="student_profile"),
# For the instructor # For the instructor
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor$',
'courseware.views.instructor_dashboard', name="instructor_dashboard"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/gradebook$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/gradebook$',
'courseware.views.gradebook'), 'courseware.views.gradebook', name='gradebook'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/grade_summary$',
'courseware.views.grade_summary', name='grade_summary'),
) )
# Multicourse wiki # Multicourse wiki
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment