Skip to content

Commit

Permalink
Merge pull request #1539 from edx/sarina/inst-dash-tasks
Browse files Browse the repository at this point in the history
Enable Pending Tasks on beta dash // Course Info prettifying
  • Loading branch information
sarina committed Oct 30, 2013
2 parents eb1b926 + b86e912 commit cb4025b
Show file tree
Hide file tree
Showing 19 changed files with 425 additions and 198 deletions.
106 changes: 85 additions & 21 deletions lms/djangoapps/instructor/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import unittest
import json
import requests
import datetime
from urllib import quote
from django.test import TestCase
from nose.tools import raises
Expand Down Expand Up @@ -761,6 +762,18 @@ def test_send_email_no_message(self):
self.assertEqual(response.status_code, 400)


class MockCompletionInfo(object):
"""Mock for get_task_completion_info"""
times_called = 0

def mock_get_task_completion_info(self, *args): # pylint: disable=unused-argument
"""Mock for get_task_completion_info"""
self.times_called += 1
if self.times_called % 2 == 0:
return True, 'Task Completed'
return False, 'Task Errored In Some Way'


@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Expand All @@ -769,15 +782,46 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):

class FakeTask(object):
""" Fake task object """
FEATURES = ['task_type', 'task_input', 'task_id', 'requester', 'created', 'task_state']
FEATURES = [
'task_type',
'task_input',
'task_id',
'requester',
'task_state',
'created',
'status',
'task_message',
'duration_sec'
]

def __init__(self):
def __init__(self, completion):
for feature in self.FEATURES:
setattr(self, feature, 'expected')
# created needs to be a datetime
self.created = datetime.datetime(2013, 10, 25, 11, 42, 35)
# set 'status' and 'task_message' attrs
success, task_message = completion()
if success:
self.status = "Complete"
else:
self.status = "Incomplete"
self.task_message = task_message
# Set 'task_output' attr, which will be parsed to the 'duration_sec' attr.
self.task_output = '{"duration_ms": 1035000}'
self.duration_sec = 1035000 / 1000.0

def make_invalid_output(self):
"""Munge task_output to be invalid json"""
self.task_output = 'HI MY NAME IS INVALID JSON'
# This should be given the value of 'unknown' if the task output
# can't be properly parsed
self.duration_sec = 'unknown'

def to_dict(self):
""" Convert fake task to dictionary representation. """
return {key: 'expected' for key in self.FEATURES}
attr_dict = {key: getattr(self, key) for key in self.FEATURES}
attr_dict['created'] = attr_dict['created'].isoformat()
return attr_dict

def setUp(self):
self.instructor = AdminFactory.create()
Expand All @@ -797,58 +841,78 @@ def setUp(self):
),
state=json.dumps({'attempts': 10}),
)
mock_factory = MockCompletionInfo()
self.tasks = [self.FakeTask(mock_factory.mock_get_task_completion_info) for _ in xrange(7)]
self.tasks[-1].make_invalid_output()

self.tasks = [self.FakeTask() for _ in xrange(6)]
def tearDown(self):
"""
Undo all patches.
"""
patch.stopall()

@patch.object(instructor_task.api, 'get_running_instructor_tasks')
def test_list_instructor_tasks_running(self, act):
""" Test list of all running tasks. """
act.return_value = self.tasks
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
print response.content
mock_factory = MockCompletionInfo()
with patch('instructor.views.api.get_task_completion_info') as mock_completion_info:
mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info
response = self.client.get(url, {})
self.assertEqual(response.status_code, 200)

# check response
self.assertTrue(act.called)
expected_tasks = [ftask.to_dict() for ftask in self.tasks]
expected_res = {'tasks': expected_tasks}
self.assertEqual(json.loads(response.content), expected_res)
actual_tasks = json.loads(response.content)['tasks']
for exp_task, act_task in zip(expected_tasks, actual_tasks):
self.assertDictEqual(exp_task, act_task)
self.assertEqual(actual_tasks, expected_tasks)

