Commit 169e27a8 by Dennis Jen

Added graded content.

parent 8b3716eb
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
ROOT = $(shell echo "$$PWD") ROOT = $(shell echo "$$PWD")
COVERAGE = $(ROOT)/build/coverage COVERAGE = $(ROOT)/build/coverage
NUM_PROCESSES = 2
NODE_BIN=./node_modules/.bin NODE_BIN=./node_modules/.bin
DJANGO_SETTINGS_MODULE := "analytics_dashboard.settings.local" DJANGO_SETTINGS_MODULE := "analytics_dashboard.settings.local"
...@@ -40,14 +39,14 @@ test_python: clean ...@@ -40,14 +39,14 @@ test_python: clean
--cover-xml --cover-xml-file=$(COVERAGE)/coverage.xml --exclude=core/admin --cover-xml --cover-xml-file=$(COVERAGE)/coverage.xml --exclude=core/admin
accept: accept:
nosetests -v acceptance_tests --processes=$(NUM_PROCESSES) --process-timeout=120 --exclude-dir=acceptance_tests/course_validation nosetests -v acceptance_tests --exclude-dir=acceptance_tests/course_validation
course_validation: course_validation:
python -m acceptance_tests.course_validation.generate_report python -m acceptance_tests.course_validation.generate_report
quality: quality:
pep8 acceptance_tests analytics_dashboard pep8 acceptance_tests analytics_dashboard common
PYTHONPATH=".:./analytics_dashboard:$PYTHONPATH" pylint --rcfile=pylintrc acceptance_tests analytics_dashboard PYTHONPATH=".:./analytics_dashboard:$PYTHONPATH" pylint --rcfile=pylintrc acceptance_tests analytics_dashboard common
validate_python: test.requirements test_python quality validate_python: test.requirements test_python quality
......
...@@ -197,17 +197,20 @@ If you already have a server running, there is also a make task you can run inst ...@@ -197,17 +197,20 @@ If you already have a server running, there is also a make task you can run inst
The tests make a few assumptions about URLs and authentication. These can be overridden by setting environment variables The tests make a few assumptions about URLs and authentication. These can be overridden by setting environment variables
when executing either of the commands above. when executing either of the commands above.
| Variable | Purpose | Default Value | | Variable | Purpose | Default Value |
|--------------------------|---------------------------------------|----------------------------------| |------------------------------|--------------------------------------------|----------------------------------|
| DASHBOARD_SERVER_URL | URL where the dashboard is served | http://127.0.0.1:9000 | | DASHBOARD_SERVER_URL | URL where the dashboard is served | http://127.0.0.1:9000 |
| API_SERVER_URL | URL where the analytics API is served | http://127.0.0.1:9001/api/v0 | | API_SERVER_URL | URL where the analytics API is served | http://127.0.0.1:9001/api/v0 |
| API_AUTH_TOKEN | Analytics API authentication token | edx | | API_AUTH_TOKEN | Analytics API authentication token | edx |
| DASHBOARD_FEEDBACK_EMAIL | Feedback email in the footer | override.this.email@example.com | | DASHBOARD_FEEDBACK_EMAIL | Feedback email in the footer | override.this.email@example.com |
| TEST_USERNAME | Username used to login to the app | edx | | TEST_USERNAME | Username used to login to the app | edx |
| TEST_PASSWORD | Password used to login to the app | edx | | TEST_PASSWORD | Password used to login to the app | edx |
| PLATFORM_NAME | Platform/organization name | edX | | PLATFORM_NAME | Platform/organization name | edX |
| APPLICATION_NAME | Name of this application | Insights | | APPLICATION_NAME | Name of this application | Insights |
| SUPPORT_URL | URL where error pages should link | http://example.com/ | | SUPPORT_URL | URL where error pages should link | http://example.com/ |
| ENABLE_COURSE_API | Indicates if the course API is enabled on the server being tested. Also, determines if course performance tests should be run. | False |
| COURSE_API_URL | URL where the course API is served | (None) |
| COURSE_API_KEY | API key used to access the course API | (None) |
Override example: Override example:
......
...@@ -9,7 +9,7 @@ def str2bool(s): ...@@ -9,7 +9,7 @@ def str2bool(s):
return s.lower() in (u"yes", u"true", u"t", u"1") return s.lower() in (u"yes", u"true", u"t", u"1")
# Dashboard settings # Dashboard settings
DASHBOARD_SERVER_URL = os.environ.get('DASHBOARD_SERVER_URL', 'http://127.0.0.1:9000') DASHBOARD_SERVER_URL = os.environ.get('DASHBOARD_SERVER_URL', 'http://localhost:9000')
DASHBOARD_FEEDBACK_EMAIL = os.environ.get('DASHBOARD_FEEDBACK_EMAIL', 'override.this.email@example.com') DASHBOARD_FEEDBACK_EMAIL = os.environ.get('DASHBOARD_FEEDBACK_EMAIL', 'override.this.email@example.com')
PLATFORM_NAME = os.environ.get('PLATFORM_NAME', 'edX') PLATFORM_NAME = os.environ.get('PLATFORM_NAME', 'edX')
APPLICATION_NAME = os.environ.get('APPLICATION_NAME', 'Insights') APPLICATION_NAME = os.environ.get('APPLICATION_NAME', 'Insights')
...@@ -33,14 +33,17 @@ BASIC_AUTH_PASSWORD = os.environ.get('BASIC_AUTH_PASSWORD') ...@@ -33,14 +33,17 @@ BASIC_AUTH_PASSWORD = os.environ.get('BASIC_AUTH_PASSWORD')
LMS_HOSTNAME = os.environ.get('LMS_HOSTNAME') LMS_HOSTNAME = os.environ.get('LMS_HOSTNAME')
LMS_USERNAME = os.environ.get('LMS_USERNAME') LMS_USERNAME = os.environ.get('LMS_USERNAME')
LMS_PASSWORD = os.environ.get('LMS_PASSWORD') LMS_PASSWORD = os.environ.get('LMS_PASSWORD')
LMS_SSL_ENABLED = str2bool(os.environ.get('LMS_SSL_ENABLED', True))
if ENABLE_OAUTH_TESTS and not (LMS_HOSTNAME and LMS_USERNAME and LMS_PASSWORD): if ENABLE_OAUTH_TESTS and not (LMS_HOSTNAME and LMS_USERNAME and LMS_PASSWORD):
raise Exception('LMS settings must be set in order to test OAuth.') raise Exception('LMS settings must be set in order to test OAuth.')
TEST_COURSE_ID = os.environ.get('TEST_COURSE_ID', u'edX/DemoX/Demo_Course') TEST_COURSE_ID = os.environ.get('TEST_COURSE_ID', u'edX/DemoX/Demo_Course')
TEST_PROBLEM_ID = os.environ.get('TEST_PROBLEM_ID', u'i4x://edX/DemoX.1/problem/05d289c5ad3d47d48a77622c4a81ec36') TEST_ASSIGNMENT_TYPE = os.environ.get('TEST_ASSIGNMENT_TYPE', 'Homework')
TEST_ASSIGNMENT_ID = os.environ.get('TEST_ASSIGNMENT_ID', u'i4x://edX/DemoX/sequential/basic_questions')
TEST_PROBLEM_ID = os.environ.get('TEST_PROBLEM_ID', u'i4x://edX/DemoX/problem/a0effb954cca4759994f1ac9e9434bf4')
TEST_PROBLEM_PART_ID = os.environ.get('TEST_PROBLEM_PART_ID', TEST_PROBLEM_PART_ID = os.environ.get('TEST_PROBLEM_PART_ID',
u'i4x-edX-DemoX_1-problem-05c289c5ad3d47d48a77622c4a81ec33_2_1') u'i4x-edX-DemoX-problem-a0effb954cca4759994f1ac9e9434bf4_2_1')
DOC_BASE_URL = os.environ.get('DOC_BASE_URL', 'http://edx-insights.readthedocs.org/en/latest') DOC_BASE_URL = os.environ.get('DOC_BASE_URL', 'http://edx-insights.readthedocs.org/en/latest')
......
...@@ -4,11 +4,12 @@ from unittest import skip ...@@ -4,11 +4,12 @@ from unittest import skip
from bok_choy.promise import EmptyPromise from bok_choy.promise import EmptyPromise
from analyticsclient.client import Client from analyticsclient.client import Client
import edx_api_client import slumber
from acceptance_tests import API_SERVER_URL, API_AUTH_TOKEN, DASHBOARD_FEEDBACK_EMAIL, SUPPORT_URL, LMS_USERNAME, \ from acceptance_tests import API_SERVER_URL, API_AUTH_TOKEN, DASHBOARD_FEEDBACK_EMAIL, SUPPORT_URL, LMS_USERNAME, \
LMS_PASSWORD, DASHBOARD_SERVER_URL, ENABLE_AUTO_AUTH, DOC_BASE_URL, COURSE_API_URL, COURSE_API_KEY, \ LMS_PASSWORD, DASHBOARD_SERVER_URL, ENABLE_AUTO_AUTH, DOC_BASE_URL, COURSE_API_URL, \
ENABLE_COURSE_API COURSE_API_KEY, ENABLE_COURSE_API
from common import BearerAuth
from acceptance_tests.pages import LMSLoginPage from acceptance_tests.pages import LMSLoginPage
...@@ -33,7 +34,7 @@ class CourseApiMixin(object): ...@@ -33,7 +34,7 @@ class CourseApiMixin(object):
super(CourseApiMixin, self).setUp() super(CourseApiMixin, self).setUp()
if ENABLE_COURSE_API: if ENABLE_COURSE_API:
self.course_api_client = edx_api_client.Client(COURSE_API_URL, COURSE_API_KEY) self.course_api_client = slumber.API(COURSE_API_URL, auth=BearerAuth(COURSE_API_KEY))
def get_course_name_or_id(self, course_id): def get_course_name_or_id(self, course_id):
""" Returns the course name if the course API is enabled; otherwise, the course ID. """ """ Returns the course name if the course API is enabled; otherwise, the course ID. """
...@@ -78,12 +79,12 @@ class AssertMixin(object): ...@@ -78,12 +79,12 @@ class AssertMixin(object):
# Ensure the table is loaded via AJAX # Ensure the table is loaded via AJAX
self.fulfill_loading_promise(table_selector) self.fulfill_loading_promise(table_selector)
# make sure the map section is present # make sure the containing element is present
element = self.page.q(css=table_selector) element = self.page.q(css=table_selector)
self.assertTrue(element.present) self.assertTrue(element.present)
# make sure the table is present # make sure the table is present
table_selector = table_selector + " table" table_selector += " table"
element = self.page.q(css=table_selector) element = self.page.q(css=table_selector)
self.assertTrue(element.present) self.assertTrue(element.present)
...@@ -95,6 +96,15 @@ class AssertMixin(object): ...@@ -95,6 +96,15 @@ class AssertMixin(object):
self.assertValidHref(download_selector) self.assertValidHref(download_selector)
def assertRowTextEquals(self, cols, expected_texts):
"""
Asserts that the given columns contain the expected text.
:param cols: Array of Selenium HTML elements.
:param expected_texts: Array of strings.
"""
actual = [col.text for col in cols]
self.assertListEqual(actual, expected_texts)
class PageTestMixin(object): class PageTestMixin(object):
def test_page(self): def test_page(self):
......
...@@ -6,11 +6,11 @@ from bok_choy.page_object import PageObject ...@@ -6,11 +6,11 @@ from bok_choy.page_object import PageObject
from bok_choy.promise import EmptyPromise from bok_choy.promise import EmptyPromise
from acceptance_tests import DASHBOARD_SERVER_URL, BASIC_AUTH_PASSWORD, BASIC_AUTH_USERNAME, LMS_HOSTNAME, \ from acceptance_tests import DASHBOARD_SERVER_URL, BASIC_AUTH_PASSWORD, BASIC_AUTH_USERNAME, LMS_HOSTNAME, \
TEST_COURSE_ID, TEST_PROBLEM_ID, TEST_PROBLEM_PART_ID TEST_COURSE_ID, TEST_PROBLEM_ID, TEST_PROBLEM_PART_ID, TEST_ASSIGNMENT_ID, TEST_ASSIGNMENT_TYPE, \
LMS_SSL_ENABLED
# pylint: disable=abstract-method class DashboardPage(PageObject): # pylint: disable=abstract-method
class DashboardPage(PageObject):
path = None path = None
basic_auth_username = None basic_auth_username = None
basic_auth_password = None basic_auth_password = None
...@@ -71,10 +71,12 @@ class CourseEnrollmentActivityPage(CoursePage): ...@@ -71,10 +71,12 @@ class CourseEnrollmentActivityPage(CoursePage):
class LMSLoginPage(PageObject): class LMSLoginPage(PageObject):
@property @property
def url(self): def url(self):
protocol = 'https' if LMS_SSL_ENABLED else 'http'
if BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD: if BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD:
return 'https://{0}:{1}@{2}/login'.format(BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD, LMS_HOSTNAME) return '{0}://{1}:{2}@{3}/login'.format(protocol, BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD, LMS_HOSTNAME)
return 'https://{0}/login'.format(LMS_HOSTNAME) return '{0}://{1}/login'.format(protocol, LMS_HOSTNAME)
def is_browser_on_page(self): def is_browser_on_page(self):
return self.browser.title.startswith('Log into') return self.browser.title.startswith('Log into')
...@@ -142,19 +144,6 @@ class CourseEngagementContentPage(CoursePage): ...@@ -142,19 +144,6 @@ class CourseEngagementContentPage(CoursePage):
'Engagement Content' in self.browser.title 'Engagement Content' in self.browser.title
class CoursePerformanceAnswerDistributionPage(CoursePage):
def __init__(self, browser, course_id=None, problem_id=None, part_id=None):
super(CoursePerformanceAnswerDistributionPage, self).__init__(browser, course_id)
self.problem_id = problem_id or TEST_PROBLEM_ID
self.part_id = part_id or TEST_PROBLEM_PART_ID
self.page_url += '/performance/graded_content/problems/{}/answer_distribution/{}/'.format(self.problem_id,
self.part_id)
def is_browser_on_page(self):
return super(CoursePerformanceAnswerDistributionPage, self).is_browser_on_page() and \
'Performance: Problem Submissions' in self.browser.title
class CourseIndexPage(DashboardPage): class CourseIndexPage(DashboardPage):
path = 'courses/' path = 'courses/'
...@@ -165,6 +154,52 @@ class CourseIndexPage(DashboardPage): ...@@ -165,6 +154,52 @@ class CourseIndexPage(DashboardPage):
return self.browser.title.startswith('Courses') return self.browser.title.startswith('Courses')
class CoursePerformanceGradedContentPage(CoursePage):
def __init__(self, browser, course_id=None):
super(CoursePerformanceGradedContentPage, self).__init__(browser, course_id)
self.page_url += '/performance/graded_content/'
def is_browser_on_page(self):
return super(CoursePerformanceGradedContentPage, self).is_browser_on_page() and \
'Graded Content' in self.browser.title
class CoursePerformanceGradedContentByTypePage(CoursePage):
def __init__(self, browser, course_id=None, assignment_type=None):
super(CoursePerformanceGradedContentByTypePage, self).__init__(browser, course_id)
self.assignment_type = assignment_type or TEST_ASSIGNMENT_TYPE
self.page_url = '{}/performance/graded_content/{}/'.format(self.page_url, self.assignment_type)
def is_browser_on_page(self):
return super(CoursePerformanceGradedContentByTypePage, self).is_browser_on_page() and \
self.assignment_type in self.browser.title
class CoursePerformanceAssignmentPage(CoursePage):
def __init__(self, browser, course_id=None, assignment_id=None):
super(CoursePerformanceAssignmentPage, self).__init__(browser, course_id)
self.assignment_id = assignment_id or TEST_ASSIGNMENT_ID
self.page_url = '{}/performance/graded_content/assignments/{}/'.format(self.page_url, self.assignment_id)
def is_browser_on_page(self):
return super(CoursePerformanceAssignmentPage, self).is_browser_on_page() and \
'Graded Content' in self.browser.title
class CoursePerformanceAnswerDistributionPage(CoursePage):
def __init__(self, browser, course_id=None, assignment_id=None, problem_id=None, part_id=None):
super(CoursePerformanceAnswerDistributionPage, self).__init__(browser, course_id)
self.assignment_id = assignment_id or TEST_ASSIGNMENT_ID
self.problem_id = problem_id or TEST_PROBLEM_ID
self.part_id = part_id or TEST_PROBLEM_PART_ID
self.page_url += '/performance/graded_content/assignments/{}/problems/{}/parts/{}/answer_distribution/'.format(
self.assignment_id, self.problem_id, self.part_id)
def is_browser_on_page(self):
return super(CoursePerformanceAnswerDistributionPage, self).is_browser_on_page() and \
self.browser.title.startswith('Performance: Problem Submissions')
class ErrorPage(DashboardPage): class ErrorPage(DashboardPage):
error_code = None error_code = None
error_title = None error_title = None
...@@ -191,8 +226,3 @@ class NotFoundErrorPage(ErrorPage): ...@@ -191,8 +226,3 @@ class NotFoundErrorPage(ErrorPage):
class AccessDeniedErrorPage(ErrorPage): class AccessDeniedErrorPage(ErrorPage):
error_code = 403 error_code = 403
error_title = u'Access Denied' error_title = u'Access Denied'
class AuthErrorPage(ErrorPage):
error_title = u'Authentication Failed'
path = u'auth/error/'
from unittest import skipUnless from unittest import skipUnless
from unittest import skip
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
...@@ -18,7 +17,6 @@ class OAuth2FlowTests(LoginMixin, WebAppTest): ...@@ -18,7 +17,6 @@ class OAuth2FlowTests(LoginMixin, WebAppTest):
self.insights_login_page = LoginPage(self.browser) self.insights_login_page = LoginPage(self.browser)
@skip("Skipping: LMS is not Available")
def test_login(self): def test_login(self):
self.login_with_lms() self.login_with_lms()
......
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from acceptance_tests import ENABLE_COURSE_API
from acceptance_tests.mixins import CoursePageTestsMixin from acceptance_tests.mixins import CoursePageTestsMixin
from acceptance_tests.pages import CourseHomePage from acceptance_tests.pages import CourseHomePage
...@@ -78,6 +79,20 @@ class CourseHomeTests(CoursePageTestsMixin, WebAppTest): ...@@ -78,6 +79,20 @@ class CourseHomeTests(CoursePageTestsMixin, WebAppTest):
} }
] ]
if ENABLE_COURSE_API:
table_items.append({
'name': 'Performance',
'icon': 'fa-check-square-o',
'heading': 'How are students doing on course assignments?',
'items': [
{
'title': 'How are students doing on graded course assignments?',
'view': 'courses:performance:graded_content',
'breadcrumbs': ['Graded Content']
}
]
})
table_outer = self.page.browser.find_element_by_css_selector('.course-home-table-outer') table_outer = self.page.browser.find_element_by_css_selector('.course-home-table-outer')
headings = table_outer.find_elements_by_css_selector('header .heading') headings = table_outer.find_elements_by_css_selector('header .heading')
......
import datetime import datetime
from bok_choy.web_app_test import WebAppTest
from analyticsclient.constants import activity_type as at from analyticsclient.constants import activity_type as at
from bok_choy.web_app_test import WebAppTest
from acceptance_tests import ENABLE_FORUM_POSTS from acceptance_tests import ENABLE_FORUM_POSTS
from acceptance_tests.mixins import CoursePageTestsMixin from acceptance_tests.mixins import CoursePageTestsMixin
......
import datetime import datetime
from bok_choy.web_app_test import WebAppTest
from analyticsclient.constants import demographic, UNKNOWN_COUNTRY_CODE, enrollment_modes from analyticsclient.constants import demographic, UNKNOWN_COUNTRY_CODE, enrollment_modes
from bok_choy.web_app_test import WebAppTest
from acceptance_tests import ENABLE_ENROLLMENT_MODES from acceptance_tests import ENABLE_ENROLLMENT_MODES
from acceptance_tests.mixins import CoursePageTestsMixin from acceptance_tests.mixins import CoursePageTestsMixin
...@@ -35,7 +35,7 @@ class CourseEnrollmentActivityTests(CoursePageTestsMixin, WebAppTest): ...@@ -35,7 +35,7 @@ class CourseEnrollmentActivityTests(CoursePageTestsMixin, WebAppTest):
self._test_enrollment_trend_table() self._test_enrollment_trend_table()
def _get_data_update_message(self): def _get_data_update_message(self):
current_enrollment = self.course.enrollment()[0] current_enrollment = self.get_enrollment_data()[-1]
last_updated = datetime.datetime.strptime(current_enrollment['created'], self.api_datetime_format) last_updated = datetime.datetime.strptime(current_enrollment['created'], self.api_datetime_format)
return 'Enrollment activity data was last updated %(update_date)s at %(update_time)s UTC.' % \ return 'Enrollment activity data was last updated %(update_date)s at %(update_time)s UTC.' % \
self.format_last_updated_date_and_time(last_updated) self.format_last_updated_date_and_time(last_updated)
......
import datetime import datetime
from bok_choy.web_app_test import WebAppTest
import analyticsclient.constants.education_level as EDUCATION_LEVEL
from analyticsclient.constants import demographic from analyticsclient.constants import demographic
import analyticsclient.constants.education_level as EDUCATION_LEVEL
import analyticsclient.constants.gender as GENDER import analyticsclient.constants.gender as GENDER
from bok_choy.web_app_test import WebAppTest
from acceptance_tests.mixins import CourseDemographicsPageTestsMixin from acceptance_tests.mixins import CourseDemographicsPageTestsMixin
from acceptance_tests.pages import CourseEnrollmentDemographicsAgePage, CourseEnrollmentDemographicsEducationPage, \ from acceptance_tests.pages import CourseEnrollmentDemographicsAgePage, CourseEnrollmentDemographicsEducationPage, \
......
from unittest import skipUnless from unittest import skipUnless
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from acceptance_tests import PLATFORM_NAME, APPLICATION_NAME, SUPPORT_URL, ENABLE_ERROR_PAGE_TESTS from acceptance_tests import PLATFORM_NAME, APPLICATION_NAME, SUPPORT_URL, ENABLE_ERROR_PAGE_TESTS
from acceptance_tests.pages import ServerErrorPage, NotFoundErrorPage, AccessDeniedErrorPage, AuthErrorPage from acceptance_tests.pages import ServerErrorPage, NotFoundErrorPage, AccessDeniedErrorPage
@skipUnless(ENABLE_ERROR_PAGE_TESTS, 'Error page tests are not enabled.') @skipUnless(ENABLE_ERROR_PAGE_TESTS, 'Error page tests are not enabled.')
class ErrorPagesTests(WebAppTest): class ErrorPagesTests(WebAppTest):
error_page_classes = [ServerErrorPage, NotFoundErrorPage, AccessDeniedErrorPage, AuthErrorPage] error_page_classes = [ServerErrorPage, NotFoundErrorPage, AccessDeniedErrorPage]
def test_valid_pages(self): def test_valid_pages(self):
for page_class in self.error_page_classes: for page_class in self.error_page_classes:
......
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from acceptance_tests.mixins import LoginMixin, LogoutMixin, FooterLegalMixin, PageTestMixin
from acceptance_tests import OPEN_SOURCE_URL, RESEARCH_URL, SUPPORT_URL, SHOW_LANDING_RESEARCH from acceptance_tests import OPEN_SOURCE_URL, RESEARCH_URL, SUPPORT_URL, SHOW_LANDING_RESEARCH
from acceptance_tests.mixins import LoginMixin, LogoutMixin, FooterLegalMixin, PageTestMixin
from acceptance_tests.pages import LandingPage from acceptance_tests.pages import LandingPage
......
from unittest import TestCase from unittest import TestCase
import requests import requests
from acceptance_tests import DASHBOARD_SERVER_URL from acceptance_tests import DASHBOARD_SERVER_URL
......
...@@ -4,15 +4,19 @@ from django.db import models ...@@ -4,15 +4,19 @@ from django.db import models
class User(AbstractUser): class User(AbstractUser):
""" """ Custom user model. """
Custom user model.
"""
# TODO: it may not be necessary to store the language # TODO: it may not be necessary to store the language
# preferences. Saving it in the session should be enough. # preferences. Saving it in the session should be enough.
language = models.CharField(max_length=255, null=True, choices=settings.LANGUAGES, default=None) language = models.CharField(max_length=255, null=True, choices=settings.LANGUAGES, default=None)
@property
def access_token(self):
try:
return self.social_auth.first().extra_data[u'access_token'] # pylint: disable=no-member
except Exception: # pylint: disable=broad-except
return None
class Meta(object): class Meta(object):
get_latest_by = 'date_joined' get_latest_by = 'date_joined'
db_table = 'analytics_dashboard_user' # Legacy table name db_table = 'analytics_dashboard_user' # Legacy table name
from django.test import TestCase
from django_dynamic_fixture import G
from social.apps.django_app.default.models import UserSocialAuth
from core.models import User
class UserTests(TestCase):
def test_access_token(self):
user = G(User)
self.assertIsNone(user.access_token)
social_auth = G(UserSocialAuth, user=user)
self.assertIsNone(user.access_token)
access_token = u'My voice is my passport. Verify me.'
social_auth.extra_data[u'access_token'] = access_token
social_auth.save()
self.assertEqual(user.access_token, access_token)
...@@ -5,7 +5,7 @@ from django.contrib.auth import get_user_model ...@@ -5,7 +5,7 @@ from django.contrib.auth import get_user_model
from django.test.utils import override_settings from django.test.utils import override_settings
from django.test import TestCase from django.test import TestCase
from core.utils import delete_auto_auth_users from core.utils import delete_auto_auth_users, sanitize_cache_key
User = get_user_model() User = get_user_model()
...@@ -36,3 +36,13 @@ class UtilsTests(TestCase): ...@@ -36,3 +36,13 @@ class UtilsTests(TestCase):
# No users should have been deleted # No users should have been deleted
self.assertEqual(User.objects.count(), user_count) self.assertEqual(User.objects.count(), user_count)
def test_sanitize_cache_key(self):
keys = ['', ' ', 'I am a key. AMA!']
for key in keys:
sanitized = sanitize_cache_key(key)
self.assertLess(len(sanitized), 250)
# TODO Add a proper assertion to ensure all control characters are removed.
self.assertNotIn(' ', sanitized)
from hashlib import md5
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
User = get_user_model() User = get_user_model()
...@@ -9,3 +12,10 @@ def delete_auto_auth_users(): ...@@ -9,3 +12,10 @@ def delete_auto_auth_users():
raise ValueError('AUTO_AUTH_USERNAME_PREFIX is not set.') raise ValueError('AUTO_AUTH_USERNAME_PREFIX is not set.')
User.objects.filter(username__startswith=settings.AUTO_AUTH_USERNAME_PREFIX).delete() User.objects.filter(username__startswith=settings.AUTO_AUTH_USERNAME_PREFIX).delete()
def sanitize_cache_key(key):
"""
Returns a memcached-safe (no spaces or control characters) key.
"""
return md5(key).hexdigest()
...@@ -14,7 +14,7 @@ class BasePresenter(object): ...@@ -14,7 +14,7 @@ class BasePresenter(object):
for the presenters to use to access the data API. for the presenters to use to access the data API.
""" """
def __init__(self, course_id, timeout=5): def __init__(self, course_id, timeout=10):
self.client = Client(base_url=settings.DATA_API_URL, self.client = Client(base_url=settings.DATA_API_URL,
auth_token=settings.DATA_API_AUTH_TOKEN, auth_token=settings.DATA_API_AUTH_TOKEN,
timeout=timeout) timeout=timeout)
......
{% load i18n %}
{% load dashboard_extras %}
<div class="row">
<div class="col-xs-12">
<div class="graded-content-nav">
<ul class="nav navbar-nav">
<li class="{% if active == "assignment_type" %}active{% endif %} nav-section">
<div class="dropdown">
<span class="link-label dropdown-toggle" id="assignmentTypeMenu" data-toggle="dropdown">
{% if assignment_type %}
{{assignment_type}}
{% else %}
{% trans "Select Assignment Type" %}
{% endif %}
<span class="caret"></span>
</span>
<ul class="dropdown-menu" role="menu" aria-labelledby="assignmentTypeMenu">
<li role="presentation">
<a role="menuitem" href="{% url "courses:performance:graded_content" course_id=course_id %}">
{# Translators: This refers to all course assignment types (e.g. homework, exam, lab). #}
{% trans "All Assignments" %}
</a>
</li>
{% for assignment_type in assignment_types %}
<li role="presentation">
<a role="menuitem"
href="{% url "courses:performance:graded_content_by_type" course_id=course_id assignment_type=assignment_type %}">
{{ assignment_type }}
</a>
</li>
{% endfor %}
</ul>
</div>
</li>
{% if assignments %}
<li class="{% if active == "assignments" %}active{% endif %} nav-section">
<div class="dropdown">
<span class="link-label dropdown-toggle" id="assignmentMenu" data-toggle="dropdown">
{% if assignment_name %}
{{assignment_name}}
{% else %}
{% blocktrans %}
Select {{assignment_type}}
{% endblocktrans %}
{% endif %}
<span class="caret"></span>
</span>
<ul class="dropdown-menu" role="menu" aria-labelledby="assignmentMenu">
{% for assignment in assignments %}
<li role="presentation" {% if not assignment.total_submissions > 0 %}class="disabled"{% endif %}>
<a role="menuitem"
{% if assignment.total_submissions > 0 %}href="{% url "courses:performance:assignment" course_id=course_id assignment_id=assignment.id %}"{% endif %}>
{{ assignment.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</li>
{% endif %}
{% if problems %}
<li class="{% if active == "problems" %}active{% endif %} nav-section">
<div class="dropdown">
<span class="link-label dropdown-toggle" id="problemMenu" data-toggle="dropdown">
{% trans "Problems" %}
<span class="caret"></span>
</span>
<ul class="dropdown-menu" role="menu" aria-labelledby="problemMenu">
{% for problem in problems %}
{% if problem.part_ids|length == 0 %}
{% include 'courses/_graded_content_nav_problem.html' with course_id=course_id assignment_id=assignment.id problem_id=problem.id part_id=None enabled=False name=problem.name %}
{% else %}
{% include 'courses/_graded_content_nav_problem.html' with course_id=course_id assignment_id=assignment.id problem_id=problem.id part_id=problem.part_ids.0 enabled=True name=problem.name %}
{% endif %}
{% endfor %}
</ul>
</div>
</li>
{% endif %}
</ul>
<div class="section-heading">
<span class="section-heading-note small">{{ heading_note }}</span>
</div>
</div>
</div>
</div>
{% load i18n %}
{% load dashboard_extras %}
<li role="presentation" {% if not enabled %}class="disabled"{% endif %}>
<a role="menuitem"
{% if enabled %}
href="{% url "courses:performance:answer_distribution" course_id=course_id assignment_id=assignment_id problem_id=problem_id problem_part_id=part_id %}"
{% endif %}
>
{% captureas empty %}
{# Translators: This indicates that no text was provided. #}
{% trans "(empty)" %}
{% endcaptureas %}
{{ name|default_if_none:empty }}
</a>
</li>
...@@ -15,10 +15,8 @@ ...@@ -15,10 +15,8 @@
{% block child_content %} {% block child_content %}
<section class="view-section" data-section="performance-answer-distribution" aria-hidden="true"> <section class="view-section" data-section="performance-answer-distribution" aria-hidden="true">
{% trans "How did students answer this problem?" as heading_note %}
<div class="section-heading"> {% include "courses/_graded_content_nav.html" with active="problems" assignment_name=assignment_name assignment_type=assignment_type assignment_types=assignment_types assignments=assignments assignment=assignment problems=assignment.problems heading_note=heading_note %}
<span class="section-heading-note small">{% trans "How did students answer this problem?" %}</span>
</div>
<div class="col-xs-12 problem-description"> <div class="col-xs-12 problem-description">
...@@ -57,7 +55,7 @@ ...@@ -57,7 +55,7 @@
{% for question in questions %} {% for question in questions %}
<li role="presentation"> <li role="presentation">
<a role="menuitem"tabindex="-1" class="truncate" <a role="menuitem"tabindex="-1" class="truncate"
href="{% url 'courses:performance:answer_distribution' course_id=course_id content_id=problem_id problem_part_id=question.part_id %}"> href="{% url 'courses:performance:answer_distribution' course_id=course_id assignment_id=assignment.id problem_id=problem_id problem_part_id=question.part_id %}">
{{question.question}} {{question.question}}
</a> </a>
</li> </li>
......
{% extends "courses/base-course.html" %}
{% load i18n %}
{% load rjs %}
{% load dashboard_extras %}
{% block javascript %}
{{ block.super }}
<script src="{% static_rjs 'js/performance-graded-content-assignment-main.js' %}"></script>
{% endblock javascript %}
{% block child_content %}
<section class="view-section">
{% trans "How are students doing on this assignment?" as heading_note %}
{% include "courses/_graded_content_nav.html" with active="assignments" heading_note=heading_note assignment_name=assignment_name assignment_types=assignment_types heading_note=heading_note assignments=assignments problems=assignment.problems %}
<div class="section-content section-data-graph">
<div class="section-content section-data-viz">
<div class="analytics-chart-container">
{% include "courses/submissions_chart_info.html"%}
{% trans "These bars show the correct and incorrect submission counts for each problem. Only the last submission from each student is counted." as tip_text %}
{% include "chart_tooltip.html" with tip_text=tip_text track_category="bar" %}
<div id="chart-view" class="analytics-chart">
{% include "loading.html" %}
</div>
</div>
</div>
</div>
</section>
<section class="view-section">
<div class="section-heading">
<h4 class="section-title">{% trans "Problem Submissions" %}</h4>
</div>
{% if js_data.course.problems %}
<div class="section-content section-data-table" data-role="problem-table">
{% include "loading.html" %}
</div>
{% else %}
{% show_table_error %}
{% endif %}
</section>
{% endblock %}
{% extends "courses/base-course.html" %}
{% load i18n %}
{% load dashboard_extras %}
{% block child_content %}
<section class="view-section">
{% trans "Which assignment type do you want to investigate?" as heading_note %}
{% include "courses/_graded_content_nav.html" with active="assignment_type" assignment_types=assignment_types heading_note=heading_note %}
<div class="section-content section-data-table grading-policy">
<div class="tooltip-container">
{% trans "Click an assignment type to view more detail information about the assignments." as tip_text %}
{% include "chart_tooltip.html" with tip_text=tip_text track_category="grading_policy" %}
</div>
<div class="row">
<div class="col-xs-12">
{% for item in grading_policy %}
{% widthratio item.weight 1 100 as policy_ratio %}
{% captureas policy_description %}
{# Translators: This describes the percent an assignment contributes to that of a learner's grade (e.g. Exam 60%). #}
{% blocktrans with item.assignment_type as assignment_type %}
{{assignment_type}} {{policy_ratio}}%
{% endblocktrans %}
{% endcaptureas %}
{% widthratio item.weight 1 max_policy_display_percent as bar_ratio %}
<div style="width: {{bar_ratio}}%; min-width: {{min_policy_display_percent}}%" class="policy-item has-tooltip" title="{{policy_description}}">
<a href="{% url 'courses:performance:graded_content_by_type' course_id=course_id assignment_type=item.assignment_type %}">
<div class="policy-item-box">
{% captureas policy_percent %}
{# Translators: This describes the percent of a learner's grade (e.g. 60%). #}
{% blocktrans %}{{policy_ratio}}%{% endblocktrans %}
{% endcaptureas %}
<div class="weight">{{policy_percent}}</div>
</div>
<div class="type">{{item.assignment_type}}</div>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
</section>
{% endblock %}
{% extends "courses/base-course.html" %}
{% load i18n %}
{% load rjs %}
{% load dashboard_extras %}
{% block javascript %}
{{ block.super }}
<script src="{% static_rjs 'js/performance-graded-content-assignment-types-main.js' %}"></script>
{% endblock javascript %}
{% block child_content %}
<section class="view-section">
{% captureas heading_note %}
{% blocktrans %}
How are students doing on the {{assignment_type}}?
{% endblocktrans %}
{% endcaptureas %}
{% include "courses/_graded_content_nav.html" with active="assignment_type" assignment_type=assignment_type assignment_types=assignment_types assignments=assignments %}
<div class="section-content section-data-graph">
<div class="section-content section-data-viz">
<div class="analytics-chart-container">
{% if js_data.course.assignmentsHaveSubmissions %}
{% include "courses/submissions_chart_info.html"%}
{% trans "These are correct and incorrect submission counts for each assignment. Only the last submission from each student is counted." as tip_text %}
{% include "chart_tooltip.html" with tip_text=tip_text track_category="bar" %}
{% endif %}
<div id="chart-view" class="analytics-chart {% if not js_data.course.assignmentsHaveSubmissions%}message-only-chart{% endif %}">
{% if js_data.course.assignmentsHaveSubmissions %}
{% include "loading.html" %}
{% else %}
<div class="clearfix"></div>
<div class="chart-message-container">
<p class="text-center">
{% trans "No submissions received for these assignments." %}
</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</section>
<section class="view-section">
<div class="section-heading">
<h4 class="section-title">{% trans "Assignment Submissions" %}</h4>
</div>
{% if js_data.course.assignments %}
<div class="section-content section-data-table" data-role="assignment-table">
{% include "loading.html" %}
</div>
{% else %}
{% show_table_error %}
{% endif %}
</section>
{% endblock %}
{% load i18n %}
{# Translators: This describes the data displayed in the chart below. #}
<div class="chart-info">{% trans "Submissions" %}</div>
import copy
import urllib
import uuid
from courses.tests.utils import CREATED_DATETIME_STRING
class CoursePerformanceDataFactory(object):
""" Factory that can be used to generate data for course performance-related presenters and APIs. """
course_id = "edX/DemoX/Demo_Course"
assignment_types = ['Homework', 'Exam']
grading_policy = [
{
"assignment_type": "Homework",
"count": 5,
"dropped": 1,
"weight": 0.2
},
{
"assignment_type": "Exam",
"count": 4,
"dropped": 0,
"weight": 0.8
}
]
def __init__(self):
self._structure = {}
self._assignments = []
self._generate_structure()
def _generate_block(self, block_type, block_format=None, display_name=None, graded=True, children=None):
return {
'id': 'i4x://edX/DemoX/{}/{}'.format(block_type, uuid.uuid4().hex),
'display_name': display_name,
'graded': graded,
'format': block_format,
'type': block_type,
'children': children or []
}
def _generate_structure(self):
root = 'i4x://edX/DemoX/course/Demo_Course'
self._structure = {
'root': root,
'blocks': {
root: {
'id': root,
'display_name': 'Demo Course',
'graded': False,
'format': None,
'type': 'course',
'children': []
}
}
}
self._assignments = []
for gp in self.grading_policy:
count = gp['count']
assignment_type = gp['assignment_type']
for assignment_index in range(1, count + 1):
display_name = '{} {}'.format(assignment_type, assignment_index)
children = []
# Generate the children
for problem_index in range(1, 4):
problem = self._generate_block('problem',
assignment_type,
'{} Problem {}'.format(display_name, problem_index))
problem_id = problem['id']
children.append(problem_id)
self._structure['blocks'][problem_id] = problem
assignment = self._generate_block('sequential', assignment_type, display_name, children=children)
assignment_id = assignment['id']
self._structure['blocks'][assignment_id] = assignment
self._structure['blocks'][root]['children'].append(assignment_id)
self._assignments.append(assignment)
def present_assignments(self):
presented = []
for assignment_index, assignment in enumerate(self._assignments):
problems = []
for problem_index, child in enumerate(assignment['children']):
block = self._structure['blocks'][child]
_id = block['id']
part_id = '{}_1_2'.format(_id)
correct_percent = 1.0
if problem_index == 0:
correct_percent = 0
url_template = '/courses/{}/performance/graded_content/assignments/{}/problems/' \
'{}/parts/{}/answer_distribution/'
problems.append({
'index': problem_index + 1,
'total_submissions': problem_index,
'correct_submissions': problem_index,
'correct_percent': correct_percent,
'incorrect_submissions': 0.0,
'incorrect_percent': 0,
'id': _id,
'name': block['display_name'],
'part_ids': [part_id],
'url': urllib.quote(url_template.format(
CoursePerformanceDataFactory.course_id, assignment['id'], _id, part_id))
})
num_problems = len(problems)
url_template = '/courses/{}/performance/graded_content/assignments/{}/'
presented_assignment = {
'index': assignment_index + 1,
'id': assignment['id'],
'name': assignment['display_name'],
'assignment_type': assignment['format'],
'problems': problems,
'num_problems': num_problems,
'total_submissions': num_problems,
'correct_submissions': num_problems,
'correct_percent': 1.0,
'incorrect_submissions': 0,
'incorrect_percent': 0.0,
'url': urllib.quote(url_template.format(
CoursePerformanceDataFactory.course_id, assignment['id']))
}
presented.append(presented_assignment)
return presented
def problems(self):
problems = []
for assignment in self._assignments:
for index, problem in enumerate(assignment['children']):
problem = self._structure['blocks'][problem]
problems.append({
"module_id": problem["id"],
"total_submissions": index,
"correct_submissions": index,
"part_ids": ["{}_1_2".format(problem["id"])],
"created": CREATED_DATETIME_STRING
})
return problems
@property
def structure(self):
return copy.deepcopy(self._structure)
@property
def assignments(self):
return copy.deepcopy(self._assignments)
import datetime import datetime
import mock
from django.test import TestCase
import analyticsclient.constants.activity_type as AT import analyticsclient.constants.activity_type as AT
from django.core.cache import cache
from django.test import TestCase
import mock
from courses.presenters import BasePresenter from courses.presenters import BasePresenter
from courses.presenters.engagement import CourseEngagementPresenter from courses.presenters.engagement import CourseEngagementPresenter
from courses.presenters.enrollment import CourseEnrollmentPresenter, CourseEnrollmentDemographicsPresenter from courses.presenters.enrollment import CourseEnrollmentPresenter, CourseEnrollmentDemographicsPresenter
from courses.presenters.performance import CoursePerformancePresenter from courses.presenters.performance import CoursePerformancePresenter
from courses.tests import utils, SwitchMixin from courses.tests import utils, SwitchMixin
from courses.tests.factories import CoursePerformanceDataFactory
class BasePresenterTests(TestCase):
def setUp(self):
self.presenter = BasePresenter('edX/DemoX/Demo_Course')
def test_init(self):
presenter = BasePresenter('edX/DemoX/Demo_Course')
self.assertEqual(presenter.client.timeout, 10)
presenter = BasePresenter('edX/DemoX/Demo_Course', timeout=15)
self.assertEqual(presenter.client.timeout, 15)
def test_parse_api_date(self):
self.assertEqual(self.presenter.parse_api_date('2014-01-01'), datetime.date(year=2014, month=1, day=1))
def test_parse_api_datetime(self):
self.assertEqual(self.presenter.parse_api_datetime(u'2014-09-18T145957'),
datetime.datetime(year=2014, month=9, day=18, hour=14, minute=59, second=57))
def test_strip_time(self):
self.assertEqual(self.presenter.strip_time('2014-01-01T000000'), '2014-01-01')
def test_get_current_date(self):
dt_format = '%Y-%m-%d'
self.assertEqual(self.presenter.get_current_date(), datetime.datetime.utcnow().strftime(dt_format))
class CourseEngagementPresenterTests(SwitchMixin, TestCase): class CourseEngagementPresenterTests(SwitchMixin, TestCase):
...@@ -88,32 +116,6 @@ class CourseEngagementPresenterTests(SwitchMixin, TestCase): ...@@ -88,32 +116,6 @@ class CourseEngagementPresenterTests(SwitchMixin, TestCase):
self.assertSummaryAndTrendsValid(True, self.get_expected_trends_small(True)) self.assertSummaryAndTrendsValid(True, self.get_expected_trends_small(True))
class BasePresenterTests(TestCase):
def setUp(self):
self.presenter = BasePresenter('edX/DemoX/Demo_Course')
def test_init(self):
presenter = BasePresenter('edX/DemoX/Demo_Course')
self.assertEqual(presenter.client.timeout, 5)
presenter = BasePresenter('edX/DemoX/Demo_Course', timeout=15)
self.assertEqual(presenter.client.timeout, 15)
def test_parse_api_date(self):
self.assertEqual(self.presenter.parse_api_date('2014-01-01'), datetime.date(year=2014, month=1, day=1))
def test_parse_api_datetime(self):
self.assertEqual(self.presenter.parse_api_datetime(u'2014-09-18T145957'),
datetime.datetime(year=2014, month=9, day=18, hour=14, minute=59, second=57))
def test_strip_time(self):
self.assertEqual(self.presenter.strip_time('2014-01-01T000000'), '2014-01-01')
def test_get_current_date(self):
dt_format = '%Y-%m-%d'
self.assertEqual(self.presenter.get_current_date(), datetime.datetime.utcnow().strftime(dt_format))
class CourseEnrollmentPresenterTests(SwitchMixin, TestCase): class CourseEnrollmentPresenterTests(SwitchMixin, TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
...@@ -244,11 +246,13 @@ class CourseEnrollmentDemographicsPresenterTests(TestCase): ...@@ -244,11 +246,13 @@ class CourseEnrollmentDemographicsPresenterTests(TestCase):
self.assertEqual(known_percent, 0.5) self.assertEqual(known_percent, 0.5)
class CoursePerformanceAnswerDistributionPresenterTests(TestCase): class CoursePerformancePresenterTests(TestCase):
def setUp(self): def setUp(self):
cache.clear()
self.course_id = 'edX/DemoX/Demo_Course' self.course_id = 'edX/DemoX/Demo_Course'
self.problem_id = 'i4x://edX/DemoX.1/problem/05d289c5ad3d47d48a77622c4a81ec36' self.problem_id = 'i4x://edX/DemoX.1/problem/05d289c5ad3d47d48a77622c4a81ec36'
self.presenter = CoursePerformancePresenter(self.course_id) self.presenter = CoursePerformancePresenter(None, self.course_id)
self.factory = CoursePerformanceDataFactory()
@mock.patch('analyticsclient.module.Module.answer_distribution') @mock.patch('analyticsclient.module.Module.answer_distribution')
def test_multiple_answer_distribution(self, mock_answer_distribution): def test_multiple_answer_distribution(self, mock_answer_distribution):
...@@ -327,3 +331,59 @@ class CoursePerformanceAnswerDistributionPresenterTests(TestCase): ...@@ -327,3 +331,59 @@ class CoursePerformanceAnswerDistributionPresenterTests(TestCase):
else: else:
self.assertListEqual(answer_distribution_entry.answer_distribution_limited, self.assertListEqual(answer_distribution_entry.answer_distribution_limited,
expected_answer_distribution[:12]) expected_answer_distribution[:12])
@mock.patch('slumber.Resource.get', mock.Mock(return_value=CoursePerformanceDataFactory.grading_policy))
def test_grading_policy(self):
""" Verify the presenter returns the correct grading policy. """
grading_policy = self.presenter.grading_policy()
self.assertListEqual(grading_policy, CoursePerformanceDataFactory.grading_policy)
percent = self.presenter.get_max_policy_display_percent(grading_policy)
self.assertEqual(100, percent)
percent = self.presenter.get_max_policy_display_percent([{'weight': 0.0}, {'weight': 1.0}, {'weight': 0.04}])
self.assertEqual(90, percent)
@mock.patch('courses.presenters.performance.CoursePerformancePresenter.grading_policy',
mock.Mock(return_value=CoursePerformanceDataFactory.grading_policy))
def test_assignment_types(self):
""" Verify the presenter returns the correct assignment types. """
self.assertListEqual(self.presenter.assignment_types(), CoursePerformanceDataFactory.assignment_types)
def test_assignments(self):
""" Verify the presenter returns the correct assignments and sets the last updated date. """
self.assertIsNone(self.presenter.last_updated)
with mock.patch('slumber.Resource.get', mock.Mock(return_value=self.factory.structure)):
with mock.patch('analyticsclient.course.Course.problems', self.factory.problems):
# With no assignment type set, the method should return all assignment types.
assignments = self.presenter.assignments()
expected_assignments = self.factory.present_assignments()
self.assertListEqual(assignments, expected_assignments)
self.assertEqual(self.presenter.last_updated, utils.CREATED_DATETIME)
# With an assignment type set, the presenter should return only the assignments of the specified type.
self.maxDiff = None
for assignment_type in self.factory.assignment_types:
cache.clear()
expected = [assignment for assignment in expected_assignments if
assignment[u'assignment_type'] == assignment_type]
for index, assignment in enumerate(expected):
assignment[u'index'] = index + 1
self.assertListEqual(self.presenter.assignments(assignment_type), expected)
def test_assignment(self):
""" Verify the presenter returns a specific assignment. """
with mock.patch('courses.presenters.performance.CoursePerformancePresenter.assignments',
mock.Mock(return_value=self.factory.present_assignments())):
# The method should return None if the assignment does not exist.
self.assertIsNone(self.presenter.assignment(None))
self.assertIsNone(self.presenter.assignment('non-existent-id'))
# The method should return an individual assignment if the ID exists.
assignment = self.factory.present_assignments()[0]
self.assertDictEqual(self.presenter.assignment(assignment[u'id']), assignment)
import json import json
import logging
from ddt import ddt, data, unpack from analyticsclient.exceptions import NotFoundError
from django.contrib.humanize.templatetags.humanize import intcomma from ddt import ddt, data
import httpretty
import mock
from django.core.urlresolvers import reverse
from django.core.cache import cache from django.core.cache import cache
from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from django.contrib.humanize.templatetags.humanize import intcomma
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from analyticsclient.exceptions import NotFoundError import httpretty
import mock
from core.tests.test_views import RedirectTestCaseMixin, UserTestCaseMixin from core.tests.test_views import RedirectTestCaseMixin, UserTestCaseMixin
from courses.permissions import set_user_course_permissions, revoke_user_course_permissions from courses.permissions import set_user_course_permissions, revoke_user_course_permissions
...@@ -19,6 +20,8 @@ from courses.tests.utils import set_empty_permissions, get_mock_api_enrollment_d ...@@ -19,6 +20,8 @@ from courses.tests.utils import set_empty_permissions, get_mock_api_enrollment_d
DEMO_COURSE_ID = 'course-v1:edX+DemoX+Demo_2014' DEMO_COURSE_ID = 'course-v1:edX+DemoX+Demo_2014'
DEPRECATED_DEMO_COURSE_ID = 'edX/DemoX/Demo_Course' DEPRECATED_DEMO_COURSE_ID = 'edX/DemoX/Demo_Course'
logger = logging.getLogger(__name__)
class CourseAPIMixin(SwitchMixin): class CourseAPIMixin(SwitchMixin):
""" """
...@@ -29,15 +32,35 @@ class CourseAPIMixin(SwitchMixin): ...@@ -29,15 +32,35 @@ class CourseAPIMixin(SwitchMixin):
{'id': course_key, 'name': 'Test ' + course_key} for course_key in [DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID] {'id': course_key, 'name': 'Test ' + course_key} for course_key in [DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID]
]} ]}
def mock_course_api(self, path, body): def mock_course_api(self, path, body=None, **kwargs):
""" """
Registers an HTTP mock for the specified course API path. The mock returns the specified data. Registers an HTTP mock for the specified course API path. The mock returns the specified data.
The calling test function MUST activate httpretty. The calling test function MUST activate httpretty.
Arguments
body -- Data returned by the mocked API
kwargs -- Additional arguments passed to httpretty.register_uri()
""" """
url = '{}/{}/{}/'.format(settings.COURSE_API_URL, settings.COURSE_API_VERSION, path)
body = json.dumps(body) # Avoid developer confusion when httpretty is not active and fail the test now.
httpretty.register_uri(httpretty.GET, url, body=body, content_type="application/json") if not httpretty.is_enabled():
self.fail('httpretty is not enabled. The mock will not be used!')
body = body or {}
# Remove trailing slashes from the path. They will be added back later.
path = path.strip(u'/')
url = '{}/{}/'.format(settings.COURSE_API_URL, path)
default_kwargs = {
'body': kwargs.get('body', json.dumps(body)),
'content_type': 'application/json'
}
default_kwargs.update(kwargs)
httpretty.register_uri(httpretty.GET, url, **default_kwargs)
logger.debug('Mocking Course API URL: %s', url)
def mock_course_detail(self, course_id): def mock_course_detail(self, course_id):
path = 'courses/{}'.format(course_id) path = 'courses/{}'.format(course_id)
...@@ -180,24 +203,6 @@ class CourseViewTestMixin(CourseAPIMixin, NavAssertMixin, ViewTestMixin): ...@@ -180,24 +203,6 @@ class CourseViewTestMixin(CourseAPIMixin, NavAssertMixin, ViewTestMixin):
self.assertEqual(context['course_name'], course_name) self.assertEqual(context['course_name'], course_name)
@ddt
class ProblemViewTestMixin(NavAssertMixin, ViewTestMixin):
presenter_method = None
PROBLEM_ID = 'i4x://edX/DemoX.1/problem/05d289c5ad3d47d48a77622c4a81ec36'
TEXT_PROBLEM_PART_ID = 'i4x-edX-DemoX_1-problem-5e3c6d6934494d87b3a025676c7517c1_2_1'
NUMERIC_PROBLEM_PART_ID = 'i4x-edX-DemoX_1-problem-5e3c6d6934494d87b3a025676c7517c1_3_1'
RANDOMIZED_PROBLEM_PART_ID = 'i4x-edX-DemoX_1-problem-5e3c6d6934494d87b3a025676c7517c1_3_1'
# API returns different data (e.g. text answers, numeric answers, and randomized answers), resulting in
# different renderings for these problem part IDs.
@data((DEMO_COURSE_ID, PROBLEM_ID, TEXT_PROBLEM_PART_ID), (DEMO_COURSE_ID, PROBLEM_ID, NUMERIC_PROBLEM_PART_ID),
(DEMO_COURSE_ID, PROBLEM_ID, RANDOMIZED_PROBLEM_PART_ID))
@unpack
def test_valid_course(self, course_id, problem_id, problem_part_id):
self.assertViewIsValid(course_id, problem_id, problem_part_id)
# pylint: disable=abstract-method # pylint: disable=abstract-method
class CourseEnrollmentViewTestMixin(CourseViewTestMixin): class CourseEnrollmentViewTestMixin(CourseViewTestMixin):
active_secondary_nav_label = None active_secondary_nav_label = None
......
...@@ -68,6 +68,7 @@ class CourseIndexViewTests(CourseAPIMixin, ViewTestMixin, MiddlewareAssertionMix ...@@ -68,6 +68,7 @@ class CourseIndexViewTests(CourseAPIMixin, ViewTestMixin, MiddlewareAssertionMix
self.toggle_switch('enable_course_api', True) self.toggle_switch('enable_course_api', True)
self.mock_course_list() self.mock_course_list()
courses = self._create_course_list(DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID, with_name=True) courses = self._create_course_list(DEMO_COURSE_ID, DEPRECATED_DEMO_COURSE_ID, with_name=True)
self.assertIsNotNone(httpretty.last_request())
self.assertCourseListEquals(courses) self.assertCourseListEquals(courses)
# Test with mixed permissions # Test with mixed permissions
......
...@@ -4,11 +4,10 @@ import csv ...@@ -4,11 +4,10 @@ import csv
import datetime import datetime
from analyticsclient.client import Client from analyticsclient.client import Client
from analyticsclient.constants import UNKNOWN_COUNTRY_CODE from analyticsclient.constants import enrollment_modes, UNKNOWN_COUNTRY_CODE
import analyticsclient.constants.activity_type as AT import analyticsclient.constants.activity_type as AT
import analyticsclient.constants.education_level as EDUCATION_LEVEL import analyticsclient.constants.education_level as EDUCATION_LEVEL
import analyticsclient.constants.gender as GENDER import analyticsclient.constants.gender as GENDER
from analyticsclient.constants import enrollment_modes
from courses.permissions import set_user_course_permissions from courses.permissions import set_user_course_permissions
from courses.presenters.performance import AnswerDistributionEntry from courses.presenters.performance import AnswerDistributionEntry
...@@ -232,7 +231,7 @@ def get_mock_api_enrollment_age_data(course_id): ...@@ -232,7 +231,7 @@ def get_mock_api_enrollment_age_data(course_id):
data = [ data = [
{'course_id': course_id, 'birth_year': 1900, 'count': 100, 'created': CREATED_DATETIME_STRING}, {'course_id': course_id, 'birth_year': 1900, 'count': 100, 'created': CREATED_DATETIME_STRING},
{'course_id': course_id, 'birth_year': 2000, 'count': 400, 'created': CREATED_DATETIME_STRING}, {'course_id': course_id, 'birth_year': 2000, 'count': 400, 'created': CREATED_DATETIME_STRING},
{'course_id': course_id, 'birth_year': 2014, 'count': 500, 'created': CREATED_DATETIME_STRING}, {'course_id': course_id, 'birth_year': 2015, 'count': 500, 'created': CREATED_DATETIME_STRING},
{'course_id': course_id, 'birth_year': None, 'count': 1000, 'created': CREATED_DATETIME_STRING} {'course_id': course_id, 'birth_year': None, 'count': 1000, 'created': CREATED_DATETIME_STRING}
] ]
...@@ -240,6 +239,7 @@ def get_mock_api_enrollment_age_data(course_id): ...@@ -240,6 +239,7 @@ def get_mock_api_enrollment_age_data(course_id):
def get_presenter_enrollment_binned_ages(): def get_presenter_enrollment_binned_ages():
# TODO Make this code less brittle. It currently relies on the current year being 2015.
current_year = datetime.date.today().year current_year = datetime.date.today().year
oldest = current_year - 100 oldest = current_year - 100
binned = [] binned = []
...@@ -251,10 +251,10 @@ def get_presenter_enrollment_binned_ages(): ...@@ -251,10 +251,10 @@ def get_presenter_enrollment_binned_ages():
binned[0]['count'] = 100 binned[0]['count'] = 100
binned[0]['percent'] = 0.05 binned[0]['percent'] = 0.05
# adjust year 2014 # adjust year 2015
index_2014 = 2014 - current_year - 1 index_2015 = 2015 - current_year - 1
binned[index_2014]['count'] = 500 binned[index_2015]['count'] = 500
binned[index_2014]['percent'] = 0.25 binned[index_2015]['percent'] = 0.25
# adjust year 2000 # adjust year 2000
index_2000 = 2000 - current_year - 1 index_2000 = 2000 - current_year - 1
...@@ -269,7 +269,7 @@ def get_presenter_enrollment_binned_ages(): ...@@ -269,7 +269,7 @@ def get_presenter_enrollment_binned_ages():
def get_presenter_enrollment_ages_summary(): def get_presenter_enrollment_ages_summary():
current_year = datetime.date.today().year current_year = datetime.date.today().year
return { return {
'median': (current_year * 2 - 2000 - 2014) * 0.5, 'median': (current_year * 2 - 2000 - 2015) * 0.5,
'under_25': 0.9, 'under_25': 0.9,
'between_26_40': 0.0, 'between_26_40': 0.0,
'over_40': 0.1 'over_40': 0.1
......
...@@ -5,9 +5,15 @@ from django.conf.urls import url, patterns, include ...@@ -5,9 +5,15 @@ from django.conf.urls import url, patterns, include
from courses import views from courses import views
from courses.views import enrollment, engagement, performance, csv from courses.views import enrollment, engagement, performance, csv
COURSE_ID_PATTERN = r'(?P<course_id>[^/+]+[/+][^/+]+[/+][^/]+)'
CONTENT_ID_PATTERN = r'(?P<content_id>[\.a-zA-Z0-9_+\/:-]+)' CONTENT_ID_PATTERN = r'(?P<content_id>[\.a-zA-Z0-9_+\/:-]+)'
PROBLEM_PART_ID_PATTERN = r'(?P<problem_part_id>[^/]+)' COURSE_ID_PATTERN = r'(?P<course_id>[^/+]+[/+][^/+]+[/+][^/]+)'
PROBLEM_PART_ID_PATTERN = CONTENT_ID_PATTERN.replace('content_id', 'problem_part_id')
ASSIGNMENT_ID_PATTERN = CONTENT_ID_PATTERN.replace('content_id', 'assignment_id')
PROBLEM_ID_PATTERN = CONTENT_ID_PATTERN.replace('content_id', 'problem_id')
answer_distribution_regex = \
r'^graded_content/assignments/{assignment_id}/problems/{problem_id}/parts/{part_id}/answer_distribution/$'.format(
assignment_id=ASSIGNMENT_ID_PATTERN, problem_id=PROBLEM_ID_PATTERN, part_id=PROBLEM_PART_ID_PATTERN)
ENROLLMENT_URLS = patterns( ENROLLMENT_URLS = patterns(
'', '',
...@@ -26,9 +32,17 @@ ENGAGEMENT_URLS = patterns( ...@@ -26,9 +32,17 @@ ENGAGEMENT_URLS = patterns(
PERFORMANCE_URLS = patterns( PERFORMANCE_URLS = patterns(
'', '',
url(r'^graded_content/problems/{}/answer_distribution/{}/$'.format(CONTENT_ID_PATTERN, PROBLEM_PART_ID_PATTERN), url(r'^graded_content/$', performance.PerformanceGradedContent.as_view(), name='graded_content'),
performance.PerformanceAnswerDistributionView.as_view(), url(r'^graded_content/(?P<assignment_type>[\w ]+)/$',
name='answer_distribution'), performance.PerformanceGradedContentByType.as_view(),
name='graded_content_by_type'),
url(answer_distribution_regex, performance.PerformanceAnswerDistributionView.as_view(), name='answer_distribution'),
# This MUST come AFTER the answer distribution pattern; otherwise, the answer distribution pattern
# will be interpreted as an assignment pattern.
url(r'^graded_content/assignments/{}/$'.format(ASSIGNMENT_ID_PATTERN),
performance.PerformanceAssignment.as_view(),
name='assignment'),
) )
CSV_URLS = patterns( CSV_URLS = patterns(
......
...@@ -44,6 +44,9 @@ class sorting(object): ...@@ -44,6 +44,9 @@ class sorting(object):
return [sorting._tryint(c) for c in re.split('([0-9]+)', s)] return [sorting._tryint(c) for c in re.split('([0-9]+)', s)]
@staticmethod @staticmethod
def natural_sort(l, field): def natural_sort(l, field=None):
""" Natural sort from Ned Batchelder - http://nedbatchelder.com/blog/200712.html#e20071211T054956 """ """ Natural sort from Ned Batchelder - http://nedbatchelder.com/blog/200712.html#e20071211T054956 """
l.sort(key=lambda x: sorting._alphanum_key(x[field])) if field:
l.sort(key=lambda x: sorting._alphanum_key(x[field]))
else:
l.sort(key=sorting._alphanum_key)
...@@ -18,9 +18,10 @@ import slumber ...@@ -18,9 +18,10 @@ import slumber
from slumber.exceptions import HttpClientError from slumber.exceptions import HttpClientError
from waffle import switch_is_active from waffle import switch_is_active
from analyticsclient.client import Client from analyticsclient.client import Client
from analyticsclient.exceptions import NotFoundError from analyticsclient.exceptions import NotFoundError, ClientError
from edx_api_client.auth import TokenAuth
from common import BearerAuth
from core.utils import sanitize_cache_key
from courses import permissions from courses import permissions
from courses.serializers import LazyEncoder from courses.serializers import LazyEncoder
from courses.utils import is_feature_enabled from courses.utils import is_feature_enabled
...@@ -31,6 +32,7 @@ logger = logging.getLogger(__name__) ...@@ -31,6 +32,7 @@ logger = logging.getLogger(__name__)
class CourseAPIMixin(object): class CourseAPIMixin(object):
access_token = None
course_api_enabled = False course_api_enabled = False
course_api = None course_api = None
course_id = None course_id = None
...@@ -47,13 +49,16 @@ class CourseAPIMixin(object): ...@@ -47,13 +49,16 @@ class CourseAPIMixin(object):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.course_api_enabled = switch_is_active('enable_course_api') self.course_api_enabled = switch_is_active('enable_course_api')
if self.course_api_enabled: if self.course_api_enabled and request.user.is_authenticated():
logger.debug('Instantiating Course API with URL: %s', settings.COURSE_API_URL) self.access_token = request.user.access_token
self.course_api = slumber.API(settings.COURSE_API_URL, auth=TokenAuth(settings.COURSE_API_KEY)).v0.courses self.course_api = slumber.API(settings.COURSE_API_URL, auth=BearerAuth(self.access_token)).courses
return super(CourseAPIMixin, self).dispatch(request, *args, **kwargs) return super(CourseAPIMixin, self).dispatch(request, *args, **kwargs)
def get_course_info(self, course_id, depth=0): def _course_detail_cache_key(self, course_id):
return sanitize_cache_key('course_{}_details'.format(course_id))
def get_course_info(self, course_id):
""" """
Retrieve course info from the Course API. Retrieve course info from the Course API.
...@@ -61,15 +66,14 @@ class CourseAPIMixin(object): ...@@ -61,15 +66,14 @@ class CourseAPIMixin(object):
Arguments Arguments
course_id -- ID of the course for which data should be retrieved course_id -- ID of the course for which data should be retrieved
depth -- Number of (tree) levels worth of data to retrieve
extra_fields -- Additional course fields to retrieve
""" """
key = u'_'.join([unicode(course_id), unicode(depth)]) key = self._course_detail_cache_key(course_id)
info = cache.get(key) info = cache.get(key)
if not info: if not info:
try: try:
info = self.course_api(course_id).get(depth=depth) logger.debug("Retrieving detail for course: %s", course_id)
info = self.course_api(course_id).get()
cache.set(key, info) cache.set(key, info)
except HttpClientError as e: except HttpClientError as e:
logger.error("Unable to retrieve course info for %s: %s", course_id, e) logger.error("Unable to retrieve course info for %s: %s", course_id, e)
...@@ -82,7 +86,15 @@ class CourseAPIMixin(object): ...@@ -82,7 +86,15 @@ class CourseAPIMixin(object):
course_ids = ','.join(course_ids) course_ids = ','.join(course_ids)
try: try:
return self.course_api.get(course_id=course_ids)['results'] course_details = self.course_api.get(course_id=course_ids)['results']
# Cache the information so that it doesn't need to be retrieved later.
for course in course_details:
course_id = course['id']
key = self._course_detail_cache_key(course_id)
cache.set(key, course)
return course_details
except HttpClientError as e: except HttpClientError as e:
logger.error("Unable to retrieve course data: %s", e) logger.error("Unable to retrieve course data: %s", e)
return [] return []
...@@ -255,7 +267,15 @@ class CourseNavBarMixin(object): ...@@ -255,7 +267,15 @@ class CourseNavBarMixin(object):
'label': _('Engagement'), 'label': _('Engagement'),
'view': 'courses:engagement:content', 'view': 'courses:engagement:content',
'icon': 'fa-bar-chart', 'icon': 'fa-bar-chart',
},
{
'name': 'performance',
'label': _('Performance'),
'view': 'courses:performance:graded_content',
'icon': 'fa-check-square-o',
'switch': 'enable_course_api',
} }
] ]
# Remove disabled items # Remove disabled items
...@@ -351,8 +371,12 @@ class CourseView(LoginRequiredMixin, CourseValidMixin, CoursePermissionMixin, Te ...@@ -351,8 +371,12 @@ class CourseView(LoginRequiredMixin, CourseValidMixin, CoursePermissionMixin, Te
# the template can rendering a loading error message for the section # the template can rendering a loading error message for the section
try: try:
return super(CourseView, self).dispatch(request, *args, **kwargs) return super(CourseView, self).dispatch(request, *args, **kwargs)
except NotFoundError: except NotFoundError as e:
logger.error('The requested data from the Analytics Data API was not found: %s', e)
raise Http404 raise Http404
except ClientError as e:
logger.error('An error occurred while retrieving data from the Analytics Data API: %s', e)
raise
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(CourseView, self).get_context_data(**kwargs) context = super(CourseView, self).get_context_data(**kwargs)
...@@ -391,7 +415,7 @@ class CourseHome(CourseTemplateWithNavView): ...@@ -391,7 +415,7 @@ class CourseHome(CourseTemplateWithNavView):
page_title = _('Course Home') page_title = _('Course Home')
def get_table_items(self): def get_table_items(self):
return [ items = [
{ {
'name': _('Enrollment'), 'name': _('Enrollment'),
'icon': 'fa-child', 'icon': 'fa-child',
...@@ -439,6 +463,22 @@ class CourseHome(CourseTemplateWithNavView): ...@@ -439,6 +463,22 @@ class CourseHome(CourseTemplateWithNavView):
] ]
if switch_is_active('enable_course_api'):
items.append({
'name': _('Performance'),
'icon': 'fa-check-square-o',
'heading': _('How are students doing on course assignments?'),
'items': [
{
'title': _('How are students doing on graded course assignments?'),
'view': 'courses:performance:graded_content',
'breadcrumbs': [_('Graded Content')]
}
]
})
return items
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(CourseHome, self).get_context_data(**kwargs) context = super(CourseHome, self).get_context_data(**kwargs)
context.update({ context.update({
......
import logging import logging
from django.conf import settings from django.conf import settings
from django.http import Http404
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from analyticsclient.exceptions import NotFoundError from slumber.exceptions import SlumberBaseException
from courses.presenters.performance import CoursePerformancePresenter from courses.presenters.performance import CoursePerformancePresenter
from courses.views import CourseTemplateWithNavView from courses.views import CourseTemplateWithNavView, CourseAPIMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class PerformanceTemplateView(CourseTemplateWithNavView): class PerformanceTemplateView(CourseTemplateWithNavView, CourseAPIMixin):
"""
Base view for course performance pages.
"""
assignment_type = None
assignment_id = None
assignment = None
presenter = None
# Translators: Do not translate UTC. # Translators: Do not translate UTC.
update_message = _('Problem submission data was last updated %(update_date)s at %(update_time)s UTC.') update_message = _('Problem submission data was last updated %(update_date)s at %(update_time)s UTC.')
secondary_nav_items = [
{'name': 'graded_content', 'label': _('Graded Content'), 'view': 'courses:performance_graded_content'},
]
active_primary_nav_item = 'performance'
page_title = _('Graded Content')
active_secondary_nav_item = 'graded_content'
def dispatch(self, request, *args, **kwargs):
self.assignment_id = kwargs.get('assignment_id')
try:
return super(PerformanceTemplateView, self).dispatch(request, *args, **kwargs)
except SlumberBaseException as e:
# Return the appropriate response if a 404 occurred.
response = getattr(e, 'response')
if response is not None and response.status_code == 404:
logger.info('Course API data not found for %s: %s', self.course_id, e)
raise Http404
# Not a 404. Continue raising the error.
logger.error('An error occurred while using Slumber to communicate with an API: %s', e)
raise
def get_context_data(self, **kwargs):
context = super(PerformanceTemplateView, self).get_context_data(**kwargs)
self.presenter = CoursePerformancePresenter(self.access_token, self.course_id)
context['assignment_types'] = self.presenter.assignment_types()
if self.assignment_id:
assignment = self.presenter.assignment(self.assignment_id)
if assignment:
context['assignment'] = assignment
context['assignment_name'] = assignment['name']
self.assignment = assignment
self.assignment_type = assignment['assignment_type']
else:
logger.info('Assignment %s not found.', self.assignment_id)
raise Http404
if self.assignment_type:
assignments = self.presenter.assignments(self.assignment_type)
context['js_data']['course']['assignments'] = assignments
context['js_data']['course']['assignmentsHaveSubmissions'] = self.presenter.has_submissions(assignments)
context['js_data']['course']['assignmentType'] = self.assignment_type
context.update({
'assignment_type': self.assignment_type,
'assignments': assignments,
'update_message': self.get_last_updated_message(self.presenter.last_updated)
})
return context
class PerformanceAnswerDistributionView(PerformanceTemplateView): class PerformanceAnswerDistributionView(PerformanceTemplateView):
template_name = 'courses/performance_answer_distribution.html' template_name = 'courses/performance_answer_distribution.html'
...@@ -24,22 +87,17 @@ class PerformanceAnswerDistributionView(PerformanceTemplateView): ...@@ -24,22 +87,17 @@ class PerformanceAnswerDistributionView(PerformanceTemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(PerformanceAnswerDistributionView, self).get_context_data(**kwargs) context = super(PerformanceAnswerDistributionView, self).get_context_data(**kwargs)
presenter = CoursePerformancePresenter(self.course_id) presenter = self.presenter
problem_id = self.kwargs['content_id'] problem_id = self.kwargs['problem_id']
part_id = self.kwargs['problem_part_id'] part_id = self.kwargs['problem_part_id']
view_live_url = None view_live_url = None
if settings.LMS_COURSE_SHORTCUT_BASE_URL: if settings.LMS_COURSE_SHORTCUT_BASE_URL:
view_live_url = '{0}/{1}/jump_to/{2}'.format(settings.LMS_COURSE_SHORTCUT_BASE_URL, view_live_url = '{0}/{1}/jump_to/{2}'.format(settings.LMS_COURSE_SHORTCUT_BASE_URL, self.course_id,
self.course_id, problem_id) problem_id)
try: answer_distribution_entry = presenter.get_answer_distribution(problem_id, part_id)
answer_distribution_entry = presenter.get_answer_distribution(problem_id, part_id)
except NotFoundError:
logger.error("Failed to retrieve performance answer distribution data for %s.", part_id)
# if the problem_part_id isn't found, a NotFoundError is thrown and a 404 should be displayed
raise NotFoundError
context['js_data']['course'].update({ context['js_data']['course'].update({
'answerDistribution': answer_distribution_entry.answer_distribution, 'answerDistribution': answer_distribution_entry.answer_distribution,
...@@ -49,15 +107,76 @@ class PerformanceAnswerDistributionView(PerformanceTemplateView): ...@@ -49,15 +107,76 @@ class PerformanceAnswerDistributionView(PerformanceTemplateView):
}) })
context.update({ context.update({
'course_id': self.course_id,
'questions': answer_distribution_entry.questions, 'questions': answer_distribution_entry.questions,
'active_question': answer_distribution_entry.active_question, 'active_question': answer_distribution_entry.active_question,
'problem_id': problem_id, 'problem_id': problem_id,
'problem_part_id': part_id, 'problem_part_id': part_id,
'problem_part_description': answer_distribution_entry.problem_part_description, 'problem_part_description': answer_distribution_entry.problem_part_description,
'view_live_url': view_live_url, 'view_live_url': view_live_url
'update_message': self.get_last_updated_message(answer_distribution_entry.last_updated)
}) })
context['page_data'] = self.get_page_data(context) context['page_data'] = self.get_page_data(context)
return context return context
class PerformanceGradedContent(PerformanceTemplateView):
template_name = 'courses/performance_graded_content.html'
page_name = 'performance_graded_content'
def get_context_data(self, **kwargs):
context = super(PerformanceGradedContent, self).get_context_data(**kwargs)
grading_policy = self.presenter.grading_policy()
context.update({
'grading_policy': grading_policy,
'max_policy_display_percent': self.presenter.get_max_policy_display_percent(grading_policy),
'min_policy_display_percent': CoursePerformancePresenter.MIN_POLICY_DISPLAY_PERCENT,
'page_data': self.get_page_data(context)
})
return context
class PerformanceGradedContentByType(PerformanceTemplateView):
template_name = 'courses/performance_graded_content_by_type.html'
page_name = 'performance_graded_content_by_type'
def dispatch(self, request, *args, **kwargs):
self.assignment_type = kwargs['assignment_type']
return super(PerformanceGradedContentByType, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super(PerformanceGradedContentByType, self).get_context_data(**kwargs)
assignment_type = self.assignment_type
assignments = self.presenter.assignments(assignment_type)
if not assignments:
# If there are no assignments, either the course is incomplete or the assignment type is invalid.
# It is more likely that the assignment type is invalid, so return a 404.
logger.info('No assignments of type %s were found for course %s', assignment_type, self.course_id)
raise Http404
context.update({
'page_data': self.get_page_data(context),
'page_title': _('Graded Content: %(assignment_type)s') % {'assignment_type': self.assignment_type}
})
return context
class PerformanceAssignment(PerformanceTemplateView):
template_name = 'courses/performance_assignment.html'
page_name = 'performance_assignment'
def get_context_data(self, **kwargs):
context = super(PerformanceAssignment, self).get_context_data(**kwargs)
context['js_data']['course']['problems'] = self.assignment['problems']
context.update({
'page_data': self.get_page_data(context)
})
return context
...@@ -9,6 +9,7 @@ from analytics_dashboard.settings.base import * ...@@ -9,6 +9,7 @@ from analytics_dashboard.settings.base import *
from analytics_dashboard.settings.logger import get_logger_config from analytics_dashboard.settings.logger import get_logger_config
########## DEBUG CONFIGURATION ########## DEBUG CONFIGURATION
# See: https://docs.djangoproject.com/en/dev/ref/settings/#debug # See: https://docs.djangoproject.com/en/dev/ref/settings/#debug
DEBUG = True DEBUG = True
...@@ -83,12 +84,12 @@ FULL_APPLICATION_NAME = '{0} {1}'.format(PLATFORM_NAME, APPLICATION_NAME) ...@@ -83,12 +84,12 @@ FULL_APPLICATION_NAME = '{0} {1}'.format(PLATFORM_NAME, APPLICATION_NAME)
########## AUTHENTICATION/AUTHORIZATION ########## AUTHENTICATION/AUTHORIZATION
# Set these to the correct values for your OAuth2/OpenID Connect provider # Set these to the correct values for your OAuth2/OpenID Connect provider
SOCIAL_AUTH_EDX_OIDC_KEY = 'dummy-key' SOCIAL_AUTH_EDX_OIDC_KEY = 'replace-me'
SOCIAL_AUTH_EDX_OIDC_SECRET = 'dummy-secret' SOCIAL_AUTH_EDX_OIDC_SECRET = 'replace-me'
SOCIAL_AUTH_EDX_OIDC_URL_ROOT = 'http://0.0.0.0:8000/oauth2' SOCIAL_AUTH_EDX_OIDC_URL_ROOT = 'http://127.0.0.1:8000/oauth2'
# This value should be the same as SOCIAL_AUTH_EDX_OIDC_SECRET # This value should be the same as SOCIAL_AUTH_EDX_OIDC_SECRET
SOCIAL_AUTH_EDX_OIDC_ID_TOKEN_DECRYPTION_KEY = 'dummy-decryption-key' SOCIAL_AUTH_EDX_OIDC_ID_TOKEN_DECRYPTION_KEY = SOCIAL_AUTH_EDX_OIDC_SECRET
ENABLE_AUTO_AUTH = True ENABLE_AUTO_AUTH = True
...@@ -105,7 +106,6 @@ HELP_URL = '#' ...@@ -105,7 +106,6 @@ HELP_URL = '#'
SEGMENT_IO_KEY = os.environ.get('SEGMENT_WRITE_KEY') SEGMENT_IO_KEY = os.environ.get('SEGMENT_WRITE_KEY')
########## END SEGMENT.IO ########## END SEGMENT.IO
LOGGING = get_logger_config(debug=DEBUG, dev_env=True, local_loglevel='DEBUG') COURSE_API_URL = 'http://127.0.0.1:8000/api/course_structure/v0/'
COURSE_API_URL = 'http://127.0.0.1:8000/api' LOGGING = get_logger_config(debug=DEBUG, dev_env=True, local_loglevel='DEBUG')
COURSE_API_KEY = 'edx'
from __future__ import absolute_import from __future__ import absolute_import
from analytics_dashboard.settings.logger import get_logger_config
from analytics_dashboard.settings.base import * from analytics_dashboard.settings.base import *
########## TEST SETTINGS ########## TEST SETTINGS
...@@ -22,6 +22,10 @@ DATABASES = { ...@@ -22,6 +22,10 @@ DATABASES = {
ENABLE_AUTO_AUTH = True ENABLE_AUTO_AUTH = True
SOCIAL_AUTH_EDX_OIDC_URL_ROOT = 'http://example.com' SOCIAL_AUTH_EDX_OIDC_URL_ROOT = 'http://example.com'
LMS_COURSE_SHORTCUT_BASE_URL = 'http://lms-host'
COURSE_API_URL = 'http://course-api-host' COURSE_API_URL = 'http://course-api-host'
COURSE_API_VERSION = 'v0'
COURSE_API_KEY = 'edx' LOGGING = get_logger_config(debug=DEBUG, dev_env=True, local_loglevel='DEBUG')
# Compressing assets slows down view rendering. Since we don't actually need assets, don't bother compressing them.
COMPRESS_ENABLED = False
...@@ -35,6 +35,7 @@ require(['vendor/domReady!', 'load/init-page'], function(doc, page) { ...@@ -35,6 +35,7 @@ require(['vendor/domReady!', 'load/init-page'], function(doc, page) {
model: courseModel, model: courseModel,
modelAttribute: 'answerDistributionLimited', modelAttribute: 'answerDistributionLimited',
dataType: 'count', dataType: 'count',
truncateXTicks: true,
trends: [{ trends: [{
title: function(index) { title: function(index) {
if (courseModel.get('answerDistributionLimited')[index].correct) { if (courseModel.get('answerDistributionLimited')[index].correct) {
......
require(['vendor/domReady!', 'load/init-page'], function (doc, page) {
'use strict';
require(['d3', 'underscore', 'views/data-table-view', 'views/stacked-bar-view'],
function (d3, _, DataTableView, StackedBarView) {
var model = page.models.courseModel,
graphSubmissionColumns = [
{
key: 'correct_submissions',
percent_key: 'correct_percent',
title: gettext('Correct'),
className: 'text-right',
type: 'number',
color: '#4BB4FB'
},
{
key: 'incorrect_submissions',
percent_key: 'incorrect_percent',
title: gettext('Incorrect'),
className: 'text-right',
type: 'number',
color: '#CA0061'
}
],
tableColumns = [
{key: 'index', title: gettext('Sequence'), type: 'number', className: 'text-right'},
{key: 'name', title: gettext('Problem Name'), type: 'hasNull'}
].concat(graphSubmissionColumns);
tableColumns.push({
key: 'total_submissions',
title: gettext('Total'),
className: 'text-right',
type: 'number',
color: '#4BB4FB'
});
new StackedBarView({
el: '#chart-view',
model: model,
modelAttribute: 'problems',
truncateXTicks: true,
trends: graphSubmissionColumns,
x: {key: 'id', displayKey: 'name'},
y: {key: 'count'},
interactiveTooltipValueTemplate: function(trend) {
/* Translators: <%=value%> will be replaced by a number followed by a percentage.
For example, "400 (29%)" */
return _.template(gettext('<%=value%> (<%=percent%>)'))({
value: trend.value,
percent: d3.format('.1%')(trend.point[trend.options.percent_key])
});
},
click: function (d) { if (_(d).has('url')) { document.location.href = d.url; }}
});
new DataTableView({
el: '[data-role=problem-table]',
model: model,
modelAttribute: 'problems',
columns: tableColumns,
sorting: ['index']
});
});
});
require(['vendor/domReady!', 'load/init-page'], function (doc, page) {
'use strict';
require(['d3', 'underscore', 'views/data-table-view', 'views/stacked-bar-view'],
function (d3, _, DataTableView, StackedBarView) {
var model = page.models.courseModel,
graphSubmissionColumns = [
{
key: 'correct_submissions',
percent_key: 'correct_percent',
title: gettext('Correct'),
className: 'text-right',
type: 'number',
color: '#4BB4FB'
},
{
key: 'incorrect_submissions',
percent_key: 'incorrect_percent',
title: gettext('Incorrect'),
className: 'text-right',
type: 'number',
color: '#CA0061'
}
],
tableColumns = [
{key: 'index', title: gettext('Sequence'), type: 'number', className: 'text-right'},
{key: 'name', title: gettext('Assignment Name')},
{
key: 'num_problems',
title: gettext('Problems in Assignment'),
type: 'number', className: 'text-right'
}
].concat(graphSubmissionColumns);
tableColumns.push({
key: 'total_submissions',
title: gettext('Total'),
className: 'text-right',
type: 'number',
color: '#4BB4FB'
});
if (model.get('assignmentsHaveSubmissions')) {
new StackedBarView({
el: '#chart-view',
model: model,
modelAttribute: 'assignments',
truncateXTicks: true,
trends: graphSubmissionColumns,
x: {key: 'id', displayKey: 'name'},
y: {key: 'count'},
interactiveTooltipValueTemplate: function (trend) {
/* Translators: <%=value%> will be replaced by a number followed by a percentage.
For example, "400 (29%)" */
return _.template(gettext('<%=value%> (<%=percent%>)'))({
value: trend.value,
percent: d3.format('.1%')(trend.point[trend.options.percent_key])
});
},
click: function (d) {
if (_(d).has('url')) {
document.location.href = d.url;
}
}
});
}
new DataTableView({
el: '[data-role=assignment-table]',
model: model,
modelAttribute: 'assignments',
columns: tableColumns,
sorting: ['index']
});
});
});
define(['models/course-model', 'views/chart-view'], function(CourseModel, ChartView) { define(['d3', 'models/course-model', 'views/chart-view'], function(d3, CourseModel, ChartView) {
'use strict'; 'use strict';
describe('Chart view', function () { describe('Chart view', function () {
...@@ -31,7 +31,8 @@ define(['models/course-model', 'views/chart-view'], function(CourseModel, ChartV ...@@ -31,7 +31,8 @@ define(['models/course-model', 'views/chart-view'], function(CourseModel, ChartV
} }
}), }),
assembledData, assembledData,
actualTrend; actualTrend,
explicitXTicks;
view.render = jasmine.createSpy('render'); view.render = jasmine.createSpy('render');
expect(view.render).not.toHaveBeenCalled(); expect(view.render).not.toHaveBeenCalled();
...@@ -61,6 +62,9 @@ define(['models/course-model', 'views/chart-view'], function(CourseModel, ChartV ...@@ -61,6 +62,9 @@ define(['models/course-model', 'views/chart-view'], function(CourseModel, ChartV
} }
} }
// check that x data can be parsed correctly
expect(view.parseXData({date: '2014-01-01'})).toBe('2014-01-01');
// check the data passed to nvd3 // check the data passed to nvd3
assembledData = view.assembleTrendData(); assembledData = view.assembleTrendData();
expect(assembledData.length).toBe(2); expect(assembledData.length).toBe(2);
...@@ -69,16 +73,106 @@ define(['models/course-model', 'views/chart-view'], function(CourseModel, ChartV ...@@ -69,16 +73,106 @@ define(['models/course-model', 'views/chart-view'], function(CourseModel, ChartV
// 'key' is the title/label of the of the trend // 'key' is the title/label of the of the trend
expect(actualTrend.key).toBe('A Label'); expect(actualTrend.key).toBe('A Label');
expect(actualTrend.values.length).toBe(2); expect(actualTrend.values.length).toBe(2);
expect(actualTrend.values).toContain({date: '2014-01-01', yData: 10}); expect(actualTrend.values).toContain({date: '2014-01-01', yData: 10, trendA: 10, trendB: 0});
expect(actualTrend.values).toContain({date: '2014-01-02', yData: 20}); expect(actualTrend.values).toContain({date: '2014-01-02', yData: 20, trendA: 20, trendB: 1000});
expect(actualTrend.color).toBe('#8DA0CB'); expect(actualTrend.color).toBe('#8DA0CB');
actualTrend = assembledData[1]; actualTrend = assembledData[1];
expect(actualTrend.key).toBe('B Label'); expect(actualTrend.key).toBe('B Label');
expect(actualTrend.values.length).toBe(2); expect(actualTrend.values.length).toBe(2);
expect(actualTrend.values).toContain({date: '2014-01-01', yData: 0}); expect(actualTrend.values).toContain({date: '2014-01-01', yData: 0, trendA: 10, trendB: 0});
expect(actualTrend.values).toContain({date: '2014-01-02', yData: 1000}); expect(actualTrend.values).toContain({date: '2014-01-02', yData: 1000, trendA: 20, trendB: 1000});
expect(actualTrend.color).toBeUndefined(); expect(actualTrend.color).toBeUndefined();
explicitXTicks = view.getExplicitXTicks(assembledData);
expect(explicitXTicks.length).toBe(2);
expect(explicitXTicks[0]).toBe('2014-01-01');
expect(explicitXTicks[1]).toBe('2014-01-02');
});
});
describe('Chart view', function () {
it('should build x label mappings', function () {
var model = new CourseModel(),
view = new ChartView({
model: model,
el: document.createElement('div'),
modelAttribute: 'assignments',
trends: [
{
key: 'correct_submissions',
title: 'Correct Submissions',
color: '#4BB4FB'
},
{
key: 'incorrect_submissions',
title: 'Incorrect Submissions',
color: '#CA0061'
}
],
x: {key: 'id', displayKey: 'name'},
y: {key: 'count'}
}),
mapping;
view.render = jasmine.createSpy('render');
expect(view.render).not.toHaveBeenCalled();
// mock getChart (otherwise, an error is thrown)
view.getChart = jasmine.createSpy('getChart');
// phantomjs doesn't have the bind method on function object
// (see https://github.com/novus/nvd3/issues/367) and nvd3 will
// throw an error when it tries to render (when trend data is set).
try {
model.set('assignments', [
{
id: 'assignment_1',
name: 'Assignment 1',
correct_submissions: 100,
incorrect_submissions: 200
},
{
id: 'assignment_2',
name: 'Assignment 2',
correct_submissions: 100,
incorrect_submissions: 200
}
]);
} catch (e) {
if (e.name !== 'TypeError') {
throw e;
}
}
// check the data passed to nvd3
mapping = view.buildXLabelMapping();
expect(mapping.assignment_1).toBe('Assignment 1');
expect(view.formatXTick('assignment_1')).toBe('Assignment 1');
expect(mapping.assignment_2).toBe('Assignment 2');
expect(view.formatXTick('assignment_2')).toBe('Assignment 2');
});
});
describe('Chart view', function () {
it('should format y values', function () {
var view = new ChartView({
el: document.createElement('div'),
model: new CourseModel()
});
expect(view.getYAxisFormat()(1000)).toBe('1000');
view = new ChartView({
el: document.createElement('div'),
model: new CourseModel(),
dataType: 'percent'
});
expect(view.getYAxisFormat()(0.1024)).toBe('10.2%');
}); });
}); });
......
...@@ -100,6 +100,26 @@ define(['models/course-model', 'views/data-table-view'], function(CourseModel, D ...@@ -100,6 +100,26 @@ define(['models/course-model', 'views/data-table-view'], function(CourseModel, D
row[dataType] = null; row[dataType] = null;
expect(func(row, renderType)).toBe('(empty)'); expect(func(row, renderType)).toBe('(empty)');
}); });
it('should format display of a formatted number', function() {
var dataType = 'myData',
renderType = 'display',
model = new CourseModel(),
view = new DataTableView({
el: document.createElement('div'),
model: model,
modelAttribute: 'ages'
}),
func = view.createFormatNumberFunc(dataType),
row = {};
row[dataType] = 3;
expect(func(row, renderType)).toBe('3');
row[dataType] = 1234567;
expect(func(row, renderType)).toBe('1,234,567');
});
}); });
}); });
...@@ -14,7 +14,8 @@ define(['models/course-model', 'views/discrete-bar-view'], function(CourseModel, ...@@ -14,7 +14,8 @@ define(['models/course-model', 'views/discrete-bar-view'], function(CourseModel,
} }
}], }],
x: { key: 'category' }, x: { key: 'category' },
y: { key: 'count' } y: { key: 'count' },
dataType: 'percent'
}), }),
data = [ data = [
{ {
...@@ -45,10 +46,16 @@ define(['models/course-model', 'views/discrete-bar-view'], function(CourseModel, ...@@ -45,10 +46,16 @@ define(['models/course-model', 'views/discrete-bar-view'], function(CourseModel,
} }
} }
expect(view.options.barSelector).toBe('.discreteBar');
expect(view.getChart).toHaveBeenCalled(); expect(view.getChart).toHaveBeenCalled();
expect(view.parseXData(data[0])).toBe('Cloudy'); expect(view.parseXData(data[0])).toBe('Cloudy');
expect(view.parseXData(data[1])).toBe('(empty)'); expect(view.formatXValue(data[0].category)).toBe('Cloudy');
expect(view.parseXData(data[1])).toBe(null);
expect(view.formatXValue(data[1].category)).toBe('(empty)');
expect(view.getYAxisFormat()(0.5)).toBe('50.0%');
assembledData = view.assembleTrendData(); assembledData = view.assembleTrendData();
expect(assembledData.length).toBe(1); expect(assembledData.length).toBe(1);
......
define(['models/course-model', 'views/stacked-bar-view'], function(CourseModel, StackedBarView) {
'use strict';
describe('Stacked bar view', function () {
it('should format labels for display', function () {
var model = new CourseModel(),
view = new StackedBarView({
model: model,
el: document.createElement('div'),
modelAttribute: 'data',
trends: [
{
key: 'correct_submissions',
title: 'Correct',
color: '#4BB4FB'
},
{
key: 'incorrect_submissions',
title: 'Incorrect',
color: '#CA0061'
}
],
x: { key: 'id', displayKey: 'name' },
y: { key: 'count' },
click: function() {}
}),
data = [
{
id: 123,
name: 'sameName',
correct_submissions: 100,
incorrect_submissions: 200
},
{
id: 456,
name: 'sameName',
correct_submissions: 30,
incorrect_submissions: 10
}
],
assembledData,
xLabelMapping;
view.render = jasmine.createSpy('render');
expect(view.render).not.toHaveBeenCalled();
// mock getChart (otherwise, an error is thrown)
view.getChart = jasmine.createSpy('getChart');
// phantomjs doesn't have the bind method on function object
// (see https://github.com/novus/nvd3/issues/367) and nvd3 will
// throw an error when it tries to render (when trend data is set).
try {
model.set('data', data);
} catch (e) {
if (e.name !== 'TypeError') {
throw e;
}
}
expect(view.options.barSelector).toBe('.nv-bar');
expect(view.getChart).toHaveBeenCalled();
xLabelMapping = view.buildXLabelMapping();
expect(view.parseXData(data[0])).toBe(123);
expect(view.formatXTick(123)).toBe('sameName');
expect(xLabelMapping[123]).toBe('sameName');
expect(view.parseXData(data[1])).toBe(456);
expect(view.formatXTick(456)).toBe('sameName');
expect(xLabelMapping[456]).toBe('sameName');
assembledData = view.assembleTrendData();
expect(assembledData.length).toBe(2);
expect(assembledData[0].color).toBe('#4BB4FB');
expect(assembledData[1].color).toBe('#CA0061');
});
});
});
...@@ -36,7 +36,7 @@ define(['utils/utils'], function (Utils) { ...@@ -36,7 +36,7 @@ define(['utils/utils'], function (Utils) {
}); });
}); });
it('should return node attributes', function () { it('should format dates', function () {
expect(Utils.formatDate('2014-01-31')).toEqual('January 31, 2014'); expect(Utils.formatDate('2014-01-31')).toEqual('January 31, 2014');
expect(Utils.formatDate('2014-01-01')).toEqual('January 1, 2014'); expect(Utils.formatDate('2014-01-01')).toEqual('January 1, 2014');
}); });
...@@ -70,4 +70,11 @@ define(['utils/utils'], function (Utils) { ...@@ -70,4 +70,11 @@ define(['utils/utils'], function (Utils) {
}); });
}); });
describe('localizeNumber', function () {
it('should format values', function () {
expect(Utils.localizeNumber(14)).toEqual('14');
expect(Utils.localizeNumber(12345)).toEqual('12,345');
});
});
}); });
...@@ -11,7 +11,7 @@ define(['backbone'], ...@@ -11,7 +11,7 @@ define(['backbone'],
initialize: function (options) { initialize: function (options) {
var self = this; var self = this;
self.modelAttribute = options.modelAttribute; self.modelAttribute = options.modelAttribute;
self.listenTo(this.model, 'change:' + self.modelAttribute, self.render); self.listenTo(self.model, 'change:' + self.modelAttribute, self.render);
}, },
renderIfDataAvailable: function () { renderIfDataAvailable: function () {
......
/** /**
* Abstract class for NVD3 bar charts (includes discrete bar and histogram). * Abstract class for NVD3 bar charts (includes discrete bar and histogram).
*/ */
define(['nvd3', 'underscore', 'utils/utils', 'views/chart-view'], define(['d3', 'nvd3', 'underscore', 'utils/utils', 'views/chart-view'],
function (nvd3, _, Utils, ChartView) { function (d3, nvd3, _, Utils, ChartView) {
'use strict'; 'use strict';
var BarView = ChartView.extend({ var BarView = ChartView.extend({
defaults: _.extend({}, ChartView.prototype.defaults, { defaults: _.extend({}, ChartView.prototype.defaults, {
graphShiftSelector: '.nv-barsWrap', graphShiftSelector: '.nv-barsWrap',
tipCharLimit: 250 // clip and add ellipses to long tooltips tipCharLimit: 250, // clip and add ellipses to long tooltips
barSelector: '.nv-bar'
} }
), ),
...@@ -24,6 +25,8 @@ define(['nvd3', 'underscore', 'utils/utils', 'views/chart-view'], ...@@ -24,6 +25,8 @@ define(['nvd3', 'underscore', 'utils/utils', 'views/chart-view'],
trend = self.options.trends[0], trend = self.options.trends[0],
maxNumber = trend.maxNumber; maxNumber = trend.maxNumber;
xValue = ChartView.prototype.formatXTick.call(self, xValue);
if (!_(maxNumber).isUndefined()) { if (!_(maxNumber).isUndefined()) {
// e.g. 100+ // e.g. 100+
xValue = xValue >= maxNumber ? maxNumber + '+' : xValue; xValue = xValue >= maxNumber ? maxNumber + '+' : xValue;
...@@ -32,50 +35,107 @@ define(['nvd3', 'underscore', 'utils/utils', 'views/chart-view'], ...@@ -32,50 +35,107 @@ define(['nvd3', 'underscore', 'utils/utils', 'views/chart-view'],
return xValue; return xValue;
}, },
initChart: function(chart) { /**
* Returns function for displaying a truncated label.
*/
truncateXTickFunc: function () {
var self = this; var self = this;
ChartView.prototype.initChart.call(self, chart);
// NVD3's bar views display tooltips differently than for graphs return function (d) {
chart.tooltipContent(function(key, x, y, e) {
var trend = self.options.trends[0], d = self.formatXValue(d);
// 'e' contains the raw x-value and 'x' could be formatted (e.g. truncated, ellipse, etc.)
xValue = self.formatXValue(e.point[self.options.x.key]), var barWidth = d3.select(self.options.barSelector).attr('width'), // jshint ignore:line
swatchColor = trend.color, // e.g #ff9988 or a function // this is a rough estimate of how wide a character is
label = trend.title, // e.g. 'my title' or a function chartWidth = 5,
tipText = xValue; characterLimit = Math.floor(barWidth / chartWidth),
formattedLabel = d;
// bar colors can be dynamically assigned based on value
if (_(swatchColor).isFunction()) {
swatchColor = trend.color(x, e.pointIndex);
}
// bar label can be dynamically assigned based on value if (_(formattedLabel).size() > characterLimit) {
if (_(label).isFunction()) { formattedLabel = Utils.truncateText(d, characterLimit);
label = trend.title(e.pointIndex);
} }
// create the tooltip when hovering over a bar return formattedLabel;
if (_(self.options).has('interactiveTooltipHeaderTemplate')) { };
if (_(self.options).has('tipCharLimit')) { },
// truncate long labels in the tooltips
if (_(xValue).size() > self.options.tipCharLimit) { addChartClick: function() {
tipText = Utils.truncateText(xValue, self.options.tipCharLimit); var self = this;
d3.selectAll('rect.nv-bar')
.style('cursor', 'pointer')
.on('click', function(d) {
self.options.click(d);
});
},
buildTrendTip: function(trend, x, y, e) {
var self = this,
swatchColor = trend.color, // e.g #ff9988 or a function
label = trend.title; // e.g. 'my title' or a function
// bar colors can be dynamically assigned based on value
if (_(swatchColor).isFunction()) {
swatchColor = trend.color(x, e.pointIndex);
} else {
swatchColor = trend.color;
}
// bar label can be dynamically assigned based on value
if (_(label).isFunction()) {
label = trend.title(e.pointIndex);
} else {
label = trend.title;
}
if (_(self.options).has('interactiveTooltipValueTemplate')) {
y = self.options.interactiveTooltipValueTemplate({value: y, point: e.point, options: trend});
}
} return {
label: label,
color: swatchColor,
value: y
};
},
/**
* Builds the header for the interactive tooltip.
*/
buildTipHeading: function(point) {
var self = this,
heading = self.formatXValue(point[self.options.x.key]),
charLimit = self.options.tipCharLimit;
// create the tooltip when hovering over a bar
if (_(self.options).has('interactiveTooltipHeaderTemplate')) {
if (_(self.options).has('tipCharLimit')) {
// truncate long labels in the tooltips
if (_(heading).size() > charLimit) {
heading = Utils.truncateText(heading, charLimit);
} }
tipText = self.options.interactiveTooltipHeaderTemplate({value: tipText});
} }
heading = self.options.interactiveTooltipHeaderTemplate({value: heading});
}
return heading;
},
initChart: function(chart) {
var self = this;
ChartView.prototype.initChart.call(self, chart);
// NVD3's bar views display tooltips differently than for graphs
chart.tooltipContent(function(key, x, y, e) {
var trend = self.options.trends[e.seriesIndex],
// 'e' contains the raw x-value and 'x' could be formatted (e.g. truncated, ellipse, etc.)
tips = [self.buildTrendTip(trend, x, y, e)];
return self.hoverTooltipTemplate({ return self.hoverTooltipTemplate({
xValue: tipText, xValue: self.buildTipHeading(e.point),
label: label, tips: tips
yValue: y,
swatchColor: swatchColor
}); });
}); });
} }
}); });
return BarView; return BarView;
......
...@@ -12,7 +12,8 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li ...@@ -12,7 +12,8 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li
excludeData: [], // e.g. excludes data rows from chart (e.g. 'Unknown') excludeData: [], // e.g. excludes data rows from chart (e.g. 'Unknown')
dataType: 'int', // e.g. int, percent dataType: 'int', // e.g. int, percent
xAxisMargin: 6, xAxisMargin: 6,
graphShiftSelector: null // Selector used for shifting chart position graphShiftSelector: null, // Selector used for shifting chart position
truncateXTicks: false // Determines if x axis ticks should be truncated
} }
), ),
...@@ -45,7 +46,7 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li ...@@ -45,7 +46,7 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li
// parse and format the data for nvd3 // parse and format the data for nvd3
combinedTrends = _(trendOptions).map(function (trendOption) { combinedTrends = _(trendOptions).map(function (trendOption) {
var values = _(data).map(function (datum) { var values = _(data).map(function (datum) {
var keyedValue = {}, var keyedValue = _(datum).clone(),
yKey = trendOption.key || self.options.y.key; yKey = trendOption.key || self.options.y.key;
keyedValue[self.options.y.key] = datum[yKey]; keyedValue[self.options.y.key] = datum[yKey];
keyedValue[self.options.x.key] = datum[self.options.x.key]; keyedValue[self.options.x.key] = datum[self.options.x.key];
...@@ -64,11 +65,30 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li ...@@ -64,11 +65,30 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li
return combinedTrends; return combinedTrends;
}, },
/**
* If option x.displayKey is provided, build a mapping from the key
* to the display name. E.g.: x: {key: 'id', displayKey: 'name'}.
* This can be useful if the dataset has unique identifiers that
* aren't display friendly.
*/
buildXLabelMapping: function () {
var self = this,
data = self.model.get(self.options.modelAttribute),
mapping;
if (_(self.options.x).has('displayKey')) {
mapping = _.object(_(data).pluck(self.options.x.key), _(data).pluck(self.options.x.displayKey));
}
return mapping;
},
styleChart: function () { styleChart: function () {
var canvas = d3.select(this.el), var self = this,
canvas = d3.select(self.el),
// ex. translate(200, 200) or translate(200 200) // ex. translate(200, 200) or translate(200 200)
translateRegex = /translate\((\d+)[,\s]\s*(\d+)\)/g, translateRegex = /translate\((\d+)[,\s]\s*(\d+)\)/g,
xAxisMargin = this.options.xAxisMargin, xAxisMargin = self.options.xAxisMargin,
axisEl, axisEl,
matches; matches;
...@@ -94,9 +114,9 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li ...@@ -94,9 +114,9 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li
axisEl.attr('transform', 'translate(' + matches[1] + ',' + axisEl.attr('transform', 'translate(' + matches[1] + ',' +
(parseInt(matches[2], 10) + xAxisMargin) + ')'); (parseInt(matches[2], 10) + xAxisMargin) + ')');
if (this.options.graphShiftSelector) { if (self.options.graphShiftSelector) {
// Shift the graph down so that it sits flush with the X-axis // Shift the graph down so that it sits flush with the X-axis
canvas.select(this.options.graphShiftSelector) canvas.select(self.options.graphShiftSelector)
.attr('transform', 'translate(' + [0, xAxisMargin].join(',') + ')'); .attr('transform', 'translate(' + [0, xAxisMargin].join(',') + ')');
} }
}, },
...@@ -115,7 +135,19 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li ...@@ -115,7 +135,19 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li
* @param d Data along the x-axis to format. * @param d Data along the x-axis to format.
*/ */
formatXTick: function (d) { formatXTick: function (d) {
return d; var self = this,
label = d;
if (_(self).has('xLabelMapping') && _(self.xLabelMapping).has(d)) {
label = self.xLabelMapping[d];
}
return label;
},
/**
* Truncate (e.g. add ellipses) long labels shown beneath the bar.
*/
truncateXTick: function (d) { // jshint ignore:line
throw 'Not implemented';
}, },
parseXData: function (d) { parseXData: function (d) {
...@@ -178,24 +210,36 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li ...@@ -178,24 +210,36 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li
'</tr>' + '</tr>' +
'</thead>' + '</thead>' +
'<tbody>' + '<tbody>' +
'<% _.each(tips, function(tip) { %>' +
'<tr class="nv-pointer-events-none">' + '<tr class="nv-pointer-events-none">' +
'<td class="legend-color-guide nv-pointer-events-none">' + '<td class="legend-color-guide nv-pointer-events-none">' +
'<div class="nv-pointer-events-none" style="background-color: <%=swatchColor%>;">' + '<div class="nv-pointer-events-none" style="background-color: <%=tip.color%>;">' +
'</div>' + '</div>' +
'</td>' + '</td>' +
'<td class="key nv-pointer-events-none"><%=label%></td>' + '<td class="key nv-pointer-events-none"><%=tip.label%></td>' +
'<td class="value nv-pointer-events-none"><%=yValue%></td>' + '<td class="value nv-pointer-events-none"><%=tip.value%></td>' +
'</tr>' + '</tr>' +
'<% }); %>' +
'</tbody>' + '</tbody>' +
'</table>' '</table>'
), ),
/**
* Implement this to enable users to click on chart elements. This
* is called when render is complete and the click option is specified.
*/
addChartClick: function (d) { // jshint ignore:line
throw 'Not implemented';
},
render: function () { render: function () {
AttributeListenerView.prototype.render.call(this); AttributeListenerView.prototype.render.call(this);
var self = this, var self = this,
canvas = d3.select(self.el), canvas = d3.select(self.el),
assembledData = self.assembleTrendData(); assembledData = self.assembleTrendData(),
xLabelMapping = self.buildXLabelMapping();
self.xLabelMapping = xLabelMapping;
self.chart = self.getChart(); self.chart = self.getChart();
self.initChart(self.chart); self.initChart(self.chart);
...@@ -210,7 +254,7 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li ...@@ -210,7 +254,7 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li
} }
} }
self.chart.xAxis.tickFormat(self.formatXTick); self.chart.xAxis.tickFormat(self.options.truncateXTicks ? self.truncateXTickFunc() : self.formatXTick);
self.chart.yAxis self.chart.yAxis
.showMaxMin(false) .showMaxMin(false)
...@@ -231,7 +275,11 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li ...@@ -231,7 +275,11 @@ define(['d3', 'jquery', 'nvd3', 'underscore', 'utils/utils', 'views/attribute-li
self.styleChart(); self.styleChart();
}); });
return this; if (_(self.options.click).isFunction()) {
self.addChartClick();
}
return self;
} }
}); });
......
...@@ -6,28 +6,12 @@ define(['d3', 'nvd3', 'underscore', 'utils/utils', 'views/bar-view'], ...@@ -6,28 +6,12 @@ define(['d3', 'nvd3', 'underscore', 'utils/utils', 'views/bar-view'],
defaults: _.extend({}, BarView.prototype.defaults, { defaults: _.extend({}, BarView.prototype.defaults, {
// unsetting because this view will always display all x-ticks // unsetting because this view will always display all x-ticks
displayExplicitTicksThreshold: undefined displayExplicitTicksThreshold: undefined,
barSelector: '.discreteBar'
} }
), ),
/** /**
* Add ellipses for long labels shown beneath the bar.
*/
formatXTick: function (d) {
var barWidth = d3.select('.discreteBar').attr('width'),
// this is a rough estimate of how wide a character is
chartWidth = 5,
characterLimit = Math.floor(barWidth / chartWidth),
formattedLabel = d;
if (_(formattedLabel).size() > characterLimit) {
formattedLabel = Utils.truncateText(d, characterLimit);
}
return formattedLabel;
},
/**
* Returns the original bar label or "(empty)" if no label provided. * Returns the original bar label or "(empty)" if no label provided.
*/ */
formatXValue: function (xValue) { formatXValue: function (xValue) {
...@@ -37,12 +21,6 @@ define(['d3', 'nvd3', 'underscore', 'utils/utils', 'views/bar-view'], ...@@ -37,12 +21,6 @@ define(['d3', 'nvd3', 'underscore', 'utils/utils', 'views/bar-view'],
return _(xValue).isNull() ? gettext('(empty)') : xValue; return _(xValue).isNull() ? gettext('(empty)') : xValue;
}, },
parseXData: function (d) {
var self = this,
value = BarView.prototype.parseXData.call(self, d);
return self.formatXValue(value);
},
getChart: function () { getChart: function () {
return nvd3.models.discreteBarChart(); return nvd3.models.discreteBarChart();
}, },
......
define(['nvd3', 'underscore', 'views/discrete-bar-view'],
function (nvd3, _, DiscreteBarView) {
'use strict';
var StackedBarView = DiscreteBarView.extend({
defaults: _.extend({}, DiscreteBarView.prototype.defaults, {
barSelector: '.nv-bar'
}
),
getChart: function () {
return nvd3.models.multiBarChart();
},
initChart: function (chart) {
var self = this;
DiscreteBarView.prototype.initChart.call(self, chart);
chart.stacked(true)
.showControls(false)
.showLegend(false);
chart.tooltipContent(function(key, x, y, e) {
var tips = [];
_(self.options.trends).each(function(trend) {
var trendY = self.getYAxisFormat()(e.point[trend.key]);
tips.push(self.buildTrendTip(trend, x, trendY, e));
});
return self.hoverTooltipTemplate({
xValue: self.buildTipHeading(e.point),
tips: tips
});
});
}
});
return StackedBarView;
});
...@@ -13,13 +13,14 @@ define(['nvd3', 'views/trends-view'], ...@@ -13,13 +13,14 @@ define(['nvd3', 'views/trends-view'],
}, },
render: function () { render: function () {
TrendsView.prototype.render.call(this); var self = this;
TrendsView.prototype.render.call(self);
// Disable expansion of stacked chart datasets // Disable expansion of stacked chart datasets
this.chart.stacked.dispatch.on('areaClick', null); self.chart.stacked.dispatch.on('areaClick', null);
this.chart.stacked.dispatch.on('areaClick.toggle', null); self.chart.stacked.dispatch.on('areaClick.toggle', null);
return this; return self;
} }
}); });
......
...@@ -29,7 +29,7 @@ button.chart-info { ...@@ -29,7 +29,7 @@ button.chart-info {
} }
.chart-tooltip { .chart-tooltip {
@extend .pull-right; float: right;
padding-right: $padding-small-horizontal; padding-right: $padding-small-horizontal;
padding-top: $padding-large-vertical; padding-top: $padding-large-vertical;
} }
...@@ -593,5 +593,98 @@ table.dataTable thead th.sorting_desc:after { ...@@ -593,5 +593,98 @@ table.dataTable thead th.sorting_desc:after {
// shifts it down so that it's even with the adjacent button // shifts it down so that it's even with the adjacent button
padding-top: 8px; padding-top: 8px;
} }
}
.grading-policy {
a {
color: inherit;
}
.tooltip-container {
text-align: right;
.chart-tooltip {
float: none;
padding: 0 0 $padding-large-vertical;
}
}
.policy-item {
@extend .text-center;
// float the policy item so that it's all on one line
@extend .pull-left;
// adds padding to the inside so that the policy items don't touch
padding: 5px;
.policy-item-box {
background-color: $edx-blue-l1;
margin-bottom: $padding-base-vertical;
height: 50px;
line-height: 50px;
.weight {
display: inline-block;
vertical-align: middle;
font-weight: bold;
}
}
}
}
.static-legend {
.nvd3 .nv-legend .nv-series {
cursor: default;
}
}
.graded-content-nav {
@extend .tertiary-nav;
margin-bottom: -$padding-base-vertical;
.navbar-nav {
.active .dropdown {
color: $navbar-default-link-active-color;
&:hover {
color: inherit;
}
}
.dropdown, .separator {
padding: $nav-link-padding;
color: $lens-nav-text-color;
}
.dropdown {
cursor: pointer;
&:hover {
color: $edx-gray-d2
}
.dropdown-menu {
margin-top: -1px;
margin-left: -1px;
li > a {
color: $dropdown-link-color;
padding-bottom: $padding-small-vertical;
&:hover {
// Override the transparent background set by the .navbar-nav selector.
background: $dropdown-link-hover-bg;
}
}
> .disabled > a {
&, &:hover, &:focus {
color: $navbar-default-link-disabled-color;
background-color: $navbar-default-link-disabled-bg;
}
}
}
}
}
} }
{% load i18n %} {% load i18n %}
{% load firstof from future %}
{% comment %} {% comment %}
Partial: App-wide header element Partial: App-wide header element
......
...@@ -45,6 +45,14 @@ ...@@ -45,6 +45,14 @@
exclude: ['js/common'] exclude: ['js/common']
}, },
{ {
name: 'js/performance-graded-content-assignment-types-main',
exclude: ['js/common']
},
{
name: 'js/performance-graded-content-assignment-main',
exclude: ['js/common']
},
{
name: 'js/performance-answer-distribution-main', name: 'js/performance-answer-distribution-main',
exclude: ['js/common'] exclude: ['js/common']
} }
......
from requests.auth import AuthBase
class CourseStructure(object):
@staticmethod
def _filter_children(blocks, key, **kwargs):
"""
Given the blocks locates the nested graded or ungraded problems.
"""
block = blocks[key]
block_type = kwargs.pop(u'block_type', None)
if block_type:
kwargs[u'type'] = block_type
kwargs.setdefault(u'graded', False)
matched = True
for name, value in kwargs.iteritems():
matched &= (block.get(name, None) == value)
if not matched:
break
if matched:
return [block]
children = []
for child in block[u'children']:
children += CourseStructure._filter_children(blocks, child, **kwargs)
return children
@staticmethod
def course_structure_to_assignments(structure, graded=None, assignment_type=None):
"""
Returns the assignments and nested problems from the given course structure.
"""
blocks = structure[u'blocks']
root = blocks[structure[u'root']]
# Break down the course structure into assignments and nested problems, returning only the data
# we absolutely need.
assignments = []
kwargs = {
'graded': graded
}
if assignment_type:
kwargs[u'format'] = assignment_type
filtered = CourseStructure._filter_children(blocks, root[u'id'], **kwargs)
for assignment in filtered:
filtered_children = CourseStructure._filter_children(blocks, assignment[u'id'], graded=graded,
block_type=u'problem')
problems = []
for problem in filtered_children:
problems.append({
'id': problem['id'],
'name': problem['display_name'],
'total_submissions': 0,
'correct_submissions': 0,
'incorrect_submissions': 0,
})
assignments.append({
'id': assignment['id'],
'name': assignment['display_name'],
'assignment_type': assignment['format'],
'problems': problems,
})
return assignments
class BearerAuth(AuthBase):
""" Attaches Bearer Authentication to the given Request object. """
def __init__(self, token):
""" Instantiate the auth class. """
self.token = token
def __call__(self, r):
""" Update the request headers. """
r.headers['Authorization'] = 'Bearer {0}'.format(self.token)
return r
...@@ -20,7 +20,7 @@ git+https://github.com/edx/python-social-auth.git@pyjwt-fix#egg=python-social-au ...@@ -20,7 +20,7 @@ git+https://github.com/edx/python-social-auth.git@pyjwt-fix#egg=python-social-au
git+https://github.com/pinax/django-announcements.git@bda39727f2c9158c0bb5eba1604b44f14deae5b0#egg=django-announcements # MIT git+https://github.com/pinax/django-announcements.git@bda39727f2c9158c0bb5eba1604b44f14deae5b0#egg=django-announcements # MIT
git+https://github.com/edx/django-lang-pref-middleware.git@0.1.0#egg=django-lang-pref-middleware git+https://github.com/edx/django-lang-pref-middleware.git@0.1.0#egg=django-lang-pref-middleware
-e git+https://github.com/edx/edx-analytics-data-api-client.git@0.4.0#egg=edx-analytics-data-api-client # edX git+https://github.com/edx/edx-analytics-data-api-client.git@0.5.2#egg=edx-analytics-data-api-client # edX
git+https://github.com/edx/edx-server-api-client.git@0.1.0#egg=edx-server-api-client git+https://github.com/edx/edx-server-api-client.git@0.1.0#egg=edx-server-api-client
git+https://github.com/edx/i18n-tools.git@0d7847f9dfa2281640527b4dc51f5854f950f9b7#egg=i18n_tools git+https://github.com/edx/i18n-tools.git@0d7847f9dfa2281640527b4dc51f5854f950f9b7#egg=i18n_tools
git+https://github.com/edx/opaque-keys.git@d45d0bd8d64c69531be69178b9505b5d38806ce0#egg=opaque-keys git+https://github.com/edx/opaque-keys.git@d45d0bd8d64c69531be69178b9505b5d38806ce0#egg=opaque-keys
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