Commit cb4025b1 by Sarina Canelake

Merge pull request #1539 from edx/sarina/inst-dash-tasks

Enable Pending Tasks on beta dash // Course Info prettifying 
parents eb1b9260 b86e9129
...@@ -5,6 +5,7 @@ Unit tests for instructor.api methods. ...@@ -5,6 +5,7 @@ Unit tests for instructor.api methods.
import unittest import unittest
import json import json
import requests import requests
import datetime
from urllib import quote from urllib import quote
from django.test import TestCase from django.test import TestCase
from nose.tools import raises from nose.tools import raises
...@@ -761,6 +762,18 @@ class TestInstructorSendEmail(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -761,6 +762,18 @@ class TestInstructorSendEmail(ModuleStoreTestCase, LoginEnrollmentTestCase):
self.assertEqual(response.status_code, 400) 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) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
""" """
...@@ -769,15 +782,46 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -769,15 +782,46 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
class FakeTask(object): class FakeTask(object):
""" Fake task 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: for feature in self.FEATURES:
setattr(self, feature, 'expected') 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): def to_dict(self):
""" Convert fake task to dictionary representation. """ """ 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): def setUp(self):
self.instructor = AdminFactory.create() self.instructor = AdminFactory.create()
...@@ -797,58 +841,78 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -797,58 +841,78 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
), ),
state=json.dumps({'attempts': 10}), 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') @patch.object(instructor_task.api, 'get_running_instructor_tasks')
def test_list_instructor_tasks_running(self, act): def test_list_instructor_tasks_running(self, act):
""" Test list of all running tasks. """ """ Test list of all running tasks. """
act.return_value = self.tasks act.return_value = self.tasks
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id}) url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, {}) mock_factory = MockCompletionInfo()
print response.content 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) self.assertEqual(response.status_code, 200)
# check response # check response
self.assertTrue(act.called) self.assertTrue(act.called)
expected_tasks = [ftask.to_dict() for ftask in self.tasks] expected_tasks = [ftask.to_dict() for ftask in self.tasks]
expected_res = {'tasks': expected_tasks} actual_tasks = json.loads(response.content)['tasks']
self.assertEqual(json.loads(response.content), expected_res) 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') @patch.object(instructor_task.api, 'get_instructor_task_history')
def test_list_instructor_tasks_problem(self, act): def test_list_instructor_tasks_problem(self, act):
""" Test list task history for problem. """ """ Test list task history for problem. """
act.return_value = self.tasks act.return_value = self.tasks
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id}) url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, { mock_factory = MockCompletionInfo()
'problem_urlname': self.problem_urlname, 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
print response.content response = self.client.get(url, {
'problem_urlname': self.problem_urlname,
})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# check response # check response
self.assertTrue(act.called) self.assertTrue(act.called)
expected_tasks = [ftask.to_dict() for ftask in self.tasks] expected_tasks = [ftask.to_dict() for ftask in self.tasks]
expected_res = {'tasks': expected_tasks} actual_tasks = json.loads(response.content)['tasks']
self.assertEqual(json.loads(response.content), expected_res) 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') @patch.object(instructor_task.api, 'get_instructor_task_history')
def test_list_instructor_tasks_problem_student(self, act): def test_list_instructor_tasks_problem_student(self, act):
""" Test list task history for problem AND student. """ """ Test list task history for problem AND student. """
act.return_value = self.tasks act.return_value = self.tasks
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id}) url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, { mock_factory = MockCompletionInfo()
'problem_urlname': self.problem_urlname, with patch('instructor.views.api.get_task_completion_info') as mock_completion_info:
'unique_student_identifier': self.student.email, mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info
}) response = self.client.get(url, {
print response.content 'problem_urlname': self.problem_urlname,
'unique_student_identifier': self.student.email,
})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# check response # check response
self.assertTrue(act.called) self.assertTrue(act.called)
expected_tasks = [ftask.to_dict() for ftask in self.tasks] expected_tasks = [ftask.to_dict() for ftask in self.tasks]
expected_res = {'tasks': expected_tasks} actual_tasks = json.loads(response.content)['tasks']
self.assertEqual(json.loads(response.content), expected_res) 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) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
......
...@@ -8,6 +8,7 @@ Many of these GETs may become PUTs in the future. ...@@ -8,6 +8,7 @@ Many of these GETs may become PUTs in the future.
import re import re
import logging import logging
import json
import requests import requests
from django.conf import settings from django.conf import settings
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
...@@ -30,6 +31,7 @@ from courseware.models import StudentModule ...@@ -30,6 +31,7 @@ from courseware.models import StudentModule
from student.models import unique_id_for_user from student.models import unique_id_for_user
import instructor_task.api import instructor_task.api
from instructor_task.api_helper import AlreadyRunningError from instructor_task.api_helper import AlreadyRunningError
from instructor_task.views import get_task_completion_info
import instructor.enrollment as enrollment import instructor.enrollment as enrollment
from instructor.enrollment import enroll_email, unenroll_email from instructor.enrollment import enroll_email, unenroll_email
from instructor.views.tools import strip_if_string, get_student_from_identifier from instructor.views.tools import strip_if_string, get_student_from_identifier
...@@ -675,9 +677,42 @@ def list_instructor_tasks(request, course_id): ...@@ -675,9 +677,42 @@ def list_instructor_tasks(request, course_id):
tasks = instructor_task.api.get_running_instructor_tasks(course_id) tasks = instructor_task.api.get_running_instructor_tasks(course_id)
def extract_task_features(task): def extract_task_features(task):
""" Convert task to dict for json rendering """ """
features = ['task_type', 'task_input', 'task_id', 'requester', 'created', 'task_state'] Convert task to dict for json rendering.
return dict((feature, str(getattr(task, feature))) for feature in features) 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 = { response_payload = {
'tasks': map(extract_task_features, tasks), 'tasks': map(extract_task_features, tasks),
......
...@@ -18,7 +18,7 @@ from xmodule.modulestore.django import modulestore ...@@ -18,7 +18,7 @@ from xmodule.modulestore.django import modulestore
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds from xblock.fields import ScopeIds
from courseware.access import has_access 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_client.utils import has_forum_access
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -45,27 +45,32 @@ def instructor_dashboard_2(request, course_id): ...@@ -45,27 +45,32 @@ def instructor_dashboard_2(request, course_id):
raise Http404() raise Http404()
sections = [ sections = [
_section_course_info(course_id, access), _section_course_info(course_id),
_section_membership(course_id, access), _section_membership(course_id, access),
_section_student_admin(course_id, access), _section_student_admin(course_id, access),
_section_data_download(course_id), _section_data_download(course_id),
_section_analytics(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'] enrollment_count = sections[0]['enrollment_count']
disable_buttons = False disable_buttons = False
max_enrollment_for_buttons = settings.MITX_FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS") max_enrollment_for_buttons = settings.MITX_FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS")
if max_enrollment_for_buttons is not None: if max_enrollment_for_buttons is not None:
disable_buttons = enrollment_count > max_enrollment_for_buttons 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 = { context = {
'course': course, 'course': course,
'old_dashboard_url': reverse('instructor_dashboard', kwargs={'course_id': course_id}), 'old_dashboard_url': reverse('instructor_dashboard', kwargs={'course_id': course_id}),
'studio_url': studio_url,
'sections': sections, 'sections': sections,
'disable_buttons': disable_buttons, 'disable_buttons': disable_buttons,
} }
...@@ -86,15 +91,19 @@ section_display_name will be used to generate link titles in the nav bar. ...@@ -86,15 +91,19 @@ section_display_name will be used to generate link titles in the nav bar.
""" # pylint: disable=W0105 """ # pylint: disable=W0105
def _section_course_info(course_id, access): def _section_course_info(course_id):
""" Provide data for the corresponding dashboard section """ """ Provide data for the corresponding dashboard section """
course = get_course_by_id(course_id, depth=None) course = get_course_by_id(course_id, depth=None)
course_org, course_num, course_name = course_id.split('/')
section_data = { section_data = {
'section_key': 'course_info', 'section_key': 'course_info',
'section_display_name': _('Course Info'), 'section_display_name': _('Course Info'),
'course_id': course_id, 'course_id': course_id,
'access': access, 'course_org': course_org,
'course_num': course_num,
'course_name': course_name,
'course_display_name': course.display_name, 'course_display_name': course.display_name,
'enrollment_count': CourseEnrollment.objects.filter(course_id=course_id).count(), 'enrollment_count': CourseEnrollment.objects.filter(course_id=course_id).count(),
'has_started': course.has_started(), 'has_started': course.has_started(),
...@@ -156,6 +165,7 @@ def _section_data_download(course_id): ...@@ -156,6 +165,7 @@ def _section_data_download(course_id):
'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': 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_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}), '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 return section_data
...@@ -171,7 +181,8 @@ def _section_send_email(course_id, access, course): ...@@ -171,7 +181,8 @@ def _section_send_email(course_id, access, course):
'section_display_name': _('Email'), 'section_display_name': _('Email'),
'access': access, 'access': access,
'send_email': reverse('send_email', kwargs={'course_id': course_id}), '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 return section_data
......
...@@ -1589,14 +1589,16 @@ def get_background_task_table(course_id, problem_url=None, student=None, task_ty ...@@ -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) success, task_message = get_task_completion_info(instructor_task)
status = "Complete" if success else "Incomplete" status = "Complete" if success else "Incomplete"
# generate row for this task: # generate row for this task:
row = [str(instructor_task.task_type), row = [
str(instructor_task.task_id), str(instructor_task.task_type),
str(instructor_task.requester), str(instructor_task.task_id),
instructor_task.created.isoformat(' '), str(instructor_task.requester),
duration_sec, instructor_task.created.isoformat(' '),
str(instructor_task.task_state), duration_sec,
status, str(instructor_task.task_state),
task_message] status,
task_message
]
datatable['data'].append(row) datatable['data'].append(row)
if problem_url is None: if problem_url is None:
......
...@@ -29,7 +29,8 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True ...@@ -29,7 +29,8 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True
MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard) 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_INSTRUCTOR_ANALYTICS'] = True
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = 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_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True
......
...@@ -230,9 +230,7 @@ class Analytics ...@@ -230,9 +230,7 @@ class Analytics
# export for use # export for use
# create parent namespaces if they do not already exist. # create parent namespaces if they do not already exist.
# abort if underscore can not be found. _.defaults window, InstructorDashboard: {}
if _? _.defaults window.InstructorDashboard, sections: {}
_.defaults window, InstructorDashboard: {} _.defaults window.InstructorDashboard.sections,
_.defaults window.InstructorDashboard, sections: {} Analytics: Analytics
_.defaults window.InstructorDashboard.sections,
Analytics: Analytics
### ###
Course Info Section Course Info Section
This is the implementation of the simplest section
of the instructor dashboard.
imports from other modules. imports from other modules.
wrap in (-> ... apply) to defer evaluation wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order). such that the value can be defined later than this assignment (file load order).
### ###
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments # Load utilities
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks
# A typical section object. # A typical section object.
# constructed with $section, a jquery object # constructed with $section, a jquery object
# which holds the section body container. # which holds the section body container.
class CourseInfo class CourseInfo
constructor: (@$section) -> constructor: (@$section) ->
# attach self to html so that instructor_dashboard.coffee can find
# this object to call event handlers like 'onClickTitle'
@$section.data 'wrapper', @
# gather elements
@instructor_tasks = new (PendingInstructorTasks()) @$section
@$course_errors_wrapper = @$section.find '.course-errors-wrapper' @$course_errors_wrapper = @$section.find '.course-errors-wrapper'
# if there are errors # if there are errors
...@@ -37,12 +41,15 @@ class CourseInfo ...@@ -37,12 +41,15 @@ class CourseInfo
else else
@$course_errors_wrapper.addClass 'open' @$course_errors_wrapper.addClass 'open'
# handler for when the section title is clicked.
onClickTitle: -> @instructor_tasks.task_poller.start()
# handler for when the section is closed
onExit: -> @instructor_tasks.task_poller.stop()
# export for use # export for use
# create parent namespaces if they do not already exist. # create parent namespaces if they do not already exist.
# abort if underscore can not be found. _.defaults window, InstructorDashboard: {}
if _? _.defaults window.InstructorDashboard, sections: {}
_.defaults window, InstructorDashboard: {} _.defaults window.InstructorDashboard.sections,
_.defaults window.InstructorDashboard, sections: {} CourseInfo: CourseInfo
_.defaults window.InstructorDashboard.sections,
CourseInfo: CourseInfo
...@@ -6,13 +6,16 @@ wrap in (-> ... apply) to defer evaluation ...@@ -6,13 +6,16 @@ wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order). such that the value can be defined later than this assignment (file load order).
### ###
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments # Load utilities
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks
# Data Download Section # Data Download Section
class DataDownload class DataDownload
constructor: (@$section) -> constructor: (@$section) ->
# attach self to html so that instructor_dashboard.coffee can find
# this object to call event handlers like 'onClickTitle'
@$section.data 'wrapper', @
# gather elements # gather elements
@$display = @$section.find '.data-display' @$display = @$section.find '.data-display'
@$display_text = @$display.find '.data-display-text' @$display_text = @$display.find '.data-display-text'
...@@ -21,9 +24,9 @@ class DataDownload ...@@ -21,9 +24,9 @@ class DataDownload
@$list_studs_btn = @$section.find("input[name='list-profiles']'") @$list_studs_btn = @$section.find("input[name='list-profiles']'")
@$list_anon_btn = @$section.find("input[name='list-anon-ids']'") @$list_anon_btn = @$section.find("input[name='list-anon-ids']'")
@$grade_config_btn = @$section.find("input[name='dump-gradeconf']'") @$grade_config_btn = @$section.find("input[name='dump-gradeconf']'")
@instructor_tasks = new (PendingInstructorTasks()) @$section
# attach click handlers # attach click handlers
# The list-anon case is always CSV # The list-anon case is always CSV
@$list_anon_btn.click (e) => @$list_anon_btn.click (e) =>
url = @$list_anon_btn.data 'endpoint' url = @$list_anon_btn.data 'endpoint'
...@@ -80,6 +83,11 @@ class DataDownload ...@@ -80,6 +83,11 @@ class DataDownload
@clear_display() @clear_display()
@$display_text.html data['grading_config_summary'] @$display_text.html data['grading_config_summary']
# handler for when the section title is clicked.
onClickTitle: -> @instructor_tasks.task_poller.start()
# handler for when the section is closed
onExit: -> @instructor_tasks.task_poller.stop()
clear_display: -> clear_display: ->
@$display_text.empty() @$display_text.empty()
...@@ -89,9 +97,7 @@ class DataDownload ...@@ -89,9 +97,7 @@ class DataDownload
# export for use # export for use
# create parent namespaces if they do not already exist. # create parent namespaces if they do not already exist.
# abort if underscore can not be found. _.defaults window, InstructorDashboard: {}
if _? _.defaults window.InstructorDashboard, sections: {}
_.defaults window, InstructorDashboard: {} _.defaults window.InstructorDashboard.sections,
_.defaults window.InstructorDashboard, sections: {} DataDownload: DataDownload
_.defaults window.InstructorDashboard.sections,
DataDownload: DataDownload
...@@ -118,7 +118,7 @@ setup_instructor_dashboard = (idash_content) => ...@@ -118,7 +118,7 @@ setup_instructor_dashboard = (idash_content) =>
location.hash = "#{HASH_LINK_PREFIX}#{section_name}" location.hash = "#{HASH_LINK_PREFIX}#{section_name}"
sections_have_loaded.after -> sections_have_loaded.after ->
$section.data('wrapper')?.onClickTitle?() $section.data('wrapper').onClickTitle()
# call onExit handler if exiting a section to a different section. # call onExit handler if exiting a section to a different section.
unless $section.is $active_section unless $section.is $active_section
......
...@@ -487,9 +487,7 @@ class Membership ...@@ -487,9 +487,7 @@ class Membership
# export for use # export for use
# create parent namespaces if they do not already exist. # create parent namespaces if they do not already exist.
# abort if underscore can not be found. _.defaults window, InstructorDashboard: {}
if _? _.defaults window.InstructorDashboard, sections: {}
_.defaults window, InstructorDashboard: {} _.defaults window.InstructorDashboard.sections,
_.defaults window.InstructorDashboard, sections: {} Membership: Membership
_.defaults window.InstructorDashboard.sections,
Membership: Membership
...@@ -6,8 +6,10 @@ wrap in (-> ... apply) to defer evaluation ...@@ -6,8 +6,10 @@ wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order). such that the value can be defined later than this assignment (file load order).
### ###
# Load utilities
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks
class SendEmail class SendEmail
constructor: (@$container) -> constructor: (@$container) ->
...@@ -79,23 +81,25 @@ class SendEmail ...@@ -79,23 +81,25 @@ class SendEmail
class Email class Email
# enable subsections. # enable subsections.
constructor: (@$section) -> constructor: (@$section) ->
# attach self to html # attach self to html so that instructor_dashboard.coffee can find
# so that instructor_dashboard.coffee can find this object # this object to call event handlers like 'onClickTitle'
# to call event handlers like 'onClickTitle'
@$section.data 'wrapper', @ @$section.data 'wrapper', @
# isolate # initialize SendEmail subsection # isolate # initialize SendEmail subsection
plantTimeout 0, => new SendEmail @$section.find '.send-email' plantTimeout 0, => new SendEmail @$section.find '.send-email'
@instructor_tasks = new (PendingInstructorTasks()) @$section
# handler for when the section title is clicked. # handler for when the section title is clicked.
onClickTitle: -> onClickTitle: -> @instructor_tasks.task_poller.start()
# handler for when the section is closed
onExit: -> @instructor_tasks.task_poller.stop()
# export for use # export for use
# create parent namespaces if they do not already exist. # create parent namespaces if they do not already exist.
# abort if underscore can not be found. _.defaults window, InstructorDashboard: {}
if _? _.defaults window.InstructorDashboard, sections: {}
_.defaults window, InstructorDashboard: {} _.defaults window.InstructorDashboard.sections,
_.defaults window.InstructorDashboard, sections: {} Email: Email
_.defaults window.InstructorDashboard.sections,
Email: Email
...@@ -6,10 +6,10 @@ wrap in (-> ... apply) to defer evaluation ...@@ -6,10 +6,10 @@ wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order). such that the value can be defined later than this assignment (file load order).
### ###
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments # Load utilities
plantInterval = -> window.InstructorDashboard.util.plantInterval.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
load_IntervalManager = -> window.InstructorDashboard.util.IntervalManager create_task_list_table = -> window.InstructorDashboard.util.create_task_list_table.apply this, arguments
PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks
# get jquery element and assert its existance # get jquery element and assert its existance
...@@ -21,57 +21,11 @@ find_and_assert = ($root, selector) -> ...@@ -21,57 +21,11 @@ find_and_assert = ($root, selector) ->
else else
item item
# render a task list table to the DOM
# `$table_tasks` the $element in which to put the table
# `tasks_data`
create_task_list_table = ($table_tasks, tasks_data) ->
$table_tasks.empty()
options =
enableCellNavigation: true
enableColumnReorder: false
autoHeight: true
rowHeight: 60
forceFitColumns: true
columns = [
id: 'task_type'
field: 'task_type'
name: 'Task Type'
,
id: 'requester'
field: 'requester'
name: 'Requester'
width: 30
,
id: 'task_input'
field: 'task_input'
name: 'Input'
,
id: 'task_state'
field: 'task_state'
name: 'State'
width: 30
,
id: 'task_id'
field: 'task_id'
name: 'Task ID'
width: 50
,
id: 'created'
field: 'created'
name: 'Created'
]
table_data = tasks_data
$table_placeholder = $ '<div/>', class: 'slickgrid'
$table_tasks.append $table_placeholder
grid = new Slick.Grid($table_placeholder, table_data, columns, options)
class StudentAdmin class StudentAdmin
constructor: (@$section) -> constructor: (@$section) ->
# attach self to html so that instructor_dashboard.coffee can find
# this object to call event handlers like 'onClickTitle'
@$section.data 'wrapper', @ @$section.data 'wrapper', @
# gather buttons # gather buttons
...@@ -93,22 +47,13 @@ class StudentAdmin ...@@ -93,22 +47,13 @@ class StudentAdmin
@$btn_rescore_problem_all = @$section.find "input[name='rescore-problem-all']" @$btn_rescore_problem_all = @$section.find "input[name='rescore-problem-all']"
@$btn_task_history_all = @$section.find "input[name='task-history-all']" @$btn_task_history_all = @$section.find "input[name='task-history-all']"
@$table_task_history_all = @$section.find ".task-history-all-table" @$table_task_history_all = @$section.find ".task-history-all-table"
@$table_running_tasks = @$section.find ".running-tasks-table" @instructor_tasks = new (PendingInstructorTasks()) @$section
# response areas # response areas
@$request_response_error_progress = find_and_assert @$section, ".student-specific-container .request-response-error" @$request_response_error_progress = find_and_assert @$section, ".student-specific-container .request-response-error"
@$request_response_error_grade = find_and_assert @$section, ".student-grade-container .request-response-error" @$request_response_error_grade = find_and_assert @$section, ".student-grade-container .request-response-error"
@$request_response_error_all = @$section.find ".course-specific-container .request-response-error" @$request_response_error_all = @$section.find ".course-specific-container .request-response-error"
# start polling for task list
# if the list is in the DOM
if @$table_running_tasks.length > 0
# reload every 20 seconds.
TASK_LIST_POLL_INTERVAL = 20000
@reload_running_tasks_list()
@task_poller = new (load_IntervalManager()) TASK_LIST_POLL_INTERVAL, =>
@reload_running_tasks_list()
# attach click handlers # attach click handlers
# go to student progress page # go to student progress page
...@@ -294,14 +239,6 @@ class StudentAdmin ...@@ -294,14 +239,6 @@ class StudentAdmin
create_task_list_table @$table_task_history_all, data.tasks create_task_list_table @$table_task_history_all, data.tasks
error: std_ajax_err => @$request_response_error_all.text gettext("Error listing task history for this student and problem.") error: std_ajax_err => @$request_response_error_all.text gettext("Error listing task history for this student and problem.")
reload_running_tasks_list: =>
list_endpoint = @$table_running_tasks.data 'endpoint'
$.ajax
dataType: 'json'
url: list_endpoint
success: (data) => create_task_list_table @$table_running_tasks, data.tasks
error: std_ajax_err => console.warn "error listing all instructor tasks"
# wraps a function, but first clear the error displays # wraps a function, but first clear the error displays
clear_errors_then: (cb) -> clear_errors_then: (cb) ->
@$request_response_error_progress.empty() @$request_response_error_progress.empty()
...@@ -317,17 +254,15 @@ class StudentAdmin ...@@ -317,17 +254,15 @@ class StudentAdmin
@$request_response_error_all.empty() @$request_response_error_all.empty()
# handler for when the section title is clicked. # handler for when the section title is clicked.
onClickTitle: -> @task_poller?.start() onClickTitle: -> @instructor_tasks.task_poller.start()
# handler for when the section is closed # handler for when the section is closed
onExit: -> @task_poller?.stop() onExit: -> @instructor_tasks.task_poller.stop()
# export for use # export for use
# create parent namespaces if they do not already exist. # create parent namespaces if they do not already exist.
# abort if underscore can not be found. _.defaults window, InstructorDashboard: {}
if _? _.defaults window.InstructorDashboard, sections: {}
_.defaults window, InstructorDashboard: {} _.defaults window.InstructorDashboard.sections,
_.defaults window.InstructorDashboard, sections: {} StudentAdmin: StudentAdmin
_.defaults window.InstructorDashboard.sections,
StudentAdmin: StudentAdmin
...@@ -6,6 +6,15 @@ plantTimeout = (ms, cb) -> setTimeout cb, ms ...@@ -6,6 +6,15 @@ plantTimeout = (ms, cb) -> setTimeout cb, ms
plantInterval = (ms, cb) -> setInterval cb, ms plantInterval = (ms, cb) -> setInterval cb, ms
# get jquery element and assert its existance
find_and_assert = ($root, selector) ->
item = $root.find selector
if item.length != 1
console.error "element selection failed for '#{selector}' resulted in length #{item.length}"
throw "Failed Element Selection"
else
item
# standard ajax error wrapper # standard ajax error wrapper
# #
# wraps a `handler` function so that first # wraps a `handler` function so that first
...@@ -17,6 +26,72 @@ std_ajax_err = (handler) -> (jqXHR, textStatus, errorThrown) -> ...@@ -17,6 +26,72 @@ std_ajax_err = (handler) -> (jqXHR, textStatus, errorThrown) ->
handler.apply this, arguments handler.apply this, arguments
# render a task list table to the DOM
# `$table_tasks` the $element in which to put the table
# `tasks_data`
create_task_list_table = ($table_tasks, tasks_data) ->
$table_tasks.empty()
options =
enableCellNavigation: true
enableColumnReorder: false
autoHeight: true
rowHeight: 60
forceFitColumns: true
columns = [
id: 'task_type'
field: 'task_type'
name: 'Task Type'
minWidth: 100
,
id: 'task_input'
field: 'task_input'
name: 'Task inputs'
minWidth: 150
,
id: 'task_id'
field: 'task_id'
name: 'Task ID'
minWidth: 150
,
id: 'requester'
field: 'requester'
name: 'Requester'
minWidth: 80
,
id: 'created'
field: 'created'
name: 'Submitted'
minWidth: 120
,
id: 'duration_sec'
field: 'duration_sec'
name: 'Duration (sec)'
minWidth: 80
,
id: 'task_state'
field: 'task_state'
name: 'State'
minWidth: 80
,
id: 'status'
field: 'status'
name: 'Task Status'
minWidth: 80
,
id: 'task_message'
field: 'task_message'
name: 'Task Progress'
minWidth: 120
]
table_data = tasks_data
$table_placeholder = $ '<div/>', class: 'slickgrid'
$table_tasks.append $table_placeholder
grid = new Slick.Grid($table_placeholder, table_data, columns, options)
# Helper class for managing the execution of interval tasks. # Helper class for managing the execution of interval tasks.
# Handles pausing and restarting. # Handles pausing and restarting.
class IntervalManager class IntervalManager
...@@ -26,8 +101,8 @@ class IntervalManager ...@@ -26,8 +101,8 @@ class IntervalManager
@intervalID = null @intervalID = null
# Start or restart firing every `ms` milliseconds. # Start or restart firing every `ms` milliseconds.
# Soes not fire immediately.
start: -> start: ->
@fn()
if @intervalID is null if @intervalID is null
@intervalID = setInterval @fn, @ms @intervalID = setInterval @fn, @ms
...@@ -37,6 +112,30 @@ class IntervalManager ...@@ -37,6 +112,30 @@ class IntervalManager
@intervalID = null @intervalID = null
class PendingInstructorTasks
### Pending Instructor Tasks Section ####
constructor: (@$section) ->
# Currently running tasks
@$table_running_tasks = find_and_assert @$section, ".running-tasks-table"
# start polling for task list
# if the list is in the DOM
if @$table_running_tasks.length > 0
# reload every 20 seconds.
TASK_LIST_POLL_INTERVAL = 20000
@reload_running_tasks_list()
@task_poller = new IntervalManager(TASK_LIST_POLL_INTERVAL, => @reload_running_tasks_list())
# Populate the running tasks list
reload_running_tasks_list: =>
list_endpoint = @$table_running_tasks.data 'endpoint'
$.ajax
dataType: 'json'
url: list_endpoint
success: (data) => create_task_list_table @$table_running_tasks, data.tasks
error: std_ajax_err => console.warn "error listing all instructor tasks"
### /Pending Instructor Tasks Section ####
# export for use # export for use
# create parent namespaces if they do not already exist. # create parent namespaces if they do not already exist.
# abort if underscore can not be found. # abort if underscore can not be found.
...@@ -47,3 +146,5 @@ if _? ...@@ -47,3 +146,5 @@ if _?
plantInterval: plantInterval plantInterval: plantInterval
std_ajax_err: std_ajax_err std_ajax_err: std_ajax_err
IntervalManager: IntervalManager IntervalManager: IntervalManager
create_task_list_table: create_task_list_table
PendingInstructorTasks: PendingInstructorTasks
...@@ -14,9 +14,16 @@ ...@@ -14,9 +14,16 @@
.olddash-button-wrapper { .olddash-button-wrapper {
position: absolute; position: absolute;
top: 17px; top: 16px;
right: 15px; right: 15px;
@include font-size(14); @include font-size(16);
}
.studio-edit-link{
position: absolute;
top: 40px;
right: 15px;
@include font-size(16);
} }
// system feedback - messages // system feedback - messages
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%page args="section_data"/> <%page args="section_data"/>
<h2>${_("Course Information")}</h2> <div class="enrollment-wrapper">
<h2>${_("Enrollment Information")}</h2>
<span class="tip">${_("Total number of enrollees (instructors, staff members, and students)")}</span>
<br/><br/>
<span style="color: green;"><b>${ section_data['enrollment_count'] }</b></span>
<div class="basic-data">
${_("Course Name")}:
${ section_data['course_display_name'] }
</div> </div>
<hr>
<div class="basic-data"> <div class="basic-wrapper">
${_("Course ID")}: <h2>${_("Basic Course Information")}</h2>
${ section_data['course_id'] }
</div>
<div class="basic-data"> <ul class="list-input">
${_("Students Enrolled")}: <li class="field text is-not-editable" id="field-course-organization">
${ section_data['enrollment_count'] } <label for="course-organization">${_("Organization:")}</label>
</div> <b>${ section_data['course_org'] }</b>
</li>
<div class="basic-data"> <li class="field text is-not-editable" id="field-course-number">
${_("Started")}: <label for="course-number">${_("Course Number:")}</label>
${ section_data['has_started'] } <b>${ section_data['course_num'] }</b>
</div> </li>
<div class="basic-data"> <li class="field text is-not-editable" id="field-course-name">
${_("Ended")}: <label for="course-name">${_("Course Name:")}</label>
${ section_data['has_ended'] } <b>${ section_data['course_name'] }</b>
</div> </li>
<li class="field text is-not-editable" id="field-course-display-name">
<label for="course-display-name">${_("Course Display Name:")}</label>
<b>${ section_data['course_display_name'] }</b>
</li>
<li class="field text is-not-editable" id="field-course-started">
<label for="start-date">${_("Has the course started?")}</label>
<div class="basic-data"> <b>${_("Yes") if section_data['grade_cutoffs'] else _("No")}</b>
${_("Grade Cutoffs")}:
${ section_data['grade_cutoffs'] } </li>
<li class="field text is-not-editable" id="field-course-ended">
<label for="start-date">${_("Has the course ended?")}</label>
%if section_data['has_ended']:
<b>${_("Yes")}</b>
%else:
<b>${_("No")}</b>
%endif
</li>
<li class="field text is-not-editable" id="field-grade-cutoffs">
<label for="start-date">${_("Grade Cutoffs:")}</label>
<b>${ section_data['grade_cutoffs'] }</b>
</li>
</ul>
</div> </div>
## <div class="basic-data">
## Offline Grades Available:
## ${ section_data['offline_grades'] }
## </div>
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']: %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<div class="running-tasks-container action-type-container"> <div class="running-tasks-container action-type-container">
<hr> <hr>
<h2> ${_("Pending Instructor Tasks")} </h2> <h2> ${_("Pending Instructor Tasks")} </h2>
<p>${_("The status for any active tasks appears in a table below.")} </p> <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 class="running-tasks-table" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></div>
</div> </div>
...@@ -69,6 +90,3 @@ ...@@ -69,6 +90,3 @@
</div> </div>
<br> <br>
%endif %endif
...@@ -19,4 +19,16 @@ ...@@ -19,4 +19,16 @@
<div class="data-display-text"></div> <div class="data-display-text"></div>
<div class="data-display-table"></div> <div class="data-display-table"></div>
<div class="request-response-error"></div> <div class="request-response-error"></div>
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<div class="running-tasks-container action-type-container">
<hr>
<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> </div>
...@@ -50,10 +50,14 @@ ...@@ -50,10 +50,14 @@
<section class="container"> <section class="container">
<div class="instructor-dashboard-wrapper-2"> <div class="instructor-dashboard-wrapper-2">
<div class="olddash-button-wrapper"><a href="${ old_dashboard_url }"> ${_("Back to Standard Dashboard")} </a></div> <div class="olddash-button-wrapper"><a href="${ old_dashboard_url }"> ${_("Back to Standard Dashboard")} </a></div>
%if studio_url:
## not checking access because if user can see this, they are at least course staff (with studio edit access)
<div class="studio-edit-link"><a href="${studio_url}" target="_blank">${_('Edit Course In Studio')}</a></div>
%endif
<section class="instructor-dashboard-content-2"> <section class="instructor-dashboard-content-2">
## <h1>Instructor Dashboard</h1> <h1>${_("Instructor Dashboard")}</h1>
<hr />
## links which are tied to idash-sections below. ## links which are tied to idash-sections below.
## the links are acativated and handled in instructor_dashboard.coffee ## the links are acativated and handled in instructor_dashboard.coffee
## when the javascript loads, it clicks on the first section ## when the javascript loads, it clicks on the first section
......
...@@ -54,4 +54,16 @@ ...@@ -54,4 +54,16 @@
<br /> <br />
<input type="button" name="send" value="${_("Send Email")}" data-endpoint="${ section_data['send_email'] }" > <input type="button" name="send" value="${_("Send Email")}" data-endpoint="${ section_data['send_email'] }" >
<div class="request-response-error"></div> <div class="request-response-error"></div>
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<div class="running-tasks-container action-type-container">
<hr>
<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> </div>
...@@ -109,3 +109,15 @@ ...@@ -109,3 +109,15 @@
</p> </p>
</div> </div>
%endif %endif
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<div class="running-tasks-container action-type-container">
<hr>
<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
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