Commit b53899aa by cahrens

Merge branch 'master' into christina/course-creator-table

parents 293ee0b0 bc0e5908
......@@ -78,3 +78,4 @@ Peter Fogg <peter.p.fogg@gmail.com>
Bethany LaPenta <lapentab@mit.edu>
Renzo Lucioni <renzolucioni@gmail.com>
Felix Sun <felixsun@mit.edu>
Adam Palay <adam@edx.org>
......@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Common: Student information is now passed to the tracking log via POST instead of GET.
Common: Add tests for documentation generation to test suite
Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems
......@@ -13,6 +15,8 @@ LMS: Users are no longer auto-activated if they click "reset password"
This is now done when they click on the link in the reset password
email they receive (along with usual path through activation email).
LMS: Fixed a reflected XSS problem in the static textbook views.
LMS: Problem rescoring. Added options on the Grades tab of the
Instructor Dashboard to allow a particular student's submission for a
particular problem to be rescored. Provides an option to see a
......
......@@ -152,6 +152,12 @@ otherwise noted.
Please see ``LICENSE.txt`` for details.
Documentation
------------
High-level documentation of the code is located in the `doc` subdirectory. Start
with `overview.md` to get an introduction to the architecture of the system.
How to Contribute
-----------------
......
......@@ -115,7 +115,7 @@ def clickActionLink(checklist, task, actionText):
# text will be empty initially, wait for it to populate
def verify_action_link_text(driver):
return action_link.text == actionText
return world.css_text('#course-checklist' + str(checklist) + ' a', index=task) == actionText
world.wait_for(verify_action_link_text)
action_link.click()
world.css_click('#course-checklist' + str(checklist) + ' a', index=task)
Feature: Component Adding
As a course author, I want to be able to add a wide variety of components
@skip
Scenario: I can add components
Given I have opened a new course in studio
And I am editing a new unit
When I add the following components:
| Component |
| Discussion |
| Blank HTML |
| LaTex |
| Blank Problem|
| Dropdown |
| Multi Choice |
| Numerical |
| Text Input |
| Advanced |
| Circuit |
| Custom Python|
| Image Mapped |
| Math Input |
| Problem LaTex|
| Adaptive Hint|
| Video |
Then I see the following components:
| Component |
| Discussion |
| Blank HTML |
| LaTex |
| Blank Problem|
| Dropdown |
| Multi Choice |
| Numerical |
| Text Input |
| Advanced |
| Circuit |
| Custom Python|
| Image Mapped |
| Math Input |
| Problem LaTex|
| Adaptive Hint|
| Video |
@skip
Scenario: I can delete Components
Given I have opened a new course in studio
And I am editing a new unit
And I add the following components:
| Component |
| Discussion |
| Blank HTML |
| LaTex |
| Blank Problem|
| Dropdown |
| Multi Choice |
| Numerical |
| Text Input |
| Advanced |
| Circuit |
| Custom Python|
| Image Mapped |
| Math Input |
| Problem LaTex|
| Adaptive Hint|
| Video |
When I will confirm all alerts
And I delete all components
Then I see no components
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_true
DATA_LOCATION = 'i4x://edx/templates'
@step(u'I am editing a new unit')
def add_unit(step):
css_selectors = ['a.new-courseware-section-button', 'input.new-section-name-save', 'a.new-subsection-item',
'input.new-subsection-name-save', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item']
for selector in css_selectors:
world.css_click(selector)
@step(u'I add the following components:')
def add_components(step):
for component in [step_hash['Component'] for step_hash in step.hashes]:
assert component in COMPONENT_DICTIONARY
for css in COMPONENT_DICTIONARY[component]['steps']:
world.css_click(css)
@step(u'I see the following components')
def check_components(step):
for component in [step_hash['Component'] for step_hash in step.hashes]:
assert component in COMPONENT_DICTIONARY
assert_true(COMPONENT_DICTIONARY[component]['found_func'](), "{} couldn't be found".format(component))
@step(u'I delete all components')
def delete_all_components(step):
for _ in range(len(COMPONENT_DICTIONARY)):
world.css_click('a.delete-button')
@step(u'I see no components')
def see_no_components(steps):
assert world.is_css_not_present('li.component')
def step_selector_list(data_type, path, index=1):
selector_list = ['a[data-type="{}"]'.format(data_type)]
if index != 1:
selector_list.append('a[id="ui-id-{}"]'.format(index))
if path is not None:
selector_list.append('a[data-location="{}/{}/{}"]'.format(DATA_LOCATION, data_type, path))
return selector_list
def found_text_func(text):
return lambda: world.browser.is_text_present(text)
def found_css_func(css):
return lambda: world.is_css_present(css, wait_time=2)
COMPONENT_DICTIONARY = {
'Discussion': {
'steps': step_selector_list('discussion', None),
'found_func': found_css_func('section.xmodule_DiscussionModule')
},
'Blank HTML': {
'steps': step_selector_list('html', 'Blank_HTML_Page'),
#this one is a blank html so a more refined search is being done
'found_func': lambda: '\n \n' in [x.html for x in world.css_find('section.xmodule_HtmlModule')]
},
'LaTex': {
'steps': step_selector_list('html', 'E-text_Written_in_LaTeX'),
'found_func': found_text_func('EXAMPLE: E-TEXT PAGE')
},
'Blank Problem': {
'steps': step_selector_list('problem', 'Blank_Common_Problem'),
'found_func': found_text_func('BLANK COMMON PROBLEM')
},
'Dropdown': {
'steps': step_selector_list('problem', 'Dropdown'),
'found_func': found_text_func('DROPDOWN')
},
'Multi Choice': {
'steps': step_selector_list('problem', 'Multiple_Choice'),
'found_func': found_text_func('MULTIPLE CHOICE')
},
'Numerical': {
'steps': step_selector_list('problem', 'Numerical_Input'),
'found_func': found_text_func('NUMERICAL INPUT')
},
'Text Input': {
'steps': step_selector_list('problem', 'Text_Input'),
'found_func': found_text_func('TEXT INPUT')
},
'Advanced': {
'steps': step_selector_list('problem', 'Blank_Advanced_Problem', index=2),
'found_func': found_text_func('BLANK ADVANCED PROBLEM')
},
'Circuit': {
'steps': step_selector_list('problem', 'Circuit_Schematic_Builder', index=2),
'found_func': found_text_func('CIRCUIT SCHEMATIC BUILDER')
},
'Custom Python': {
'steps': step_selector_list('problem', 'Custom_Python-Evaluated_Input', index=2),
'found_func': found_text_func('CUSTOM PYTHON-EVALUATED INPUT')
},
'Image Mapped': {
'steps': step_selector_list('problem', 'Image_Mapped_Input', index=2),
'found_func': found_text_func('IMAGE MAPPED INPUT')
},
'Math Input': {
'steps': step_selector_list('problem', 'Math_Expression_Input', index=2),
'found_func': found_text_func('MATH EXPRESSION INPUT')
},
'Problem LaTex': {
'steps': step_selector_list('problem', 'Problem_Written_in_LaTeX', index=2),
'found_func': found_text_func('PROBLEM WRITTEN IN LATEX')
},
'Adaptive Hint': {
'steps': step_selector_list('problem', 'Problem_with_Adaptive_Hint', index=2),
'found_func': found_text_func('PROBLEM WITH ADAPTIVE HINT')
},
'Video': {
'steps': step_selector_list('video', None),
'found_func': found_css_func('section.xmodule_VideoModule')
}
}
......@@ -60,8 +60,7 @@ def change_date(_step, new_date):
@step(u'I should see the date "([^"]*)"$')
def check_date(_step, date):
date_css = 'span.date-display'
date_html = world.css_find(date_css)
assert date == date_html.html
assert date == world.css_html(date_css)
@step(u'I modify the handout to "([^"]*)"$')
......@@ -74,8 +73,7 @@ def edit_handouts(_step, text):
@step(u'I see the handout "([^"]*)"$')
def check_handout(_step, handout):
handout_css = 'div.handouts-content'
handouts = world.css_find(handout_css)
assert handout in handouts.html
assert handout in world.css_html(handout_css)
def change_text(text):
......
......@@ -47,7 +47,7 @@ def confirm_change(step):
range_css = '.range'
all_ranges = world.css_find(range_css)
for i in range(len(all_ranges)):
assert all_ranges[i].html != '0-50'
assert world.css_html(range_css, index=i) != '0-50'
@step(u'I change assignment type "([^"]*)" to "([^"]*)"$')
......
......@@ -9,14 +9,14 @@ from selenium.webdriver.common.keys import Keys
def go_to_static(_step):
menu_css = 'li.nav-course-courseware'
static_css = 'li.nav-course-courseware-pages'
world.css_find(menu_css).click()
world.css_find(static_css).click()
world.css_click(menu_css)
world.css_click(static_css)
@step(u'I add a new page')
def add_page(_step):
button_css = 'a.new-button'
world.css_find(button_css).click()
world.css_click(button_css)
@step(u'I should( not)? see a "([^"]*)" static page$')
......@@ -33,13 +33,13 @@ def click_edit_delete(_step, edit_delete, page):
button_css = 'a.%s-button' % edit_delete
index = get_index(page)
assert index != -1
world.css_find(button_css)[index].click()
world.css_click(button_css, index=index)
@step(u'I change the name to "([^"]*)"$')
def change_name(_step, new_name):
settings_css = '#settings-mode'
world.css_find(settings_css).click()
world.css_click(settings_css)
input_css = 'input.setting-input'
name_input = world.css_find(input_css)
old_name = name_input.value
......@@ -47,13 +47,13 @@ def change_name(_step, new_name):
name_input._element.send_keys(Keys.END, Keys.BACK_SPACE)
name_input._element.send_keys(new_name)
save_button = 'a.save-button'
world.css_find(save_button).click()
world.css_click(save_button)
def get_index(name):
page_name_css = 'section[data-type="HTMLModule"]'
all_pages = world.css_find(page_name_css)
for i in range(len(all_pages)):
if all_pages[i].html == '\n {name}\n'.format(name=name):
if world.css_html(page_name_css, index=i) == '\n {name}\n'.format(name=name):
return i
return -1
......@@ -21,6 +21,7 @@ Feature: Upload Files
When I upload the file "test"
And I delete the file "test"
Then I should not see the file "test" was uploaded
And I see a confirmation that the file was deleted
Scenario: Users can download files
Given I have opened a new course in studio
......
......@@ -16,14 +16,14 @@ HTTP_PREFIX = "http://localhost:8001"
def go_to_uploads(_step):
menu_css = 'li.nav-course-courseware'
uploads_css = 'li.nav-course-courseware-uploads'
world.css_find(menu_css).click()
world.css_find(uploads_css).click()
world.css_click(menu_css)
world.css_click(uploads_css)
@step(u'I upload the file "([^"]*)"$')
def upload_file(_step, file_name):
upload_css = 'a.upload-button'
world.css_find(upload_css).click()
world.css_click(upload_css)
file_css = 'input.file-input'
upload = world.css_find(file_css)
......@@ -32,7 +32,7 @@ def upload_file(_step, file_name):
upload._element.send_keys(os.path.abspath(path))
close_css = 'a.close-button'
world.css_find(close_css).click()
world.css_click(close_css)
@step(u'I should( not)? see the file "([^"]*)" was uploaded$')
......@@ -67,7 +67,7 @@ def no_duplicate(_step, file_name):
all_names = world.css_find(names_css)
only_one = False
for i in range(len(all_names)):
if file_name == all_names[i].html:
if file_name == world.css_html(names_css, index=i):
only_one = not only_one
assert only_one
......@@ -90,11 +90,17 @@ def modify_upload(_step, file_name):
cur_file.write(new_text)
@step('I see a confirmation that the file was deleted')
def i_see_a_delete_confirmation(_step):
alert_css = '#notification-confirmation'
assert world.is_css_present(alert_css)
def get_index(file_name):
names_css = 'td.name-col > a.filename'
all_names = world.css_find(names_css)
for i in range(len(all_names)):
if file_name == all_names[i].html:
if file_name == world.css_html(names_css, index=i):
return i
return -1
......
......@@ -344,6 +344,28 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
err_cnt = perform_xlint('common/test/data', ['full'])
self.assertGreater(err_cnt, 0)
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/full/.*'])
def test_module_preview_in_whitelist(self):
'''
Tests the ajax callback to render an XModule
'''
direct_store = modulestore('direct')
import_from_xml(direct_store, 'common/test/data/', ['full'])
html_module_location = Location(['i4x', 'edX', 'full', 'html', 'html_90', None])
url = reverse('preview_component', kwargs={'location': html_module_location.url()})
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertIn('Inline content', resp.content)
# also try a custom response which will trigger the 'is this course in whitelist' logic
problem_module_location = Location(['i4x', 'edX', 'full', 'problem', 'H1P1_Energy', None])
url = reverse('preview_component', kwargs={'location': problem_module_location.url()})
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
def test_delete(self):
direct_store = modulestore('direct')
import_from_xml(direct_store, 'common/test/data/', ['full'])
......@@ -612,18 +634,42 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertEqual(resp.status_code, 400)
def test_delete_course(self):
"""
This test will import a course, make a draft item, and delete it. This will also assert that the
draft content is also deleted
"""
module_store = modulestore('direct')
import_from_xml(module_store, 'common/test/data/', ['full'])
content_store = contentstore()
draft_store = modulestore('draft')
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
# verify that we actually have assets
assets = content_store.get_all_content_for_course(location)
self.assertNotEquals(len(assets), 0)
# get a vertical (and components in it) to put into 'draft'
vertical = module_store.get_item(Location(['i4x', 'edX', 'full',
'vertical', 'vertical_66', None]), depth=1)
draft_store.clone_item(vertical.location, vertical.location)
for child in vertical.get_children():
draft_store.clone_item(child.location, child.location)
# delete the course
delete_course(module_store, content_store, location, commit=True)
items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
# assert that there's absolutely no non-draft modules in the course
# this should also include all draft items
items = draft_store.get_items(Location(['i4x', 'edX', 'full', None, None]))
self.assertEqual(len(items), 0)
# assert that all content in the asset library is also deleted
assets = content_store.get_all_content_for_course(location)
self.assertEqual(len(assets), 0)
def verify_content_existence(self, store, root_dir, location, dirname, category_name, filename_suffix=''):
filesystem = OSFS(root_dir / 'test_export')
self.assertTrue(filesystem.exists(dirname))
......
......@@ -105,7 +105,6 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertEqual(jsondetails['string'], 'string')
def test_update_and_fetch(self):
# # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions
jsondetails = CourseDetails.fetch(self.course_location)
jsondetails.syllabus = "<a href='foo'>bar</a>"
# encode - decode to convert date fields and other data which changes form
......@@ -128,6 +127,11 @@ class CourseDetailsTestCase(CourseTestCase):
CourseDetails.update_from_json(jsondetails.__dict__).effort,
jsondetails.effort, "After set effort"
)
jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC())
self.assertEqual(
CourseDetails.update_from_json(jsondetails.__dict__).start_date,
jsondetails.start_date
)
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def test_marketing_site_fetch(self):
......@@ -235,8 +239,7 @@ class CourseDetailsViewTest(CourseTestCase):
dt1 = date.from_json(encoded[field])
dt2 = details[field]
expected_delta = datetime.timedelta(0)
self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context)
self.assertEqual(dt1, dt2, msg="{} != {} at {}".format(dt1, dt2, context))
else:
self.fail(field + " missing from encoded but in details at " + context)
elif field in encoded and encoded[field] is not None:
......
"""Tests for CMS's requests to logs"""
from django.test import TestCase
from django.core.urlresolvers import reverse
from contentstore.views.requests import event as cms_user_track
class CMSLogTest(TestCase):
"""
Tests that request to logs from CMS return 204s
"""
def test_post_answers_to_log(self):
"""
Checks that student answer requests submitted to cms's "/event" url
via POST are correctly returned as 204s
"""
requests = [
{"event": "my_event", "event_type": "my_event_type", "page": "my_page"},
{"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"}
]
for request_params in requests:
response = self.client.post(reverse(cms_user_track), request_params)
self.assertEqual(response.status_code, 204)
def test_get_answers_to_log(self):
"""
Checks that student answer requests submitted to cms's "/event" url
via GET are correctly returned as 204s
"""
requests = [
{"event": "my_event", "event_type": "my_event_type", "page": "my_page"},
{"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"}
]
for request_params in requests:
response = self.client.get(reverse(cms_user_track), request_params)
self.assertEqual(response.status_code, 204)
......@@ -2,12 +2,13 @@ from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME
from auth.authz import is_user_in_course_group_role
from django.core.exceptions import PermissionDenied
from ..utils import get_course_location_for_item
from xmodule.modulestore import Location
def get_location_and_verify_access(request, org, course, name):
"""
Create the location tuple verify that the user has permissions
to view the location. Returns the location.
Create the location, verify that the user has permissions
to view the location. Returns the location as a Location
"""
location = ['i4x', org, course, 'course', name]
......@@ -15,7 +16,7 @@ def get_location_and_verify_access(request, org, course, name):
if not has_access(request.user, location):
raise PermissionDenied()
return location
return Location(location)
def has_access(user, location, role=STAFF_ROLE_NAME):
......
......@@ -258,7 +258,7 @@ def import_course(request, org, course, name):
_module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT,
[course_subdir], load_error_modules=False,
static_content_store=contentstore(),
target_location_namespace=Location(location),
target_location_namespace=location,
draft_store=modulestore())
# we can blow this away when we're done importing.
......
......@@ -67,7 +67,9 @@ def update_checklist(request, org, course, name, checklist_index=None):
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
index = int(checklist_index)
course_module.checklists[index] = json.loads(request.body)
checklists, modified = expand_checklist_action_urls(course_module)
# seeming noop which triggers kvs to record that the metadata is not default
course_module.checklists = course_module.checklists
checklists, _ = expand_checklist_action_urls(course_module)
modulestore.update_metadata(location, own_metadata(course_module))
return HttpResponse(json.dumps(checklists[index]), mimetype="application/json")
else:
......
......@@ -38,7 +38,8 @@ __all__ = ['OPEN_ENDED_COMPONENT_TYPES',
log = logging.getLogger(__name__)
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
# NOTE: edit_unit assumes this list is disjoint from ADVANCED_COMPONENT_TYPES
COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video']
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
NOTE_COMPONENT_TYPES = ['notes']
......@@ -220,7 +221,7 @@ def edit_unit(request, location):
'section': containing_section,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'unit_state': unit_state,
'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None,
'published_date': get_default_time_display(item.cms.published_date) if item.cms.published_date is not None else None
})
......
......@@ -153,7 +153,7 @@ def course_info(request, org, course, name, provided_id=None):
course_module = modulestore().get_item(location)
# get current updates
location = ['i4x', org, course, 'course_info', "updates"]
location = Location(['i4x', org, course, 'course_info', "updates"])
return render_to_response('course_info.html', {
'active_tab': 'courseinfo-tab',
......
from django.http import HttpResponseServerError, HttpResponseNotFound
from django.http import (HttpResponse, HttpResponseServerError,
HttpResponseNotFound)
from mitxmako.shortcuts import render_to_string, render_to_response
import functools
import json
__all__ = ['not_found', 'server_error', 'render_404', 'render_500']
def jsonable_error(status=500, message="The Studio servers encountered an error"):
"""
A decorator to make an error view return an JSON-formatted message if
it was requested via AJAX.
"""
def outer(func):
@functools.wraps(func)
def inner(request, *args, **kwargs):
if request.is_ajax():
content = json.dumps({"error": message})
return HttpResponse(content, content_type="application/json",
status=status)
else:
return func(request, *args, **kwargs)
return inner
return outer
@jsonable_error(404, "Resource not found")
def not_found(request):
return render_to_response('error.html', {'error': '404'})
@jsonable_error(500, "The Studio servers encountered an error")
def server_error(request):
return render_to_response('error.html', {'error': '500'})
@jsonable_error(404, "Resource not found")
def render_404(request):
return HttpResponseNotFound(render_to_string('404.html', {}))
@jsonable_error(500, "The Studio servers encountered an error")
def render_500(request):
return HttpResponseServerError(render_to_string('500.html', {}))
......@@ -17,10 +17,13 @@ from xmodule.modulestore.mongo import MongoUsage
from xmodule.x_module import ModuleSystem
from xblock.runtime import DbModel
from util.sandboxing import can_execute_unsafe_code
import static_replace
from .session_kv_store import SessionKeyValueStore
from .requests import render_from_lms
from .access import has_access
from ..utils import get_course_for_item
__all__ = ['preview_dispatch', 'preview_component']
......@@ -93,6 +96,8 @@ def preview_module_system(request, preview_id, descriptor):
MongoUsage(preview_id, descriptor.location.url()),
)
course_id = get_course_for_item(descriptor.location).location.course_id
return ModuleSystem(
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
......@@ -104,6 +109,7 @@ def preview_module_system(request, preview_id, descriptor):
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
user=request.user,
xblock_model_data=preview_model_data,
can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
)
......
......@@ -25,4 +25,4 @@ class SessionKeyValueStore(KeyValueStore):
del self._session[tuple(key)]
def has(self, key):
return key in self._descriptor_model_data or key in self._session
return key.field_name in self._descriptor_model_data or tuple(key) in self._session
......@@ -74,7 +74,7 @@ class CourseDetails(object):
Decode the json into CourseDetails and save any changed attrs to the db
"""
# TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
course_location = jsondict['course_location']
course_location = Location(jsondict['course_location'])
# Will probably want to cache the inflight courses because every blur generates an update
descriptor = get_modulestore(course_location).get_item(course_location)
......
......@@ -28,7 +28,7 @@ MODULESTORE_OPTIONS = {
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
},
'direct': {
......@@ -36,7 +36,7 @@ MODULESTORE = {
'OPTIONS': MODULESTORE_OPTIONS
},
'draft': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
}
}
......
......@@ -105,6 +105,8 @@ ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", [])
#Timezone overrides
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
......@@ -142,10 +144,12 @@ DATADOG_API = AUTH_TOKENS.get("DATADOG_API")
# Celery Broker
CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "")
CELERY_BROKER_HOSTNAME = ENV_TOKENS.get("CELERY_BROKER_HOSTNAME", "")
CELERY_BROKER_VHOST = ENV_TOKENS.get("CELERY_BROKER_VHOST", "")
CELERY_BROKER_USER = AUTH_TOKENS.get("CELERY_BROKER_USER", "")
CELERY_BROKER_PASSWORD = AUTH_TOKENS.get("CELERY_BROKER_PASSWORD", "")
BROKER_URL = "{0}://{1}:{2}@{3}".format(CELERY_BROKER_TRANSPORT,
CELERY_BROKER_USER,
CELERY_BROKER_PASSWORD,
CELERY_BROKER_HOSTNAME)
BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT,
CELERY_BROKER_USER,
CELERY_BROKER_PASSWORD,
CELERY_BROKER_HOSTNAME,
CELERY_BROKER_VHOST)
......@@ -27,7 +27,7 @@ modulestore_options = {
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': modulestore_options
},
'direct': {
......
......@@ -53,7 +53,7 @@ MODULESTORE_OPTIONS = {
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
},
'direct': {
......@@ -61,7 +61,7 @@ MODULESTORE = {
'OPTIONS': MODULESTORE_OPTIONS
},
'draft': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
}
}
......@@ -140,3 +140,6 @@ SEGMENT_IO_KEY = '***REMOVED***'
MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
# Enabling SQL tracking logs for testing on common/djangoapps/track
MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True
......@@ -19,12 +19,15 @@ $ ->
if ajaxSettings.notifyOnError is false
return
if jqXHR.responseText
try
message = JSON.parse(jqXHR.responseText).error
catch error
message = _.str.truncate(jqXHR.responseText, 300)
else
message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
msg = new CMS.Views.Notification.Error(
"title": gettext("Studio's having trouble saving your work")
"message": message
"title": gettext("Studio's having trouble saving your work")
"message": message
)
msg.show()
......
cms/static/img/logo-edx-studio.png

2.4 KB | W: | H:

cms/static/img/logo-edx-studio.png

4.67 KB | W: | H:

cms/static/img/logo-edx-studio.png
cms/static/img/logo-edx-studio.png
cms/static/img/logo-edx-studio.png
cms/static/img/logo-edx-studio.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -56,11 +56,11 @@ $(document).ready(function() {
// nav - dropdown related
$body.click(function(e) {
$('.nav-dropdown .nav-item .wrapper-nav-sub').removeClass('is-shown');
$('.nav-dropdown .nav-item .title').removeClass('is-selected');
$('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown');
$('.nav-dd .nav-item .title').removeClass('is-selected');
});
$('.nav-dropdown .nav-item .title').click(function(e) {
$('.nav-dd .nav-item .title').click(function(e) {
$subnav = $(this).parent().find('.wrapper-nav-sub');
$title = $(this).parent().find('.title');
......@@ -71,8 +71,8 @@ $(document).ready(function() {
$subnav.removeClass('is-shown');
$title.removeClass('is-selected');
} else {
$('.nav-dropdown .nav-item .title').removeClass('is-selected');
$('.nav-dropdown .nav-item .wrapper-nav-sub').removeClass('is-shown');
$('.nav-dd .nav-item .title').removeClass('is-selected');
$('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown');
$title.addClass('is-selected');
$subnav.addClass('is-shown');
}
......
......@@ -23,7 +23,12 @@ function removeAsset(e){
{ 'location': row.data('id') },
function() {
// show the post-commit confirmation
$(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false');
var deleted = new CMS.Views.Notification.Confirmation({
title: gettext("Your file has been deleted."),
closeIcon: false,
maxShown: 2000
});
deleted.show();
row.remove();
analytics.track('Deleted Asset', {
'course': course_location_analytics,
......
......@@ -47,7 +47,7 @@ $gray-d2: shade($gray,40%);
$gray-d3: shade($gray,60%);
$gray-d4: shade($gray,80%);
$blue: rgb(85, 151, 221);
$blue: rgb(0, 159, 230);
$blue-l1: tint($blue,20%);
$blue-l2: tint($blue,40%);
$blue-l3: tint($blue,60%);
......
......@@ -135,7 +135,48 @@
// ====================
// layout-based buttons
// simple dropdown button styling - should we move this elsewhere?
.btn-dd {
@extend .btn;
@extend .btn-pill;
padding:($baseline/4) ($baseline/2);
border-width: 1px;
border-style: solid;
border-color: transparent;
text-align: center;
&:hover, &:active {
@extend .fake-link;
border-color: $gray-l3;
}
&.current, &.active, &.is-selected {
@include box-shadow(inset 0 1px 2px 1px $shadow-l1);
border-color: $gray-l3;
}
}
// layout-based buttons - nav dd
.btn-dd-nav-primary {
@extend .btn-dd;
background: $white;
border-color: $white;
color: $gray-d1;
&:hover, &:active {
background: $white;
color: $blue-s1;
}
&.current, &.active {
background: $white;
color: $gray-d4;
&:hover, &:active {
color: $blue-s1;
}
}
}
// ====================
......
......@@ -18,20 +18,161 @@ nav {
// ====================
// primary
// tabs
// ====================
// right hand side
// dropdown
.nav-dd {
// ====================
.title {
// tabs
.label, .icon-caret-down {
display: inline-block;
vertical-align: middle;
}
// ====================
.ui-toggle-dd {
@include transition(rotate .25s ease-in-out .25s);
margin-left: ($baseline/10);
display: inline-block;
vertical-align: middle;
}
// dropdown
// dropped down state
&.is-selected {
// ====================
.ui-toggle-dd {
@include transform(rotate(-180deg));
@include transform-origin(50% 50%);
}
}
}
.nav-item {
position: relative;
//
&:hover {
}
}
.wrapper-nav-sub {
@include transition (opacity 1.0s ease-in-out 0s);
position: absolute;
top: ($baseline*2.5);
opacity: 0.0;
pointer-events: none;
width: ($baseline*8);
// dropped down state
&.is-shown {
opacity: 1.0;
pointer-events: auto;
}
}
.nav-sub {
@include border-radius(2px);
@include box-sizing(border-box);
@include box-shadow(0 1px 1px $shadow-l1);
position: relative;
width: 100%;
border: 1px solid $gray-l3;
padding: ($baseline/2) ($baseline*0.75);
background: $white;
&:after, &:before {
bottom: 100%;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
}
// ui triangle/nub
&:after {
border-color: rgba(255, 255, 255, 0);
border-bottom-color: $white;
border-width: 10px;
}
&:before {
border-color: rgba(178, 178, 178, 0);
border-bottom-color: $gray-l3;
border-width: 11px;
}
.nav-item {
@extend .t-action3;
display: block;
margin: 0 0 ($baseline/4) 0;
border-bottom: 1px solid $gray-l5;
padding: 0 0($baseline/4) 0;
font-weight: 500;
&:last-child {
margin-bottom: 0;
border-bottom: none;
padding-bottom: 0;
}
a {
display: block;
&:hover, &:active {
color: $blue-s1;
}
}
}
}
// CASE: left-hand side arrow/dd
&.ui-left {
.wrapper-nav-sub {
left: 0;
}
.nav-sub {
text-align: left;
// ui triangle/nub
&:after {
left: $baseline;
margin-left: -10px;
}
&:before {
left: $baseline;
margin-left: -11px;
}
}
}
// CASE: right-hand side arrow/dd
&.ui-right {
.wrapper-nav-sub {
left: none;
right: 0;
}
.nav-sub {
// ui triangle/nub
&:after {
right: $baseline;
margin-right: -10px;
}
&:before {
right: $baseline;
margin-right: -11px;
}
}
}
}
......@@ -11,7 +11,7 @@
@include box-shadow(0 2px 3px $shadow);
height: ($baseline*35) !important;
background: $white !important;
border: 1px solid $gray;
border: 2px solid $blue;
}
#tender_window {
......@@ -23,11 +23,12 @@
}
#tender_closer {
color: $blue-l2 !important;
color: $white-t2 !important;
text-transform: uppercase;
top: 16px !important;
&:hover {
color: $blue-l4 !important;
color: $white !important;
}
}
......@@ -42,15 +43,15 @@
font-family: 'Open Sans', sans-serif;
}
.widget-layout .search,
.widget-layout .tabs,
.widget-layout .footer,
.widget-layout .search,
.widget-layout .tabs,
.widget-layout .footer,
.widget-layout .header h1 a {
display: none;
}
.widget-layout .header {
background: rgb(85, 151, 221);
background: rgb(0, 159, 230);
padding: 10px 20px;
}
......@@ -264,4 +265,4 @@
.widget-layout .form-actions .btn-post_topic:hover, .widget-layout .form-actions .btn-post_topic:active {
background-color: #16ca57;
color: #fff;
}
\ No newline at end of file
}
......@@ -72,14 +72,7 @@ body.index {
}
.logo {
@extend .text-hide;
position: relative;
top: 3px;
display: inline-block;
vertical-align: baseline;
width: 282px;
height: 57px;
background: transparent url('../img/logo-edx-studio-white.png') 0 0 no-repeat;
font-weight: 600;
}
.tagline {
......
......@@ -316,6 +316,12 @@ body.course.settings {
.link-courseURL {
@extend .t-copy-lead1;
@include box-sizing(border-box);
display: block;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&:hover {
......
......@@ -40,7 +40,7 @@
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">Course Content</small>
<small class="subtitle">Content</small>
<span class="sr">&gt; </span>Files &amp; Uploads
</h1>
......
......@@ -44,7 +44,7 @@
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">Course Content</small>
<small class="subtitle">Content</small>
<span class="sr">&gt; </span>Course Updates
</h1>
......
......@@ -19,7 +19,7 @@
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">Course Content</small>
<small class="subtitle">Content</small>
<span class="sr">&gt; </span>Static Pages
</h1>
......
<%inherit file="base.html" />
<%!
import logging
from xmodule.util.date_utils import get_default_time_display
from xmodule.util.date_utils import get_default_time_display, almost_same_datetime
%>
<%! from django.core.urlresolvers import reverse %>
......@@ -47,9 +47,10 @@
placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
</div>
</div>
% if subsection.lms.start != parent_item.lms.start and subsection.lms.start:
% if subsection.lms.start and not almost_same_datetime(subsection.lms.start, parent_item.lms.start):
% if parent_item.lms.start is None:
<p class="notice">The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset.
<p class="notice">The date above differs from the release date of
${parent_item.display_name_with_default}, which is unset.
% else:
<p class="notice">The date above differs from the release date of ${parent_item.display_name_with_default} –
${get_default_time_display(parent_item.lms.start)}.
......
......@@ -121,7 +121,7 @@
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">Course Content</small>
<small class="subtitle">Content</small>
<span class="sr">&gt; </span>Course Outline
</h1>
......@@ -165,9 +165,9 @@
<span class="published-status">This section has not been released.</span>
<a href="#" class="schedule-button" data-date="" data-time="" data-id="${section.location}">Schedule</a>
%else:
<span class="published-status"><strong>Will Release:</strong>
<span class="published-status"><strong>Will Release:</strong>
${date_utils.get_default_time_display(section.lms.start)}</span>
<a href="#" class="edit-button" data-date="${start_date_str}"
<a href="#" class="edit-button" data-date="${start_date_str}"
data-time="${start_time_str}" data-id="${section.location}">Edit</a>
%endif
</div>
......
......@@ -10,22 +10,12 @@ from course_groups.cohorts import (get_cohort, get_course_cohorts,
from xmodule.modulestore.django import modulestore, _MODULESTORES
from xmodule.modulestore.tests.django_utils import xml_store_config
# NOTE: running this with the lms.envs.test config works without
# manually overriding the modulestore. However, running with
# cms.envs.test doesn't.
def xml_store_config(data_dir):
return {
'default': {
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
'OPTIONS': {
'data_dir': data_dir,
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
}
}
}
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
......@@ -33,7 +23,6 @@ TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestCohorts(django.test.TestCase):
@staticmethod
def topic_name_to_id(course, name):
"""
......@@ -44,7 +33,6 @@ class TestCohorts(django.test.TestCase):
run=course.url_name,
name=name)
@staticmethod
def config_course_cohorts(course, discussions,
cohorted,
......@@ -90,7 +78,6 @@ class TestCohorts(django.test.TestCase):
course.cohort_config = d
def setUp(self):
"""
Make sure that course is reloaded every time--clear out the modulestore.
......@@ -99,7 +86,6 @@ class TestCohorts(django.test.TestCase):
# to course. We don't have a course.clone() method.
_MODULESTORES.clear()
def test_get_cohort(self):
"""
Make sure get_cohort() does the right thing when the course is cohorted
......@@ -115,7 +101,7 @@ class TestCohorts(django.test.TestCase):
cohort = CourseUserGroup.objects.create(name="TestCohort",
course_id=course.id,
group_type=CourseUserGroup.COHORT)
group_type=CourseUserGroup.COHORT)
cohort.users.add(user)
......@@ -145,7 +131,7 @@ class TestCohorts(django.test.TestCase):
cohort = CourseUserGroup.objects.create(name="TestCohort",
course_id=course.id,
group_type=CourseUserGroup.COHORT)
group_type=CourseUserGroup.COHORT)
# user1 manually added to a cohort
cohort.users.add(user1)
......@@ -179,7 +165,6 @@ class TestCohorts(django.test.TestCase):
self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup",
"user2 should still be in originally placed cohort")
def test_auto_cohorting_randomization(self):
"""
Make sure get_cohort() randomizes properly.
......@@ -209,8 +194,6 @@ class TestCohorts(django.test.TestCase):
self.assertGreater(num_users, 1)
self.assertLess(num_users, 50)
def test_get_course_cohorts(self):
course1_id = 'a/b/c'
course2_id = 'e/f/g'
......@@ -224,14 +207,12 @@ class TestCohorts(django.test.TestCase):
course_id=course1_id,
group_type=CourseUserGroup.COHORT)
# second course should have no cohorts
self.assertEqual(get_course_cohorts(course2_id), [])
cohorts = sorted([c.name for c in get_course_cohorts(course1_id)])
self.assertEqual(cohorts, ['TestCohort', 'TestCohort2'])
def test_is_commentable_cohorted(self):
course = modulestore().get_course("edX/toy/2012_Fall")
self.assertFalse(course.is_cohorted)
......
......@@ -9,9 +9,11 @@ from urlparse import parse_qs
from django.conf import settings
from django.test import TestCase, LiveServerTestCase
from django.test.utils import override_settings
# from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.test.client import RequestFactory
from unittest import skipUnless
class MyFetcher(HTTPFetcher):
......@@ -59,21 +61,17 @@ class MyFetcher(HTTPFetcher):
final_url=final_url,
headers=response_headers,
status=status,
)
)
class OpenIdProviderTest(TestCase):
"""
Tests of the OpenId login
"""
# def setUp(self):
# username = 'viewtest'
# email = 'view@test.com'
# password = 'foo'
# user = User.objects.create_user(username, email, password)
def testBeginLoginWithXrdsUrl(self):
# skip the test if openid is not enabled (as in cms.envs.test):
if not settings.MITX_FEATURES.get('AUTH_USE_OPENID') or not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'):
return
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
def test_begin_login_with_xrds_url(self):
# the provider URL must be converted to an absolute URL in order to be
# used as an openid provider.
......@@ -99,10 +97,9 @@ class OpenIdProviderTest(TestCase):
"got code {0} for url '{1}'. Expected code {2}"
.format(resp.status_code, url, code))
def testBeginLoginWithLoginUrl(self):
# skip the test if openid is not enabled (as in cms.envs.test):
if not settings.MITX_FEATURES.get('AUTH_USE_OPENID') or not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'):
return
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
def test_begin_login_with_login_url(self):
# the provider URL must be converted to an absolute URL in order to be
# used as an openid provider.
......@@ -150,49 +147,70 @@ class OpenIdProviderTest(TestCase):
# <input name="openid.return_to" type="hidden" value="http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H" />
# <input name="openid.assoc_handle" type="hidden" value="{HMAC-SHA1}{50ff8120}{rh87+Q==}" />
def testOpenIdSetup(self):
if not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'):
return
def attempt_login(self, expected_code, **kwargs):
""" Attempt to log in through the open id provider login """
url = reverse('openid-provider-login')
post_args = {
"openid.mode": "checkid_setup",
"openid.return_to": "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H",
"openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}",
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.ns": "http://specs.openid.net/auth/2.0",
"openid.realm": "http://testserver/",
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.ns.ax": "http://openid.net/srv/ax/1.0",
"openid.ax.mode": "fetch_request",
"openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname",
"openid.ax.type.fullname": "http://axschema.org/namePerson",
"openid.ax.type.lastname": "http://axschema.org/namePerson/last",
"openid.ax.type.firstname": "http://axschema.org/namePerson/first",
"openid.ax.type.nickname": "http://axschema.org/namePerson/friendly",
"openid.ax.type.email": "http://axschema.org/contact/email",
"openid.ax.type.old_email": "http://schema.openid.net/contact/email",
"openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly",
"openid.ax.type.old_fullname": "http://schema.openid.net/namePerson",
}
"openid.mode": "checkid_setup",
"openid.return_to": "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H",
"openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}",
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.ns": "http://specs.openid.net/auth/2.0",
"openid.realm": "http://testserver/",
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.ns.ax": "http://openid.net/srv/ax/1.0",
"openid.ax.mode": "fetch_request",
"openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname",
"openid.ax.type.fullname": "http://axschema.org/namePerson",
"openid.ax.type.lastname": "http://axschema.org/namePerson/last",
"openid.ax.type.firstname": "http://axschema.org/namePerson/first",
"openid.ax.type.nickname": "http://axschema.org/namePerson/friendly",
"openid.ax.type.email": "http://axschema.org/contact/email",
"openid.ax.type.old_email": "http://schema.openid.net/contact/email",
"openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly",
"openid.ax.type.old_fullname": "http://schema.openid.net/namePerson",
}
# override the default args with any given arguments
for key in kwargs:
post_args["openid." + key] = kwargs[key]
resp = self.client.post(url, post_args)
code = 200
code = expected_code
self.assertEqual(resp.status_code, code,
"got code {0} for url '{1}'. Expected code {2}"
.format(resp.status_code, url, code))
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
def test_open_id_setup(self):
""" Attempt a standard successful login """
self.attempt_login(200)
# In order for this absolute URL to work (i.e. to get xrds, then authentication)
# in the test environment, we either need a live server that works with the default
# fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher.
# Here we do the former.
class OpenIdProviderLiveServerTest(LiveServerTestCase):
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
def test_invalid_namespace(self):
""" Test for 403 error code when the namespace of the request is invalid"""
self.attempt_login(403, ns="http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0")
def testBeginLogin(self):
# skip the test if openid is not enabled (as in cms.envs.test):
if not settings.MITX_FEATURES.get('AUTH_USE_OPENID') or not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'):
return
@override_settings(OPENID_PROVIDER_TRUSTED_ROOTS=['http://apps.cs50.edx.org'])
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
def test_invalid_return_url(self):
""" Test for 403 error code when the url"""
self.attempt_login(403, return_to="http://apps.cs50.edx.or")
class OpenIdProviderLiveServerTest(LiveServerTestCase):
"""
In order for this absolute URL to work (i.e. to get xrds, then authentication)
in the test environment, we either need a live server that works with the default
fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher.
Here we do the former.
"""
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
def test_begin_login(self):
# the provider URL must be converted to an absolute URL in order to be
# used as an openid provider.
provider_url = reverse('openid-provider-xrds')
......
......@@ -36,7 +36,7 @@ import django_openid_auth.views as openid_views
from django_openid_auth import auth as openid_auth
from openid.consumer.consumer import SUCCESS
from openid.server.server import Server
from openid.server.server import Server, ProtocolError, UntrustedReturnURL
from openid.server.trustroot import TrustRoot
from openid.extensions import ax, sreg
......@@ -102,7 +102,7 @@ def openid_login_complete(request,
oid_backend = openid_auth.OpenIDBackend()
details = oid_backend._extract_user_details(openid_response)
log.debug('openid success, details=%s' % details)
log.debug('openid success, details=%s', details)
url = getattr(settings, 'OPENID_SSO_SERVER_URL', None)
external_domain = "openid:%s" % url
......@@ -132,7 +132,7 @@ def external_login_or_signup(request,
try:
eamap = ExternalAuthMap.objects.get(external_id=external_id,
external_domain=external_domain)
log.debug('Found eamap=%s' % eamap)
log.debug('Found eamap=%s', eamap)
except ExternalAuthMap.DoesNotExist:
# go render form for creating edX user
eamap = ExternalAuthMap(external_id=external_id,
......@@ -141,11 +141,11 @@ def external_login_or_signup(request,
eamap.external_email = email
eamap.external_name = fullname
eamap.internal_password = generate_password()
log.debug('Created eamap=%s' % eamap)
log.debug('Created eamap=%s', eamap)
eamap.save()
log.info("External_Auth login_or_signup for %s : %s : %s : %s" % (external_domain, external_id, email, fullname))
log.info(u"External_Auth login_or_signup for %s : %s : %s : %s", external_domain, external_id, email, fullname)
internal_user = eamap.user
if internal_user is None:
if settings.MITX_FEATURES.get('AUTH_USE_SHIB'):
......@@ -157,7 +157,7 @@ def external_login_or_signup(request,
eamap.user = link_user
eamap.save()
internal_user = link_user
log.info('SHIB: Linking existing account for %s' % eamap.external_email)
log.info('SHIB: Linking existing account for %s', eamap.external_email)
# now pass through to log in
else:
# otherwise, there must have been an error, b/c we've already linked a user with these external
......@@ -168,10 +168,10 @@ def external_login_or_signup(request,
% getattr(settings, 'TECH_SUPPORT_EMAIL', 'techsupport@class.stanford.edu')))
return default_render_failure(request, failure_msg)
except User.DoesNotExist:
log.info('SHIB: No user for %s yet, doing signup' % eamap.external_email)
log.info('SHIB: No user for %s yet, doing signup', eamap.external_email)
return signup(request, eamap)
else:
log.info('No user for %s yet, doing signup' % eamap.external_email)
log.info('No user for %s yet. doing signup', eamap.external_email)
return signup(request, eamap)
# We trust shib's authentication, so no need to authenticate using the password again
......@@ -183,17 +183,17 @@ def external_login_or_signup(request,
else:
auth_backend = 'django.contrib.auth.backends.ModelBackend'
user.backend = auth_backend
log.info('SHIB: Logging in linked user %s' % user.email)
log.info('SHIB: Logging in linked user %s', user.email)
else:
uname = internal_user.username
user = authenticate(username=uname, password=eamap.internal_password)
if user is None:
log.warning("External Auth Login failed for %s / %s" %
(uname, eamap.internal_password))
log.warning("External Auth Login failed for %s / %s",
uname, eamap.internal_password)
return signup(request, eamap)
if not user.is_active:
log.warning("User %s is not active" % (uname))
log.warning("User %s is not active", uname)
# TODO: improve error page
msg = 'Account not yet activated: please look for link in your email'
return default_render_failure(request, msg)
......@@ -208,7 +208,7 @@ def external_login_or_signup(request,
student_views.try_change_enrollment(enroll_request)
else:
student_views.try_change_enrollment(request)
log.info("Login success - {0} ({1})".format(user.username, user.email))
log.info("Login success - %s (%s)", user.username, user.email)
if retfun is None:
return redirect('/')
return retfun()
......@@ -261,7 +261,7 @@ def signup(request, eamap=None):
except ValidationError:
context['ask_for_email'] = True
log.info('EXTAUTH: Doing signup for %s' % eamap.external_id)
log.info('EXTAUTH: Doing signup for %s', eamap.external_id)
return student_views.register_user(request, extra_context=context)
......@@ -405,7 +405,7 @@ def shib_login(request):
shib['sn'] = shib['sn'].split(";")[0].strip().capitalize().decode('utf-8')
shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize().decode('utf-8')
log.info("SHIB creds returned: %r" % shib)
log.info("SHIB creds returned: %r", shib)
return external_login_or_signup(request,
external_id=shib['REMOTE_USER'],
......@@ -640,7 +640,10 @@ def provider_login(request):
error = False
if 'openid.mode' in request.GET or 'openid.mode' in request.POST:
# decode request
openid_request = server.decodeRequest(querydict)
try:
openid_request = server.decodeRequest(querydict)
except (UntrustedReturnURL, ProtocolError):
openid_request = None
if not openid_request:
return default_render_failure(request, "Invalid OpenID request")
......@@ -697,8 +700,8 @@ def provider_login(request):
user = User.objects.get(email=email)
except User.DoesNotExist:
request.session['openid_error'] = True
msg = "OpenID login failed - Unknown user email: {0}".format(email)
log.warning(msg)
msg = "OpenID login failed - Unknown user email: %s"
log.warning(msg, email)
return HttpResponseRedirect(openid_request_url)
# attempt to authenticate user (but not actually log them in...)
......@@ -708,9 +711,8 @@ def provider_login(request):
user = authenticate(username=username, password=password)
if user is None:
request.session['openid_error'] = True
msg = "OpenID login failed - password for {0} is invalid"
msg = msg.format(email)
log.warning(msg)
msg = "OpenID login failed - password for %s is invalid"
log.warning(msg, email)
return HttpResponseRedirect(openid_request_url)
# authentication succeeded, so fetch user information
......@@ -720,10 +722,8 @@ def provider_login(request):
if 'openid_error' in request.session:
del request.session['openid_error']
# fullname field comes from user profile
profile = UserProfile.objects.get(user=user)
log.info("OpenID login success - {0} ({1})".format(user.username,
user.email))
log.info("OpenID login success - %s (%s)",
user.username, user.email)
# redirect user to return_to location
url = endpoint + urlquote(user.username)
......@@ -753,8 +753,8 @@ def provider_login(request):
# the account is not active, so redirect back to the login page:
request.session['openid_error'] = True
msg = "Login failed - Account not active for user {0}".format(username)
log.warning(msg)
msg = "Login failed - Account not active for user %s"
log.warning(msg, username)
return HttpResponseRedirect(openid_request_url)
# determine consumer domain if applicable
......
......@@ -153,21 +153,36 @@ def click_link(partial_text):
@world.absorb
def css_text(css_selector):
def css_text(css_selector, index=0):
# Wait for the css selector to appear
if world.is_css_present(css_selector):
try:
return world.browser.find_by_css(css_selector).first.text
return world.browser.find_by_css(css_selector)[index].text
except StaleElementReferenceException:
# The DOM was still redrawing. Wait a second and try again.
world.wait(1)
return world.browser.find_by_css(css_selector).first.text
return world.browser.find_by_css(css_selector)[index].text
else:
return ""
@world.absorb
def css_html(css_selector, index=0, max_attempts=5):
"""
Returns the HTML of a css_selector and will retry if there is a StaleElementReferenceException
"""
assert is_css_present(css_selector)
attempt = 0
while attempt < max_attempts:
try:
return world.browser.find_by_css(css_selector)[index].html
except:
attempt += 1
return ''
@world.absorb
def css_visible(css_selector):
assert is_css_present(css_selector)
return world.browser.find_by_css(css_selector).visible
......
from django.db import models
from django.db import models
class TrackingLog(models.Model):
"""Defines the fields that are stored in the tracking log database"""
dtcreated = models.DateTimeField('creation date', auto_now_add=True)
username = models.CharField(max_length=32, blank=True)
ip = models.CharField(max_length=32, blank=True)
......@@ -16,6 +15,9 @@ class TrackingLog(models.Model):
host = models.CharField(max_length=64, blank=True)
def __unicode__(self):
s = "[%s] %s@%s: %s | %s | %s | %s" % (self.time, self.username, self.ip, self.event_source,
self.event_type, self.page, self.event)
return s
fmt = (
u"[{self.time}] {self.username}@{self.ip}: "
u"{self.event_source}| {self.event_type} | "
u"{self.page} | {self.event}"
)
return fmt.format(self=self)
"""Tests for student tracking"""
from django.test import TestCase
from django.core.urlresolvers import reverse, NoReverseMatch
from track.models import TrackingLog
from track.views import user_track
from nose.plugins.skip import SkipTest
class TrackingTest(TestCase):
"""
Tests that tracking logs correctly handle events
"""
def test_post_answers_to_log(self):
"""
Checks that student answer requests submitted to track.views via POST
are correctly logged in the TrackingLog db table
"""
requests = [
{"event": "my_event", "event_type": "my_event_type", "page": "my_page"},
{"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"}
]
for request_params in requests:
try: # because /event maps to two different views in lms and cms, we're only going to test lms here
response = self.client.post(reverse(user_track), request_params)
except NoReverseMatch:
raise SkipTest()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, 'success')
tracking_logs = TrackingLog.objects.order_by('-dtcreated')
log = tracking_logs[0]
self.assertEqual(log.event, request_params["event"])
self.assertEqual(log.event_type, request_params["event_type"])
self.assertEqual(log.page, request_params["page"])
def test_get_answers_to_log(self):
"""
Checks that student answer requests submitted to track.views via GET
are correctly logged in the TrackingLog db table
"""
requests = [
{"event": "my_event", "event_type": "my_event_type", "page": "my_page"},
{"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"}
]
for request_params in requests:
try: # because /event maps to two different views in lms and cms, we're only going to test lms here
response = self.client.get(reverse(user_track), request_params)
except NoReverseMatch:
raise SkipTest()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, 'success')
tracking_logs = TrackingLog.objects.order_by('-dtcreated')
log = tracking_logs[0]
self.assertEqual(log.event, request_params["event"])
self.assertEqual(log.event_type, request_params["event_type"])
self.assertEqual(log.page, request_params["page"])
......@@ -34,9 +34,10 @@ def log_event(event):
def user_track(request):
"""
Log when GET call to "event" URL is made by a user.
Log when POST call to "event" URL is made by a user. Uses request.REQUEST
to allow for GET calls.
GET call should provide "event_type", "event", and "page" arguments.
GET or POST call should provide "event_type", "event", and "page" arguments.
"""
try: # TODO: Do the same for many of the optional META parameters
username = request.user.username
......@@ -59,13 +60,14 @@ def user_track(request):
"session": scookie,
"ip": request.META['REMOTE_ADDR'],
"event_source": "browser",
"event_type": request.GET['event_type'],
"event": request.GET['event'],
"event_type": request.REQUEST['event_type'],
"event": request.REQUEST['event'],
"agent": agent,
"page": request.GET['page'],
"page": request.REQUEST['page'],
"time": datetime.datetime.now(UTC).isoformat(),
"host": request.META['SERVER_NAME'],
}
}
log_event(event)
return HttpResponse('success')
......@@ -92,7 +94,7 @@ def server_track(request, event_type, event, page=None):
"page": page,
"time": datetime.datetime.now(UTC).isoformat(),
"host": request.META['SERVER_NAME'],
}
}
if event_type.startswith("/event_logs") and request.user.is_staff: # don't log
return
......@@ -136,7 +138,7 @@ def task_track(request_info, task_info, event_type, event, page=None):
"page": page,
"time": datetime.datetime.utcnow().isoformat(),
"host": request_info.get('host', 'unknown')
}
}
log_event(event)
......
......@@ -15,8 +15,7 @@ def expect_json(view_function):
# e.g. 'charset', so we can't do a direct string compare
if request.META.get('CONTENT_TYPE', '').lower().startswith("application/json"):
cloned_request = copy.copy(request)
cloned_request.POST = cloned_request.POST.copy()
cloned_request.POST.update(json.loads(request.body))
cloned_request.POST = json.loads(request.body)
return view_function(cloned_request, *args, **kwargs)
else:
return view_function(request, *args, **kwargs)
......
import re
from django.conf import settings
def can_execute_unsafe_code(course_id):
"""
Determine if this course is allowed to run unsafe code.
For use from the ModuleStore. Checks the `course_id` against a list of whitelisted
regexes.
Returns a boolean, true if the course can run outside the sandbox.
"""
# To decide if we can run unsafe code, we check the course id against
# a list of regexes configured on the server.
for regex in settings.COURSES_WITH_UNSAFE_CODE:
if re.match(regex, course_id):
return True
return False
"""
Tests for sandboxing.py in util app
"""
from django.test import TestCase
from util.sandboxing import can_execute_unsafe_code
from django.test.utils import override_settings
class SandboxingTest(TestCase):
"""
Test sandbox whitelisting
"""
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/full/.*'])
def test_sandbox_exclusion(self):
"""
Test to make sure that a non-match returns false
"""
self.assertFalse(can_execute_unsafe_code('edX/notful/empty'))
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/full/.*'])
def test_sandbox_inclusion(self):
"""
Test to make sure that a match works across course runs
"""
self.assertTrue(can_execute_unsafe_code('edX/full/2012_Fall'))
self.assertTrue(can_execute_unsafe_code('edX/full/2013_Spring'))
......@@ -4,7 +4,10 @@ import sys
from django.conf import settings
from django.core.validators import ValidationError, validate_email
from django.http import Http404, HttpResponse, HttpResponseNotAllowed
from django.views.decorators.csrf import requires_csrf_token
from django.views.defaults import server_error
from django.http import (Http404, HttpResponse, HttpResponseNotAllowed,
HttpResponseServerError)
from dogapi import dog_stats_api
from mitxmako.shortcuts import render_to_response
import zendesk
......@@ -16,6 +19,19 @@ import track.views
log = logging.getLogger(__name__)
@requires_csrf_token
def jsonable_server_error(request, template_name='500.html'):
"""
500 error handler that serves JSON on an AJAX request, and proxies
to the Django default `server_error` view otherwise.
"""
if request.is_ajax():
msg = {"error": "The edX servers encountered an error"}
return HttpResponseServerError(json.dumps(msg))
else:
return server_error(request, template_name=template_name)
def calculate(request):
''' Calculator in footer of every page. '''
equation = request.GET['equation']
......@@ -228,4 +244,3 @@ def accepts(request, media_type):
"""Return whether this request has an Accept header that matches type"""
accept = parse_accept_header(request.META.get("HTTP_ACCEPT", ""))
return media_type in [t for (t, p, q) in accept]
......@@ -55,6 +55,7 @@ setup(
"word_cloud = xmodule.word_cloud_module:WordCloudDescriptor",
"hidden = xmodule.hidden_module:HiddenDescriptor",
"raw = xmodule.raw_module:RawDescriptor",
"crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor",
],
'console_scripts': [
'xmodule_assets = xmodule.static_content:main',
......
......@@ -2,7 +2,8 @@ from pymongo import Connection
import gridfs
from gridfs.errors import NoFile
from xmodule.modulestore.mongo import location_to_query, Location
from xmodule.modulestore import Location
from xmodule.modulestore.mongo.base import location_to_query
from xmodule.contentstore.content import XASSET_LOCATION_TAG
import logging
......
.crowdsource-wrapper {
@include box-shadow(inset 0 1px 2px 1px rgba(0,0,0,0.1));
@include border-radius(2px);
display: none;
margin-top: 20px;
padding: (15px);
background: rgb(253, 248, 235);
}
#answer-tabs {
background: #FFFFFF;
border: none;
margin-bottom: 20px;
padding-bottom: 20px;
}
#answer-tabs .ui-widget-header {
border-bottom: 1px solid #DCDCDC;
background: #FDF8EB;
}
#answer-tabs .ui-tabs-nav .ui-state-default {
border: 1px solid #DCDCDC;
background: #E6E6E3;
margin-bottom: 0px;
}
#answer-tabs .ui-tabs-nav .ui-state-default:hover {
background: #FFFFFF;
}
#answer-tabs .ui-tabs-nav .ui-state-active:hover {
background: #FFFFFF;
}
#answer-tabs .ui-tabs-nav .ui-state-active {
border: 1px solid #DCDCDC;
background: #FFFFFF;
}
#answer-tabs .ui-tabs-nav .ui-state-active a {
color: #222222;
background: #FFFFFF;
}
#answer-tabs .ui-tabs-nav .ui-state-default a:hover {
color: #222222;
background: #FFFFFF;
}
#answer-tabs .custom-hint {
height: 100px;
width: 100%;
}
.hint-inner-container {
padding-left: 15px;
padding-right: 15px;
font-size: 16px;
}
.vote {
padding-top: 0px !important;
padding-bottom: 0px !important;
}
......@@ -166,7 +166,6 @@ nav.sequence-nav {
p {
background: #333;
color: #fff;
display: none;
font-family: $sans-serif;
line-height: lh();
left: 0px;
......
......@@ -223,6 +223,7 @@ class @Problem
@el.removeClass 'showed'
else
@gentle_alert response.success
Logger.log 'problem_graded', [@answers, response.contents], @url
reset: =>
Logger.log 'problem_reset', @answers
......
class @Hinter
# The client side code for the crowdsource_hinter.
# Contains code for capturing problem checks and making ajax calls to
# the server component. Also contains styling code to clear default
# text on a textarea.
constructor: (element) ->
@el = $(element).find('.crowdsource-wrapper')
@url = @el.data('url')
Logger.listen('problem_graded', @el.data('child-url'), @capture_problem)
@render()
capture_problem: (event_type, data, element) =>
# After a problem gets graded, we get the info here.
# We want to send this info to the server in another AJAX
# request.
answers = data[0]
response = data[1]
if response.search(/class="correct/) == -1
# Incorrect. Get hints.
$.postWithPrefix "#{@url}/get_hint", answers, (response) =>
@render(response.contents)
else
# Correct. Get feedback from students.
$.postWithPrefix "#{@url}/get_feedback", answers, (response) =>
@render(response.contents)
$: (selector) ->
$(selector, @el)
bind: =>
window.update_schematics()
@$('input.vote').click @vote
@$('input.submit-hint').click @submit_hint
@$('.custom-hint').click @clear_default_text
@$('#answer-tabs').tabs({active: 0})
@$('.expand-goodhint').click @expand_goodhint
expand_goodhint: =>
if @$('.goodhint').css('display') == 'none'
@$('.goodhint').css('display', 'block')
else
@$('.goodhint').css('display', 'none')
vote: (eventObj) =>
target = @$(eventObj.currentTarget)
post_json = {'answer': target.data('answer'), 'hint': target.data('hintno')}
$.postWithPrefix "#{@url}/vote", post_json, (response) =>
@render(response.contents)
submit_hint: (eventObj) =>
target = @$(eventObj.currentTarget)
textarea_id = '#custom-hint-' + target.data('answer')
post_json = {'answer': target.data('answer'), 'hint': @$(textarea_id).val()}
$.postWithPrefix "#{@url}/submit_hint",post_json, (response) =>
@render(response.contents)
clear_default_text: (eventObj) =>
target = @$(eventObj.currentTarget)
if target.data('cleared') == undefined
target.val('')
target.data('cleared', true)
render: (content) ->
if content
# Trim leading and trailing whitespace
content = content.replace /^\s+|\s+$/g, ""
if content
@el.html(content)
@el.show()
JavascriptLoader.executeModuleScripts @el, () =>
@bind()
@$('#previous-answer-0').css('display', 'inline')
else
@el.hide()
......@@ -111,7 +111,15 @@ class @Sequence
if (1 <= new_position) and (new_position <= @num_contents)
Logger.log "seq_goto", old: @position, new: new_position, id: @id
# On Sequence chage, destroy any existing polling thread
analytics.pageview @id
# navigation by clicking the tab directly
analytics.track "Accessed Sequential Directly",
sequence_id: @id
current_sequential: @position
target_sequential: new_position
# On Sequence change, destroy any existing polling thread
# for queued submissions, see ../capa/display.coffee
if window.queuePollerID
window.clearTimeout(window.queuePollerID)
......@@ -125,12 +133,30 @@ class @Sequence
event.preventDefault()
new_position = @position + 1
Logger.log "seq_next", old: @position, new: new_position, id: @id
analytics.pageview @id
# navigation using the next arrow
analytics.track "Accessed Next Sequential",
sequence_id: @id
current_sequential: @position
target_sequential: new_position
@render new_position
previous: (event) =>
event.preventDefault()
new_position = @position - 1
Logger.log "seq_prev", old: @position, new: new_position, id: @id
analytics.pageview @id
# navigation using the previous arrow
analytics.track "Accessed Previous Sequential",
sequence_id: @id
current_sequential: @position
target_sequential: new_position
@render new_position
link_for: (position) ->
......
......@@ -16,16 +16,7 @@ log = logging.getLogger('mitx.' + 'modulestore')
URL_RE = re.compile("""
(?P<tag>[^:]+)://
(?P<org>[^/]+)/
(?P<course>[^/]+)/
(?P<category>[^/]+)/
(?P<name>[^@]+)
(@(?P<revision>[^/]+))?
""", re.VERBOSE)
MISSING_SLASH_URL_RE = re.compile("""
(?P<tag>[^:]+):/
(?P<tag>[^:]+)://?
(?P<org>[^/]+)/
(?P<course>[^/]+)/
(?P<category>[^/]+)/
......@@ -180,13 +171,8 @@ class Location(_LocationBase):
if isinstance(location, basestring):
match = URL_RE.match(location)
if match is None:
# cdodge:
# check for a dropped slash near the i4x:// element of the location string. This can happen with some
# redirects (e.g. edx.org -> www.edx.org which I think happens in Nginx)
match = MISSING_SLASH_URL_RE.match(location)
if match is None:
log.debug('location is instance of %s but no URL match' % basestring)
raise InvalidLocationError(location)
log.debug('location is instance of %s but no URL match' % basestring)
raise InvalidLocationError(location)
groups = match.groupdict()
check_dict(groups)
return _LocationBase.__new__(_cls, **groups)
......
"""
Provide names as exported by older mongo.py module
"""
from xmodule.modulestore.mongo.base import MongoModuleStore, MongoKeyValueStore, MongoUsage
# Backwards compatibility for prod systems that refererence
# xmodule.modulestore.mongo.DraftMongoModuleStore
from xmodule.modulestore.mongo.draft import DraftModuleStore as DraftMongoModuleStore
"""
Modulestore backed by Mongodb.
Stores individual XModules as single documents with the following
structure:
{
'_id': <location.as_dict>,
'metadata': <dict containing all Scope.settings fields>
'definition': <dict containing all Scope.content fields>
'definition.children': <list of all child location.url()s>
}
"""
import pymongo
import sys
import logging
......@@ -18,11 +32,9 @@ from xmodule.error_module import ErrorDescriptor
from xblock.runtime import DbModel, KeyValueStore, InvalidScopeError
from xblock.core import Scope
from . import ModuleStoreBase, Location, namedtuple_to_son
from .draft import DraftModuleStore
from .exceptions import (ItemNotFoundError,
DuplicateItemError)
from .inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata
from xmodule.modulestore import ModuleStoreBase, Location, namedtuple_to_son
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError
from xmodule.modulestore.inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata
log = logging.getLogger(__name__)
......@@ -33,6 +45,7 @@ log = logging.getLogger(__name__)
def get_course_id_no_run(location):
'''
Return the first two components of the course_id for this location (org/course)
'''
return "/".join([location.org, location.course])
......@@ -616,6 +629,9 @@ class MongoModuleStore(ModuleStoreBase):
return item
def fire_updated_modulestore_signal(self, course_id, location):
"""
Send a signal using `self.modulestore_update_signal`, if that has been set
"""
if self.modulestore_update_signal is not None:
self.modulestore_update_signal.send(self, modulestore=self, course_id=course_id,
location=location)
......@@ -759,14 +775,3 @@ class MongoModuleStore(ModuleStoreBase):
are loaded on demand, rather than up front
"""
return {}
# DraftModuleStore is first, because it needs to intercept calls to MongoModuleStore
class DraftMongoModuleStore(DraftModuleStore, MongoModuleStore):
"""
Version of MongoModuleStore with draft capability mixed in
"""
"""
Version of MongoModuleStore with draft capability mixed in
"""
pass
......@@ -2,6 +2,8 @@ from xmodule.contentstore.content import StaticContent
from xmodule.modulestore import Location
from xmodule.modulestore.mongo import MongoModuleStore
import logging
def clone_course(modulestore, contentstore, source_location, dest_location, delete_original=False):
# first check to see if the modulestore is Mongo backed
......@@ -102,10 +104,38 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
return True
def _delete_modules_except_course(modulestore, modules, source_location, commit):
"""
This helper method will just enumerate through a list of modules and delete them, except for the
top-level course module
"""
for module in modules:
if module.category != 'course':
logging.debug("Deleting {0}...".format(module.location))
if commit:
# sanity check. Make sure we're not deleting a module in the incorrect course
if module.location.org != source_location.org or module.location.course != source_location.course:
raise Exception('Module {0} is not in same namespace as {1}. This should not happen! Aborting...'.format(module.location, source_location))
modulestore.delete_item(module.location)
def _delete_assets(contentstore, assets, commit):
"""
This helper method will enumerate through a list of assets and delete them
"""
for asset in assets:
asset_loc = Location(asset["_id"])
id = StaticContent.get_id_from_location(asset_loc)
logging.debug("Deleting {0}...".format(id))
if commit:
contentstore.delete(id)
def delete_course(modulestore, contentstore, source_location, commit=False):
# first check to see if the modulestore is Mongo backed
if not isinstance(modulestore, MongoModuleStore):
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
"""
This method will actually do the work to delete all content in a course in a MongoDB backed
courseware store. BE VERY CAREFUL, this is not reversable.
"""
# check to see if the source course is actually there
if not modulestore.has_item(source_location):
......@@ -113,30 +143,19 @@ def delete_course(modulestore, contentstore, source_location, commit=False):
# first delete all of the thumbnails
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
for thumb in thumbs:
thumb_loc = Location(thumb["_id"])
id = StaticContent.get_id_from_location(thumb_loc)
print "Deleting {0}...".format(id)
if commit:
contentstore.delete(id)
_delete_assets(contentstore, thumbs, commit)
# then delete all of the assets
assets = contentstore.get_all_content_for_course(source_location)
for asset in assets:
asset_loc = Location(asset["_id"])
id = StaticContent.get_id_from_location(asset_loc)
print "Deleting {0}...".format(id)
if commit:
contentstore.delete(id)
_delete_assets(contentstore, assets, commit)
# then delete all course modules
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
_delete_modules_except_course(modulestore, modules, source_location, commit)
for module in modules:
if module.category != 'course': # save deleting the course module for last
print "Deleting {0}...".format(module.location)
if commit:
modulestore.delete_item(module.location)
# then delete all draft course modules
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, 'draft'])
_delete_modules_except_course(modulestore, modules, source_location, commit)
# finally delete the top-level course module itself
print "Deleting {0}...".format(source_location)
......@@ -144,4 +163,3 @@ def delete_course(modulestore, contentstore, source_location, commit=False):
modulestore.delete_item(source_location)
return True
......@@ -8,6 +8,70 @@ import xmodule.modulestore.django
from xmodule.templates import update_templates
def mongo_store_config(data_dir):
"""
Defines default module store using MongoModuleStore.
Use of this config requires mongo to be running.
"""
store = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'test_xmodule',
'collection': 'modulestore_%s' % uuid4().hex,
'fs_root': data_dir,
'render_template': 'mitxmako.shortcuts.render_to_string'
}
}
}
store['direct'] = store['default']
return store
def draft_mongo_store_config(data_dir):
"""
Defines default module store using DraftMongoModuleStore.
"""
modulestore_options = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'test_xmodule',
'collection': 'modulestore_%s' % uuid4().hex,
'fs_root': data_dir,
'render_template': 'mitxmako.shortcuts.render_to_string'
}
return {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'OPTIONS': modulestore_options
},
'direct': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': modulestore_options
}
}
def xml_store_config(data_dir):
"""
Defines default module store using XMLModuleStore.
"""
return {
'default': {
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
'OPTIONS': {
'data_dir': data_dir,
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
}
}
}
class ModuleStoreTestCase(TestCase):
""" Subclass for any test case that uses the mongodb
module store. This populates a uniquely named modulestore
......
......@@ -19,14 +19,13 @@ class XModuleCourseFactory(Factory):
ABSTRACT_FACTORY = True
@classmethod
def _create(cls, target_class, *args, **kwargs):
def _create(cls, target_class, **kwargs):
template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
org = kwargs.get('org')
number = kwargs.get('number')
display_name = kwargs.get('display_name')
location = Location('i4x', org, number,
'course', Location.clean(display_name))
org = kwargs.pop('org', None)
number = kwargs.pop('number', None)
display_name = kwargs.pop('display_name', None)
location = Location('i4x', org, number, 'course', Location.clean(display_name))
try:
store = modulestore('direct')
......@@ -41,7 +40,7 @@ class XModuleCourseFactory(Factory):
new_course.display_name = display_name
new_course.lms.start = datetime.datetime.now(UTC)
new_course.tabs = kwargs.get(
new_course.tabs = kwargs.pop(
'tabs',
[
{"type": "courseware"},
......@@ -51,14 +50,14 @@ class XModuleCourseFactory(Factory):
{"type": "progress", "name": "Progress"}
]
)
new_course.discussion_link = kwargs.get('discussion_link')
# Update the data in the mongo datastore
store.update_metadata(new_course.location.url(), own_metadata(new_course))
# The rest of kwargs become attributes on the course:
for k, v in kwargs.iteritems():
setattr(new_course, k, v)
data = kwargs.get('data')
if data is not None:
store.update_item(new_course.location, data)
# Update the data in the mongo datastore
store.update_metadata(new_course.location, own_metadata(new_course))
store.update_item(new_course.location, new_course._model_data._kvs._data)
# update_item updates the the course as it exists in the modulestore, but doesn't
# update the instance we are working with, so have to refetch the course after updating it.
......@@ -101,7 +100,7 @@ class XModuleItemFactory(Factory):
return parent._replace(category=attr.category, name=dest_name)
@classmethod
def _create(cls, target_class, *args, **kwargs):
def _create(cls, target_class, **kwargs):
"""
Uses *kwargs*:
......
"""
Methods for exporting course data to XML
"""
import logging
from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata
from fs.osfs import OSFS
from json import dumps
import json
import datetime
class EdxJSONEncoder(json.JSONEncoder):
"""
Custom JSONEncoder that handles `Location` and `datetime.datetime` objects.
`Location`s are encoded as their url string form, and `datetime`s as
ISO date strings
"""
def default(self, obj):
if isinstance(obj, Location):
return obj.url()
elif isinstance(obj, datetime.datetime):
if obj.tzinfo is not None:
if obj.utcoffset() is None:
return obj.isoformat() + 'Z'
else:
return obj.isoformat()
else:
return obj.isoformat()
else:
return super(EdxJSONEncoder, self).default(obj)
def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir, draft_modulestore=None):
"""
Export all modules from `modulestore` and content from `contentstore` as xml to `root_dir`.
`modulestore`: A `ModuleStore` object that is the source of the modules to export
`contentstore`: A `ContentStore` object that is the source of the content to export
`course_location`: The `Location` of the `CourseModuleDescriptor` to export
`root_dir`: The directory to write the exported xml to
`course_dir`: The name of the directory inside `root_dir` to write the course content to
`draft_modulestore`: An optional `DraftModuleStore` that contains draft content, which will be exported
alongside the public content in the course.
"""
course = modulestore.get_item(course_location)
......@@ -35,12 +74,12 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
policies_dir = export_fs.makeopendir('policies')
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
grading_policy.write(dumps(course.grading_policy))
grading_policy.write(dumps(course.grading_policy, cls=EdxJSONEncoder))
# export all of the course metadata in policy.json
with course_run_policy_dir.open('policy.json', 'w') as course_policy:
policy = {'course/' + course.location.name: own_metadata(course)}
course_policy.write(dumps(policy))
course_policy.write(dumps(policy, cls=EdxJSONEncoder))
# export draft content
# NOTE: this code assumes that verticals are the top most draftable container
......
......@@ -59,7 +59,7 @@ class SequenceModule(SequenceFields, XModule):
# TODO: Cache progress or children array?
children = self.get_children()
progresses = [child.get_progress() for child in children]
progress = reduce(Progress.add_counts, progresses)
progress = reduce(Progress.add_counts, progresses, None)
return progress
def handle_ajax(self, dispatch, data): # TODO: bounds checking
......
......@@ -49,7 +49,7 @@ class CustomTagDescriptor(RawDescriptor):
else:
# TODO (vshnayder): better exception type
raise Exception("Could not find impl attribute in customtag {0}"
.format(location))
.format(self.location))
params = dict(xmltree.items())
......
# Tests for xmodule.util.date_utils
"""Tests for xmodule.util.date_utils"""
from nose.tools import assert_equals
from xmodule.util import date_utils
import datetime
from nose.tools import assert_equals, assert_false
from xmodule.util.date_utils import get_default_time_display, almost_same_datetime
from datetime import datetime, timedelta, tzinfo
from pytz import UTC
def test_get_default_time_display():
assert_equals("", date_utils.get_default_time_display(None))
test_time = datetime.datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC)
assert_equals("", get_default_time_display(None))
test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC)
assert_equals(
"Mar 12, 1992 at 15:03 UTC",
date_utils.get_default_time_display(test_time))
get_default_time_display(test_time))
assert_equals(
"Mar 12, 1992 at 15:03 UTC",
date_utils.get_default_time_display(test_time, True))
get_default_time_display(test_time, True))
assert_equals(
"Mar 12, 1992 at 15:03",
date_utils.get_default_time_display(test_time, False))
get_default_time_display(test_time, False))
def test_get_default_time_display_notz():
test_time = datetime.datetime(1992, 3, 12, 15, 3, 30)
test_time = datetime(1992, 3, 12, 15, 3, 30)
assert_equals(
"Mar 12, 1992 at 15:03 UTC",
date_utils.get_default_time_display(test_time))
get_default_time_display(test_time))
assert_equals(
"Mar 12, 1992 at 15:03 UTC",
date_utils.get_default_time_display(test_time, True))
get_default_time_display(test_time, True))
assert_equals(
"Mar 12, 1992 at 15:03",
date_utils.get_default_time_display(test_time, False))
get_default_time_display(test_time, False))
# pylint: disable=W0232
class NamelessTZ(datetime.tzinfo):
class NamelessTZ(tzinfo):
"""Static timezone for testing"""
def utcoffset(self, _dt):
return datetime.timedelta(hours=-3)
return timedelta(hours=-3)
def dst(self, _dt):
return datetime.timedelta(0)
return timedelta(0)
def test_get_default_time_display_no_tzname():
assert_equals("", date_utils.get_default_time_display(None))
test_time = datetime.datetime(1992, 3, 12, 15, 3, 30, tzinfo=NamelessTZ())
assert_equals("", get_default_time_display(None))
test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=NamelessTZ())
assert_equals(
"Mar 12, 1992 at 15:03-0300",
date_utils.get_default_time_display(test_time))
get_default_time_display(test_time))
assert_equals(
"Mar 12, 1992 at 15:03-0300",
date_utils.get_default_time_display(test_time, True))
get_default_time_display(test_time, True))
assert_equals(
"Mar 12, 1992 at 15:03",
date_utils.get_default_time_display(test_time, False))
get_default_time_display(test_time, False))
def test_almost_same_datetime():
assert almost_same_datetime(
datetime(2013, 5, 3, 10, 20, 30),
datetime(2013, 5, 3, 10, 21, 29)
)
assert almost_same_datetime(
datetime(2013, 5, 3, 11, 20, 30),
datetime(2013, 5, 3, 10, 21, 29),
timedelta(hours=1)
)
assert_false(
almost_same_datetime(
datetime(2013, 5, 3, 11, 20, 30),
datetime(2013, 5, 3, 10, 21, 29)
)
)
assert_false(
almost_same_datetime(
datetime(2013, 5, 3, 11, 20, 30),
datetime(2013, 5, 3, 10, 21, 29),
timedelta(minutes=10)
)
)
"""
Tests of XML export
"""
import unittest
import pytz
from datetime import datetime, timedelta, tzinfo
from fs.osfs import OSFS
from path import path
from tempfile import mkdtemp
import shutil
from xmodule.modulestore.xml import XMLModuleStore
from xmodule.modulestore.xml_exporter import EdxJSONEncoder
from xmodule.modulestore import Location
# from ~/mitx_all/mitx/common/lib/xmodule/xmodule/tests/
# to ~/mitx_all/mitx/common/test
......@@ -127,3 +136,64 @@ class RoundTripTestCase(unittest.TestCase):
def test_word_cloud_roundtrip(self):
self.check_export_roundtrip(DATA_DIR, "word_cloud")
class TestEdxJsonEncoder(unittest.TestCase):
"""
Tests for xml_exporter.EdxJSONEncoder
"""
def setUp(self):
self.encoder = EdxJSONEncoder()
class OffsetTZ(tzinfo):
"""A timezone with non-None utcoffset"""
def utcoffset(self, _dt):
return timedelta(hours=4)
self.offset_tz = OffsetTZ()
class NullTZ(tzinfo):
"""A timezone with None as its utcoffset"""
def utcoffset(self, _dt):
return None
self.null_utc_tz = NullTZ()
def test_encode_location(self):
loc = Location('i4x', 'org', 'course', 'category', 'name')
self.assertEqual(loc.url(), self.encoder.default(loc))
loc = Location('i4x', 'org', 'course', 'category', 'name', 'version')
self.assertEqual(loc.url(), self.encoder.default(loc))
def test_encode_naive_datetime(self):
self.assertEqual(
"2013-05-03T10:20:30.000100",
self.encoder.default(datetime(2013, 5, 3, 10, 20, 30, 100))
)
self.assertEqual(
"2013-05-03T10:20:30",
self.encoder.default(datetime(2013, 5, 3, 10, 20, 30))
)
def test_encode_utc_datetime(self):
self.assertEqual(
"2013-05-03T10:20:30+00:00",
self.encoder.default(datetime(2013, 5, 3, 10, 20, 30, 0, pytz.UTC))
)
self.assertEqual(
"2013-05-03T10:20:30+04:00",
self.encoder.default(datetime(2013, 5, 3, 10, 20, 30, 0, self.offset_tz))
)
self.assertEqual(
"2013-05-03T10:20:30Z",
self.encoder.default(datetime(2013, 5, 3, 10, 20, 30, 0, self.null_utc_tz))
)
def test_fallthrough(self):
with self.assertRaises(TypeError):
self.encoder.default(None)
with self.assertRaises(TypeError):
self.encoder.default({})
"""
Convenience methods for working with datetime objects
"""
import datetime
def get_default_time_display(dt, show_timezone=True):
"""
Converts a datetime to a string representation. This is the default
......@@ -11,7 +16,7 @@ def get_default_time_display(dt, show_timezone=True):
if dt is None:
return ""
timezone = ""
if dt is not None and show_timezone:
if show_timezone:
if dt.tzinfo is not None:
try:
timezone = " " + dt.tzinfo.tzname(dt)
......@@ -20,3 +25,14 @@ def get_default_time_display(dt, show_timezone=True):
else:
timezone = " UTC"
return dt.strftime("%b %d, %Y at %H:%M") + timezone
def almost_same_datetime(dt1, dt2, allowed_delta=datetime.timedelta(minutes=1)):
"""
Returns true if these are w/in a minute of each other. (in case secs saved to db
or timezone aren't same)
:param dt1:
:param dt2:
"""
return abs(dt1 - dt2) < allowed_delta
......@@ -10,6 +10,7 @@ from xblock.core import Dict, Scope
from xmodule.x_module import (XModuleDescriptor, policy_key)
from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.xml_exporter import EdxJSONEncoder
log = logging.getLogger(__name__)
......@@ -84,7 +85,7 @@ def serialize_field(value):
By default, this is the result of calling json.dumps on the input value.
"""
return json.dumps(value)
return json.dumps(value, cls=EdxJSONEncoder)
def deserialize_field(field, value):
......
......@@ -3,20 +3,10 @@ describe 'Logger', ->
expect(window.log_event).toBe Logger.log
describe 'log', ->
it 'sends an event to Segment.io, if the event is whitelisted and the data is not a dictionary', ->
spyOn(analytics, 'track')
Logger.log 'seq_goto', 'data'
expect(analytics.track).toHaveBeenCalledWith 'seq_goto', value: 'data'
it 'sends an event to Segment.io, if the event is whitelisted and the data is a dictionary', ->
spyOn(analytics, 'track')
Logger.log 'seq_goto', value: 'data'
expect(analytics.track).toHaveBeenCalledWith 'seq_goto', value: 'data'
it 'send a request to log event', ->
spyOn $, 'getWithPrefix'
spyOn $, 'postWithPrefix'
Logger.log 'example', 'data'
expect($.getWithPrefix).toHaveBeenCalledWith '/event',
expect($.postWithPrefix).toHaveBeenCalledWith '/event',
event_type: 'example'
event: '"data"'
page: window.location.href
......
class @Logger
# events we want sent to Segment.io for tracking
SEGMENT_IO_WHITELIST = ["seq_goto", "seq_next", "seq_prev", "problem_check", "problem_reset", "problem_show", "problem_save"]
@log: (event_type, data) ->
# Segment.io event tracking
if event_type in SEGMENT_IO_WHITELIST
# to avoid changing the format of data sent to our servers, we only massage it here
if typeof data isnt 'object' or data is null
analytics.track event_type, value: data
else
analytics.track event_type, data
# listeners[event_type][element] -> list of callbacks
listeners = {}
@log: (event_type, data, element = null) ->
# Check to see if we're listening for the event type.
if event_type of listeners
# Cool. Do the elements also match?
# null element in the listener dictionary means any element will do.
# null element in the @log call means we don't know the element name.
if null of listeners[event_type]
# Make the callbacks.
for callback in listeners[event_type][null]
callback(event_type, data, element)
else if element of listeners[event_type]
for callback in listeners[event_type][element]
callback(event_type, data, element)
$.getWithPrefix '/event',
# Regardless of whether any callbacks were made, log this event.
$.postWithPrefix '/event',
event_type: event_type
event: JSON.stringify(data)
page: window.location.href
@listen: (event_type, element, callback) ->
# Add a listener. If you want any element to trigger this listener,
# do element = null
if event_type not of listeners
listeners[event_type] = {}
if element not of listeners[event_type]
listeners[event_type][element] = [callback]
else
listeners[event_type][element].push callback
@bind: ->
window.onunload = ->
$.ajaxWithPrefix
......@@ -26,5 +42,5 @@ class @Logger
page: window.location.href
async: false
# Keeping this for conpatibility issue only.
# Keeping this for compatibility issue only.
@log_event = Logger.log
## The hinter module passes in a field called ${op}, which determines which
## sub-function to render.
<%def name="get_hint()">
% if best_hint != '':
<h4> Hints from students who made similar mistakes: </h4>
<ul>
<li> ${best_hint} </li>
% endif
% if rand_hint_1 != '':
<li> ${rand_hint_1} </li>
% endif
% if rand_hint_2 != '':
<li> ${rand_hint_2} </li>
% endif
</ul>
</%def>
<%def name="get_feedback()">
<p><em> Participation in the hinting system is strictly optional, and will not influence your grade. </em></p>
<p>
Help your classmates by writing hints for this problem. Start by picking one of your previous incorrect answers from below:
</p>
<div id="answer-tabs">
<ul>
% for index, answer in index_to_answer.items():
<li><a href="#previous-answer-${index}"> ${answer} </a></li>
% endfor
</ul>
% for index, answer in index_to_answer.items():
<div class = "previous-answer" id="previous-answer-${index}">
<div class = "hint-inner-container">
% if index in index_to_hints and len(index_to_hints[index]) > 0:
<p>
Which hint would be most effective to show a student who also got ${answer}?
</p>
% for hint_text, hint_pk in index_to_hints[index]:
<p>
<input class="vote" data-answer="${index}" data-hintno="${hint_pk}" type="button" value="Vote"/>
${hint_text}
</p>
% endfor
<p>
Don't like any of the hints above? You can also submit your own.
</p>
% endif
<p>
What hint would you give a student who made the same mistake you did? Please don't give away the answer.
</p>
<textarea cols="50" class="custom-hint" id="custom-hint-${index}">
What would you say to help someone who got this wrong answer?
(Don't give away the answer, please.)
</textarea>
<br/><br/>
<input class="submit-hint" data-answer="${index}" type="button" value="submit">
</div></div>
% endfor
</div>
<p>Read about <a class="expand-goodhint" href="javascript:void(0);">what makes a good hint</a>.</p>
<div class="goodhint" style="display:none">
<h4>What makes a good hint?</h4>
<p>It depends on the type of problem you ran into. For stupid errors --
an arithmetic error or similar -- simply letting the student you'll be
helping to check their signs is sufficient.</p>
<p>For deeper errors of understanding, the best hints allow students to
discover a contradiction in how they are thinking about the
problem. An example that clearly demonstrates inconsistency or
<a href="http://en.wikipedia.org/wiki/Cognitive_dissonance" target="_blank"> cognitive dissonace </a>
is ideal, although in most cases, not possible.</p>
<p>
Good hints either:
<ul>
<li> Point out the specific misunderstanding your classmate might have </li>
<li> Point to concepts or theories where your classmates might have a
misunderstanding </li>
<li> Show simpler, analogous examples. </li>
<li> Provide references to relevant parts of the text </li>
</ul>
</p>
<p>Still, remember even a crude hint -- virtually anything short of
giving away the answer -- is better than no hint.</p>
<p>
<a href="http://www.apa.org/education/k12/misconceptions.aspx?item=2" target="_blank">Learn even more</a>
</p> </div>
</%def>
<%def name="show_votes()">
Thank you for voting!
<br />
% for hint, votes in hint_and_votes:
<span style="color:green"> ${votes} votes. </span>
${hint}
<br />
% endfor
</%def>
<%def name="simple_message()">
${message}
</%def>
% if op == "get_hint":
${get_hint()}
% endif
% if op == "get_feedback":
${get_feedback()}
% endif
% if op == "submit_hint":
${simple_message()}
% endif
% if op == "vote":
${show_votes()}
% endif
......@@ -64,6 +64,12 @@ You should be familiar with the following. If you're not, go read some docs...
from a Location object, and the ModuleSystem knows how to render things,
track events, and complain about 404s
- XModules and XModuleDescriptors are uniquely identified by a Location object, encoding the organization, course, category, name, and possibly revision of the module.
- XModule initialization: XModules are instantiated by the `XModuleDescriptor.xmodule` method, and given a ModuleSystem, the descriptor which instantiated it, and their relevant model data.
- XModuleDescriptor initialization: If an XModuleDescriptor is loaded from an XML-based course, the XML data is passed into its `from_xml` method, which is responsible for instantiating a descriptor with the correct attributes. If it's in Mongo, the descriptor is instantiated directly. The module's attributes will be present in the `model_data` dict.
- `course.xml` format. We use python setuptools to connect supported tags with the descriptors that handle them. See `common/lib/xmodule/setup.py`. There are checking and validation tools in `common/validate`.
- the xml import+export functionality is in `xml_module.py:XmlDescriptor`, which is a mixin class that's used by the actual descriptor classes.
......
......@@ -37,7 +37,7 @@ from courseware.access import has_access
from courseware.masquerade import setup_masquerade
from courseware.model_data import LmsKeyValueStore, LmsUsage, ModelDataCache
from courseware.models import StudentModule
from util.sandboxing import can_execute_unsafe_code
log = logging.getLogger(__name__)
......@@ -61,9 +61,9 @@ def make_track_function(request):
'''
import track.views
def f(event_type, event):
def function(event_type, event):
return track.views.server_track(request, event_type, event, page='x_module')
return f
return function
def toc_for_course(user, request, course, active_chapter, active_section, model_data_cache):
......@@ -171,9 +171,9 @@ def get_xqueue_callback_url_prefix(request):
should go back to the LMS, not to the worker.
"""
prefix = '{proto}://{host}'.format(
proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http'),
host=request.get_host()
)
proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http'),
host=request.get_host()
)
return settings.XQUEUE_INTERFACE.get('callback_url', prefix)
......@@ -313,14 +313,6 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
statsd.increment("lms.courseware.question_answered", tags=tags)
def can_execute_unsafe_code():
# To decide if we can run unsafe code, we check the course id against
# a list of regexes configured on the server.
for regex in settings.COURSES_WITH_UNSAFE_CODE:
if re.match(regex, course_id):
return True
return False
# TODO (cpennington): When modules are shared between courses, the static
# prefix is going to have to be specific to the module, not the directory
# that the xml was loaded from
......@@ -348,7 +340,7 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours
open_ended_grading_interface=open_ended_grading_interface,
s3_interface=s3_interface,
cache=cache,
can_execute_unsafe_code=can_execute_unsafe_code,
can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
)
# pass position specified in URL to module through ModuleSystem
system.set('position', position)
......
......@@ -32,13 +32,11 @@ class BaseTestXmodule(ModuleStoreTestCase):
1. TEMPLATE_NAME
2. DATA
3. MODEL_DATA
4. COURSE_DATA and USER_COUNT if needed
This class should not contain any tests, because TEMPLATE_NAME
should be defined in child class.
"""
USER_COUNT = 2
COURSE_DATA = {}
# Data from YAML common/lib/xmodule/xmodule/templates/NAME/default.yaml
TEMPLATE_NAME = ""
......@@ -47,7 +45,7 @@ class BaseTestXmodule(ModuleStoreTestCase):
def setUp(self):
self.course = CourseFactory.create(data=self.COURSE_DATA)
self.course = CourseFactory.create()
# Turn off cache.
modulestore().request_cache = None
......
from uuid import uuid4
from xmodule.modulestore.tests.django_utils import xml_store_config, mongo_store_config, draft_mongo_store_config
from django.conf import settings
def mongo_store_config(data_dir):
"""
Defines default module store using MongoModuleStore.
Use of this config requires mongo to be running.
"""
store = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'test_xmodule',
'collection': 'modulestore',
'fs_root': data_dir,
'render_template': 'mitxmako.shortcuts.render_to_string'
}
}
}
store['direct'] = store['default']
return store
def draft_mongo_store_config(data_dir):
"""
Defines default module store using DraftMongoModuleStore.
"""
modulestore_options = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'xmodule',
'collection': 'modulestore_%s' % uuid4().hex,
'fs_root': data_dir,
'render_template': 'mitxmako.shortcuts.render_to_string'
}
return {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'OPTIONS': modulestore_options
},
'direct': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': modulestore_options
}
}
def xml_store_config(data_dir):
"""
Defines default module store using XMLModuleStore.
"""
return {
'default': {
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
'OPTIONS': {
'data_dir': data_dir,
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
}
}
}
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR)
......
......@@ -15,7 +15,7 @@ from django.core.urlresolvers import reverse
from django.contrib.auth.models import Group, User
from courseware.access import _course_staff_group_name
from courseware.tests.helpers import LoginEnrollmentTestCase
from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE
from modulestore_config import TEST_DATA_XML_MODULESTORE
from xmodule.modulestore.django import modulestore
import xmodule.modulestore.django
import json
......
......@@ -12,6 +12,7 @@ from xmodule.modulestore.django import modulestore
import courseware.module_render as render
from courseware.tests.tests import LoginEnrollmentTestCase
from courseware.model_data import ModelDataCache
from modulestore_config import TEST_DATA_XML_MODULESTORE
from .factories import UserFactory
......@@ -21,21 +22,6 @@ class Stub:
pass
def xml_store_config(data_dir):
return {
'default': {
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
'OPTIONS': {
'data_dir': data_dir,
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
}
}
}
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class ModuleRenderTestCase(LoginEnrollmentTestCase):
def setUp(self):
......
......@@ -3,7 +3,6 @@ import datetime
from django.test import TestCase
from django.http import Http404
from django.conf import settings
from django.test.utils import override_settings
from django.contrib.auth.models import User
from django.test.client import RequestFactory
......@@ -14,28 +13,13 @@ from xmodule.modulestore.django import modulestore
import courseware.views as views
from xmodule.modulestore import Location
from pytz import UTC
from modulestore_config import TEST_DATA_XML_MODULESTORE
class Stub():
pass
# This part is required for modulestore() to work properly
def xml_store_config(data_dir):
return {
'default': {
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
'OPTIONS': {
'data_dir': data_dir,
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
}
}
}
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestJumpTo(TestCase):
"""Check the jumpto link for a course"""
......@@ -67,8 +51,8 @@ class ViewsTestCase(TestCase):
self.date = datetime.datetime(2013, 1, 22, tzinfo=UTC)
self.course_id = 'edX/toy/2012_Fall'
self.enrollment = CourseEnrollment.objects.get_or_create(user=self.user,
course_id=self.course_id,
created=self.date)[0]
course_id=self.course_id,
created=self.date)[0]
self.location = ['tag', 'org', 'course', 'category', 'name']
self._MODULESTORES = {}
# This is a CourseDescriptor object
......
......@@ -16,7 +16,10 @@ from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.xml import XMLModuleStore
from helpers import LoginEnrollmentTestCase
from modulestore_config import TEST_DATA_DIR, TEST_DATA_XML_MODULESTORE, TEST_DATA_MONGO_MODULESTORE, TEST_DATA_DRAFT_MONGO_MODULESTORE
from modulestore_config import TEST_DATA_DIR,\
TEST_DATA_XML_MODULESTORE,\
TEST_DATA_MONGO_MODULESTORE,\
TEST_DATA_DRAFT_MONGO_MODULESTORE
class ActivateLoginTest(LoginEnrollmentTestCase):
......
from django.test.utils import override_settings
from django.test.client import Client
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from django.core.urlresolvers import reverse
from util.testing import UrlResetMixin
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from nose.tools import assert_true
from mock import patch, Mock
import logging
log = logging.getLogger(__name__)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class ViewsExceptionTestCase(UrlResetMixin, ModuleStoreTestCase):
@patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
# Patching the ENABLE_DISCUSSION_SERVICE value affects the contents of urls.py,
# so we need to call super.setUp() which reloads urls.py (because
# of the UrlResetMixin)
super(ViewsExceptionTestCase, self).setUp()
# create a course
self.course = CourseFactory.create(org='MITx', course='999',
display_name='Robot Super Course')
# Patch the comment client user save method so it does not try
# to create a new cc user when creating a django user
with patch('student.models.cc.User.save'):
uname = 'student'
email = 'student@edx.org'
password = 'test'
# Create the student
self.student = UserFactory(username=uname, password=password, email=email)
# Enroll the student in the course
CourseEnrollmentFactory(user=self.student, course_id=self.course.id)
# Log the student in
self.client = Client()
assert_true(self.client.login(username=uname, password=password))
@patch('student.models.cc.User.from_django_user')
@patch('student.models.cc.User.active_threads')
def test_user_profile_exception(self, mock_threads, mock_from_django_user):
# Mock the code that makes the HTTP requests to the cs_comment_service app
# for the profiled user's active threads
mock_threads.return_value = [], 1, 1
# Mock the code that makes the HTTP request to the cs_comment_service app
# that gets the current user's info
mock_from_django_user.return_value = Mock()
url = reverse('django_comment_client.forum.views.user_profile',
kwargs={'course_id': self.course.id, 'user_id': '12345'}) # There is no user 12345
self.response = self.client.get(url)
self.assertEqual(self.response.status_code, 404)
@patch('student.models.cc.User.from_django_user')
@patch('student.models.cc.User.active_threads')
def test_user_followed_threads_exception(self, mock_threads, mock_from_django_user):
# Mock the code that makes the HTTP requests to the cs_comment_service app
# for the profiled user's active threads
mock_threads.return_value = [], 1, 1
# Mock the code that makes the HTTP request to the cs_comment_service app
# that gets the current user's info
mock_from_django_user.return_value = Mock()
url = reverse('django_comment_client.forum.views.followed_threads',
kwargs={'course_id': self.course.id, 'user_id': '12345'}) # There is no user 12345
self.response = self.client.get(url)
self.assertEqual(self.response.status_code, 404)
......@@ -114,7 +114,7 @@ def inline_discussion(request, course_id, discussion_id):
threads, query_params = get_threads(request, course_id, discussion_id, per_page=INLINE_THREADS_PER_PAGE)
cc_user = cc.User.from_django_user(request.user)
user_info = cc_user.to_dict()
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError):
# TODO (vshnayder): since none of this code seems to be aware of the fact that
# sometimes things go wrong, I suspect that the js client is also not
# checking for errors on request. Check and fix as needed.
......@@ -141,8 +141,8 @@ def inline_discussion(request, course_id, discussion_id):
if is_moderator:
cohorts = get_course_cohorts(course_id)
for c in cohorts:
cohorts_list.append({'name': c.name, 'id': c.id})
for cohort in cohorts:
cohorts_list.append({'name': cohort.name, 'id': cohort.id})
else:
#students don't get to choose
......@@ -174,11 +174,11 @@ def forum_form_discussion(request, course_id):
try:
unsafethreads, query_params = get_threads(request, course_id) # This might process a search query
threads = [utils.safe_content(thread) for thread in unsafethreads]
except (cc.utils.CommentClientMaintenanceError) as err:
except cc.utils.CommentClientMaintenanceError:
log.warning("Forum is in maintenance mode")
return render_to_response('discussion/maintenance.html', {})
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
log.error("Error loading forum discussion threads: %s" % str(err))
log.error("Error loading forum discussion threads: %s", str(err))
raise Http404
user = cc.User.from_django_user(request.user)
......@@ -244,7 +244,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
try:
thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError):
log.error("Error loading single thread.")
raise Http404
......@@ -269,7 +269,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
try:
threads, query_params = get_threads(request, course_id)
threads.append(thread.to_dict())
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError):
log.error("Error loading single thread.")
raise Http404
......@@ -369,7 +369,7 @@ def user_profile(request, course_id, user_id):
}
return render_to_response('discussion/user_profile.html', context)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError, User.DoesNotExist):
raise Http404
......@@ -412,5 +412,5 @@ def followed_threads(request, course_id, user_id):
}
return render_to_response('discussion/user_profile.html', context)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError):
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError, User.DoesNotExist):
raise Http404
"""
Views for hint management.
Along with the crowdsource_hinter xmodule, this code is still
experimental, and should not be used in new courses, yet.
"""
import json
import re
from django.http import HttpResponse, Http404
from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response, render_to_string
from courseware.courses import get_course_with_access
from courseware.models import XModuleContentField
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
@ensure_csrf_cookie
def hint_manager(request, course_id):
try:
get_course_with_access(request.user, course_id, 'staff', depth=None)
except Http404:
out = 'Sorry, but students are not allowed to access the hint manager!'
return HttpResponse(out)
if request.method == 'GET':
out = get_hints(request, course_id, 'mod_queue')
return render_to_response('courseware/hint_manager.html', out)
field = request.POST['field']
if not (field == 'mod_queue' or field == 'hints'):
# Invalid field. (Don't let users continue - they may overwrite other db's)
out = 'Error in hint manager - an invalid field was accessed.'
return HttpResponse(out)
if request.POST['op'] == 'delete hints':
delete_hints(request, course_id, field)
if request.POST['op'] == 'switch fields':
pass
if request.POST['op'] == 'change votes':
change_votes(request, course_id, field)
if request.POST['op'] == 'add hint':
add_hint(request, course_id, field)
if request.POST['op'] == 'approve':
approve(request, course_id, field)
rendered_html = render_to_string('courseware/hint_manager_inner.html', get_hints(request, course_id, field))
return HttpResponse(json.dumps({'success': True, 'contents': rendered_html}))
def get_hints(request, course_id, field):
"""
Load all of the hints submitted to the course.
Args:
`request` -- Django request object.
`course_id` -- The course id, like 'Me/19.002/test_course'
`field` -- Either 'hints' or 'mod_queue'; specifies which set of hints to load.
Keys in returned dict:
- 'field': Same as input
- 'other_field': 'mod_queue' if `field` == 'hints'; and vice-versa.
- 'field_label', 'other_field_label': English name for the above.
- 'all_hints': A list of [answer, pk dict] pairs, representing all hints.
Sorted by answer.
- 'id_to_name': A dictionary mapping problem id to problem name.
"""
if field == 'mod_queue':
other_field = 'hints'
field_label = 'Hints Awaiting Moderation'
other_field_label = 'Approved Hints'
elif field == 'hints':
other_field = 'mod_queue'
field_label = 'Approved Hints'
other_field_label = 'Hints Awaiting Moderation'
# The course_id is of the form school/number/classname.
# We want to use the course_id to find all matching definition_id's.
# To do this, just take the school/number part - leave off the classname.
chopped_id = '/'.join(course_id.split('/')[:-1])
chopped_id = re.escape(chopped_id)
all_hints = XModuleContentField.objects.filter(field_name=field, definition_id__regex=chopped_id)
# big_out_dict[problem id] = [[answer, {pk: [hint, votes]}], sorted by answer]
# big_out_dict maps a problem id to a list of [answer, hints] pairs, sorted in order of answer.
big_out_dict = {}
# id_to name maps a problem id to the name of the problem.
# id_to_name[problem id] = Display name of problem
id_to_name = {}
for hints_by_problem in all_hints:
loc = Location(hints_by_problem.definition_id)
name = location_to_problem_name(loc)
if name is None:
continue
id_to_name[hints_by_problem.definition_id] = name
def answer_sorter(thing):
"""
`thing` is a tuple, where `thing[0]` contains an answer, and `thing[1]` contains
a dict of hints. This function returns an index based on `thing[0]`, which
is used as a key to sort the list of things.
"""
try:
return float(thing[0])
except ValueError:
# Put all non-numerical answers first.
return float('-inf')
# Answer list contains [answer, dict_of_hints] pairs.
answer_list = sorted(json.loads(hints_by_problem.value).items(), key=answer_sorter)
big_out_dict[hints_by_problem.definition_id] = answer_list
render_dict = {'field': field,
'other_field': other_field,
'field_label': field_label,
'other_field_label': other_field_label,
'all_hints': big_out_dict,
'id_to_name': id_to_name}
return render_dict
def location_to_problem_name(loc):
"""
Given the location of a crowdsource_hinter module, try to return the name of the
problem it wraps around. Return None if the hinter no longer exists.
"""
try:
descriptor = modulestore().get_items(loc)[0]
return descriptor.get_children()[0].display_name
except IndexError:
# Sometimes, the problem is no longer in the course. Just
# don't include said problem.
return None
def delete_hints(request, course_id, field):
"""
Deletes the hints specified.
`request.POST` contains some fields keyed by integers. Each such field contains a
[problem_defn_id, answer, pk] tuple. These tuples specify the hints to be deleted.
Example `request.POST`:
{'op': 'delete_hints',
'field': 'mod_queue',
1: ['problem_whatever', '42.0', '3'],
2: ['problem_whatever', '32.5', '12']}
"""
for key in request.POST:
if key == 'op' or key == 'field':
continue
problem_id, answer, pk = request.POST.getlist(key)
# Can be optimized - sort the delete list by problem_id, and load each problem
# from the database only once.
this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id)
problem_dict = json.loads(this_problem.value)
del problem_dict[answer][pk]
this_problem.value = json.dumps(problem_dict)
this_problem.save()
def change_votes(request, course_id, field):
"""
Updates the number of votes.
The numbered fields of `request.POST` contain [problem_id, answer, pk, new_votes] tuples.
- Very similar to `delete_hints`. Is there a way to merge them? Nah, too complicated.
"""
for key in request.POST:
if key == 'op' or key == 'field':
continue
problem_id, answer, pk, new_votes = request.POST.getlist(key)
this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id)
problem_dict = json.loads(this_problem.value)
# problem_dict[answer][pk] points to a [hint_text, #votes] pair.
problem_dict[answer][pk][1] = int(new_votes)
this_problem.value = json.dumps(problem_dict)
this_problem.save()
def add_hint(request, course_id, field):
"""
Add a new hint. `request.POST`:
op
field
problem - The problem id
answer - The answer to which a hint will be added
hint - The text of the hint
"""
problem_id = request.POST['problem']
answer = request.POST['answer']
hint_text = request.POST['hint']
this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id)
hint_pk_entry = XModuleContentField.objects.get(field_name='hint_pk', definition_id=problem_id)
this_pk = int(hint_pk_entry.value)
hint_pk_entry.value = this_pk + 1
hint_pk_entry.save()
problem_dict = json.loads(this_problem.value)
if answer not in problem_dict:
problem_dict[answer] = {}
problem_dict[answer][this_pk] = [hint_text, 1]
this_problem.value = json.dumps(problem_dict)
this_problem.save()
def approve(request, course_id, field):
"""
Approve a list of hints, moving them from the mod_queue to the real
hint list. POST:
op, field
(some number) -> [problem, answer, pk]
"""
for key in request.POST:
if key == 'op' or key == 'field':
continue
problem_id, answer, pk = request.POST.getlist(key)
# Can be optimized - sort the delete list by problem_id, and load each problem
# from the database only once.
problem_in_mod = XModuleContentField.objects.get(field_name=field, definition_id=problem_id)
problem_dict = json.loads(problem_in_mod.value)
hint_to_move = problem_dict[answer][pk]
del problem_dict[answer][pk]
problem_in_mod.value = json.dumps(problem_dict)
problem_in_mod.save()
problem_in_hints = XModuleContentField.objects.get(field_name='hints', definition_id=problem_id)
problem_dict = json.loads(problem_in_hints.value)
if answer not in problem_dict:
problem_dict[answer] = {}
problem_dict[answer][pk] = hint_to_move
problem_in_hints.value = json.dumps(problem_dict)
problem_in_hints.save()
......@@ -6,14 +6,11 @@ Unit tests for enrollment methods in views.py
from django.test.utils import override_settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from courseware.access import _course_staff_group_name
from courseware.tests.helpers import LoginEnrollmentTestCase
from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE
from xmodule.modulestore.django import modulestore
from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE, LoginEnrollmentTestCase
from student.models import CourseEnrollment, CourseEnrollmentAllowed
from instructor.views import get_and_clean_student_list, send_mail_to_student
from django.core import mail
......
......@@ -27,11 +27,11 @@ class TestGradebook(ModuleStoreTestCase):
modulestore().request_cache = modulestore().metadata_inheritance_cache_subsystem = None
course_data = {}
kwargs = {}
if self.grading_policy is not None:
course_data['grading_policy'] = self.grading_policy
kwargs['grading_policy'] = self.grading_policy
self.course = CourseFactory.create(data=course_data)
self.course = CourseFactory.create(**kwargs)
chapter = ItemFactory.create(
parent_location=self.course.location,
template="i4x://edx/templates/sequential/Empty",
......
import json
from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
from courseware.models import XModuleContentField
from courseware.tests.factories import ContentFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
import instructor.hint_manager as view
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class HintManagerTest(ModuleStoreTestCase):
def setUp(self):
"""
Makes a course, which will be the same for all tests.
Set up mako middleware, which is necessary for template rendering to happen.
"""
self.course = CourseFactory.create(org='Me', number='19.002', display_name='test_course')
self.url = '/courses/Me/19.002/test_course/hint_manager'
self.user = UserFactory.create(username='robot', email='robot@edx.org', password='test', is_staff=True)
self.c = Client()
self.c.login(username='robot', password='test')
self.problem_id = 'i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_001'
self.course_id = 'Me/19.002/test_course'
ContentFactory.create(field_name='hints',
definition_id=self.problem_id,
value=json.dumps({'1.0': {'1': ['Hint 1', 2],
'3': ['Hint 3', 12]},
'2.0': {'4': ['Hint 4', 3]}
}))
ContentFactory.create(field_name='mod_queue',
definition_id=self.problem_id,
value=json.dumps({'2.0': {'2': ['Hint 2', 1]}}))
ContentFactory.create(field_name='hint_pk',
definition_id=self.problem_id,
value=5)
# Mock out location_to_problem_name, which ordinarily accesses the modulestore.
# (I can't figure out how to get fake structures into the modulestore.)
view.location_to_problem_name = lambda loc: "Test problem"
def test_student_block(self):
"""
Makes sure that students cannot see the hint management view.
"""
c = Client()
UserFactory.create(username='student', email='student@edx.org', password='test')
c.login(username='student', password='test')
out = c.get(self.url)
print out
self.assertTrue('Sorry, but students are not allowed to access the hint manager!' in out.content)
def test_staff_access(self):
"""
Makes sure that staff can access the hint management view.
"""
out = self.c.get('/courses/Me/19.002/test_course/hint_manager')
print out
self.assertTrue('Hints Awaiting Moderation' in out.content)
def test_invalid_field_access(self):
"""
Makes sure that field names other than 'mod_queue' and 'hints' are
rejected.
"""
out = self.c.post(self.url, {'op': 'delete hints', 'field': 'all your private data'})
print out
self.assertTrue('an invalid field was accessed' in out.content)
def test_switchfields(self):
"""
Checks that the op: 'switch fields' POST request works.
"""
out = self.c.post(self.url, {'op': 'switch fields', 'field': 'mod_queue'})
print out
self.assertTrue('Hint 2' in out.content)
def test_gethints(self):
"""
Checks that gethints returns the right data.
"""
request = RequestFactory()
post = request.post(self.url, {'field': 'mod_queue'})
out = view.get_hints(post, self.course_id, 'mod_queue')
print out
self.assertTrue(out['other_field'] == 'hints')
expected = {self.problem_id: [(u'2.0', {u'2': [u'Hint 2', 1]})]}
self.assertTrue(out['all_hints'] == expected)
def test_gethints_other(self):
"""
Same as above, with hints instead of mod_queue
"""
request = RequestFactory()
post = request.post(self.url, {'field': 'hints'})
out = view.get_hints(post, self.course_id, 'hints')
print out
self.assertTrue(out['other_field'] == 'mod_queue')
expected = {self.problem_id: [('1.0', {'1': ['Hint 1', 2],
'3': ['Hint 3', 12]}),
('2.0', {'4': ['Hint 4', 3]})
]}
self.assertTrue(out['all_hints'] == expected)
def test_deletehints(self):
"""
Checks that delete_hints deletes the right stuff.
"""
request = RequestFactory()
post = request.post(self.url, {'field': 'hints',
'op': 'delete hints',
1: [self.problem_id, '1.0', '1']})
view.delete_hints(post, self.course_id, 'hints')
problem_hints = XModuleContentField.objects.get(field_name='hints', definition_id=self.problem_id).value
self.assertTrue('1' not in json.loads(problem_hints)['1.0'])
def test_changevotes(self):
"""
Checks that vote changing works.
"""
request = RequestFactory()
post = request.post(self.url, {'field': 'hints',
'op': 'change votes',
1: [self.problem_id, '1.0', '1', 5]})
view.change_votes(post, self.course_id, 'hints')
problem_hints = XModuleContentField.objects.get(field_name='hints', definition_id=self.problem_id).value
# hints[answer][hint_pk (string)] = [hint text, vote count]
print json.loads(problem_hints)['1.0']['1']
self.assertTrue(json.loads(problem_hints)['1.0']['1'][1] == 5)
def test_addhint(self):
"""
Check that instructors can add new hints.
"""
request = RequestFactory()
post = request.post(self.url, {'field': 'mod_queue',
'op': 'add hint',
'problem': self.problem_id,
'answer': '3.14',
'hint': 'This is a new hint.'})
view.add_hint(post, self.course_id, 'mod_queue')
problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value
self.assertTrue('3.14' in json.loads(problem_hints))
def test_approve(self):
"""
Check that instructors can approve hints. (Move them
from the mod_queue to the hints.)
"""
request = RequestFactory()
post = request.post(self.url, {'field': 'mod_queue',
'op': 'approve',
1: [self.problem_id, '2.0', '2']})
view.approve(post, self.course_id, 'mod_queue')
problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value
self.assertTrue('2.0' not in json.loads(problem_hints) or len(json.loads(problem_hints)['2.0']) == 0)
problem_hints = XModuleContentField.objects.get(field_name='hints', definition_id=self.problem_id).value
self.assertTrue(json.loads(problem_hints)['2.0']['2'] == ['Hint 2', 1])
self.assertTrue(len(json.loads(problem_hints)['2.0']) == 2)
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