@patch.object(instructor_task.api, 'get_instructor_task_history')
def test_list_instructor_tasks_problem(self, act):
""" Test list task history for problem. """
act.return_value = self.tasks
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_urlname': self.problem_urlname,
})
print response.content
mock_factory = MockCompletionInfo()
with patch('instructor.views.api.get_task_completion_info') as mock_completion_info:
mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info
response = self.client.get(url, {
'problem_urlname': self.problem_urlname,
})
self.assertEqual(response.status_code, 200)

# check response
self.assertTrue(act.called)
expected_tasks = [ftask.to_dict() for ftask in self.tasks]
expected_res = {'tasks': expected_tasks}
self.assertEqual(json.loads(response.content), expected_res)
actual_tasks = json.loads(response.content)['tasks']
for exp_task, act_task in zip(expected_tasks, actual_tasks):
self.assertDictEqual(exp_task, act_task)
self.assertEqual(actual_tasks, expected_tasks)

@patch.object(instructor_task.api, 'get_instructor_task_history')
def test_list_instructor_tasks_problem_student(self, act):
""" Test list task history for problem AND student. """
act.return_value = self.tasks
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_urlname': self.problem_urlname,
'unique_student_identifier': self.student.email,
})
print response.content
mock_factory = MockCompletionInfo()
with patch('instructor.views.api.get_task_completion_info') as mock_completion_info:
mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info
response = self.client.get(url, {
'problem_urlname': self.problem_urlname,
'unique_student_identifier': self.student.email,
})
self.assertEqual(response.status_code, 200)

# check response
self.assertTrue(act.called)
expected_tasks = [ftask.to_dict() for ftask in self.tasks]
expected_res = {'tasks': expected_tasks}
self.assertEqual(json.loads(response.content), expected_res)
actual_tasks = json.loads(response.content)['tasks']
for exp_task, act_task in zip(expected_tasks, actual_tasks):
self.assertDictEqual(exp_task, act_task)

self.assertEqual(actual_tasks, expected_tasks)


@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
Expand Down
41 changes: 38 additions & 3 deletions lms/djangoapps/instructor/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import re
import logging
import json
import requests
from django.conf import settings
from django_future.csrf import ensure_csrf_cookie
Expand All @@ -30,6 +31,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
import instructor.enrollment as enrollment
from instructor.enrollment import enroll_email, unenroll_email
from instructor.views.tools import strip_if_string, get_student_from_identifier
Expand Down Expand Up @@ -675,9 +677,42 @@ def list_instructor_tasks(request, course_id):
tasks = instructor_task.api.get_running_instructor_tasks(course_id)

def extract_task_features(task):
""" Convert task to dict for json rendering """
features = ['task_type', 'task_input', 'task_id', 'requester', 'created', 'task_state']
return dict((feature, str(getattr(task, feature))) for feature in features)
"""
Convert task to dict for json rendering.
Expects tasks have the following features:
* task_type (str, type of task)
* task_input (dict, input(s) to the task)
* task_id (str, celery id of the task)
* requester (str, username who submitted the task)
* task_state (str, state of task eg PROGRESS, COMPLETED)
* created (datetime, when the task was completed)
* task_output (optional)
"""
# Pull out information from the task
features = ['task_type', 'task_input', 'task_id', 'requester', 'task_state']
task_feature_dict = {feature: str(getattr(task, feature)) for feature in features}
# Some information (created, duration, status, task message) require additional formatting
task_feature_dict['created'] = task.created.isoformat()

# Get duration info, if known
duration_sec = 'unknown'
if hasattr(task, 'task_output') and task.task_output is not None:
try:
task_output = json.loads(task.task_output)
except ValueError:
log.error("Could not parse task output as valid json; task output: %s", task.task_output)
else:
if 'duration_ms' in task_output:
duration_sec = int(task_output['duration_ms'] / 1000.0)
task_feature_dict['duration_sec'] = duration_sec

# Get progress status message & success information
success, task_message = get_task_completion_info(task)
status = _("Complete") if success else _("Incomplete")
task_feature_dict['status'] = status
task_feature_dict['task_message'] = task_message

return task_feature_dict

