Commit 87f19469 by David Ormsbee

Merge pull request #1750 from edx/sarina/dormsbee_grades_download

Sarina/dormsbee grades download
parents 3efcc3d6 cf524906
......@@ -5,6 +5,10 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
LMS: Add feature for providing background grade report generation via Celery
instructor task, with reports uploaded to S3. Feature is visible on the beta
instructor dashboard. LMS-58
LMS: Add a user-visible alert modal when a forums AJAX request fails.
Blades: Add template for checkboxes response to studio. BLD-193.
......
......@@ -769,7 +769,7 @@ class CourseEnrollment(models.Model):
activation_changed = True
mode_changed = False
# if mode is None, the call to update_enrollment didn't specify a new
# if mode is None, the call to update_enrollment didn't specify a new
# mode, so leave as-is
if self.mode != mode and mode is not None:
self.mode = mode
......@@ -967,6 +967,14 @@ class CourseEnrollment(models.Model):
def enrollments_for_user(cls, user):
return CourseEnrollment.objects.filter(user=user, is_active=1)
@classmethod
def users_enrolled_in(cls, course_id):
"""Return a queryset of User for every user enrolled in the course."""
return User.objects.filter(
courseenrollment__course_id=course_id,
courseenrollment__is_active=True
)
def activate(self):
"""Makes this `CourseEnrollment` record active. Saves immediately."""
self.update_enrollment(is_active=True)
......
......@@ -236,14 +236,11 @@ class TestCourseGrader(TestSubmittingProblems):
make up the final grade. (For display)
"""
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course.id, self.student_user, self.course)
fake_request = self.factory.get(reverse('progress',
kwargs={'course_id': self.course.id}))
fake_request = self.factory.get(
reverse('progress', kwargs={'course_id': self.course.id})
)
return grades.grade(self.student_user, fake_request,
self.course, field_data_cache)
return grades.grade(self.student_user, fake_request, self.course)
def get_progress_summary(self):
"""
......@@ -257,16 +254,13 @@ class TestCourseGrader(TestSubmittingProblems):
etc.
"""
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course.id, self.student_user, self.course)
fake_request = self.factory.get(reverse('progress',
kwargs={'course_id': self.course.id}))
fake_request = self.factory.get(
reverse('progress', kwargs={'course_id': self.course.id})
)
progress_summary = grades.progress_summary(self.student_user,
fake_request,
self.course,
field_data_cache)
progress_summary = grades.progress_summary(
self.student_user, fake_request, self.course
)
return progress_summary
def check_grade_percent(self, percent):
......
......@@ -14,6 +14,7 @@ from django.shortcuts import redirect
from mitxmako.shortcuts import render_to_response, render_to_string
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from django.db import transaction
from markupsafe import escape
from courseware import grades
......@@ -643,8 +644,9 @@ def mktg_course_about(request, course_id):
except (ValueError, Http404) as e:
# if a course does not exist yet, display a coming
# soon button
return render_to_response('courseware/mktg_coming_soon.html',
{'course_id': course_id})
return render_to_response(
'courseware/mktg_coming_soon.html', {'course_id': course_id}
)
registered = registered_for_course(course, request.user)
......@@ -659,21 +661,36 @@ def mktg_course_about(request, course_id):
settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'))
course_modes = CourseMode.modes_for_course(course.id)
return render_to_response('courseware/mktg_course_about.html',
{
'course': course,
'registered': registered,
'allow_registration': allow_registration,
'course_target': course_target,
'show_courseware_link': show_courseware_link,
'course_modes': course_modes,
})
return render_to_response(
'courseware/mktg_course_about.html',
{
'course': course,
'registered': registered,
'allow_registration': allow_registration,
'course_target': course_target,
'show_courseware_link': show_courseware_link,
'course_modes': course_modes,
}
)
@login_required
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@transaction.commit_manually
def progress(request, course_id, student_id=None):
""" User progress. We show the grade bar and every problem score.
"""
Wraps "_progress" with the manual_transaction context manager just in case
there are unanticipated errors.
"""
with grades.manual_transaction():
return _progress(request, course_id, student_id)
def _progress(request, course_id, student_id):
"""
Unwrapped version of "progress".
User progress. We show the grade bar and every problem score.
Course staff are allowed to see the progress of students in their class.
"""
......@@ -696,26 +713,26 @@ def progress(request, course_id, student_id=None):
# additional DB lookup (this kills the Progress page in particular).
student = User.objects.prefetch_related("groups").get(id=student.id)
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course_id, student, course, depth=None)
courseware_summary = grades.progress_summary(student, request, course)
courseware_summary = grades.progress_summary(student, request, course,
field_data_cache)
grade_summary = grades.grade(student, request, course, field_data_cache)
grade_summary = grades.grade(student, request, course)
if courseware_summary is None:
#This means the student didn't have access to the course (which the instructor requested)
raise Http404
context = {'course': course,
'courseware_summary': courseware_summary,
'grade_summary': grade_summary,
'staff_access': staff_access,
'student': student,
}
context.update()
context = {
'course': course,
'courseware_summary': courseware_summary,
'grade_summary': grade_summary,
'staff_access': staff_access,
'student': student,
}
with grades.manual_transaction():
response = render_to_response('courseware/progress.html', context)
return render_to_response('courseware/progress.html', context)
return response
@login_required
......
......@@ -29,7 +29,6 @@ from courseware.models import StudentModule
# modules which are mocked in test cases.
import instructor_task.api
from instructor.access import allow_access
import instructor.views.api
from instructor.views.api import _split_input_list, _msk_from_problem_urlname, common_exceptions_400
......@@ -128,6 +127,8 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
('send_email', {'send_to': 'staff', 'subject': 'test', 'message': 'asdf'}),
('list_instructor_tasks', {}),
('list_background_email_tasks', {}),
('list_grade_downloads', {}),
('calculate_grades_csv', {}),
]
# Endpoints that only Instructors can access
self.instructor_level_endpoints = [
......@@ -790,6 +791,50 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa
self.assertTrue(body.startswith('"User ID","Anonymized user ID"\n"2","42"\n'))
self.assertTrue(body.endswith('"7","42"\n'))
def test_list_grade_downloads(self):
url = reverse('list_grade_downloads', kwargs={'course_id': self.course.id})
with patch('instructor_task.models.LocalFSGradesStore.links_for') as mock_links_for:
mock_links_for.return_value = [
('mock_file_name_1', 'https://1.mock.url'),
('mock_file_name_2', 'https://2.mock.url'),
]
response = self.client.get(url, {})
expected_response = {
"downloads": [
{
"url": "https://1.mock.url",
"link": "<a href=\"https://1.mock.url\">mock_file_name_1</a>",
"name": "mock_file_name_1"
},
{
"url": "https://2.mock.url",
"link": "<a href=\"https://2.mock.url\">mock_file_name_2</a>",
"name": "mock_file_name_2"
}
]
}
res_json = json.loads(response.content)
self.assertEqual(res_json, expected_response)
def test_calculate_grades_csv_success(self):
url = reverse('calculate_grades_csv', kwargs={'course_id': self.course.id})
with patch('instructor_task.api.submit_calculate_grades_csv') as mock_cal_grades:
mock_cal_grades.return_value = True
response = self.client.get(url, {})
success_status = "Your grade report is being generated! You can view the status of the generation task in the 'Pending Instructor Tasks' section."
self.assertIn(success_status, response.content)
def test_calculate_grades_csv_already_running(self):
url = reverse('calculate_grades_csv', kwargs={'course_id': self.course.id})
with patch('instructor_task.api.submit_calculate_grades_csv') as mock_cal_grades:
mock_cal_grades.side_effect = AlreadyRunningError()
response = self.client.get(url, {})
already_running_status = "A grade report generation task is already in progress. Check the 'Pending Instructor Tasks' table for the status of the task. When completed, the report will be available for download in the table below."
self.assertIn(already_running_status, response.content)
def test_get_students_features_csv(self):
"""
Test that some minimum of information is formatted
......@@ -802,7 +847,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa
def test_get_distribution_no_feature(self):
"""
Test that get_distribution lists available features
when supplied no feature quparameter.
when supplied no feature parameter.
"""
url = reverse('get_distribution', kwargs={'course_id': self.course.id})
response = self.client.get(url)
......
......@@ -32,6 +32,7 @@ from student.models import unique_id_for_user
import instructor_task.api
from instructor_task.api_helper import AlreadyRunningError
from instructor_task.views import get_task_completion_info
from instructor_task.models import GradesStore
import instructor.enrollment as enrollment
from instructor.enrollment import enroll_email, unenroll_email, get_email_params
from instructor.views.tools import strip_if_string, get_student_from_identifier
......@@ -753,6 +754,42 @@ def list_instructor_tasks(request, course_id):
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
def list_grade_downloads(_request, course_id):
"""
List grade CSV files that are available for download for this course.
"""
grades_store = GradesStore.from_config()
response_payload = {
'downloads': [
dict(name=name, url=url, link='<a href="{}">{}</a>'.format(url, name))
for name, url in grades_store.links_for(course_id)
]
}
return JsonResponse(response_payload)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
def calculate_grades_csv(request, course_id):
"""
AlreadyRunningError is raised if the course's grades are already being updated.
"""
try:
instructor_task.api.submit_calculate_grades_csv(request, course_id)
success_status = _("Your grade report is being generated! You can view the status of the generation task in the 'Pending Instructor Tasks' section.")
return JsonResponse({"status": success_status})
except AlreadyRunningError:
already_running_status = _("A grade report generation task is already in progress. Check the 'Pending Instructor Tasks' table for the status of the task. When completed, the report will be available for download in the table below.")
return JsonResponse({
"status": already_running_status
})
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params('rolename')
def list_forum_members(request, course_id):
"""
......
......@@ -37,4 +37,10 @@ urlpatterns = patterns('', # nopep8
'instructor.views.api.proxy_legacy_analytics', name="proxy_legacy_analytics"),
url(r'^send_email$',
'instructor.views.api.send_email', name="send_email"),
# Grade downloads...
url(r'^list_grade_downloads$',
'instructor.views.api.list_grade_downloads', name="list_grade_downloads"),
url(r'calculate_grades_csv$',
'instructor.views.api.calculate_grades_csv', name="calculate_grades_csv"),
)
......@@ -171,6 +171,8 @@ def _section_data_download(course_id, access):
'get_students_features_url': reverse('get_students_features', kwargs={'course_id': course_id}),
'get_anon_ids_url': reverse('get_anon_ids', kwargs={'course_id': course_id}),
'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': course_id}),
'list_grade_downloads_url': reverse('list_grade_downloads', kwargs={'course_id': course_id}),
'calculate_grades_csv_url': reverse('calculate_grades_csv', kwargs={'course_id': course_id}),
}
return section_data
......
......@@ -16,7 +16,8 @@ from instructor_task.models import InstructorTask
from instructor_task.tasks import (rescore_problem,
reset_problem_attempts,
delete_problem_state,
send_bulk_course_email)
send_bulk_course_email,
calculate_grades_csv)
from instructor_task.api_helper import (check_arguments_for_rescoring,
encode_problem_and_student_input,
......@@ -206,3 +207,15 @@ def submit_bulk_course_email(request, course_id, email_id):
# create the key value by using MD5 hash:
task_key = hashlib.md5(task_key_stub).hexdigest()
return submit_task(request, task_type, task_class, course_id, task_input, task_key)
def submit_calculate_grades_csv(request, course_id):
"""
AlreadyRunningError is raised if the course's grades are already being updated.
"""
task_type = 'grade_course'
task_class = calculate_grades_csv
task_input = {}
task_key = ""
return submit_task(request, task_type, task_class, course_id, task_input, task_key)
......@@ -12,9 +12,20 @@ file and check it in at the same time as your model changes. To do that,
ASSUMPTIONS: modules have unique IDs, even across different module_types
"""
from cStringIO import StringIO
from gzip import GzipFile
from uuid import uuid4
import csv
import json
import hashlib
import os
import os.path
import urllib
from boto.s3.connection import S3Connection
from boto.s3.key import Key
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models, transaction
......@@ -176,3 +187,220 @@ class InstructorTask(models.Model):
def create_output_for_revoked():
"""Creates standard message to store in output format for revoked tasks."""
return json.dumps({'message': 'Task revoked before running'})
class GradesStore(object):
"""
Simple abstraction layer that can fetch and store CSV files for grades
download. Should probably refactor later to create a GradesFile object that
can simply be appended to for the sake of memory efficiency, rather than
passing in the whole dataset. Doing that for now just because it's simpler.
"""
@classmethod
def from_config(cls):
"""
Return one of the GradesStore subclasses depending on django
configuration. Look at subclasses for expected configuration.
"""
storage_type = settings.GRADES_DOWNLOAD.get("STORAGE_TYPE")
if storage_type.lower() == "s3":
return S3GradesStore.from_config()
elif storage_type.lower() == "localfs":
return LocalFSGradesStore.from_config()
class S3GradesStore(GradesStore):
"""
Grades store backed by S3. The directory structure we use to store things
is::
`{bucket}/{root_path}/{sha1 hash of course_id}/filename`
We might later use subdirectories or metadata to do more intelligent
grouping and querying, but right now it simply depends on its own
conventions on where files are stored to know what to display. Clients using
this class can name the final file whatever they want.
"""
def __init__(self, bucket_name, root_path):
self.root_path = root_path
conn = S3Connection(
settings.AWS_ACCESS_KEY_ID,
settings.AWS_SECRET_ACCESS_KEY
)
self.bucket = conn.get_bucket(bucket_name)
@classmethod
def from_config(cls):
"""
The expected configuration for an `S3GradesStore` is to have a
`GRADES_DOWNLOAD` dict in settings with the following fields::
STORAGE_TYPE : "s3"
BUCKET : Your bucket name, e.g. "grades-bucket"
ROOT_PATH : The path you want to store all course files under. Do not
use a leading or trailing slash. e.g. "staging" or
"staging/2013", not "/staging", or "/staging/"
Since S3 access relies on boto, you must also define `AWS_ACCESS_KEY_ID`
and `AWS_SECRET_ACCESS_KEY` in settings.
"""
return cls(
settings.GRADES_DOWNLOAD['BUCKET'],
settings.GRADES_DOWNLOAD['ROOT_PATH']
)
def key_for(self, course_id, filename):
"""Return the S3 key we would use to store and retrive the data for the
given filename."""
hashed_course_id = hashlib.sha1(course_id)
key = Key(self.bucket)
key.key = "{}/{}/{}".format(
self.root_path,
hashed_course_id.hexdigest(),
filename
)
return key
def store(self, course_id, filename, buff):
"""
Store the contents of `buff` in a directory determined by hashing
`course_id`, and name the file `filename`. `buff` is typically a
`StringIO`, but can be anything that implements `.getvalue()`.
This method assumes that the contents of `buff` are gzip-encoded (it
will add the appropriate headers to S3 to make the decompression
transparent via the browser). Filenames should end in whatever
suffix makes sense for the original file, so `.txt` instead of `.gz`
"""
key = self.key_for(course_id, filename)
data = buff.getvalue()
key.size = len(data)
key.content_encoding = "gzip"
key.content_type = "text/csv"
# Just setting the content encoding and type above should work
# according to the docs, but when experimenting, this was necessary for
# it to actually take.
key.set_contents_from_string(
data,
headers={
"Content-Encoding": "gzip",
"Content-Length": len(data),
"Content-Type": "text/csv",
}
)
def store_rows(self, course_id, filename, rows):
"""
Given a `course_id`, `filename`, and `rows` (each row is an iterable of
strings), create a buffer that is a gzip'd csv file, and then `store()`
that buffer.
Even though we store it in gzip format, browsers will transparently
download and decompress it. Filenames should end in `.csv`, not `.gz`.
"""
output_buffer = StringIO()
gzip_file = GzipFile(fileobj=output_buffer, mode="wb")
csv.writer(gzip_file).writerows(rows)
gzip_file.close()
self.store(course_id, filename, output_buffer)
def links_for(self, course_id):
"""
For a given `course_id`, return a list of `(filename, url)` tuples. `url`
can be plugged straight into an href
"""
course_dir = self.key_for(course_id, '')
return sorted(
[
(key.key.split("/")[-1], key.generate_url(expires_in=300))
for key in self.bucket.list(prefix=course_dir.key)
],
reverse=True
)
class LocalFSGradesStore(GradesStore):
"""
LocalFS implementation of a GradesStore. This is meant for debugging
purposes and is *absolutely not for production use*. Use S3GradesStore for
that. We use this in tests and for local development. When it generates
links, it will make file:/// style links. That means you actually have to
copy them and open them in a separate browser window, for security reasons.
This lets us do the cheap thing locally for debugging without having to open
up a separate URL that would only be used to send files in dev.
"""
def __init__(self, root_path):
"""
Initialize with root_path where we're going to store our files. We
will build a directory structure under this for each course.
"""
self.root_path = root_path
if not os.path.exists(root_path):
os.makedirs(root_path)
@classmethod
def from_config(cls):
"""
Generate an instance of this object from Django settings. It assumes
that there is a dict in settings named GRADES_DOWNLOAD and that it has
a ROOT_PATH that maps to an absolute file path that the web app has
write permissions to. `LocalFSGradesStore` will create any intermediate
directories as needed. Example::
STORAGE_TYPE : "localfs"
ROOT_PATH : /tmp/edx/grade-downloads/
"""
return cls(settings.GRADES_DOWNLOAD['ROOT_PATH'])
def path_to(self, course_id, filename):
"""Return the full path to a given file for a given course."""
return os.path.join(self.root_path, urllib.quote(course_id, safe=''), filename)
def store(self, course_id, filename, buff):
"""
Given the `course_id` and `filename`, store the contents of `buff` in
that file. Overwrite anything that was there previously. `buff` is
assumed to be a StringIO objecd (or anything that can flush its contents
to string using `.getvalue()`).
"""
full_path = self.path_to(course_id, filename)
directory = os.path.dirname(full_path)
if not os.path.exists(directory):
os.mkdir(directory)
with open(full_path, "wb") as f:
f.write(buff.getvalue())
def store_rows(self, course_id, filename, rows):
"""
Given a course_id, filename, and rows (each row is an iterable of strings),
write this data out.
"""
output_buffer = StringIO()
csv.writer(output_buffer).writerows(rows)
self.store(course_id, filename, output_buffer)
def links_for(self, course_id):
"""
For a given `course_id`, return a list of `(filename, url)` tuples. `url`
can be plugged straight into an href. Note that `LocalFSGradesStore`
will generate `file://` type URLs, so you'll need to copy the URL and
open it in a new browser window. Again, this class is only meant for
local development.
"""
course_dir = self.path_to(course_id, '')
if not os.path.exists(course_dir):
return []
return sorted(
[
(filename, ("file://" + urllib.quote(os.path.join(course_dir, filename))))
for filename in os.listdir(course_dir)
],
reverse=True
)
......@@ -19,6 +19,7 @@ a problem URL and optionally a student. These are used to set up the initial va
of the query for traversing StudentModule objects.
"""
from django.conf import settings
from django.utils.translation import ugettext_noop
from celery import task
from functools import partial
......@@ -29,6 +30,7 @@ from instructor_task.tasks_helper import (
rescore_problem_module_state,
reset_attempts_module_state,
delete_problem_module_state,
push_grades_to_s3,
)
from bulk_email.tasks import perform_delegate_email_batches
......@@ -127,3 +129,13 @@ def send_bulk_course_email(entry_id, _xmodule_instance_args):
action_name = ugettext_noop('emailed')
visit_fcn = perform_delegate_email_batches
return run_main_task(entry_id, visit_fcn, action_name)
@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=E1102
def calculate_grades_csv(entry_id, xmodule_instance_args):
"""
Grade a course and push the results to an S3 bucket for download.
"""
action_name = ugettext_noop('graded')
task_fn = partial(push_grades_to_s3, xmodule_instance_args)
return run_main_task(entry_id, task_fn, action_name)
......@@ -4,24 +4,27 @@ running state of a course.
"""
import json
import urllib
from datetime import datetime
from time import time
from celery import Task, current_task
from celery.utils.log import get_task_logger
from celery.states import SUCCESS, FAILURE
from django.contrib.auth.models import User
from django.db import transaction, reset_queries
from dogapi import dog_stats_api
from pytz import UTC
from xmodule.modulestore.django import modulestore
from track.views import task_track
from courseware.grades import iterate_grades_for
from courseware.models import StudentModule
from courseware.model_data import FieldDataCache
from courseware.module_render import get_module_for_descriptor_internal
from instructor_task.models import InstructorTask, PROGRESS
from instructor_task.models import GradesStore, InstructorTask, PROGRESS
from student.models import CourseEnrollment
# define different loggers for use within tasks and on client side
TASK_LOG = get_task_logger(__name__)
......@@ -465,3 +468,106 @@ def delete_problem_module_state(xmodule_instance_args, _module_descriptor, stude
track_function = _get_track_function_for_task(student_module.student, xmodule_instance_args)
track_function('problem_delete_state', {})
return UPDATE_STATUS_SUCCEEDED
def push_grades_to_s3(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name):
"""
For a given `course_id`, generate a grades CSV file for all students that
are enrolled, and store using a `GradesStore`. Once created, the files can
be accessed by instantiating another `GradesStore` (via
`GradesStore.from_config()`) and calling `link_for()` on it. Writes are
buffered, so we'll never write part of a CSV file to S3 -- i.e. any files
that are visible in GradesStore will be complete ones.
As we start to add more CSV downloads, it will probably be worthwhile to
make a more general CSVDoc class instead of building out the rows like we
do here.
"""
start_time = datetime.now(UTC)
status_interval = 100
enrolled_students = CourseEnrollment.users_enrolled_in(course_id)
num_total = enrolled_students.count()
num_attempted = 0
num_succeeded = 0
num_failed = 0
curr_step = "Calculating Grades"
def update_task_progress():
"""Return a dict containing info about current task"""
current_time = datetime.now(UTC)
progress = {
'action_name': action_name,
'attempted': num_attempted,
'succeeded': num_succeeded,
'failed': num_failed,
'total': num_total,
'duration_ms': int((current_time - start_time).total_seconds() * 1000),
'step': curr_step,
}
_get_current_task().update_state(state=PROGRESS, meta=progress)
return progress
# Loop over all our students and build our CSV lists in memory
header = None
rows = []
err_rows = [["id", "username", "error_msg"]]
for student, gradeset, err_msg in iterate_grades_for(course_id, enrolled_students):
# Periodically update task status (this is a cache write)
if num_attempted % status_interval == 0:
update_task_progress()
num_attempted += 1
if gradeset:
# We were able to successfully grade this student for this course.
num_succeeded += 1
if not header:
header = [section['label'] for section in gradeset[u'section_breakdown']]
rows.append(["id", "email", "username", "grade"] + header)
percents = {
section['label']: section.get('percent', 0.0)
for section in gradeset[u'section_breakdown']
if 'label' in section
}
# Not everybody has the same gradable items. If the item is not
# found in the user's gradeset, just assume it's a 0. The aggregated
# grades for their sections and overall course will be calculated
# without regard for the item they didn't have access to, so it's
# possible for a student to have a 0.0 show up in their row but
# still have 100% for the course.
row_percents = [percents.get(label, 0.0) for label in header]
rows.append([student.id, student.email, student.username, gradeset['percent']] + row_percents)
else:
# An empty gradeset means we failed to grade a student.
num_failed += 1
err_rows.append([student.id, student.username, err_msg])
# By this point, we've got the rows we're going to stuff into our CSV files.
curr_step = "Uploading CSVs"
update_task_progress()
# Generate parts of the file name
timestamp_str = start_time.strftime("%Y-%m-%d-%H%M")
course_id_prefix = urllib.quote(course_id.replace("/", "_"))
# Perform the actual upload
grades_store = GradesStore.from_config()
grades_store.store_rows(
course_id,
"{}_grade_report_{}.csv".format(course_id_prefix, timestamp_str),
rows
)
# If there are any error rows (don't count the header), write them out as well
if len(err_rows) > 1:
grades_store.store_rows(
course_id,
"{}_grade_report_{}_err.csv".format(course_id_prefix, timestamp_str),
err_rows
)
# One last update before we close out...
return update_task_progress()
......@@ -75,7 +75,6 @@ def instructor_task_status(request):
'traceback': optional, returned if task failed and produced a traceback.
"""
output = {}
if 'task_id' in request.REQUEST:
task_id = request.REQUEST['task_id']
......
......@@ -86,6 +86,7 @@ CELERY_DEFAULT_EXCHANGE = 'edx.{0}core'.format(QUEUE_VARIANT)
HIGH_PRIORITY_QUEUE = 'edx.{0}core.high'.format(QUEUE_VARIANT)
DEFAULT_PRIORITY_QUEUE = 'edx.{0}core.default'.format(QUEUE_VARIANT)
LOW_PRIORITY_QUEUE = 'edx.{0}core.low'.format(QUEUE_VARIANT)
HIGH_MEM_QUEUE = 'edx.{0}core.high_mem'.format(QUEUE_VARIANT)
CELERY_DEFAULT_QUEUE = DEFAULT_PRIORITY_QUEUE
CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE
......@@ -93,9 +94,19 @@ CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE
CELERY_QUEUES = {
HIGH_PRIORITY_QUEUE: {},
LOW_PRIORITY_QUEUE: {},
DEFAULT_PRIORITY_QUEUE: {}
DEFAULT_PRIORITY_QUEUE: {},
HIGH_MEM_QUEUE: {},
}
# If we're a worker on the high_mem queue, set ourselves to die after processing
# one request to avoid having memory leaks take down the worker server. This env
# var is set in /etc/init/edx-workers.conf -- this should probably be replaced
# with some celery API call to see what queue we started listening to, but I
# don't know what that call is or if it's active at this point in the code.
if os.environ.get('QUEUE') == 'high_mem':
CELERYD_MAX_TASKS_PER_CHILD = 1
########################## NON-SECURE ENV CONFIG ##############################
# Things like server locations, ports, etc.
......@@ -312,3 +323,8 @@ TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
# Student identity verification settings
VERIFY_STUDENT = AUTH_TOKENS.get("VERIFY_STUDENT", VERIFY_STUDENT)
# Grades download
GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE
GRADES_DOWNLOAD = ENV_TOKENS.get("GRADES_DOWNLOAD", GRADES_DOWNLOAD)
......@@ -192,6 +192,14 @@ MITX_FEATURES = {
# Disable instructor dash buttons for downloading course data
# when enrollment exceeds this number
'MAX_ENROLLMENT_INSTR_BUTTONS': 200,
# Grade calculation started from the new instructor dashboard will write
# grades CSV files to S3 and give links for downloads.
'ENABLE_S3_GRADE_DOWNLOADS': False,
# Give course staff unrestricted access to grade downloads (if set to False,
# only edX superusers can perform the downloads)
'ALLOW_COURSE_STAFF_GRADE_DOWNLOADS': False,
}
# Used for A/B testing
......@@ -846,6 +854,7 @@ CELERY_DEFAULT_EXCHANGE_TYPE = 'direct'
HIGH_PRIORITY_QUEUE = 'edx.core.high'
DEFAULT_PRIORITY_QUEUE = 'edx.core.default'
LOW_PRIORITY_QUEUE = 'edx.core.low'
HIGH_MEM_QUEUE = 'edx.core.high_mem'
CELERY_QUEUE_HA_POLICY = 'all'
......@@ -857,7 +866,8 @@ CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE
CELERY_QUEUES = {
HIGH_PRIORITY_QUEUE: {},
LOW_PRIORITY_QUEUE: {},
DEFAULT_PRIORITY_QUEUE: {}
DEFAULT_PRIORITY_QUEUE: {},
HIGH_MEM_QUEUE: {},
}
# let logging work as configured:
......@@ -1061,3 +1071,12 @@ REGISTRATION_OPTIONAL_FIELDS = set([
'mailing_address',
'goals',
])
###################### Grade Downloads ######################
GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE
GRADES_DOWNLOAD = {
'STORAGE_TYPE': 'localfs',
'BUCKET': 'edx-grades',
'ROOT_PATH': '/tmp/edx-s3/grades',
}
......@@ -40,6 +40,7 @@ MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True
MITX_FEATURES['ENABLE_SHOPPING_CART'] = True
MITX_FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True
MITX_FEATURES['ENABLE_S3_GRADE_DOWNLOADS'] = True
FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com"
......
......@@ -17,14 +17,23 @@ class DataDownload
# this object to call event handlers like 'onClickTitle'
@$section.data 'wrapper', @
# gather elements
@$display = @$section.find '.data-display'
@$display_text = @$display.find '.data-display-text'
@$display_table = @$display.find '.data-display-table'
@$request_response_error = @$display.find '.request-response-error'
@$list_studs_btn = @$section.find("input[name='list-profiles']'")
@$list_anon_btn = @$section.find("input[name='list-anon-ids']'")
@$grade_config_btn = @$section.find("input[name='dump-gradeconf']'")
@$calculate_grades_csv_btn = @$section.find("input[name='calculate-grades-csv']'")
# response areas
@$download = @$section.find '.data-download-container'
@$download_display_text = @$download.find '.data-display-text'
@$download_display_table = @$download.find '.data-display-table'
@$download_request_response_error = @$download.find '.request-response-error'
@$grades = @$section.find '.grades-download-container'
@$grades_request_response = @$grades.find '.request-response'
@$grades_request_response_error = @$grades.find '.request-response-error'
@grade_downloads = new GradeDownloads(@$section)
@instructor_tasks = new (PendingInstructorTasks()) @$section
@clear_display()
# attach click handlers
# The list-anon case is always CSV
......@@ -43,8 +52,9 @@ class DataDownload
url += '/csv'
location.href = url
else
# Dynamically generate slickgrid table for displaying student profile information
@clear_display()
@$display_table.text 'Loading...'
@$download_display_table.text gettext('Loading...')
# fetch user list
$.ajax
......@@ -52,7 +62,7 @@ class DataDownload
url: url
error: std_ajax_err =>
@clear_display()
@$request_response_error.text "Error getting student list."
@$download_request_response_error.text gettext("Error getting student list.")
success: (data) =>
@clear_display()
......@@ -61,12 +71,13 @@ class DataDownload
enableCellNavigation: true
enableColumnReorder: false
forceFitColumns: true
rowHeight: 35
columns = ({id: feature, field: feature, name: feature} for feature in data.queried_features)
grid_data = data.students
$table_placeholder = $ '<div/>', class: 'slickgrid'
@$display_table.append $table_placeholder
@$download_display_table.append $table_placeholder
grid = new Slick.Grid($table_placeholder, grid_data, columns, options)
# grid.autosizeColumns()
......@@ -78,21 +89,103 @@ class DataDownload
url: url
error: std_ajax_err =>
@clear_display()
@$request_response_error.text "Error getting grading configuration."
@$download_request_response_error.text gettext("Error retrieving grading configuration.")
success: (data) =>
@clear_display()
@$display_text.html data['grading_config_summary']
@$download_display_text.html data['grading_config_summary']
@$calculate_grades_csv_btn.click (e) =>
# Clear any CSS styling from the request-response areas
#$(".msg-confirm").css({"display":"none"})
#$(".msg-error").css({"display":"none"})
@clear_display()
url = @$calculate_grades_csv_btn.data 'endpoint'
$.ajax
dataType: 'json'
url: url
error: std_ajax_err =>
@$grades_request_response_error.text gettext("Error generating grades. Please try again.")
$(".msg-error").css({"display":"block"})
success: (data) =>
@$grades_request_response.text data['status']
$(".msg-confirm").css({"display":"block"})
# handler for when the section title is clicked.
onClickTitle: -> @instructor_tasks.task_poller.start()
onClickTitle: ->
# Clear display of anything that was here before
@clear_display()
@instructor_tasks.task_poller.start()
@grade_downloads.downloads_poller.start()
# handler for when the section is closed
onExit: -> @instructor_tasks.task_poller.stop()
onExit: ->
@instructor_tasks.task_poller.stop()
@grade_downloads.downloads_poller.stop()
clear_display: ->
@$display_text.empty()
@$display_table.empty()
@$request_response_error.empty()
# Clear any generated tables, warning messages, etc.
@$download_display_text.empty()
@$download_display_table.empty()
@$download_request_response_error.empty()
@$grades_request_response.empty()
@$grades_request_response_error.empty()
# Clear any CSS styling from the request-response areas
$(".msg-confirm").css({"display":"none"})
$(".msg-error").css({"display":"none"})
class GradeDownloads
### Grade Downloads -- links expire quickly, so we refresh every 5 mins ####
constructor: (@$section) ->
@$grades = @$section.find '.grades-download-container'
@$grades_request_response = @$grades.find '.request-response'
@$grades_request_response_error = @$grades.find '.request-response-error'
@$grade_downloads_table = @$grades.find ".grade-downloads-table"
POLL_INTERVAL = 1000 * 60 * 5 # 5 minutes in ms
@downloads_poller = new window.InstructorDashboard.util.IntervalManager(
POLL_INTERVAL, => @reload_grade_downloads()
)
reload_grade_downloads: ->
endpoint = @$grade_downloads_table.data 'endpoint'
$.ajax
dataType: 'json'
url: endpoint
success: (data) =>
if data.downloads.length
@create_grade_downloads_table data.downloads
else
console.log "No grade CSVs ready for download"
error: std_ajax_err => console.error "Error finding grade download CSVs"
create_grade_downloads_table: (grade_downloads_data) ->
@$grade_downloads_table.empty()
options =
enableCellNavigation: true
enableColumnReorder: false
rowHeight: 30
forceFitColumns: true
columns = [
id: 'link'
field: 'link'
name: gettext('File Name (Newest First)')
toolTip: gettext("Links are generated on demand and expire within 5 minutes due to the sensitive nature of student grade information.")
sortable: false
minWidth: 150
cssClass: "file-download-link"
formatter: (row, cell, value, columnDef, dataContext) ->
'<a href="' + dataContext['url'] + '">' + dataContext['name'] + '</a>'
]
$table_placeholder = $ '<div/>', class: 'slickgrid'
@$grade_downloads_table.append $table_placeholder
grid = new Slick.Grid($table_placeholder, grade_downloads_data, columns, options)
grid.autosizeColumns()
# export for use
......
......@@ -43,7 +43,7 @@ create_task_list_table = ($table_tasks, tasks_data) ->
id: 'task_type'
field: 'task_type'
name: 'Task Type'
minWidth: 100
minWidth: 102
,
id: 'task_input'
field: 'task_input'
......
......@@ -26,6 +26,13 @@
@include font-size(16);
}
.file-download-link a {
font-size: 15px;
color: $link-color;
text-decoration: underline;
padding: 5px;
}
// system feedback - messages
.msg {
border-radius: 1px;
......@@ -117,7 +124,7 @@ section.instructor-dashboard-content-2 {
.slickgrid {
margin-left: 1px;
color:#333333;
font-size:11px;
font-size:12px;
font-family: verdana,arial,sans-serif;
.slick-header-column {
......@@ -428,13 +435,26 @@ section.instructor-dashboard-content-2 {
line-height: 1.3em;
}
.data-display {
.data-download-container {
.data-display-table {
.slickgrid {
height: 400px;
}
}
}
.grades-download-container {
.grade-downloads-table {
.slickgrid {
height: 300px;
padding: 5px;
}
// Disable horizontal scroll bar when grid only has 1 column. Remove this CSS class when more columns added.
.slick-viewport {
overflow-x: hidden !important;
}
}
}
}
......
......@@ -2,23 +2,49 @@
<%page args="section_data"/>
<h2>${_("Data Download")}</h2>
<input type="button" name="list-profiles" value="${_("List enrolled students with profile information")}" data-endpoint="${ section_data['get_students_features_url'] }">
<input type="button" name="list-profiles" value="CSV" data-csv="true">
<br>
## <input type="button" name="list-grades" value="Student grades">
## <input type="button" name="list-profiles" value="CSV" data-csv="true" class="csv">
## <br>
## <input type="button" name="list-answer-distributions" value="Answer distributions (x students got y points)">
## <br>
<input type="button" name="dump-gradeconf" value="${_("Grading Configuration")}" data-endpoint="${ section_data['get_grading_config_url'] }">
<input type="button" name="list-anon-ids" value="${_("Get Student Anonymized IDs CSV")}" data-csv="true" class="csv" data-endpoint="${ section_data['get_anon_ids_url'] }" class="${'is-disabled' if disable_buttons else ''}">
<div class="data-display">
<div class="data-display-text"></div>
<div class="data-download-container action-type-container">
<h2>${_("Data Download")}</h2>
<div class="request-response-error msg msg-error copy"></div>
<p>${_("The following button displays a list of all students enrolled in this course, along with profile information such as email address and username. The data can also be downloaded as a CSV file.")}</p>
<p><input type="button" name="list-profiles" value="${_("List enrolled students' profile information")}" data-endpoint="${ section_data['get_students_features_url'] }">
<input type="button" name="list-profiles" value="${_("Download profile information as a CSV")}" data-csv="true"></p>
<div class="data-display-table"></div>
<div class="request-response-error"></div>
<br>
<p>${_("Displays the grading configuration for the course. The grading configuration is the breakdown of graded subsections of the course (such as exams and problem sets), and can be changed on the 'Grading' page (under 'Settings') in Studio.")}</p>
<p><input type="button" name="dump-gradeconf" value="${_("Grading Configuration")}" data-endpoint="${ section_data['get_grading_config_url'] }"></p>
<div class="data-display-text"></div>
<br>
<p>${_("Download a CSV of anonymized student IDs by clicking this button.")}</p>
<p><input type="button" name="list-anon-ids" value="${_("Get Student Anonymized IDs CSV")}" data-csv="true" class="csv" data-endpoint="${ section_data['get_anon_ids_url'] }" class="${'is-disabled' if disable_buttons else ''}"></p>
</div>
%if settings.MITX_FEATURES.get('ENABLE_S3_GRADE_DOWNLOADS'):
<div class="grades-download-container action-type-container">
<hr>
<h2> ${_("Grade Reports")}</h2>
%if settings.MITX_FEATURES.get('ALLOW_COURSE_STAFF_GRADE_DOWNLOADS') or section_data['access']['admin']:
<p>${_("The following button will generate a CSV grade report for all currently enrolled students. For large courses, generating this report may take a few hours.")}</p>
<p>${_("The report is generated in the background, meaning it is OK to navigate away from this page while your report is generating. Generated reports appear in a table below and can be downloaded.")}</p>
<div class="request-response msg msg-confirm copy"></div>
<div class="request-response-error msg msg-warning copy"></div>
<br>
<p><input type="button" name="calculate-grades-csv" value="${_("Generate Grade Report")}" data-endpoint="${ section_data['calculate_grades_csv_url'] }"/></p>
%endif
<p><b>${_("Reports Available for Download")}</b></p>
<p>${_("File links are generated on demand and expire within 5 minutes due to the sensitive nature of student grade information. Please note that the report filename contains a timestamp that represents when your file was generated; this timestamp is UTC, not your local timezone.")}</p><br>
<div class="grade-downloads-table" data-endpoint="${ section_data['list_grade_downloads_url'] }" ></div>
</div>
%endif
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<div class="running-tasks-container action-type-container">
......@@ -26,9 +52,6 @@
<h2> ${_("Pending Instructor Tasks")} </h2>
<p>${_("The status for any active tasks appears in a table below.")} </p>
<br />
<div class="running-tasks-table" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></div>
</div>
%endif
</div>
......@@ -5,7 +5,6 @@
<h2>${_("Student-specific grade inspection")}</h2>
<div class="request-response-error"></div>
<p>
<!-- Doesn't work for username but this MUST work -->
${_("Specify the {platform_name} email address or username of a student here:").format(platform_name=settings.PLATFORM_NAME)}
<input type="text" name="student-select-progress" placeholder="${_("Student Email or Username")}">
</p>
......@@ -26,7 +25,6 @@
<h2>${_("Student-specific grade adjustment")}</h2>
<div class="request-response-error"></div>
<p>
<!-- Doesn't work for username but this MUST work -->
${_("Specify the {platform_name} email address or username of a student here:").format(platform_name=settings.PLATFORM_NAME)}
<input type="text" name="student-select-grade" placeholder="${_("Student Email or Username")}">
</p>
......@@ -60,18 +58,18 @@
<p>
%if section_data['access']['instructor']:
<p> ${_('You may also delete the entire state of a student for the specified problem:')} </p>
<input type="button" class="molly-guard" name="delete-state-single" value="${_("Delete Student State for Problem")}" data-endpoint="${ section_data['reset_student_attempts_url'] }">
<p><input type="button" class="molly-guard" name="delete-state-single" value="${_("Delete Student State for Problem")}" data-endpoint="${ section_data['reset_student_attempts_url'] }"></p>
%endif
</p>
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<p>
${_("Rescoring runs in the background, and status for active tasks will appear in a table on the Course Info tab. "
${_("Rescoring runs in the background, and status for active tasks will appear in the 'Pending Instructor Tasks' table. "
"To see status for all tasks submitted for this problem and student, click on this button:")}
</p>
<input type="button" name="task-history-single" value="${_("Show Background Task History for Student")}" data-endpoint="${ section_data['list_instructor_tasks_url'] }">
<p><input type="button" name="task-history-single" value="${_("Show Background Task History for Student")}" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></p>
<div class="task-history-single-table"></div>
%endif
<hr>
......@@ -101,10 +99,10 @@
</p>
<p>
<p>
${_("These actions run in the background, and status for active tasks will appear in a table on the Course Info tab. "
${_("The above actions run in the background, and status for active tasks will appear in a table on the Course Info tab. "
"To see status for all tasks submitted for this problem, click on this button")}:
</p>
<input type="button" name="task-history-all" value="${_("Show Background Task History for Problem")}" data-endpoint="${ section_data['list_instructor_tasks_url'] }">
<p><input type="button" name="task-history-all" value="${_("Show Background Task History for Problem")}" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></p>
<div class="task-history-all-table"></div>
</p>
</div>
......
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