From c89031b0772a0e25267f44015959b94b851cd861 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Tue, 4 Sep 2012 15:39:10 -0400 Subject: [PATCH 1/3] Export of answer distibutions to csv - go through all students, all of their problems, save count for each answer - return csv - url exists, but no links to it yet - Will need to integrate with Ike's new dashboard code --- lms/djangoapps/courseware/grades.py | 70 +++++++++++++++++++++++++++++ lms/djangoapps/courseware/views.py | 31 +++++++++++-- lms/urls.py | 3 ++ 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index f32da532df..eeaf15d093 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -4,11 +4,14 @@ from __future__ import division import random import logging +from collections import defaultdict from django.conf import settings +from django.contrib.auth.models import User from models import StudentModuleCache from module_render import get_module, get_instance_module from xmodule import graders +from xmodule.capa_module import CapaModule from xmodule.course_module import CourseDescriptor from xmodule.graders import Score from models import StudentModule @@ -24,6 +27,73 @@ def yield_module_descendents(module): stack.extend( next_module.get_display_items() ) yield next_module +def yield_problems(request, course, student): + """ + Return an iterator over capa_modules that this student has + potentially answered. (all that student has answered will definitely be in + the list, but there may be others as well). + """ + grading_context = course.grading_context + student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors']) + + for section_format, sections in grading_context['graded_sections'].iteritems(): + for section in sections: + + section_descriptor = section['section_descriptor'] + + # If the student hasn't seen a single problem in the section, skip it. + skip = True + for moduledescriptor in section['xmoduledescriptors']: + if student_module_cache.lookup( + course.id, moduledescriptor.category, moduledescriptor.location.url()): + skip = False + break + + if skip: + continue + + section_module = get_module(student, request, + section_descriptor.location, student_module_cache, + course.id) + if section_module is None: + # student doesn't have access to this module, or something else + # went wrong. + log.debug("couldn't get module for student {0} for section location {1}" + .format(student.username, section_descriptor.location)) + continue + + for problem in yield_module_descendents(section_module): + if isinstance(problem, CapaModule): + yield problem + +def answer_distributions(request, course): + """ + Given a course_descriptor, compute frequencies of answers for each problem: + + Format is: + + dict: (problem url_name, problem display_name, problem_id) -> (dict : answer -> count) + + TODO (vshnayder): this is currently doing a full linear pass through all + students and all problems. This will be just a little slow. + """ + + counts = defaultdict(lambda: defaultdict(int)) + + enrolled_students = User.objects.filter(courseenrollment__course_id=course.id) + + for student in enrolled_students: + for capa_module in yield_problems(request, course, student): + log.debug("looking at problem {0} for {1}. answers {2}".format( + capa_module.display_name, student.username, capa_module.lcp.student_answers)) + for problem_id in capa_module.lcp.student_answers: + answer = capa_module.lcp.student_answers[problem_id] + key = (capa_module.url_name, capa_module.display_name, problem_id) + counts[key][answer] += 1 + + return counts + + def grade(student, request, course, student_module_cache=None, keep_raw_scores=False): """ This grades a student as quickly as possible. It retuns the diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index bf361937e7..ba39d7d545 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -1,7 +1,9 @@ +import csv import json import logging import urllib import itertools +import StringIO from functools import partial @@ -219,9 +221,9 @@ def jump_to(request, course_id, location): # Rely on index to do all error handling and access control. return redirect('courseware_position', - course_id=course_id, - chapter=chapter, - section=section, + course_id=course_id, + chapter=chapter, + section=section, position=position) @ensure_csrf_cookie def course_info(request, course_id): @@ -342,7 +344,7 @@ def progress(request, course_id, student_id=None): # NOTE: To make sure impersonation by instructor works, use # student instead of request.user in the rest of the function. - # The pre-fetching of groups is done to make auth checks not require an + # The pre-fetching of groups is done to make auth checks not require an # additional DB lookup (this kills the Progress page in particular). student = User.objects.prefetch_related("groups").get(id=student.id) @@ -370,3 +372,24 @@ def progress(request, course_id, student_id=None): +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def answers_export(request, course_id): + """ + Export the distribution of student answers to all problems as a csv file. + + - only displayed to course staff + """ + course = get_course_with_access(request.user, course_id, 'staff') + + dist = grades.answer_distributions(request, course) + + response = HttpResponse(mimetype='text/csv') + response['Content-Disposition'] = 'attachment; filename=%s' % "answer_distribution.csv" + + writer = csv.writer(response) + for (url_name, display_name, answer_id), answers in dist.items(): + # HEADER? + for a in answers: + writer.writerow([url_name, display_name, answer_id, a, answers[a]]) + + return response diff --git a/lms/urls.py b/lms/urls.py index 26aa10a3f4..c58b44d2ba 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -161,6 +161,9 @@ if settings.COURSEWARE_ENABLED: 'instructor.views.grade_summary', name='grade_summary'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/enroll_students$', 'instructor.views.enroll_students', name='enroll_students'), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/answers_export$', + 'courseware.views.answers_export', name='answers_export'), + ) # discussion forums live within courseware, so courseware must be enabled first From f04cd838e591ee3112816a1bdf32baf4d9c6567b Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Wed, 5 Sep 2012 13:26:06 -0400 Subject: [PATCH 2/3] Integrate csv export of answer distributions with Ike's sweet dashboard --- lms/djangoapps/courseware/views.py | 23 ------------- lms/djangoapps/instructor/views.py | 34 ++++++++++++++++--- .../courseware/instructor_dashboard.html | 3 ++ lms/urls.py | 7 ++-- 4 files changed, 35 insertions(+), 32 deletions(-) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index ba39d7d545..60279d34c9 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -370,26 +370,3 @@ def progress(request, course_id, student_id=None): return render_to_response('courseware/progress.html', context) - - -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -def answers_export(request, course_id): - """ - Export the distribution of student answers to all problems as a csv file. - - - only displayed to course staff - """ - course = get_course_with_access(request.user, course_id, 'staff') - - dist = grades.answer_distributions(request, course) - - response = HttpResponse(mimetype='text/csv') - response['Content-Disposition'] = 'attachment; filename=%s' % "answer_distribution.csv" - - writer = csv.writer(response) - for (url_name, display_name, answer_id), answers in dist.items(): - # HEADER? - for a in answers: - writer.writerow([url_name, display_name, answer_id, a, answers[a]]) - - return response diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index 92b2401216..0c36236021 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -48,7 +48,7 @@ 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 + instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists msg = '' # msg += ('POST=%s' % dict(request.POST)).replace('<','<') @@ -99,7 +99,7 @@ def instructor_dashboard(request, course_id): 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: @@ -144,6 +144,10 @@ def instructor_dashboard(request, course_id): return return_csv('grades_%s_raw.csv' % course_id, get_student_grade_summary_data(request, course, course_id, get_raw_scores=True)) + elif 'Download CSV of answer distributions' in action: + track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard') + return return_csv('answer_dist_%s.csv' % course_id, get_answers_distribution(request, course_id)) + elif 'List course staff' in action: group = get_staff_group(course) msg += 'Staff group = %s' % group.name @@ -290,7 +294,7 @@ def grade_summary(request, course_id): @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. + """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 @@ -300,7 +304,7 @@ def enroll_students(request, course_id): 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)] @@ -328,6 +332,28 @@ def enroll_students(request, course_id): 'rejected_students': rejected_students, 'debug': new_students}) + +def get_answers_distribution(request, course_id): + """ + Get the distribution of answers for all graded problems in the course. + + Return a dict with two keys: + 'header': a header row + 'data': a list of rows + """ + course = get_course_with_access(request.user, course_id, 'staff') + + dist = grades.answer_distributions(request, course) + + d = {} + d['header'] = ['url_name', 'display name', 'answer id', 'answer', 'count'] + + d['data'] = [[url_name, display_name, answer_id, a, answers[a]] + for (url_name, display_name, answer_id), answers in dist.items() + for a in answers] + return d + + #----------------------------------------------------------------------------- diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 29397e5c41..930ec7ef88 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -58,6 +58,9 @@ table.stat_table td { +