response_payload = {
'tasks': map(extract_task_features, tasks),
Expand Down
31 changes: 21 additions & 10 deletions lms/djangoapps/instructor/views/instructor_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from courseware.access import has_access
from courseware.courses import get_course_by_id
from courseware.courses import get_course_by_id, get_cms_course_link_by_id
from django_comment_client.utils import has_forum_access
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
from student.models import CourseEnrollment
Expand All @@ -45,27 +45,32 @@ def instructor_dashboard_2(request, course_id):
raise Http404()

sections = [
_section_course_info(course_id, access),
_section_course_info(course_id),
_section_membership(course_id, access),
_section_student_admin(course_id, access),
_section_data_download(course_id),
_section_analytics(course_id),
]

# Gate access to course email by feature flag & by course-specific authorization
if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and \
is_studio_course and CourseAuthorization.instructor_email_enabled(course_id):
sections.append(_section_send_email(course_id, access, course))

studio_url = None
if is_studio_course:
studio_url = get_cms_course_link_by_id(course_id)

enrollment_count = sections[0]['enrollment_count']
disable_buttons = False
max_enrollment_for_buttons = settings.MITX_FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS")
if max_enrollment_for_buttons is not None:
disable_buttons = enrollment_count > max_enrollment_for_buttons

# Gate access by feature flag & by course-specific authorization
if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and \
is_studio_course and CourseAuthorization.instructor_email_enabled(course_id):
sections.append(_section_send_email(course_id, access, course))

context = {
'course': course,
'old_dashboard_url': reverse('instructor_dashboard', kwargs={'course_id': course_id}),
'studio_url': studio_url,
'sections': sections,
'disable_buttons': disable_buttons,
}
Expand All @@ -86,15 +91,19 @@ def instructor_dashboard_2(request, course_id):
""" # pylint: disable=W0105


def _section_course_info(course_id, access):
def _section_course_info(course_id):
""" Provide data for the corresponding dashboard section """
course = get_course_by_id(course_id, depth=None)

course_org, course_num, course_name = course_id.split('/')

section_data = {
'section_key': 'course_info',
'section_display_name': _('Course Info'),
'course_id': course_id,
'access': access,
'course_org': course_org,
'course_num': course_num,
'course_name': course_name,
'course_display_name': course.display_name,
'enrollment_count': CourseEnrollment.objects.filter(course_id=course_id).count(),
'has_started': course.has_started(),
Expand Down Expand Up @@ -156,6 +165,7 @@ def _section_data_download(course_id):
'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': course_id}),
'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}),
}
return section_data

Expand All @@ -171,7 +181,8 @@ def _section_send_email(course_id, access, course):
'section_display_name': _('Email'),
'access': access,
'send_email': reverse('send_email', kwargs={'course_id': course_id}),
'editor': email_editor
'editor': email_editor,
'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': course_id}),
}
return section_data

Expand Down
18 changes: 10 additions & 8 deletions lms/djangoapps/instructor/views/legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -1589,14 +1589,16 @@ def get_background_task_table(course_id, problem_url=None, student=None, task_ty
success, task_message = get_task_completion_info(instructor_task)
status = "Complete" if success else "Incomplete"
# generate row for this task:
row = [str(instructor_task.task_type),
str(instructor_task.task_id),
str(instructor_task.requester),
instructor_task.created.isoformat(' '),
duration_sec,
str(instructor_task.task_state),
status,
task_message]
row = [
str(instructor_task.task_type),
str(instructor_task.task_id),
str(instructor_task.requester),
instructor_task.created.isoformat(' '),
duration_sec,
str(instructor_task.task_state),
status,
task_message
]
datatable['data'].append(row)

if problem_url is None:
Expand Down
3 changes: 2 additions & 1 deletion lms/envs/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard)
MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True # Enable email for all Studio courses
MITX_FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False # Give all courses email (don't require django-admin perms)
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True
Expand Down
10 changes: 4 additions & 6 deletions lms/static/coffee/src/instructor_dashboard/analytics.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,7 @@ class Analytics

# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
Analytics: Analytics
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
Analytics: Analytics
Loading

0 comments on commit cb4025b

Please sign in to comment.