+ + %if instructor_access:


diff --git a/lms/urls.py b/lms/urls.py index c58b44d2ba..21d434272e 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -161,9 +161,6 @@ if settings.COURSEWARE_ENABLED: 'instructor.views.grade_summary', name='grade_summary'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/enroll_students$', 'instructor.views.enroll_students', name='enroll_students'), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/answers_export$', - 'courseware.views.answers_export', name='answers_export'), - ) # discussion forums live within courseware, so courseware must be enabled first @@ -200,8 +197,8 @@ if settings.WIKI_ENABLED: ) if settings.QUICKEDIT: - urlpatterns += (url(r'^quickedit/(?P[^/]*)$', 'dogfood.views.quickedit'),) - urlpatterns += (url(r'^dogfood/(?P[^/]*)$', 'dogfood.views.df_capa_problem'),) + urlpatterns += (url(r'^quickedit/(?P[^/]*)$', 'dogfood.views.quickedit'),) + urlpatterns += (url(r'^dogfood/(?P[^/]*)$', 'dogfood.views.df_capa_problem'),) if settings.ASKBOT_ENABLED: urlpatterns += (url(r'^%s' % settings.ASKBOT_URL, include('askbot.urls')), \ From a8cd4633c0dd381e35b354507640734287519ef8 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Wed, 5 Sep 2012 13:46:15 -0400 Subject: [PATCH 3/3] remove debugging statements --- lms/djangoapps/courseware/grades.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index eeaf15d093..e7e5b0d9aa 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -58,8 +58,8 @@ def yield_problems(request, course, student): if section_module is None: # student doesn't have access to this module, or something else # went wrong. - log.debug("couldn't get module for student {0} for section location {1}" - .format(student.username, section_descriptor.location)) + # log.debug("couldn't get module for student {0} for section location {1}" + # .format(student.username, section_descriptor.location)) continue for problem in yield_module_descendents(section_module): @@ -84,8 +84,6 @@ def answer_distributions(request, course): for student in enrolled_students: for capa_module in yield_problems(request, course, student): - log.debug("looking at problem {0} for {1}. answers {2}".format( - capa_module.display_name, student.username, capa_module.lcp.student_answers)) for problem_id in capa_module.lcp.student_answers: answer = capa_module.lcp.student_answers[problem_id] key = (capa_module.url_name, capa_module.display_name, problem_id)