bringing up to master

This commit is contained in:
Juho Kim
2013-06-21 17:39:38 -04:00
756 changed files with 73574 additions and 9502 deletions

View File

@@ -6,6 +6,7 @@ from django_future.csrf import ensure_csrf_cookie
import student.views
import branding
import courseware.views
from mitxmako.shortcuts import marketing_link
from util.cache import cache_if_anonymous
@@ -22,6 +23,8 @@ def index(request):
if settings.MITX_FEATURES.get('AUTH_USE_MIT_CERTIFICATES'):
from external_auth.views import ssl_login
return ssl_login(request)
if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE'):
return redirect(settings.MKTG_URLS.get('ROOT'))
university = branding.get_university(request.META.get('HTTP_HOST'))
if university is None:
@@ -34,9 +37,12 @@ def index(request):
@cache_if_anonymous
def courses(request):
"""
Render the "find courses" page. If subdomain branding is on, this is the
university profile page, otherwise it's the edX courseware.views.courses page
Render the "find courses" page. If the marketing site is enabled, redirect
to that. Otherwise, if subdomain branding is on, this is the university
profile page. Otherwise, it's the edX courseware.views.courses page
"""
if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False):
return redirect(marketing_link('COURSES'), permanent=True)
university = branding.get_university(request.META.get('HTTP_HOST'))
if university is None:

View File

@@ -16,6 +16,7 @@ from xmodule.x_module import XModule, XModuleDescriptor
from student.models import CourseEnrollmentAllowed
from courseware.masquerade import is_masquerading_as_student
from django.utils.timezone import UTC
DEBUG_ACCESS = False
@@ -133,7 +134,7 @@ def _has_access_course_desc(user, course, action):
(staff can always enroll)
"""
now = time.gmtime()
now = datetime.now(UTC())
start = course.enrollment_start
end = course.enrollment_end
@@ -242,7 +243,7 @@ def _has_access_descriptor(user, descriptor, action, course_context=None):
# Check start date
if descriptor.lms.start is not None:
now = time.gmtime()
now = datetime.now(UTC())
effective_start = _adjust_start_date_for_beta_testers(user, descriptor)
if now > effective_start:
# after start date, everyone can see it
@@ -365,7 +366,7 @@ def _course_org_staff_group_name(location, course_context=None):
def group_names_for(role, location, course_context=None):
"""Returns the group names for a given role with this location. Plural
"""Returns the group names for a given role with this location. Plural
because it will return both the name we expect now as well as the legacy
group name we support for backwards compatibility. This should not check
the DB for existence of a group (like some of its callers do) because that's
@@ -483,8 +484,7 @@ def _adjust_start_date_for_beta_testers(user, descriptor):
non-None start date.
Returns:
A time, in the same format as returned by time.gmtime(). Either the same as
start, or earlier for beta testers.
A datetime. Either the same as start, or earlier for beta testers.
NOTE: number of days to adjust should be cached to avoid looking it up thousands of
times per query.
@@ -505,15 +505,11 @@ def _adjust_start_date_for_beta_testers(user, descriptor):
beta_group = course_beta_test_group_name(descriptor.location)
if beta_group in user_groups:
debug("Adjust start time: user in group %s", beta_group)
# time_structs don't support subtraction, so convert to datetimes,
# subtract, convert back.
# (fun fact: datetime(*a_time_struct[:6]) is the beautiful syntax for
# converting time_structs into datetimes)
start_as_datetime = datetime(*descriptor.lms.start[:6])
start_as_datetime = descriptor.lms.start
delta = timedelta(descriptor.lms.days_early_for_beta)
effective = start_as_datetime - delta
# ...and back to time_struct
return effective.timetuple()
return effective
return descriptor.lms.start
@@ -564,7 +560,7 @@ def _has_access_to_location(user, location, access_level, course_context):
return True
debug("Deny: user not in groups %s", staff_groups)
if access_level == 'instructor' or access_level == 'staff': # instructors get staff privileges
if access_level == 'instructor' or access_level == 'staff': # instructors get staff privileges
instructor_groups = group_names_for_instructor(location, course_context) + \
[_course_org_instructor_group_name(location, course_context)]
for instructor_group in instructor_groups:

View File

@@ -20,7 +20,7 @@ logger = getLogger(__name__)
TEST_COURSE_ORG = 'edx'
TEST_COURSE_NAME = 'Test Course'
TEST_SECTION_NAME = "Problem"
TEST_SECTION_NAME = 'Test Section'
@step(u'The course "([^"]*)" exists$')

View File

@@ -12,7 +12,6 @@ def i_click_on_view_courseware(step):
@step('I click on the "([^"]*)" tab$')
def i_click_on_the_tab(step, tab_text):
world.click_link(tab_text)
world.save_the_html()
@step('I visit the courseware URL$')
@@ -20,11 +19,6 @@ def i_visit_the_course_info_url(step):
world.visit('/courses/MITx/6.002x/2012_Fall/courseware')
@step(u'I do not see "([^"]*)" anywhere on the page')
def i_do_not_see_text_anywhere_on_the_page(step, text):
assert world.browser.is_text_not_present(text)
@step(u'I am on the dashboard page$')
def i_am_on_the_dashboard_page(step):
assert world.is_css_present('section.courses')

View File

@@ -8,10 +8,7 @@ Scenario: I can navigate to all high - level tabs in a course
And The course "6.002x" has extra tab "Custom Tab"
And I am logged in
And I click on View Courseware
When I click on the "<TabName>" tab
Then the page title should contain "<PageTitle>"
Examples:
When I click on the tabs then the page title should contain the following titles:
| TabName | PageTitle |
| Courseware | 6.002x Courseware |
| Course Info | 6.002x Course Info |

View File

@@ -0,0 +1,11 @@
from lettuce import world, step
from nose.tools import assert_equals
@step(u'I click on the tabs then the page title should contain the following titles:')
def i_click_on_the_tab_and_check(step):
for tab_title in step.hashes:
tab_text = tab_title['TabName']
title = tab_title['PageTitle']
world.click_link(tab_text)
assert(title in world.browser.title)

View File

@@ -5,36 +5,27 @@ Feature: Homepage for web users
Scenario: User can see the "Login" button
Given I visit the homepage
Then I should see a link called "Log In"
Then I should see a link called "Log in"
Scenario: User can see the "Sign up" button
Scenario: User can see the "Register Now" button
Given I visit the homepage
Then I should see a link called "Sign Up"
Then I should see a link called "Register Now"
Scenario Outline: User can see main parts of the page
Given I visit the homepage
Then I should see a link called "<Link>"
When I click the link with the text "<Link>"
Then I should see that the path is "<Path>"
Then I should see the following links and ids
| id | Link |
| about | About |
| jobs | Jobs |
| faq | FAQ |
| contact | Contact|
| press | Press |
Examples:
| Link | Path |
| Find Courses | /courses |
| About | /about |
| Jobs | /jobs |
| Contact | /contact |
Scenario: User can visit the blog
Given I visit the homepage
When I click the link with the text "Blog"
Then I should see that the url is "http://blog.edx.org/"
# TODO: test according to domain or policy
Scenario: User can see the partner institutions
Given I visit the homepage
Then I should see "<Partner>" in the Partners section
Examples:
Then I should see the following Partners in the Partners section
| Partner |
| MITx |
| HarvardX |

View File

@@ -2,11 +2,22 @@
#pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_in
from nose.tools import assert_in, assert_equals
@step('I should see "([^"]*)" in the Partners section$')
def i_should_see_partner(step, partner):
@step(u'I should see the following Partners in the Partners section')
def i_should_see_partner(step):
partners = world.browser.find_by_css(".partner .name span")
names = set(span.text for span in partners)
assert_in(partner, names)
for partner in step.hashes:
assert_in(partner['Partner'], names)
@step(u'I should see the following links and ids')
def should_see_a_link_called(step):
for link_id_pair in step.hashes:
link_id = link_id_pair['id']
text = link_id_pair['Link']
link = world.browser.find_by_id(link_id)
assert len(link) > 0
assert_equals(link.text, text)

View File

@@ -7,7 +7,7 @@ Feature: Login in as a registered user
Given I am an edX user
And I am an unactivated user
And I visit the homepage
When I click the link with the text "Log In"
When I click the link with the text "Log in"
And I submit my credentials on the login form
Then I should see the login error message "This account has not been activated"
@@ -15,7 +15,7 @@ Feature: Login in as a registered user
Given I am an edX user
And I am an activated user
And I visit the homepage
When I click the link with the text "Log In"
When I click the link with the text "Log in"
And I submit my credentials on the login form
Then I should be on the dashboard page
@@ -23,5 +23,5 @@ Feature: Login in as a registered user
Given I am logged in
When I click the dropdown arrow
And I click the link with the text "Log Out"
Then I should see a link with the text "Log In"
Then I should see a link with the text "Log in"
And I should see that the path is "/"

View File

@@ -19,13 +19,13 @@ def i_am_an_activated_user(step):
def i_submit_my_credentials_on_the_login_form(step):
fill_in_the_login_form('email', 'robot@edx.org')
fill_in_the_login_form('password', 'test')
login_form = world.browser.find_by_css('form#login_form')
login_form.find_by_value('Access My Courses').click()
login_form = world.browser.find_by_css('form#login-form')
login_form.find_by_name('submit').click()
@step(u'I should see the login error message "([^"]*)"$')
def i_should_see_the_login_error_message(step, msg):
login_error_div = world.browser.find_by_css('form#login_form #login_error')
login_error_div = world.browser.find_by_css('.submission-error.is-shown')
assert (msg in login_error_div.text)
@@ -49,6 +49,6 @@ def user_is_an_activated_user(uname):
def fill_in_the_login_form(field, value):
login_form = world.browser.find_by_css('form#login_form')
login_form = world.browser.find_by_css('form#login-form')
form_field = login_form.find_by_name(field)
form_field.fill(value)

View File

@@ -84,3 +84,38 @@ Feature: Answer problems
| formula | incorrect |
| script | correct |
| script | incorrect |
Scenario: I can answer a problem with one attempt correctly and not reset
Given I am viewing a "multiple choice" problem with "1" attempt
When I answer a "multiple choice" problem "correctly"
Then The "Reset" button does not appear
Scenario: I can answer a problem with multiple attempts correctly and still reset the problem
Given I am viewing a "multiple choice" problem with "3" attempts
Then I should see "You have used 0 of 3 submissions" somewhere in the page
When I answer a "multiple choice" problem "correctly"
Then The "Reset" button does appear
Scenario: I can view how many attempts I have left on a problem
Given I am viewing a "multiple choice" problem with "3" attempts
Then I should see "You have used 0 of 3 submissions" somewhere in the page
When I answer a "multiple choice" problem "incorrectly"
And I reset the problem
Then I should see "You have used 1 of 3 submissions" somewhere in the page
When I answer a "multiple choice" problem "incorrectly"
And I reset the problem
Then I should see "You have used 2 of 3 submissions" somewhere in the page
And The "Final Check" button does appear
When I answer a "multiple choice" problem "correctly"
Then The "Reset" button does not appear
Scenario: I can view and hide the answer if the problem has it:
Given I am viewing a "numerical" that shows the answer "always"
When I press the button with the label "Show Answer(s)"
Then the button with the label "Hide Answer(s)" does appear
And the button with the label "Show Answer(s)" does not appear
And I should see "4.14159" somewhere in the page
When I press the button with the label "Hide Answer(s)"
Then the button with the label "Show Answer(s)" does appear
And I should not see "4.14159" anywhere on the page

View File

@@ -7,119 +7,42 @@ Steps for problem.feature lettuce tests
from lettuce import world, step
from lettuce.django import django_url
import random
import textwrap
from common import i_am_registered_for_the_course, \
TEST_SECTION_NAME, section_location
from capa.tests.response_xml_factory import OptionResponseXMLFactory, \
ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \
StringResponseXMLFactory, NumericalResponseXMLFactory, \
FormulaResponseXMLFactory, CustomResponseXMLFactory, \
CodeResponseXMLFactory
from common import i_am_registered_for_the_course, TEST_SECTION_NAME
from problems_setup import PROBLEM_DICT, answer_problem, problem_has_answer, add_problem_to_course
from nose.tools import assert_equal, assert_not_equal
# Factories from capa.tests.response_xml_factory that we will use
# to generate the problem XML, with the keyword args used to configure
# the output.
PROBLEM_FACTORY_DICT = {
'drop down': {
'factory': OptionResponseXMLFactory(),
'kwargs': {
'question_text': 'The correct answer is Option 2',
'options': ['Option 1', 'Option 2', 'Option 3', 'Option 4'],
'correct_option': 'Option 2'}},
@step(u'I am viewing a "([^"]*)" problem with "([^"]*)" attempt')
def view_problem_with_attempts(step, problem_type, attempts):
i_am_registered_for_the_course(step, 'model_course')
'multiple choice': {
'factory': MultipleChoiceResponseXMLFactory(),
'kwargs': {
'question_text': 'The correct answer is Choice 3',
'choices': [False, False, True, False],
'choice_names': ['choice_0', 'choice_1', 'choice_2', 'choice_3']}},
# Ensure that the course has this problem type
add_problem_to_course('model_course', problem_type, {'attempts': attempts})
'checkbox': {
'factory': ChoiceResponseXMLFactory(),
'kwargs': {
'question_text': 'The correct answer is Choices 1 and 3',
'choice_type': 'checkbox',
'choices': [True, False, True, False, False],
'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}},
'radio': {
'factory': ChoiceResponseXMLFactory(),
'kwargs': {
'question_text': 'The correct answer is Choice 3',
'choice_type': 'radio',
'choices': [False, False, True, False],
'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}},
'string': {
'factory': StringResponseXMLFactory(),
'kwargs': {
'question_text': 'The answer is "correct string"',
'case_sensitive': False,
'answer': 'correct string'}},
# Go to the one section in the factory-created course
# which should be loaded with the correct problem
chapter_name = TEST_SECTION_NAME.replace(" ", "_")
section_name = chapter_name
url = django_url('/courses/edx/model_course/Test_Course/courseware/%s/%s' %
(chapter_name, section_name))
'numerical': {
'factory': NumericalResponseXMLFactory(),
'kwargs': {
'question_text': 'The answer is pi + 1',
'answer': '4.14159',
'tolerance': '0.00001',
'math_display': True}},
'formula': {
'factory': FormulaResponseXMLFactory(),
'kwargs': {
'question_text': 'The solution is [mathjax]x^2+2x+y[/mathjax]',
'sample_dict': {'x': (-100, 100), 'y': (-100, 100)},
'num_samples': 10,
'tolerance': 0.00001,
'math_display': True,
'answer': 'x^2+2*x+y'}},
'script': {
'factory': CustomResponseXMLFactory(),
'kwargs': {
'question_text': 'Enter two integers that sum to 10.',
'cfn': 'test_add_to_ten',
'expect': '10',
'num_inputs': 2,
'script': textwrap.dedent("""
def test_add_to_ten(expect,ans):
try:
a1=int(ans[0])
a2=int(ans[1])
except ValueError:
a1=0
a2=0
return (a1+a2)==int(expect)
""")}},
'code': {
'factory': CodeResponseXMLFactory(),
'kwargs': {
'question_text': 'Submit code to an external grader',
'initial_display': 'print "Hello world!"',
'grader_payload': '{"grader": "ps1/Spring2013/test_grader.py"}', }},
}
world.browser.visit(url)
def add_problem_to_course(course, problem_type):
'''
Add a problem to the course we have created using factories.
'''
@step(u'I am viewing a "([^"]*)" that shows the answer "([^"]*)"')
def view_problem_with_show_answer(step, problem_type, answer):
i_am_registered_for_the_course(step, 'model_course')
assert(problem_type in PROBLEM_FACTORY_DICT)
# Ensure that the course has this problem type
add_problem_to_course('model_course', problem_type, {'showanswer': answer})
# Generate the problem XML using capa.tests.response_xml_factory
factory_dict = PROBLEM_FACTORY_DICT[problem_type]
problem_xml = factory_dict['factory'].build_xml(**factory_dict['kwargs'])
# Go to the one section in the factory-created course
# which should be loaded with the correct problem
chapter_name = TEST_SECTION_NAME.replace(" ", "_")
section_name = chapter_name
url = django_url('/courses/edx/model_course/Test_Course/courseware/%s/%s' %
(chapter_name, section_name))
# Create a problem item using our generated XML
# We set rerandomize=always in the metadata so that the "Reset" button
# will appear.
template_name = "i4x://edx/templates/problem/Blank_Common_Problem"
world.ItemFactory.create(parent_location=section_location(course),
template=template_name,
display_name=str(problem_type),
data=problem_xml,
metadata={'rerandomize': 'always'})
world.browser.visit(url)
@step(u'I am viewing a "([^"]*)" problem')
@@ -153,7 +76,7 @@ def set_external_grader_response(step, correctness):
@step(u'I answer a "([^"]*)" problem "([^"]*)ly"')
def answer_problem(step, problem_type, correctness):
def answer_problem_step(step, problem_type, correctness):
""" Mark a given problem type correct or incorrect, then submit it.
*problem_type* is a string representing the type of problem (e.g. 'drop down')
@@ -161,73 +84,18 @@ def answer_problem(step, problem_type, correctness):
"""
assert(correctness in ['correct', 'incorrect'])
if problem_type == "drop down":
select_name = "input_i4x-edx-model_course-problem-drop_down_2_1"
option_text = 'Option 2' if correctness == 'correct' else 'Option 3'
world.browser.select(select_name, option_text)
elif problem_type == "multiple choice":
if correctness == 'correct':
inputfield('multiple choice', choice='choice_2').check()
else:
inputfield('multiple choice', choice='choice_1').check()
elif problem_type == "checkbox":
if correctness == 'correct':
inputfield('checkbox', choice='choice_0').check()
inputfield('checkbox', choice='choice_2').check()
else:
inputfield('checkbox', choice='choice_3').check()
elif problem_type == 'radio':
if correctness == 'correct':
inputfield('radio', choice='choice_2').check()
else:
inputfield('radio', choice='choice_1').check()
elif problem_type == 'string':
textvalue = 'correct string' if correctness == 'correct' \
else 'incorrect'
inputfield('string').fill(textvalue)
elif problem_type == 'numerical':
textvalue = "pi + 1" if correctness == 'correct' \
else str(random.randint(-2, 2))
inputfield('numerical').fill(textvalue)
elif problem_type == 'formula':
textvalue = "x^2+2*x+y" if correctness == 'correct' else 'x^2'
inputfield('formula').fill(textvalue)
elif problem_type == 'script':
# Correct answer is any two integers that sum to 10
first_addend = random.randint(-100, 100)
second_addend = 10 - first_addend
# If we want an incorrect answer, then change
# the second addend so they no longer sum to 10
if correctness == 'incorrect':
second_addend += random.randint(1, 10)
inputfield('script', input_num=1).fill(str(first_addend))
inputfield('script', input_num=2).fill(str(second_addend))
elif problem_type == 'code':
# The fake xqueue server is configured to respond
# correct / incorrect no matter what we submit.
# Furthermore, since the inline code response uses
# JavaScript to make the code display nicely, it's difficult
# to programatically input text
# (there's not <textarea> we can just fill text into)
# For this reason, we submit the initial code in the response
# (configured in the problem XML above)
pass
assert(problem_type in PROBLEM_DICT)
answer_problem(problem_type, correctness)
# Submit the problem
check_problem(step)
@step(u'I check a problem')
def check_problem(step):
world.css_click("input.check")
@step(u'The "([^"]*)" problem displays a "([^"]*)" answer')
def assert_problem_has_answer(step, problem_type, answer_class):
'''
@@ -239,67 +107,8 @@ def assert_problem_has_answer(step, problem_type, answer_class):
by setting answer_class='blank'
'''
assert answer_class in ['correct', 'incorrect', 'blank']
if problem_type == "drop down":
if answer_class == 'blank':
assert world.browser.is_element_not_present_by_css('option[selected="true"]')
else:
actual = world.browser.find_by_css('option[selected="true"]').value
expected = 'Option 2' if answer_class == 'correct' else 'Option 3'
assert actual == expected
elif problem_type == "multiple choice":
if answer_class == 'correct':
assert_checked('multiple choice', ['choice_2'])
elif answer_class == 'incorrect':
assert_checked('multiple choice', ['choice_1'])
else:
assert_checked('multiple choice', [])
elif problem_type == "checkbox":
if answer_class == 'correct':
assert_checked('checkbox', ['choice_0', 'choice_2'])
elif answer_class == 'incorrect':
assert_checked('checkbox', ['choice_3'])
else:
assert_checked('checkbox', [])
elif problem_type == "radio":
if answer_class == 'correct':
assert_checked('radio', ['choice_2'])
elif answer_class == 'incorrect':
assert_checked('radio', ['choice_1'])
else:
assert_checked('radio', [])
elif problem_type == 'string':
if answer_class == 'blank':
expected = ''
else:
expected = 'correct string' if answer_class == 'correct' \
else 'incorrect'
assert_textfield('string', expected)
elif problem_type == 'formula':
if answer_class == 'blank':
expected = ''
else:
expected = "x^2+2*x+y" if answer_class == 'correct' else 'x^2'
assert_textfield('formula', expected)
else:
# The other response types use random data,
# which would be difficult to check
# We trade input value coverage in the other tests for
# input type coverage in this test.
pass
@step(u'I check a problem')
def check_problem(step):
world.css_click("input.check")
assert problem_type in PROBLEM_DICT
problem_has_answer(problem_type, answer_class)
@step(u'I reset the problem')
@@ -307,45 +116,31 @@ def reset_problem(step):
world.css_click('input.reset')
# Dictionaries that map problem types to the css selectors
# for correct/incorrect/unanswered marks.
# The elements are lists of selectors because a particular problem type
# might be marked in multiple ways.
# For example, multiple choice is marked incorrect differently
# depending on whether the user selects an incorrect
# item or submits without selecting any item)
CORRECTNESS_SELECTORS = {
'correct': {'drop down': ['span.correct'],
'multiple choice': ['label.choicegroup_correct'],
'checkbox': ['span.correct'],
'radio': ['label.choicegroup_correct'],
'string': ['div.correct'],
'numerical': ['div.correct'],
'formula': ['div.correct'],
'script': ['div.correct'],
'code': ['span.correct']},
@step(u'I press the button with the label "([^"]*)"$')
def press_the_button_with_label(step, buttonname):
button_css = 'button span.show-label'
elem = world.css_find(button_css).first
assert_equal(elem.text, buttonname)
elem.click()
'incorrect': {'drop down': ['span.incorrect'],
'multiple choice': ['label.choicegroup_incorrect',
'span.incorrect'],
'checkbox': ['span.incorrect'],
'radio': ['label.choicegroup_incorrect',
'span.incorrect'],
'string': ['div.incorrect'],
'numerical': ['div.incorrect'],
'formula': ['div.incorrect'],
'script': ['div.incorrect'],
'code': ['span.incorrect']},
'unanswered': {'drop down': ['span.unanswered'],
'multiple choice': ['span.unanswered'],
'checkbox': ['span.unanswered'],
'radio': ['span.unanswered'],
'string': ['div.unanswered'],
'numerical': ['div.unanswered'],
'formula': ['div.unanswered'],
'script': ['div.unanswered'],
'code': ['span.unanswered']}}
@step(u'The "([^"]*)" button does( not)? appear')
def action_button_present(step, buttonname, doesnt_appear):
button_css = 'section.action input[value*="%s"]' % buttonname
if doesnt_appear:
assert world.is_css_not_present(button_css)
else:
assert world.is_css_present(button_css)
@step(u'the button with the label "([^"]*)" does( not)? appear')
def button_with_label_present(step, buttonname, doesnt_appear):
button_css = 'button span.show-label'
elem = world.css_find(button_css).first
if doesnt_appear:
assert_not_equal(elem.text, buttonname)
else:
assert_equal(elem.text, buttonname)
@step(u'My "([^"]*)" answer is marked "([^"]*)"')
@@ -359,12 +154,11 @@ def assert_answer_mark(step, problem_type, correctness):
"""
# Determine which selector(s) to look for based on correctness
assert(correctness in CORRECTNESS_SELECTORS)
selector_dict = CORRECTNESS_SELECTORS[correctness]
assert(problem_type in selector_dict)
assert(correctness in ['correct', 'incorrect', 'unanswered'])
assert(problem_type in PROBLEM_DICT)
# At least one of the correct selectors should be present
for sel in selector_dict[problem_type]:
for sel in PROBLEM_DICT[problem_type][correctness]:
has_expected = world.is_css_present(sel)
# As soon as we find the selector, break out of the loop
@@ -373,49 +167,3 @@ def assert_answer_mark(step, problem_type, correctness):
# Expect that we found the expected selector
assert(has_expected)
def inputfield(problem_type, choice=None, input_num=1):
""" Return the <input> element for *problem_type*.
For example, if problem_type is 'string', return
the text field for the string problem in the test course.
*choice* is the name of the checkbox input in a group
of checkboxes. """
sel = ("input#input_i4x-edx-model_course-problem-%s_2_%s" %
(problem_type.replace(" ", "_"), str(input_num)))
if choice is not None:
base = "_choice_" if problem_type == "multiple choice" else "_"
sel = sel + base + str(choice)
# If the input element doesn't exist, fail immediately
assert world.is_css_present(sel)
# Retrieve the input element
return world.browser.find_by_css(sel)
def assert_checked(problem_type, choices):
'''
Assert that choice names given in *choices* are the only
ones checked.
Works for both radio and checkbox problems
'''
all_choices = ['choice_0', 'choice_1', 'choice_2', 'choice_3']
for this_choice in all_choices:
element = inputfield(problem_type, choice=this_choice)
if this_choice in choices:
assert element.checked
else:
assert not element.checked
def assert_textfield(problem_type, expected_text, input_num=1):
element = inputfield(problem_type, input_num=input_num)
assert element.value == expected_text

View File

@@ -0,0 +1,325 @@
#pylint: disable=C0111
#pylint: disable=W0621
#EVERY PROBLEM TYPE MUST HAVE THE FOLLOWING:
# -Section in Dictionary containing:
# -factory
# -kwargs
# -(optional metadata)
# -Correct, Incorrect and Unanswered CSS selectors
# -A way to answer the problem correctly and incorrectly
# -A way to check the problem was answered correctly, incorrectly and blank
from lettuce import world
import random
import textwrap
from common import section_location
from capa.tests.response_xml_factory import OptionResponseXMLFactory, \
ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \
StringResponseXMLFactory, NumericalResponseXMLFactory, \
FormulaResponseXMLFactory, CustomResponseXMLFactory, \
CodeResponseXMLFactory
# Factories from capa.tests.response_xml_factory that we will use
# to generate the problem XML, with the keyword args used to configure
# the output.
PROBLEM_DICT = {
'drop down': {
'factory': OptionResponseXMLFactory(),
'kwargs': {
'question_text': 'The correct answer is Option 2',
'options': ['Option 1', 'Option 2', 'Option 3', 'Option 4'],
'correct_option': 'Option 2'},
'correct': ['span.correct'],
'incorrect': ['span.incorrect'],
'unanswered': ['span.unanswered']},
'multiple choice': {
'factory': MultipleChoiceResponseXMLFactory(),
'kwargs': {
'question_text': 'The correct answer is Choice 3',
'choices': [False, False, True, False],
'choice_names': ['choice_0', 'choice_1', 'choice_2', 'choice_3']},
'correct': ['label.choicegroup_correct', 'span.correct'],
'incorrect': ['label.choicegroup_incorrect', 'span.incorrect'],
'unanswered': ['span.unanswered']},
'checkbox': {
'factory': ChoiceResponseXMLFactory(),
'kwargs': {
'question_text': 'The correct answer is Choices 1 and 3',
'choice_type': 'checkbox',
'choices': [True, False, True, False, False],
'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']},
'correct': ['span.correct'],
'incorrect': ['span.incorrect'],
'unanswered': ['span.unanswered']},
'radio': {
'factory': ChoiceResponseXMLFactory(),
'kwargs': {
'question_text': 'The correct answer is Choice 3',
'choice_type': 'radio',
'choices': [False, False, True, False],
'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']},
'correct': ['label.choicegroup_correct', 'span.correct'],
'incorrect': ['label.choicegroup_incorrect', 'span.incorrect'],
'unanswered': ['span.unanswered']},
'string': {
'factory': StringResponseXMLFactory(),
'kwargs': {
'question_text': 'The answer is "correct string"',
'case_sensitive': False,
'answer': 'correct string'},
'correct': ['div.correct'],
'incorrect': ['div.incorrect'],
'unanswered': ['div.unanswered']},
'numerical': {
'factory': NumericalResponseXMLFactory(),
'kwargs': {
'question_text': 'The answer is pi + 1',
'answer': '4.14159',
'tolerance': '0.00001',
'math_display': True},
'correct': ['div.correct'],
'incorrect': ['div.incorrect'],
'unanswered': ['div.unanswered']},
'formula': {
'factory': FormulaResponseXMLFactory(),
'kwargs': {
'question_text': 'The solution is [mathjax]x^2+2x+y[/mathjax]',
'sample_dict': {'x': (-100, 100), 'y': (-100, 100)},
'num_samples': 10,
'tolerance': 0.00001,
'math_display': True,
'answer': 'x^2+2*x+y'},
'correct': ['div.correct'],
'incorrect': ['div.incorrect'],
'unanswered': ['div.unanswered']},
'script': {
'factory': CustomResponseXMLFactory(),
'kwargs': {
'question_text': 'Enter two integers that sum to 10.',
'cfn': 'test_add_to_ten',
'expect': '10',
'num_inputs': 2,
'script': textwrap.dedent("""
def test_add_to_ten(expect,ans):
try:
a1=int(ans[0])
a2=int(ans[1])
except ValueError:
a1=0
a2=0
return (a1+a2)==int(expect)
""")},
'correct': ['div.correct'],
'incorrect': ['div.incorrect'],
'unanswered': ['div.unanswered']},
'code': {
'factory': CodeResponseXMLFactory(),
'kwargs': {
'question_text': 'Submit code to an external grader',
'initial_display': 'print "Hello world!"',
'grader_payload': '{"grader": "ps1/Spring2013/test_grader.py"}', },
'correct': ['span.correct'],
'incorrect': ['span.incorrect'],
'unanswered': ['span.unanswered']}
}
def answer_problem(problem_type, correctness):
if problem_type == "drop down":
select_name = "input_i4x-edx-model_course-problem-drop_down_2_1"
option_text = 'Option 2' if correctness == 'correct' else 'Option 3'
world.browser.select(select_name, option_text)
elif problem_type == "multiple choice":
if correctness == 'correct':
inputfield('multiple choice', choice='choice_2').check()
else:
inputfield('multiple choice', choice='choice_1').check()
elif problem_type == "checkbox":
if correctness == 'correct':
inputfield('checkbox', choice='choice_0').check()
inputfield('checkbox', choice='choice_2').check()
else:
inputfield('checkbox', choice='choice_3').check()
elif problem_type == 'radio':
if correctness == 'correct':
inputfield('radio', choice='choice_2').check()
else:
inputfield('radio', choice='choice_1').check()
elif problem_type == 'string':
textvalue = 'correct string' if correctness == 'correct' else 'incorrect'
inputfield('string').fill(textvalue)
elif problem_type == 'numerical':
textvalue = "pi + 1" if correctness == 'correct' else str(random.randint(-2, 2))
inputfield('numerical').fill(textvalue)
elif problem_type == 'formula':
textvalue = "x^2+2*x+y" if correctness == 'correct' else 'x^2'
inputfield('formula').fill(textvalue)
elif problem_type == 'script':
# Correct answer is any two integers that sum to 10
first_addend = random.randint(-100, 100)
second_addend = 10 - first_addend
# If we want an incorrect answer, then change
# the second addend so they no longer sum to 10
if correctness == 'incorrect':
second_addend += random.randint(1, 10)
inputfield('script', input_num=1).fill(str(first_addend))
inputfield('script', input_num=2).fill(str(second_addend))
elif problem_type == 'code':
# The fake xqueue server is configured to respond
# correct / incorrect no matter what we submit.
# Furthermore, since the inline code response uses
# JavaScript to make the code display nicely, it's difficult
# to programatically input text
# (there's not <textarea> we can just fill text into)
# For this reason, we submit the initial code in the response
# (configured in the problem XML above)
pass
def problem_has_answer(problem_type, answer_class):
if problem_type == "drop down":
if answer_class == 'blank':
assert world.browser.is_element_not_present_by_css('option[selected="true"]')
else:
actual = world.browser.find_by_css('option[selected="true"]').value
expected = 'Option 2' if answer_class == 'correct' else 'Option 3'
assert actual == expected
elif problem_type == "multiple choice":
if answer_class == 'correct':
assert_checked('multiple choice', ['choice_2'])
elif answer_class == 'incorrect':
assert_checked('multiple choice', ['choice_1'])
else:
assert_checked('multiple choice', [])
elif problem_type == "checkbox":
if answer_class == 'correct':
assert_checked('checkbox', ['choice_0', 'choice_2'])
elif answer_class == 'incorrect':
assert_checked('checkbox', ['choice_3'])
else:
assert_checked('checkbox', [])
elif problem_type == "radio":
if answer_class == 'correct':
assert_checked('radio', ['choice_2'])
elif answer_class == 'incorrect':
assert_checked('radio', ['choice_1'])
else:
assert_checked('radio', [])
elif problem_type == 'string':
if answer_class == 'blank':
expected = ''
else:
expected = 'correct string' if answer_class == 'correct' else 'incorrect'
assert_textfield('string', expected)
elif problem_type == 'formula':
if answer_class == 'blank':
expected = ''
else:
expected = "x^2+2*x+y" if answer_class == 'correct' else 'x^2'
assert_textfield('formula', expected)
else:
# The other response types use random data,
# which would be difficult to check
# We trade input value coverage in the other tests for
# input type coverage in this test.
pass
##############################
# HELPER METHODS
##############################
def add_problem_to_course(course, problem_type, extraMeta=None):
'''
Add a problem to the course we have created using factories.
'''
assert(problem_type in PROBLEM_DICT)
# Generate the problem XML using capa.tests.response_xml_factory
factory_dict = PROBLEM_DICT[problem_type]
problem_xml = factory_dict['factory'].build_xml(**factory_dict['kwargs'])
metadata = {'rerandomize': 'always'} if not 'metadata' in factory_dict else factory_dict['metadata']
if extraMeta:
metadata = dict(metadata, **extraMeta)
# Create a problem item using our generated XML
# We set rerandomize=always in the metadata so that the "Reset" button
# will appear.
template_name = "i4x://edx/templates/problem/Blank_Common_Problem"
world.ItemFactory.create(parent_location=section_location(course),
template=template_name,
display_name=str(problem_type),
data=problem_xml,
metadata=metadata)
def inputfield(problem_type, choice=None, input_num=1):
""" Return the <input> element for *problem_type*.
For example, if problem_type is 'string', return
the text field for the string problem in the test course.
*choice* is the name of the checkbox input in a group
of checkboxes. """
sel = ("input#input_i4x-edx-model_course-problem-%s_2_%s" %
(problem_type.replace(" ", "_"), str(input_num)))
if choice is not None:
base = "_choice_" if problem_type == "multiple choice" else "_"
sel = sel + base + str(choice)
# If the input element doesn't exist, fail immediately
assert world.is_css_present(sel)
# Retrieve the input element
return world.browser.find_by_css(sel)
def assert_checked(problem_type, choices):
'''
Assert that choice names given in *choices* are the only
ones checked.
Works for both radio and checkbox problems
'''
all_choices = ['choice_0', 'choice_1', 'choice_2', 'choice_3']
for this_choice in all_choices:
element = inputfield(problem_type, choice=this_choice)
if this_choice in choices:
assert element.checked
else:
assert not element.checked
def assert_textfield(problem_type, expected_text, input_num=1):
element = inputfield(problem_type, input_num=input_num)
assert element.value == expected_text

View File

@@ -13,6 +13,8 @@ Feature: Register for a course
Scenario: I can unregister for a course
Given I am registered for the course "6.002x"
And I visit the dashboard
When I click the link with the text "Unregister"
And I press the "Unregister" button in the Unenroll dialog
Then I should see "Looks like you haven't registered for any courses yet." somewhere in the page
Then I should see the course numbered "6.002x" in my dashboard
When I unregister for the course numbered "6.002x"
Then I should be on the dashboard page
And I should see an empty dashboard message
And I should NOT see the course numbered "6.002x" in my dashboard

View File

@@ -19,14 +19,24 @@ def i_register_for_the_course(step, course):
assert world.is_css_present('section.container.dashboard')
@step(u'I should see the course numbered "([^"]*)" in my dashboard$')
def i_should_see_that_course_in_my_dashboard(step, course):
@step(u'I should see an empty dashboard message')
def i_should_see_empty_dashboard(step):
empty_dash_css = 'section.empty-dashboard-message'
assert world.is_css_present(empty_dash_css)
@step(u'I should( NOT)? see the course numbered "([^"]*)" in my dashboard$')
def i_should_see_that_course_in_my_dashboard(step, doesnt_appear, course):
course_link_css = 'section.my-courses a[href*="%s"]' % course
assert world.is_css_present(course_link_css)
if doesnt_appear:
assert world.is_css_not_present(course_link_css)
else:
assert world.is_css_present(course_link_css)
@step(u'I press the "([^"]*)" button in the Unenroll dialog')
def i_press_the_button_in_the_unenroll_dialog(step, value):
button_css = 'section#unenroll-modal input[value="%s"]' % value
@step(u'I unregister for the course numbered "([^"]*)"')
def i_unregister_for_that_course(step, course):
unregister_css = 'section.info a[href*="#unenroll-modal"][data-course-number*="%s"]' % course
world.css_click(unregister_css)
button_css = 'section#unenroll-modal input[value="Unregister"]'
world.css_click(button_css)
assert world.is_css_present('section.container.dashboard')

View File

@@ -5,12 +5,12 @@ Feature: Sign in
Scenario: Sign up from the homepage
Given I visit the homepage
When I click the link with the text "Sign Up"
When I click the link with the text "Register Now"
And I fill in "email" on the registration form with "robot2@edx.org"
And I fill in "password" on the registration form with "test"
And I fill in "username" on the registration form with "robot2"
And I fill in "name" on the registration form with "Robot Two"
And I check the checkbox named "terms_of_service"
And I check the checkbox named "honor_code"
And I press the "Create My Account" button on the registration form
And I submit the registration form
Then I should see "THANKS FOR REGISTERING!" in the dashboard banner

View File

@@ -3,17 +3,18 @@
from lettuce import world, step
@step('I fill in "([^"]*)" on the registration form with "([^"]*)"$')
def when_i_fill_in_field_on_the_registration_form_with_value(step, field, value):
register_form = world.browser.find_by_css('form#register_form')
register_form = world.browser.find_by_css('form#register-form')
form_field = register_form.find_by_name(field)
form_field.fill(value)
@step('I press the "([^"]*)" button on the registration form$')
def i_press_the_button_on_the_registration_form(step, button):
register_form = world.browser.find_by_css('form#register_form')
register_form.find_by_value(button).click()
@step('I submit the registration form$')
def i_press_the_button_on_the_registration_form(step):
register_form = world.browser.find_by_css('form#register-form')
register_form.find_by_name('submit').click()
@step('I check the checkbox named "([^"]*)"$')

View File

@@ -0,0 +1,6 @@
Feature: Video component
As a student, I want to view course videos in LMS.
Scenario: Autoplay is enabled in LMS
Given the course has a Video component
Then when I view the video it has autoplay enabled

View File

@@ -0,0 +1,34 @@
#pylint: disable=C0111
from lettuce import world, step
from lettuce.django import django_url
from common import TEST_COURSE_NAME, TEST_SECTION_NAME, i_am_registered_for_the_course, section_location
############### ACTIONS ####################
@step('when I view the video it has autoplay enabled')
def does_autoplay(step):
assert(world.css_find('.video')[0]['data-autoplay'] == 'True')
@step('the course has a Video component')
def view_video(step):
coursename = TEST_COURSE_NAME.replace(' ', '_')
i_am_registered_for_the_course(step, coursename)
# Make sure we have a video
add_video_to_course(coursename)
chapter_name = TEST_SECTION_NAME.replace(" ", "_")
section_name = chapter_name
url = django_url('/courses/edx/Test_Course/Test_Course/courseware/%s/%s' %
(chapter_name, section_name))
world.browser.visit(url)
def add_video_to_course(course):
template_name = 'i4x://edx/templates/video/default'
world.ItemFactory.create(parent_location=section_location(course),
template=template_name,
display_name='Video')

View File

@@ -1,6 +1,7 @@
import json
import logging
import pyparsing
import re
import sys
import static_replace
@@ -8,6 +9,7 @@ from functools import partial
from django.conf import settings
from django.contrib.auth.models import User
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.http import Http404
@@ -212,22 +214,27 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours
#This is a hacky way to pass settings to the combined open ended xmodule
#It needs an S3 interface to upload images to S3
#It needs the open ended grading interface in order to get peer grading to be done
#TODO: refactor these settings into module-specific settings when possible.
#this first checks to see if the descriptor is the correct one, and only sends settings if it is
is_descriptor_combined_open_ended = (descriptor.__class__.__name__ == 'CombinedOpenEndedDescriptor')
is_descriptor_peer_grading = (descriptor.__class__.__name__ == 'PeerGradingDescriptor')
#Get descriptor metadata fields indicating needs for various settings
needs_open_ended_interface = getattr(descriptor, "needs_open_ended_interface", False)
needs_s3_interface = getattr(descriptor, "needs_s3_interface", False)
#Initialize interfaces to None
open_ended_grading_interface = None
s3_interface = None
if is_descriptor_combined_open_ended or is_descriptor_peer_grading:
#Create interfaces if needed
if needs_open_ended_interface:
open_ended_grading_interface = settings.OPEN_ENDED_GRADING_INTERFACE
open_ended_grading_interface['mock_peer_grading'] = settings.MOCK_PEER_GRADING
open_ended_grading_interface['mock_staff_grading'] = settings.MOCK_STAFF_GRADING
if is_descriptor_combined_open_ended:
s3_interface = {
'access_key' : getattr(settings,'AWS_ACCESS_KEY_ID',''),
'secret_access_key' : getattr(settings,'AWS_SECRET_ACCESS_KEY',''),
'storage_bucket_name' : getattr(settings,'AWS_STORAGE_BUCKET_NAME','openended')
}
if needs_s3_interface:
s3_interface = {
'access_key': getattr(settings, 'AWS_ACCESS_KEY_ID', ''),
'secret_access_key': getattr(settings, 'AWS_SECRET_ACCESS_KEY', ''),
'storage_bucket_name': getattr(settings, 'AWS_STORAGE_BUCKET_NAME', 'openended')
}
def inner_get_module(descriptor):
"""
@@ -273,6 +280,14 @@ def get_module_for_descriptor(user, request, 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
@@ -299,6 +314,8 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours
course_id=course_id,
open_ended_grading_interface=open_ended_grading_interface,
s3_interface=s3_interface,
cache=cache,
can_execute_unsafe_code=can_execute_unsafe_code,
)
# pass position specified in URL to module through ModuleSystem
system.set('position', position)
@@ -318,9 +335,8 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours
else:
err_descriptor_class = NonStaffErrorDescriptor
err_descriptor = err_descriptor_class.from_xml(
str(descriptor), descriptor.system,
org=descriptor.location.org, course=descriptor.location.course,
err_descriptor = err_descriptor_class.from_descriptor(
descriptor,
error_msg=exc_info_to_str(sys.exc_info())
)
@@ -402,6 +418,11 @@ def modx_dispatch(request, dispatch, location, course_id):
through the part before the first '?'.
- location -- the module location. Used to look up the XModule instance
- course_id -- defines the course context for this request.
Raises PermissionDenied if the user is not logged in. Raises Http404 if
the location and course_id do not identify a valid module, the module is
not accessible by the user, or the module raises NotFoundError. If the
module raises any other error, it will escape this function.
'''
# ''' (fix emacs broken parsing)
@@ -430,8 +451,19 @@ def modx_dispatch(request, dispatch, location, course_id):
return HttpResponse(json.dumps({'success': file_too_big_msg}))
p[fileinput_id] = inputfiles
try:
descriptor = modulestore().get_instance(course_id, location)
except ItemNotFoundError:
log.warn(
"Invalid location for course id {course_id}: {location}".format(
course_id=course_id,
location=location
)
)
raise Http404
model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id,
request.user, modulestore().get_instance(course_id, location))
request.user, descriptor)
instance = get_module(request.user, request, location, model_data_cache, course_id, grade_bucket_type='ajax')
if instance is None:

View File

@@ -185,6 +185,11 @@ def _combined_open_ended_grading(tab, user, course, active_page):
return tab
return []
def _notes_tab(tab, user, course, active_page):
if user.is_authenticated() and settings.MITX_FEATURES.get('ENABLE_STUDENT_NOTES'):
link = reverse('notes', args=[course.id])
return [CourseTab(tab['name'], link, active_page == 'notes')]
return []
#### Validators
@@ -227,6 +232,7 @@ VALID_TAB_TYPES = {
'peer_grading': TabImpl(null_validator, _peer_grading),
'staff_grading': TabImpl(null_validator, _staff_grading),
'open_ended': TabImpl(null_validator, _combined_open_ended_grading),
'notes': TabImpl(null_validator, _notes_tab)
}

View File

@@ -0,0 +1,108 @@
"""
integration tests for xmodule
Contains:
1. BaseTestXmodule class provides course and users
for testing Xmodules with mongo store.
"""
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from django.test.client import Client
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from xmodule.tests import test_system
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class BaseTestXmodule(ModuleStoreTestCase):
"""Base class for testing Xmodules with mongo store.
This class prepares course and users for tests:
1. create test course
2. create, enrol and login users for this course
Any xmodule should overwrite only next parameters for test:
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 = ""
DATA = ''
MODEL_DATA = {'data': '<some_module></some_module>'}
def setUp(self):
self.course = CourseFactory.create(data=self.COURSE_DATA)
# Turn off cache.
modulestore().request_cache = None
modulestore().metadata_inheritance_cache_subsystem = None
chapter = ItemFactory.create(
parent_location=self.course.location,
template="i4x://edx/templates/sequential/Empty",
)
section = ItemFactory.create(
parent_location=chapter.location,
template="i4x://edx/templates/sequential/Empty"
)
# username = robot{0}, password = 'test'
self.users = [
UserFactory.create(username='robot%d' % i, email='robot+test+%d@edx.org' % i)
for i in range(self.USER_COUNT)
]
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
self.item_descriptor = ItemFactory.create(
parent_location=section.location,
template=self.TEMPLATE_NAME,
data=self.DATA
)
location = self.item_descriptor.location
system = test_system()
system.render_template = lambda template, context: context
self.item_module = self.item_descriptor.module_class(
system, location, self.item_descriptor, self.MODEL_DATA
)
self.item_url = Location(location).url()
# login all users for acces to Xmodule
self.clients = {user.username: Client() for user in self.users}
self.login_statuses = [
self.clients[user.username].login(
username=user.username, password='test')
for user in self.users
]
self.assertTrue(all(self.login_statuses))
def get_url(self, dispatch):
"""Return item url with dispatch."""
return reverse(
'modx_dispatch',
args=(self.course.id, self.item_url, dispatch)
)
def tearDown(self):
for user in self.users:
user.delete()

View File

@@ -12,6 +12,7 @@ from courseware.models import StudentModule, XModuleContentField, XModuleSetting
from courseware.models import XModuleStudentInfoField, XModuleStudentPrefsField
from xmodule.modulestore import Location
from pytz import UTC
location = partial(Location, 'i4x', 'edX', 'test_course', 'problem')
@@ -28,8 +29,8 @@ class RegistrationFactory(StudentRegistrationFactory):
class UserFactory(StudentUserFactory):
email = 'robot@edx.org'
last_name = 'Tester'
last_login = datetime.now()
date_joined = datetime.now()
last_login = datetime.now(UTC)
date_joined = datetime.now(UTC)
class GroupFactory(StudentGroupFactory):

View File

@@ -0,0 +1,4 @@
# Load Testing
Scripts for load testing the courseware app,
mostly using [multimechanize](http://testutils.org/multi-mechanize/)

View File

@@ -0,0 +1,51 @@
# Custom Response Load Test
## Optional Installations
* [memcached](http://pypi.python.org/pypi/python-memcached/): Install this
and make sure it is running, or the Capa problem will not cache results.
* [AppArmor](http://wiki.apparmor.net): Follow the instructions in
`common/lib/codejail/README` to set up the Python sandbox environment.
If you do not set up the sandbox, the tests will still execute code in the CustomResponse,
so you can still run the tests.
* [matplotlib](http://matplotlib.org): Multi-mechanize uses this to create graphs.
## Running the Tests
This test simulates student submissions for a custom response problem.
First, clear the cache:
/etc/init.d/memcached restart
Then, run the test:
multimech-run custom_response
You can configure the parameters in `customresponse/config.cfg`,
and you can change the CustomResponse script and student submissions
in `customresponse/test_scripts/v_user.py`.
## Components Under Test
Components under test:
* Python sandbox (see `common/lib/codejail`), which uses `AppArmor`
* Caching (see `common/lib/capa/capa/safe_exec/`), which uses `memcache` in production
Components NOT under test:
* Django views
* `XModule`
* gunicorn
This allows us to avoid creating courses in mongo, logging in, using CSRF tokens,
and other inconveniences. Instead, we create a capa problem (from the capa package),
pass it Django's memcache backend, and pass the problem student submissions.
Even though the test uses `capa.capa_problem.LoncapaProblem` directly,
the `capa` should not depend on Django. For this reason, we put the
test in the `courseware` Django app.

View File

@@ -0,0 +1,22 @@
[global]
run_time = 240
rampup = 30
results_ts_interval = 10
progress_bar = on
console_logging = off
xml_report = off
[user_group-1]
threads = 10
script = v_user.py
[user_group-2]
threads = 10
script = v_user.py
[user_group-3]
threads = 10
script = v_user.py

View File

@@ -0,0 +1,115 @@
""" User script for load testing CustomResponse """
from capa.tests.response_xml_factory import CustomResponseXMLFactory
import capa.capa_problem as lcp
from xmodule.x_module import ModuleSystem
import mock
import fs.osfs
import random
import textwrap
# Use memcache running locally
CACHE_SETTINGS = {
'default': {
'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
'LOCATION': '127.0.0.1:11211'
},
}
# Configure settings so Django will let us import its cache wrapper
# Caching is the only part of Django being tested
from django.conf import settings
settings.configure(CACHES=CACHE_SETTINGS)
from django.core.cache import cache
# Script to install as the checker for the CustomResponse
TEST_SCRIPT = textwrap.dedent("""
def check_func(expect, answer_given):
return {'ok': answer_given == expect, 'msg': 'Message text'}
""")
# Submissions submitted by the student
TEST_SUBMISSIONS = [random.randint(-100, 100) for i in range(100)]
class TestContext(object):
""" One-time set up for the test that is shared across transactions.
Uses a Singleton design pattern."""
SINGLETON = None
NUM_UNIQUE_SEEDS = 20
@classmethod
def singleton(cls):
""" Return the singleton, creating one if it does not already exist."""
# If we haven't created the singleton yet, create it now
if cls.SINGLETON is None:
# Create a mock ModuleSystem, installing our cache
system = mock.MagicMock(ModuleSystem)
system.render_template = lambda template, context: "<div>%s</div>" % template
system.cache = cache
system.filestore = mock.MagicMock(fs.osfs.OSFS)
system.filestore.root_path = ""
system.DEBUG = True
# Create a custom response problem
xml_factory = CustomResponseXMLFactory()
xml = xml_factory.build_xml(script=TEST_SCRIPT, cfn="check_func", expect="42")
# Create and store the context
cls.SINGLETON = cls(system, xml)
else:
pass
# Return the singleton
return cls.SINGLETON
def __init__(self, system, xml):
""" Store context needed for the test across transactions """
self.system = system
self.xml = xml
# Construct a small pool of unique seeds
# To keep our implementation in line with the one capa actually uses,
# construct the problems, then use the seeds they generate
self.seeds = [lcp.LoncapaProblem(self.xml, 'problem_id', system=self.system).seed
for i in range(self.NUM_UNIQUE_SEEDS)]
def random_seed(self):
""" Return one of a small number of unique random seeds """
return random.choice(self.seeds)
def student_submission(self):
""" Return one of a small number of student submissions """
return random.choice(TEST_SUBMISSIONS)
class Transaction(object):
""" User script that submits a response to a CustomResponse problem """
def __init__(self):
""" Create the problem """
# Get the context (re-used across transactions)
self.context = TestContext.singleton()
# Create a new custom response problem
# using one of a small number of unique seeds
# We're assuming that the capa module is limiting the number
# of seeds (currently not the case for certain settings)
self.problem = lcp.LoncapaProblem(self.context.xml,
'1',
state=None,
seed=self.context.random_seed(),
system=self.context.system)
def run(self):
""" Submit a response to the CustomResponse problem """
answers = {'1_2_1': self.context.student_submission()}
self.problem.grade_answers(answers)
if __name__ == '__main__':
trans = Transaction()
trans.run()

View File

@@ -1,18 +1,12 @@
import unittest
import logging
import time
from mock import Mock, MagicMock, patch
from mock import Mock, patch
from django.conf import settings
from django.test import TestCase
from xmodule.course_module import CourseDescriptor
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore import Location
from xmodule.timeparse import parse_time
from xmodule.x_module import XModule, XModuleDescriptor
import courseware.access as access
from .factories import CourseEnrollmentAllowedFactory
import datetime
from django.utils.timezone import UTC
class AccessTestCase(TestCase):
@@ -77,7 +71,7 @@ class AccessTestCase(TestCase):
# TODO: override DISABLE_START_DATES and test the start date branch of the method
u = Mock()
d = Mock()
d.start = time.gmtime(time.time() - 86400) # make sure the start time is in the past
d.start = datetime.datetime.now(UTC()) - datetime.timedelta(days=1) # make sure the start time is in the past
# Always returns true because DISABLE_START_DATES is set in test.py
self.assertTrue(access._has_access_descriptor(u, d, 'load'))
@@ -85,8 +79,8 @@ class AccessTestCase(TestCase):
def test__has_access_course_desc_can_enroll(self):
u = Mock()
yesterday = time.gmtime(time.time() - 86400)
tomorrow = time.gmtime(time.time() + 86400)
yesterday = datetime.datetime.now(UTC()) - datetime.timedelta(days=1)
tomorrow = datetime.datetime.now(UTC()) + datetime.timedelta(days=1)
c = Mock(enrollment_start=yesterday, enrollment_end=tomorrow)
# User can enroll if it is between the start and end dates

View File

@@ -64,7 +64,7 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase):
def test_staff_debug_for_staff(self):
resp = self.get_cw_section()
sdebug = '<div><a href="#i4x_edX_graded_problem_H1P1_debug" id="i4x_edX_graded_problem_H1P1_trig">Staff Debug Info</a></div>'
self.assertTrue(sdebug in resp.content)
@@ -84,9 +84,9 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase):
resp = self.get_cw_section()
sdebug = '<div><a href="#i4x_edX_graded_problem_H1P1_debug" id="i4x_edX_graded_problem_H1P1_trig">Staff Debug Info</a></div>'
self.assertFalse(sdebug in resp.content)
def get_problem(self):
pun = 'H1P1'
problem_location = "i4x://edX/graded/problem/%s" % pun
@@ -105,7 +105,7 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase):
resp = self.get_problem()
html = json.loads(resp.content)['html']
print html
sabut = '<input class="show" type="button" value="Show Answer">'
sabut = '<button class="show"><span class="show-label">Show Answer(s)</span> <span class="sr">(for question(s) above - adjacent to each field)</span></button>'
self.assertTrue(sabut in html)
def test_no_showanswer_for_student(self):
@@ -116,5 +116,5 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase):
resp = self.get_problem()
html = json.loads(resp.content)['html']
print html
sabut = '<input class="show" type="button" value="Show Answer">'
sabut = '<button class="show"><span class="show-label">Show Answer(s)</span> <span class="sr">(for question(s) above - adjacent to each field)</span></button>'
self.assertFalse(sabut in html)

View File

@@ -69,19 +69,38 @@ class ModuleRenderTestCase(LoginEnrollmentTestCase):
json.dumps({'success': 'Submission aborted! Your file "%s" is too large (max size: %d MB)' %
(inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))}))
mock_request_3 = MagicMock()
mock_request_3.POST.copy.return_value = {}
mock_request_3.POST.copy.return_value = {'position': 1}
mock_request_3.FILES = False
mock_request_3.user = UserFactory()
inputfile_2 = Stub()
inputfile_2.size = 1
inputfile_2.name = 'name'
self.assertRaises(ItemNotFoundError, render.modx_dispatch,
mock_request_3, 'dummy', self.location, 'toy')
self.assertRaises(Http404, render.modx_dispatch, mock_request_3, 'dummy',
self.location, self.course_id)
mock_request_3.POST.copy.return_value = {'position': 1}
self.assertIsInstance(render.modx_dispatch(mock_request_3, 'goto_position',
self.location, self.course_id), HttpResponse)
self.assertRaises(
Http404,
render.modx_dispatch,
mock_request_3,
'goto_position',
self.location,
'bad_course_id'
)
self.assertRaises(
Http404,
render.modx_dispatch,
mock_request_3,
'goto_position',
['i4x', 'edX', 'toy', 'chapter', 'bad_location'],
self.course_id
)
self.assertRaises(
Http404,
render.modx_dispatch,
mock_request_3,
'bad_dispatch',
self.location,
self.course_id
)
def test_get_score_bucket(self):
self.assertEquals(render.get_score_bucket(0, 10), 'incorrect')

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
"""Video xmodule tests in mongo."""
from . import BaseTestXmodule
class TestVideo(BaseTestXmodule):
"""Integration tests: web client + mongo."""
TEMPLATE_NAME = "i4x://edx/templates/video/default"
DATA = '<video youtube="0.75:JMD_ifUUfsU,1.0:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"/>'
def test_handle_ajax_dispatch(self):
responses = {
user.username: self.clients[user.username].post(
self.get_url('whatever'),
{},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
for user in self.users
}
self.assertEqual(
set([
response.status_code
for _, response in responses.items()
]).pop(),
404)

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""Test for Video Xmodule functional logic.
These tests data readed from xml, not from mongo.
We have a ModuleStoreTestCase class defined in
common/lib/xmodule/xmodule/modulestore/tests/django_utils.py.
You can search for usages of this in the cms and lms tests for examples.
You use this so that it will do things like point the modulestore
setting to mongo, flush the contentstore before and after, load the
templates, etc.
You can then use the CourseFactory and XModuleItemFactory as defined in
common/lib/xmodule/xmodule/modulestore/tests/factories.py to create the
course, section, subsection, unit, etc.
"""
import json
import unittest
from mock import Mock
from lxml import etree
from xmodule.video_module import VideoDescriptor, VideoModule
from xmodule.modulestore import Location
from xmodule.tests import test_system
from xmodule.tests.test_logic import LogicTest
class VideoFactory(object):
"""A helper class to create video modules with various parameters
for testing.
"""
# tag that uses youtube videos
sample_problem_xml_youtube = """
<video show_captions="true"
youtube="0.75:jNCf2gIqpeE,1.0:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg"
data_dir=""
caption_asset_path=""
autoplay="true"
from="01:00:03" to="01:00:10"
>
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.mp4"/>
</video>
"""
@staticmethod
def create():
"""Method return Video Xmodule instance."""
location = Location(["i4x", "edX", "video", "default",
"SampleProblem1"])
model_data = {'data': VideoFactory.sample_problem_xml_youtube}
descriptor = Mock(weight="1")
system = test_system()
system.render_template = lambda template, context: context
module = VideoModule(system, location, descriptor, model_data)
return module
class VideoModuleLogicTest(LogicTest):
"""Tests for logic of Video Xmodule."""
descriptor_class = VideoDescriptor
raw_model_data = {
'data': '<video />'
}
def test_get_timeframe_no_parameters(self):
"""Make sure that timeframe() works correctly w/o parameters"""
xmltree = etree.fromstring('<video>test</video>')
output = self.xmodule.get_timeframe(xmltree)
self.assertEqual(output, ('', ''))
def test_get_timeframe_with_one_parameter(self):
"""Make sure that timeframe() works correctly with one parameter"""
xmltree = etree.fromstring(
'<video from="00:04:07">test</video>'
)
output = self.xmodule.get_timeframe(xmltree)
self.assertEqual(output, (247, ''))
def test_get_timeframe_with_two_parameters(self):
"""Make sure that timeframe() works correctly with two parameters"""
xmltree = etree.fromstring(
'''<video
from="00:04:07"
to="13:04:39"
>test</video>'''
)
output = self.xmodule.get_timeframe(xmltree)
self.assertEqual(output, (247, 47079))
class VideoModuleUnitTest(unittest.TestCase):
"""Unit tests for Video Xmodule."""
def test_video_constructor(self):
"""Make sure that all parameters extracted correclty from xml"""
module = VideoFactory.create()
# `get_html` return only context, cause we
# overwrite `system.render_template`
context = module.get_html()
expected_context = {
'track': None,
'show_captions': 'true',
'display_name': 'SampleProblem1',
'id': module.location.html_id(),
'end': 3610.0,
'caption_asset_path': '/static/subs/',
'source': '.../mit-3091x/M-3091X-FA12-L21-3_100.mp4',
'streams': '0.75:jNCf2gIqpeE,1.0:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg',
'normal_speed_video_id': 'ZwkTiUPN0mg',
'position': 0,
'start': 3603.0
}
self.assertDictEqual(context, expected_context)
self.assertEqual(
module.youtube,
'0.75:jNCf2gIqpeE,1.0:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg')
self.assertEqual(
module.video_list(),
module.youtube)
self.assertEqual(
module.position,
0)
self.assertDictEqual(
json.loads(module.get_instance_state()),
{'position': 0})

View File

@@ -3,7 +3,6 @@ Test for lms courseware app
'''
import logging
import json
import time
import random
from urlparse import urlsplit, urlunsplit
@@ -30,6 +29,8 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.xml import XMLModuleStore
import datetime
from django.utils.timezone import UTC
log = logging.getLogger("mitx." + __name__)
@@ -64,7 +65,7 @@ def mongo_store_config(data_dir):
'db': 'test_xmodule',
'collection': 'modulestore_%s' % uuid4().hex,
'fs_root': data_dir,
'render_template': 'mitxmako.shortcuts.render_to_string',
'render_template': 'mitxmako.shortcuts.render_to_string'
}
}
}
@@ -220,25 +221,20 @@ class LoginEnrollmentTestCase(TestCase):
# Now make sure that the user is now actually activated
self.assertTrue(get_user(email).is_active)
def _enroll(self, course):
"""Post to the enrollment view, and return the parsed json response"""
def try_enroll(self, course):
"""Try to enroll. Return bool success instead of asserting it."""
resp = self.client.post('/change_enrollment', {
'enrollment_action': 'enroll',
'course_id': course.id,
})
return parse_json(resp)
def try_enroll(self, course):
"""Try to enroll. Return bool success instead of asserting it."""
data = self._enroll(course)
print ('Enrollment in %s result: %s'
% (course.location.url(), str(data)))
return data['success']
print ('Enrollment in %s result status code: %s'
% (course.location.url(), str(resp.status_code)))
return resp.status_code == 200
def enroll(self, course):
"""Enroll the currently logged-in user, and check that it worked."""
data = self._enroll(course)
self.assertTrue(data['success'])
result = self.try_enroll(course)
self.assertTrue(result)
def unenroll(self, course):
"""Unenroll the currently logged-in user, and check that it worked."""
@@ -246,8 +242,7 @@ class LoginEnrollmentTestCase(TestCase):
'enrollment_action': 'unenroll',
'course_id': course.id,
})
data = parse_json(resp)
self.assertTrue(data['success'])
self.assertTrue(resp.status_code == 200)
def check_for_get_code(self, code, url):
"""
@@ -293,7 +288,7 @@ class PageLoaderTestCase(LoginEnrollmentTestCase):
'''
Choose a page in the course randomly, and assert that it loads
'''
# enroll in the course before trying to access pages
# enroll in the course before trying to access pages
courses = module_store.get_courses()
self.assertEqual(len(courses), 1)
course = courses[0]
@@ -372,6 +367,7 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase):
'''Check that all pages in test courses load properly from XML'''
def setUp(self):
super(TestCoursesLoadTestCase_XmlModulestore, self).setUp()
self.setup_viewtest_user()
xmodule.modulestore.django._MODULESTORES = {}
@@ -390,6 +386,7 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase):
'''Check that all pages in test courses load properly from Mongo'''
def setUp(self):
super(TestCoursesLoadTestCase_MongoModulestore, self).setUp()
self.setup_viewtest_user()
xmodule.modulestore.django._MODULESTORES = {}
modulestore().collection.drop()
@@ -399,6 +396,14 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase):
import_from_xml(module_store, TEST_DATA_DIR, ['toy'])
self.check_random_page_loads(module_store)
def test_full_textbooks_loads(self):
module_store = modulestore()
import_from_xml(module_store, TEST_DATA_DIR, ['full'])
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
self.assertGreater(len(course.textbooks), 0)
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestNavigation(LoginEnrollmentTestCase):
@@ -479,9 +484,6 @@ class TestDraftModuleStore(TestCase):
class TestViewAuth(LoginEnrollmentTestCase):
"""Check that view authentication works properly"""
# NOTE: setUpClass() runs before override_settings takes effect, so
# can't do imports there without manually hacking settings.
def setUp(self):
xmodule.modulestore.django._MODULESTORES = {}
@@ -602,9 +604,9 @@ class TestViewAuth(LoginEnrollmentTestCase):
"""Actually do the test, relying on settings to be right."""
# Make courses start in the future
tomorrow = time.time() + 24 * 3600
self.toy.lms.start = time.gmtime(tomorrow)
self.full.lms.start = time.gmtime(tomorrow)
tomorrow = datetime.datetime.now(UTC()) + datetime.timedelta(days=1)
self.toy.lms.start = tomorrow
self.full.lms.start = tomorrow
self.assertFalse(self.toy.has_started())
self.assertFalse(self.full.has_started())
@@ -623,8 +625,8 @@ class TestViewAuth(LoginEnrollmentTestCase):
urls = reverse_urls(['info', 'progress'], course)
urls.extend([
reverse('book', kwargs={'course_id': course.id,
'book_index': book.title})
for book in course.textbooks
'book_index': index})
for index, book in enumerate(course.textbooks)
])
return urls
@@ -635,8 +637,6 @@ class TestViewAuth(LoginEnrollmentTestCase):
"""
urls = reverse_urls(['about_course'], course)
urls.append(reverse('courses'))
# Need separate test for change_enrollment, since it's a POST view
#urls.append(reverse('change_enrollment'))
return urls
@@ -729,18 +729,18 @@ class TestViewAuth(LoginEnrollmentTestCase):
"""Actually do the test, relying on settings to be right."""
# Make courses start in the future
tomorrow = time.time() + 24 * 3600
nextday = tomorrow + 24 * 3600
yesterday = time.time() - 24 * 3600
tomorrow = datetime.datetime.now(UTC()) + datetime.timedelta(days=1)
nextday = tomorrow + datetime.timedelta(days=1)
yesterday = datetime.datetime.now(UTC()) - datetime.timedelta(days=1)
print "changing"
# toy course's enrollment period hasn't started
self.toy.enrollment_start = time.gmtime(tomorrow)
self.toy.enrollment_end = time.gmtime(nextday)
self.toy.enrollment_start = tomorrow
self.toy.enrollment_end = nextday
# full course's has
self.full.enrollment_start = time.gmtime(yesterday)
self.full.enrollment_end = time.gmtime(tomorrow)
self.full.enrollment_start = yesterday
self.full.enrollment_end = tomorrow
print "login"
# First, try with an enrolled student
@@ -779,12 +779,10 @@ class TestViewAuth(LoginEnrollmentTestCase):
self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES'])
# Make courses start in the future
tomorrow = time.time() + 24 * 3600
# nextday = tomorrow + 24 * 3600
# yesterday = time.time() - 24 * 3600
tomorrow = datetime.datetime.now(UTC()) + datetime.timedelta(days=1)
# toy course's hasn't started
self.toy.lms.start = time.gmtime(tomorrow)
self.toy.lms.start = tomorrow
self.assertFalse(self.toy.has_started())
# but should be accessible for beta testers
@@ -804,43 +802,85 @@ class TestViewAuth(LoginEnrollmentTestCase):
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestCourseGrader(LoginEnrollmentTestCase):
class TestSubmittingProblems(LoginEnrollmentTestCase):
"""Check that a course gets graded properly"""
# NOTE: setUpClass() runs before override_settings takes effect, so
# can't do imports there without manually hacking settings.
# Subclasses should specify the course slug
course_slug = "UNKNOWN"
course_when = "UNKNOWN"
def setUp(self):
xmodule.modulestore.django._MODULESTORES = {}
courses = modulestore().get_courses()
def find_course(course_id):
"""Assumes the course is present"""
return [c for c in courses if c.id == course_id][0]
self.graded_course = find_course("edX/graded/2012_Fall")
course_name = "edX/%s/%s" % (self.course_slug, self.course_when)
self.course = modulestore().get_course(course_name)
assert self.course, "Couldn't load course %r" % course_name
# create a test student
self.student = 'view@test.com'
self.password = 'foo'
self.create_account('u1', self.student, self.password)
self.activate_user(self.student)
self.enroll(self.graded_course)
self.enroll(self.course)
self.student_user = get_user(self.student)
self.factory = RequestFactory()
def problem_location(self, problem_url_name):
return "i4x://edX/{}/problem/{}".format(self.course_slug, problem_url_name)
def modx_url(self, problem_location, dispatch):
return reverse(
'modx_dispatch',
kwargs={
'course_id': self.course.id,
'location': problem_location,
'dispatch': dispatch,
}
)
def submit_question_answer(self, problem_url_name, responses):
"""
Submit answers to a question.
Responses is a dict mapping problem ids (not sure of the right term)
to answers:
{'2_1': 'Correct', '2_2': 'Incorrect'}
"""
problem_location = self.problem_location(problem_url_name)
modx_url = self.modx_url(problem_location, 'problem_check')
answer_key_prefix = 'input_i4x-edX-{}-problem-{}_'.format(self.course_slug, problem_url_name)
resp = self.client.post(modx_url,
{ (answer_key_prefix + k): v for k, v in responses.items() }
)
return resp
def reset_question_answer(self, problem_url_name):
'''resets specified problem for current user'''
problem_location = self.problem_location(problem_url_name)
modx_url = self.modx_url(problem_location, 'problem_reset')
resp = self.client.post(modx_url)
return resp
class TestCourseGrader(TestSubmittingProblems):
"""Check that a course gets graded properly"""
course_slug = "graded"
course_when = "2012_Fall"
def get_grade_summary(self):
'''calls grades.grade for current user and course'''
model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
self.graded_course.id, self.student_user, self.graded_course)
self.course.id, self.student_user, self.course)
fake_request = self.factory.get(reverse('progress',
kwargs={'course_id': self.graded_course.id}))
kwargs={'course_id': self.course.id}))
return grades.grade(self.student_user, fake_request,
self.graded_course, model_data_cache)
self.course, model_data_cache)
def get_homework_scores(self):
'''get scores for homeworks'''
@@ -849,14 +889,14 @@ class TestCourseGrader(LoginEnrollmentTestCase):
def get_progress_summary(self):
'''return progress summary structure for current user and course'''
model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
self.graded_course.id, self.student_user, self.graded_course)
self.course.id, self.student_user, self.course)
fake_request = self.factory.get(reverse('progress',
kwargs={'course_id': self.graded_course.id}))
kwargs={'course_id': self.course.id}))
progress_summary = grades.progress_summary(self.student_user,
fake_request,
self.graded_course,
self.course,
model_data_cache)
return progress_summary
@@ -865,46 +905,6 @@ class TestCourseGrader(LoginEnrollmentTestCase):
grade_summary = self.get_grade_summary()
self.assertEqual(grade_summary['percent'], percent)
def submit_question_answer(self, problem_url_name, responses):
"""
The field names of a problem are hard to determine. This method only works
for the problems used in the edX/graded course, which has fields named in the
following form:
input_i4x-edX-graded-problem-H1P3_2_1
input_i4x-edX-graded-problem-H1P3_2_2
"""
problem_location = "i4x://edX/graded/problem/%s" % problem_url_name
modx_url = reverse('modx_dispatch',
kwargs={'course_id': self.graded_course.id,
'location': problem_location,
'dispatch': 'problem_check', })
resp = self.client.post(modx_url, {
'input_i4x-edX-graded-problem-%s_2_1' % problem_url_name: responses[0],
'input_i4x-edX-graded-problem-%s_2_2' % problem_url_name: responses[1],
})
print "modx_url", modx_url, "responses", responses
print "resp", resp
return resp
def problem_location(self, problem_url_name):
'''Get location string for problem, assuming hardcoded course_id'''
return "i4x://edX/graded/problem/{0}".format(problem_url_name)
def reset_question_answer(self, problem_url_name):
'''resets specified problem for current user'''
problem_location = self.problem_location(problem_url_name)
modx_url = reverse('modx_dispatch',
kwargs={'course_id': self.graded_course.id,
'location': problem_location,
'dispatch': 'problem_reset', })
resp = self.client.post(modx_url)
return resp
def test_get_graded(self):
#### Check that the grader shows we have 0% in the course
self.check_grade_percent(0)
@@ -922,27 +922,27 @@ class TestCourseGrader(LoginEnrollmentTestCase):
return [s.earned for s in hw_section['scores']]
# Only get half of the first problem correct
self.submit_question_answer('H1P1', ['Correct', 'Incorrect'])
self.submit_question_answer('H1P1', {'2_1': 'Correct', '2_2': 'Incorrect'})
self.check_grade_percent(0.06)
self.assertEqual(earned_hw_scores(), [1.0, 0, 0]) # Order matters
self.assertEqual(earned_hw_scores(), [1.0, 0, 0]) # Order matters
self.assertEqual(score_for_hw('Homework1'), [1.0, 0.0])
# Get both parts of the first problem correct
self.reset_question_answer('H1P1')
self.submit_question_answer('H1P1', ['Correct', 'Correct'])
self.submit_question_answer('H1P1', {'2_1': 'Correct', '2_2': 'Correct'})
self.check_grade_percent(0.13)
self.assertEqual(earned_hw_scores(), [2.0, 0, 0])
self.assertEqual(score_for_hw('Homework1'), [2.0, 0.0])
# This problem is shown in an ABTest
self.submit_question_answer('H1P2', ['Correct', 'Correct'])
self.submit_question_answer('H1P2', {'2_1': 'Correct', '2_2': 'Correct'})
self.check_grade_percent(0.25)
self.assertEqual(earned_hw_scores(), [4.0, 0.0, 0])
self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0])
# This problem is hidden in an ABTest.
# Getting it correct doesn't change total grade
self.submit_question_answer('H1P3', ['Correct', 'Correct'])
self.submit_question_answer('H1P3', {'2_1': 'Correct', '2_2': 'Correct'})
self.check_grade_percent(0.25)
self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0])
@@ -951,19 +951,85 @@ class TestCourseGrader(LoginEnrollmentTestCase):
# This problem is also weighted to be 4 points (instead of default of 2)
# If the problem was unweighted the percent would have been 0.38 so we
# know it works.
self.submit_question_answer('H2P1', ['Correct', 'Correct'])
self.submit_question_answer('H2P1', {'2_1': 'Correct', '2_2': 'Correct'})
self.check_grade_percent(0.42)
self.assertEqual(earned_hw_scores(), [4.0, 4.0, 0])
# Third homework
self.submit_question_answer('H3P1', ['Correct', 'Correct'])
self.check_grade_percent(0.42) # Score didn't change
self.submit_question_answer('H3P1', {'2_1': 'Correct', '2_2': 'Correct'})
self.check_grade_percent(0.42) # Score didn't change
self.assertEqual(earned_hw_scores(), [4.0, 4.0, 2.0])
self.submit_question_answer('H3P2', ['Correct', 'Correct'])
self.check_grade_percent(0.5) # Now homework2 dropped. Score changes
self.submit_question_answer('H3P2', {'2_1': 'Correct', '2_2': 'Correct'})
self.check_grade_percent(0.5) # Now homework2 dropped. Score changes
self.assertEqual(earned_hw_scores(), [4.0, 4.0, 4.0])
# Now we answer the final question (worth half of the grade)
self.submit_question_answer('FinalQuestion', ['Correct', 'Correct'])
self.check_grade_percent(1.0) # Hooray! We got 100%
self.submit_question_answer('FinalQuestion', {'2_1': 'Correct', '2_2': 'Correct'})
self.check_grade_percent(1.0) # Hooray! We got 100%
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestSchematicResponse(TestSubmittingProblems):
"""Check that we can submit a schematic response, and it answers properly."""
course_slug = "embedded_python"
course_when = "2013_Spring"
def test_schematic(self):
resp = self.submit_question_answer('schematic_problem',
{ '2_1': json.dumps(
[['transient', {'Z': [
[0.0000004, 2.8],
[0.0000009, 2.8],
[0.0000014, 2.8],
[0.0000019, 2.8],
[0.0000024, 2.8],
[0.0000029, 0.2],
[0.0000034, 0.2],
[0.0000039, 0.2]
]}]]
)
})
respdata = json.loads(resp.content)
self.assertEqual(respdata['success'], 'correct')
self.reset_question_answer('schematic_problem')
resp = self.submit_question_answer('schematic_problem',
{ '2_1': json.dumps(
[['transient', {'Z': [
[0.0000004, 2.8],
[0.0000009, 0.0], # wrong.
[0.0000014, 2.8],
[0.0000019, 2.8],
[0.0000024, 2.8],
[0.0000029, 0.2],
[0.0000034, 0.2],
[0.0000039, 0.2]
]}]]
)
})
respdata = json.loads(resp.content)
self.assertEqual(respdata['success'], 'incorrect')
def test_check_function(self):
resp = self.submit_question_answer('cfn_problem', {'2_1': "0, 1, 2, 3, 4, 5, 'Outside of loop', 6"})
respdata = json.loads(resp.content)
self.assertEqual(respdata['success'], 'correct')
self.reset_question_answer('cfn_problem')
resp = self.submit_question_answer('cfn_problem', {'2_1': "xyzzy!"})
respdata = json.loads(resp.content)
self.assertEqual(respdata['success'], 'incorrect')
def test_computed_answer(self):
resp = self.submit_question_answer('computed_answer', {'2_1': "Xyzzy"})
respdata = json.loads(resp.content)
self.assertEqual(respdata['success'], 'correct')
self.reset_question_answer('computed_answer')
resp = self.submit_question_answer('computed_answer', {'2_1': "NO!"})
respdata = json.loads(resp.content)
self.assertEqual(respdata['success'], 'incorrect')

View File

@@ -515,6 +515,9 @@ def registered_for_course(course, user):
@ensure_csrf_cookie
@cache_if_anonymous
def course_about(request, course_id):
if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False):
raise Http404
course = get_course_with_access(request.user, course_id, 'see_exists')
registered = registered_for_course(course, request.user)
@@ -531,6 +534,37 @@ def course_about(request, course_id):
'registered': registered,
'course_target': course_target,
'show_courseware_link': show_courseware_link})
@ensure_csrf_cookie
@cache_if_anonymous
def mktg_course_about(request, course_id):
try:
course = get_course_with_access(request.user, course_id, 'see_exists')
except (ValueError, Http404) as e:
# if a course does not exist yet, display a coming
# soon button
return render_to_response('courseware/mktg_coming_soon.html',
{'course_id': course_id})
registered = registered_for_course(course, request.user)
if has_access(request.user, course, 'load'):
course_target = reverse('info', args=[course.id])
else:
course_target = reverse('about_course', args=[course.id])
allow_registration = has_access(request.user, course, 'enroll')
show_courseware_link = (has_access(request.user, course, 'load') or
settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'))
return render_to_response('courseware/mktg_course_about.html',
{'course': course,
'registered': registered,
'allow_registration': allow_registration,
'course_target': course_target,
'show_courseware_link': show_courseware_link})
@ensure_csrf_cookie

View File

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,31 @@
"""Views for debugging and diagnostics"""
import pprint
import traceback
from django.http import Http404
from django.contrib.auth.decorators import login_required
from django_future.csrf import ensure_csrf_cookie, csrf_exempt
from mitxmako.shortcuts import render_to_response
from codejail.safe_exec import safe_exec
@login_required
@ensure_csrf_cookie
def run_python(request):
"""A page to allow testing the Python sandbox on a production server."""
if not request.user.is_staff:
raise Http404
c = {}
c['code'] = ''
c['results'] = None
if request.method == 'POST':
py_code = c['code'] = request.POST.get('code')
g = {}
try:
safe_exec(py_code, g)
except Exception as e:
c['results'] = traceback.format_exc()
else:
c['results'] = pprint.pformat(g)
return render_to_response("debug/run_python_form.html", c)

View File

@@ -0,0 +1,230 @@
import logging
from django.conf import settings
from django.test.utils import override_settings
from django.test.client import Client
from django.contrib.auth.models import User
from student.tests.factories import CourseEnrollmentFactory
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from django.core.urlresolvers import reverse
from django.core.management import call_command
from util.testing import UrlResetMixin
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from nose.tools import assert_true, assert_equal
from mock import patch
log = logging.getLogger(__name__)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
@patch('comment_client.utils.requests.request')
class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase):
def setUp(self):
# This feature affects the contents of urls.py, so we change
# it before the call to super.setUp() which reloads urls.py (because
# of the UrlResetMixin)
# This setting is cleaned up at the end of the test by @override_settings, which
# restores all of the old settings
settings.MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
super(ViewsTestCase, self).setUp()
# create a course
self.course = CourseFactory.create(org='MITx', course='999',
display_name='Robot Super Course')
self.course_id = self.course.id
# seed the forums permissions and roles
call_command('seed_permissions_roles', self.course_id)
# 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 user and make them active so we can log them in.
self.student = User.objects.create_user(uname, email, password)
self.student.is_active = True
self.student.save()
# Enroll the student in the course
CourseEnrollmentFactory(user=self.student,
course_id=self.course_id)
self.client = Client()
assert_true(self.client.login(username='student', password='test'))
def test_create_thread(self, mock_request):
mock_request.return_value.status_code = 200
mock_request.return_value.text = u'{"title":"Hello",\
"body":"this is a post",\
"course_id":"MITx/999/Robot_Super_Course",\
"anonymous":false,\
"anonymous_to_peers":false,\
"commentable_id":"i4x-MITx-999-course-Robot_Super_Course",\
"created_at":"2013-05-10T18:53:43Z",\
"updated_at":"2013-05-10T18:53:43Z",\
"at_position_list":[],\
"closed":false,\
"id":"518d4237b023791dca00000d",\
"user_id":"1","username":"robot",\
"votes":{"count":0,"up_count":0,\
"down_count":0,"point":0},\
"abuse_flaggers":[],"tags":[],\
"type":"thread","group_id":null,\
"pinned":false,\
"endorsed":false,\
"unread_comments_count":0,\
"read":false,"comments_count":0}'
thread = {"body": ["this is a post"],
"anonymous_to_peers": ["false"],
"auto_subscribe": ["false"],
"anonymous": ["false"],
"title": ["Hello"]
}
url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course',
'course_id': self.course_id})
response = self.client.post(url, data=thread)
assert_true(mock_request.called)
mock_request.assert_called_with('post',
'http://localhost:4567/api/v1/i4x-MITx-999-course-Robot_Super_Course/threads',
data={'body': u'this is a post',
'anonymous_to_peers': False, 'user_id': 1,
'title': u'Hello',
'commentable_id': u'i4x-MITx-999-course-Robot_Super_Course',
'anonymous': False, 'course_id': u'MITx/999/Robot_Super_Course',
'api_key': 'PUT_YOUR_API_KEY_HERE'}, timeout=5)
assert_equal(response.status_code, 200)
def test_flag_thread(self, mock_request):
mock_request.return_value.status_code = 200
mock_request.return_value.text = u'{"title":"Hello",\
"body":"this is a post",\
"course_id":"MITx/999/Robot_Super_Course",\
"anonymous":false,\
"anonymous_to_peers":false,\
"commentable_id":"i4x-MITx-999-course-Robot_Super_Course",\
"created_at":"2013-05-10T18:53:43Z",\
"updated_at":"2013-05-10T18:53:43Z",\
"at_position_list":[],\
"closed":false,\
"id":"518d4237b023791dca00000d",\
"user_id":"1","username":"robot",\
"votes":{"count":0,"up_count":0,\
"down_count":0,"point":0},\
"abuse_flaggers":[1],"tags":[],\
"type":"thread","group_id":null,\
"pinned":false,\
"endorsed":false,\
"unread_comments_count":0,\
"read":false,"comments_count":0}'
url = reverse('flag_abuse_for_thread', kwargs={'thread_id': '518d4237b023791dca00000d', 'course_id': self.course_id})
response = self.client.post(url)
assert_true(mock_request.called)
call_list = [(('get', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d'), {'params': {'mark_as_read': True, 'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5}),
(('put', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d/abuse_flag'), {'data': {'api_key': 'PUT_YOUR_API_KEY_HERE', 'user_id': '1'}, 'timeout': 5}),
(('get', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d'), {'params': {'mark_as_read': True, 'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5})]
assert_equal(call_list, mock_request.call_args_list)
assert_equal(response.status_code, 200)
def test_un_flag_thread(self, mock_request):
mock_request.return_value.status_code = 200
mock_request.return_value.text = u'{"title":"Hello",\
"body":"this is a post",\
"course_id":"MITx/999/Robot_Super_Course",\
"anonymous":false,\
"anonymous_to_peers":false,\
"commentable_id":"i4x-MITx-999-course-Robot_Super_Course",\
"created_at":"2013-05-10T18:53:43Z",\
"updated_at":"2013-05-10T18:53:43Z",\
"at_position_list":[],\
"closed":false,\
"id":"518d4237b023791dca00000d",\
"user_id":"1","username":"robot",\
"votes":{"count":0,"up_count":0,\
"down_count":0,"point":0},\
"abuse_flaggers":[],"tags":[],\
"type":"thread","group_id":null,\
"pinned":false,\
"endorsed":false,\
"unread_comments_count":0,\
"read":false,"comments_count":0}'
url = reverse('un_flag_abuse_for_thread', kwargs={'thread_id': '518d4237b023791dca00000d', 'course_id': self.course_id})
response = self.client.post(url)
assert_true(mock_request.called)
call_list = [(('get', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d'), {'params': {'mark_as_read': True, 'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5}),
(('put', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d/abuse_unflag'), {'data': {'api_key': 'PUT_YOUR_API_KEY_HERE', 'user_id': '1'}, 'timeout': 5}),
(('get', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d'), {'params': {'mark_as_read': True, 'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5})]
assert_equal(call_list, mock_request.call_args_list)
assert_equal(response.status_code, 200)
def test_flag_comment(self, mock_request):
mock_request.return_value.status_code = 200
mock_request.return_value.text = u'{"body":"this is a comment",\
"course_id":"MITx/999/Robot_Super_Course",\
"anonymous":false,\
"anonymous_to_peers":false,\
"commentable_id":"i4x-MITx-999-course-Robot_Super_Course",\
"created_at":"2013-05-10T18:53:43Z",\
"updated_at":"2013-05-10T18:53:43Z",\
"at_position_list":[],\
"closed":false,\
"id":"518d4237b023791dca00000d",\
"user_id":"1","username":"robot",\
"votes":{"count":0,"up_count":0,\
"down_count":0,"point":0},\
"abuse_flaggers":[1],\
"type":"comment",\
"endorsed":false}'
url = reverse('flag_abuse_for_comment', kwargs={'comment_id': '518d4237b023791dca00000d', 'course_id': self.course_id})
response = self.client.post(url)
assert_true(mock_request.called)
call_list = [(('get', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d'), {'params': {'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5}),
(('put', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d/abuse_flag'), {'data': {'api_key': 'PUT_YOUR_API_KEY_HERE', 'user_id': '1'}, 'timeout': 5}),
(('get', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d'), {'params': {'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5})]
assert_equal(call_list, mock_request.call_args_list)
assert_equal(response.status_code, 200)
def test_un_flag_comment(self, mock_request):
mock_request.return_value.status_code = 200
mock_request.return_value.text = u'{"body":"this is a comment",\
"course_id":"MITx/999/Robot_Super_Course",\
"anonymous":false,\
"anonymous_to_peers":false,\
"commentable_id":"i4x-MITx-999-course-Robot_Super_Course",\
"created_at":"2013-05-10T18:53:43Z",\
"updated_at":"2013-05-10T18:53:43Z",\
"at_position_list":[],\
"closed":false,\
"id":"518d4237b023791dca00000d",\
"user_id":"1","username":"robot",\
"votes":{"count":0,"up_count":0,\
"down_count":0,"point":0},\
"abuse_flaggers":[],\
"type":"comment",\
"endorsed":false}'
url = reverse('un_flag_abuse_for_comment', kwargs={'comment_id': '518d4237b023791dca00000d', 'course_id': self.course_id})
response = self.client.post(url)
assert_true(mock_request.called)
call_list = [(('get', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d'), {'params': {'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5}),
(('put', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d/abuse_unflag'), {'data': {'api_key': 'PUT_YOUR_API_KEY_HERE', 'user_id': '1'}, 'timeout': 5}),
(('get', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d'), {'params': {'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5})]
assert_equal(call_list, mock_request.call_args_list)
assert_equal(response.status_code, 200)

View File

@@ -9,6 +9,8 @@ urlpatterns = patterns('django_comment_client.base.views', # nopep8
url(r'threads/(?P<thread_id>[\w\-]+)/delete', 'delete_thread', name='delete_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/upvote$', 'vote_for_thread', {'value': 'up'}, name='upvote_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/downvote$', 'vote_for_thread', {'value': 'down'}, name='downvote_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/flagAbuse$', 'flag_abuse_for_thread', name='flag_abuse_for_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/unFlagAbuse$', 'un_flag_abuse_for_thread', name='un_flag_abuse_for_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/unvote$', 'undo_vote_for_thread', name='undo_vote_for_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/pin$', 'pin_thread', name='pin_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/unpin$', 'un_pin_thread', name='un_pin_thread'),
@@ -23,7 +25,8 @@ urlpatterns = patterns('django_comment_client.base.views', # nopep8
url(r'comments/(?P<comment_id>[\w\-]+)/upvote$', 'vote_for_comment', {'value': 'up'}, name='upvote_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/downvote$', 'vote_for_comment', {'value': 'down'}, name='downvote_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/unvote$', 'undo_vote_for_comment', name='undo_vote_for_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/flagAbuse$', 'flag_abuse_for_comment', name='flag_abuse_for_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/unFlagAbuse$', 'un_flag_abuse_for_comment', name='un_flag_abuse_for_comment'),
url(r'^(?P<commentable_id>[\w\-.]+)/threads/create$', 'create_thread', name='create_thread'),
# TODO should we search within the board?
url(r'^(?P<commentable_id>[\w\-.]+)/threads/search_similar$', 'search_similar_threads', name='search_similar_threads'),

View File

@@ -19,14 +19,15 @@ from django.core.files.storage import get_storage_class
from django.utils.translation import ugettext as _
from django.contrib.auth.models import User
from mitxmako.shortcuts import render_to_response, render_to_string
from courseware.courses import get_course_with_access
from mitxmako.shortcuts import render_to_string
from courseware.courses import get_course_with_access, get_course_by_id
from course_groups.cohorts import get_cohort_id, is_commentable_cohorted
from django_comment_client.utils import JsonResponse, JsonError, extract, get_courseware_context
from django_comment_client.permissions import check_permissions_by_view, cached_has_permission
from django_comment_client.models import Role
from django_comment_common.models import Role
from courseware.access import has_access
log = logging.getLogger(__name__)
@@ -68,6 +69,10 @@ def ajax_content_response(request, course_id, content, template_name):
@login_required
@permitted
def create_thread(request, course_id, commentable_id):
"""
Given a course and commentble ID, create the thread
"""
log.debug("Creating new thread in %r, id %r", course_id, commentable_id)
course = get_course_with_access(request.user, course_id, 'load')
post = request.POST
@@ -119,7 +124,7 @@ def create_thread(request, course_id, commentable_id):
#patch for backward compatibility to comments service
if not 'pinned' in thread.attributes:
thread['pinned'] = False
if post.get('auto_subscribe', 'false').lower() == 'true':
user = cc.User.from_django_user(request.user)
user.follow(thread)
@@ -137,6 +142,9 @@ def create_thread(request, course_id, commentable_id):
@login_required
@permitted
def update_thread(request, course_id, thread_id):
"""
Given a course id and thread id, update a existing thread, used for both static and ajax submissions
"""
thread = cc.Thread.find(thread_id)
thread.update_attributes(**extract(request.POST, ['body', 'title', 'tags']))
thread.save()
@@ -147,6 +155,10 @@ def update_thread(request, course_id, thread_id):
def _create_comment(request, course_id, thread_id=None, parent_id=None):
"""
given a course_id, thread_id, and parent_id, create a comment,
called from create_comment to do the actual creation
"""
post = request.POST
comment = cc.Comment(**extract(post, ['body']))
@@ -183,6 +195,10 @@ def _create_comment(request, course_id, thread_id=None, parent_id=None):
@login_required
@permitted
def create_comment(request, course_id, thread_id):
"""
given a course_id and thread_id, test for comment depth. if not too deep,
call _create_comment to create the actual comment.
"""
if cc_settings.MAX_COMMENT_DEPTH is not None:
if cc_settings.MAX_COMMENT_DEPTH < 0:
return JsonError("Comment level too deep")
@@ -193,6 +209,10 @@ def create_comment(request, course_id, thread_id):
@login_required
@permitted
def delete_thread(request, course_id, thread_id):
"""
given a course_id and thread_id, delete this thread
this is ajax only
"""
thread = cc.Thread.find(thread_id)
thread.delete()
return JsonResponse(utils.safe_content(thread.to_dict()))
@@ -202,6 +222,10 @@ def delete_thread(request, course_id, thread_id):
@login_required
@permitted
def update_comment(request, course_id, comment_id):
"""
given a course_id and comment_id, update the comment with payload attributes
handles static and ajax submissions
"""
comment = cc.Comment.find(comment_id)
comment.update_attributes(**extract(request.POST, ['body']))
comment.save()
@@ -215,6 +239,10 @@ def update_comment(request, course_id, comment_id):
@login_required
@permitted
def endorse_comment(request, course_id, comment_id):
"""
given a course_id and comment_id, toggle the endorsement of this comment,
ajax only
"""
comment = cc.Comment.find(comment_id)
comment.endorsed = request.POST.get('endorsed', 'false').lower() == 'true'
comment.save()
@@ -225,6 +253,10 @@ def endorse_comment(request, course_id, comment_id):
@login_required
@permitted
def openclose_thread(request, course_id, thread_id):
"""
given a course_id and thread_id, toggle the status of this thread
ajax only
"""
thread = cc.Thread.find(thread_id)
thread.closed = request.POST.get('closed', 'false').lower() == 'true'
thread.save()
@@ -239,6 +271,10 @@ def openclose_thread(request, course_id, thread_id):
@login_required
@permitted
def create_sub_comment(request, course_id, comment_id):
"""
given a course_id and comment_id, create a response to a comment
after checking the max depth allowed, if allowed
"""
if cc_settings.MAX_COMMENT_DEPTH is not None:
if cc_settings.MAX_COMMENT_DEPTH <= cc.Comment.find(comment_id).depth:
return JsonError("Comment level too deep")
@@ -249,6 +285,10 @@ def create_sub_comment(request, course_id, comment_id):
@login_required
@permitted
def delete_comment(request, course_id, comment_id):
"""
given a course_id and comment_id delete this comment
ajax only
"""
comment = cc.Comment.find(comment_id)
comment.delete()
return JsonResponse(utils.safe_content(comment.to_dict()))
@@ -258,6 +298,9 @@ def delete_comment(request, course_id, comment_id):
@login_required
@permitted
def vote_for_comment(request, course_id, comment_id, value):
"""
given a course_id and comment_id,
"""
user = cc.User.from_django_user(request.user)
comment = cc.Comment.find(comment_id)
user.vote(comment, value)
@@ -268,6 +311,10 @@ def vote_for_comment(request, course_id, comment_id, value):
@login_required
@permitted
def undo_vote_for_comment(request, course_id, comment_id):
"""
given a course id and comment id, remove vote
ajax only
"""
user = cc.User.from_django_user(request.user)
comment = cc.Comment.find(comment_id)
user.unvote(comment)
@@ -278,34 +325,112 @@ def undo_vote_for_comment(request, course_id, comment_id):
@login_required
@permitted
def vote_for_thread(request, course_id, thread_id, value):
"""
given a course id and thread id vote for this thread
ajax only
"""
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.vote(thread, value)
return JsonResponse(utils.safe_content(thread.to_dict()))
@require_POST
@login_required
@permitted
def flag_abuse_for_thread(request, course_id, thread_id):
"""
given a course_id and thread_id flag this thread for abuse
ajax only
"""
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
thread.flagAbuse(user, thread)
return JsonResponse(utils.safe_content(thread.to_dict()))
@require_POST
@login_required
@permitted
def un_flag_abuse_for_thread(request, course_id, thread_id):
"""
given a course id and thread id, remove abuse flag for this thread
ajax only
"""
user = cc.User.from_django_user(request.user)
course = get_course_by_id(course_id)
thread = cc.Thread.find(thread_id)
removeAll = cached_has_permission(request.user, 'openclose_thread', course_id) or has_access(request.user, course, 'staff')
thread.unFlagAbuse(user, thread, removeAll)
return JsonResponse(utils.safe_content(thread.to_dict()))
@require_POST
@login_required
@permitted
def flag_abuse_for_comment(request, course_id, comment_id):
"""
given a course and comment id, flag comment for abuse
ajax only
"""
user = cc.User.from_django_user(request.user)
comment = cc.Comment.find(comment_id)
comment.flagAbuse(user, comment)
return JsonResponse(utils.safe_content(comment.to_dict()))
@require_POST
@login_required
@permitted
def un_flag_abuse_for_comment(request, course_id, comment_id):
"""
given a course_id and comment id, unflag comment for abuse
ajax only
"""
user = cc.User.from_django_user(request.user)
course = get_course_by_id(course_id)
removeAll = cached_has_permission(request.user, 'openclose_thread', course_id) or has_access(request.user, course, 'staff')
comment = cc.Comment.find(comment_id)
comment.unFlagAbuse(user, comment, removeAll)
return JsonResponse(utils.safe_content(comment.to_dict()))
@require_POST
@login_required
@permitted
def undo_vote_for_thread(request, course_id, thread_id):
"""
given a course id and thread id, remove users vote for thread
ajax only
"""
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.unvote(thread)
return JsonResponse(utils.safe_content(thread.to_dict()))
@require_POST
@login_required
@permitted
def pin_thread(request, course_id, thread_id):
"""
given a course id and thread id, pin this thread
ajax only
"""
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
thread.pin(user,thread_id)
thread.pin(user, thread_id)
return JsonResponse(utils.safe_content(thread.to_dict()))
def un_pin_thread(request, course_id, thread_id):
"""
given a course id and thread id, remove pin from this thread
ajax only
"""
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
thread.un_pin(user,thread_id)
thread.un_pin(user, thread_id)
return JsonResponse(utils.safe_content(thread.to_dict()))
@@ -323,6 +448,10 @@ def follow_thread(request, course_id, thread_id):
@login_required
@permitted
def follow_commentable(request, course_id, commentable_id):
"""
given a course_id and commentable id, follow this commentable
ajax only
"""
user = cc.User.from_django_user(request.user)
commentable = cc.Commentable.find(commentable_id)
user.follow(commentable)
@@ -343,6 +472,10 @@ def follow_user(request, course_id, followed_user_id):
@login_required
@permitted
def unfollow_thread(request, course_id, thread_id):
"""
given a course id and thread id, stop following this thread
ajax only
"""
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.unfollow(thread)
@@ -353,6 +486,10 @@ def unfollow_thread(request, course_id, thread_id):
@login_required
@permitted
def unfollow_commentable(request, course_id, commentable_id):
"""
given a course id and commentable id stop following commentable
ajax only
"""
user = cc.User.from_django_user(request.user)
commentable = cc.Commentable.find(commentable_id)
user.unfollow(commentable)
@@ -363,6 +500,10 @@ def unfollow_commentable(request, course_id, commentable_id):
@login_required
@permitted
def unfollow_user(request, course_id, followed_user_id):
"""
given a course id and user id, stop following this user
ajax only
"""
user = cc.User.from_django_user(request.user)
followed_user = cc.User.find(followed_user_id)
user.unfollow(followed_user)
@@ -373,6 +514,10 @@ def unfollow_user(request, course_id, followed_user_id):
@login_required
@permitted
def update_moderator_status(request, course_id, user_id):
"""
given a course id and user id, check if the user has moderator
and send back a user profile
"""
is_moderator = request.POST.get('is_moderator', '').lower()
if is_moderator not in ["true", "false"]:
return JsonError("Must provide is_moderator as boolean value")
@@ -402,6 +547,10 @@ def update_moderator_status(request, course_id, user_id):
@require_GET
def search_similar_threads(request, course_id, commentable_id):
"""
given a course id and commentable id, run query given in text get param
of request
"""
text = request.GET.get('text', None)
if text:
query_params = {
@@ -452,16 +601,11 @@ def upload(request, course_id): # ajax upload file to a question or answer
if not file_extension in cc_settings.ALLOWED_UPLOAD_FILE_TYPES:
file_types = "', '".join(cc_settings.ALLOWED_UPLOAD_FILE_TYPES)
msg = _("allowed file types are '%(file_types)s'") % \
{'file_types': file_types}
{'file_types': file_types}
raise exceptions.PermissionDenied(msg)
# generate new file name
new_file_name = str(
time.time()
).replace(
'.',
str(random.randint(0, 100000))
) + file_extension
new_file_name = str(time.time()).replace('.', str(random.randint(0, 100000))) + file_extension
file_storage = get_storage_class()()
# use default storage to store file
@@ -472,14 +616,14 @@ def upload(request, course_id): # ajax upload file to a question or answer
if size > cc_settings.MAX_UPLOAD_FILE_SIZE:
file_storage.delete(new_file_name)
msg = _("maximum upload file size is %(file_size)sK") % \
{'file_size': cc_settings.MAX_UPLOAD_FILE_SIZE}
{'file_size': cc_settings.MAX_UPLOAD_FILE_SIZE}
raise exceptions.PermissionDenied(msg)
except exceptions.PermissionDenied, e:
error = unicode(e)
except Exception, e:
print e
logging.critical(unicode(e))
except exceptions.PermissionDenied, err:
error = unicode(err)
except Exception, err:
print err
logging.critical(unicode(err))
error = _('Error uploading file. Please contact the site administrator. Thank you.')
if error == '':

View File

@@ -7,9 +7,9 @@ from django.http import Http404
from django.core.context_processors import csrf
from django.contrib.auth.models import User
from mitxmako.shortcuts import render_to_response, render_to_string
from mitxmako.shortcuts import render_to_response
from courseware.courses import get_course_with_access
from course_groups.cohorts import (is_course_cohorted, get_cohort_id, is_commentable_cohorted,
from course_groups.cohorts import (is_course_cohorted, get_cohort_id, is_commentable_cohorted,
get_cohorted_commentables, get_course_cohorts, get_cohort_by_id)
from courseware.access import has_access
@@ -79,7 +79,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
strip_none(extract(request.GET,
['page', 'sort_key',
'sort_order', 'text',
'tags', 'commentable_ids'])))
'tags', 'commentable_ids', 'flagged'])))
threads, page, num_pages = cc.Thread.search(query_params)
@@ -92,7 +92,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
else:
thread['group_name'] = ""
thread['group_string'] = "This post visible to everyone."
#patch for backward compatibility to comments service
if not 'pinned' in thread:
thread['pinned'] = False
@@ -108,7 +108,6 @@ def inline_discussion(request, course_id, discussion_id):
"""
Renders JSON for DiscussionModules
"""
course = get_course_with_access(request.user, course_id, 'load')
try:
@@ -175,6 +174,9 @@ 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:
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))
raise Http404
@@ -219,6 +221,7 @@ def forum_form_discussion(request, course_id):
'threads': saxutils.escape(json.dumps(threads), escapedict),
'thread_pages': query_params['num_pages'],
'user_info': saxutils.escape(json.dumps(user_info), escapedict),
'flag_moderator': cached_has_permission(request.user, 'openclose_thread', course.id) or has_access(request.user, course, 'staff'),
'annotated_content_info': saxutils.escape(json.dumps(annotated_content_info), escapedict),
'course_id': course.id,
'category_map': category_map,
@@ -241,19 +244,12 @@ 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)
#patch for backward compatibility with comments service
if not 'pinned' in thread.attributes:
thread['pinned'] = False
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
log.error("Error loading single thread.")
raise Http404
if request.is_ajax():
courseware_context = get_courseware_context(thread, course)
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
context = {'thread': thread.to_dict(), 'course_id': course_id}
# TODO: Remove completely or switch back to server side rendering
@@ -325,6 +321,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
'thread_pages': query_params['num_pages'],
'is_course_cohorted': is_course_cohorted(course_id),
'is_moderator': cached_has_permission(request.user, "see_all_cohorts", course_id),
'flag_moderator': cached_has_permission(request.user, 'openclose_thread', course.id) or has_access(request.user, course, 'staff'),
'cohorts': cohorts,
'user_cohort': get_cohort_id(request.user, course_id),
'cohorted_commentables': cohorted_commentables
@@ -400,7 +397,7 @@ def followed_threads(request, course_id, user_id):
'discussion_data': map(utils.safe_content, threads),
'page': query_params['page'],
'num_pages': query_params['num_pages'],
})
})
else:
context = {

View File

@@ -1,7 +1,7 @@
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError
from django_comment_client.models import Role
from django_comment_common.models import Role
from django.contrib.auth.models import User
@@ -12,7 +12,7 @@ class Command(BaseCommand):
dest='remove',
default=False,
help='Remove the role instead of adding it'),
)
)
args = '<user|email> <role> <course_id>'
help = 'Assign a discussion forum role to a user '

View File

@@ -7,7 +7,7 @@ Enrollments.
from django.core.management.base import BaseCommand, CommandError
from student.models import CourseEnrollment
from django_comment_client.models import assign_default_role
from django_comment_common.models import assign_default_role
class Command(BaseCommand):

View File

@@ -7,7 +7,7 @@ Enrollments.
from django.core.management.base import BaseCommand, CommandError
from student.models import CourseEnrollment
from django_comment_client.models import assign_default_role
from django_comment_common.models import assign_default_role
class Command(BaseCommand):

View File

@@ -1,15 +1,16 @@
"""
Reload forum (comment client) users from existing users.
"""
from django.core.management.base import BaseCommand, CommandError
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
import comment_client as cc
class Command(BaseCommand):
help = 'Reload forum (comment client) users from existing users'
def adduser(self,user):
def adduser(self, user):
print user
try:
cc_user = cc.User.from_django_user(user)
@@ -22,8 +23,6 @@ class Command(BaseCommand):
uset = [User.objects.get(username=x) for x in args]
else:
uset = User.objects.all()
for user in uset:
self.adduser(user)

View File

@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand, CommandError
from django_comment_client.models import Role
from django_comment_common.utils import seed_permissions_roles
class Command(BaseCommand):
@@ -13,26 +13,4 @@ class Command(BaseCommand):
raise CommandError("Too many arguments")
course_id = args[0]
administrator_role = Role.objects.get_or_create(name="Administrator", course_id=course_id)[0]
moderator_role = Role.objects.get_or_create(name="Moderator", course_id=course_id)[0]
community_ta_role = Role.objects.get_or_create(name="Community TA", course_id=course_id)[0]
student_role = Role.objects.get_or_create(name="Student", course_id=course_id)[0]
for per in ["vote", "update_thread", "follow_thread", "unfollow_thread",
"update_comment", "create_sub_comment", "unvote", "create_thread",
"follow_commentable", "unfollow_commentable", "create_comment", ]:
student_role.add_permission(per)
for per in ["edit_content", "delete_thread", "openclose_thread",
"endorse_comment", "delete_comment", "see_all_cohorts"]:
moderator_role.add_permission(per)
for per in ["manage_moderator"]:
administrator_role.add_permission(per)
moderator_role.inherit_permissions(student_role)
# For now, Community TA == Moderator, except for the styling.
community_ta_role.inherit_permissions(moderator_role)
administrator_role.inherit_permissions(moderator_role)
seed_permissions_roles(course_id)

View File

@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand, CommandError
from django_comment_client.models import Permission, Role
from django_comment_common.models import Permission, Role
from django.contrib.auth.models import User

View File

@@ -1,64 +1 @@
import logging
from django.db import models
from django.contrib.auth.models import User
from django.dispatch import receiver
from django.db.models.signals import post_save
from student.models import CourseEnrollment
from courseware.courses import get_course_by_id
FORUM_ROLE_ADMINISTRATOR = 'Administrator'
FORUM_ROLE_MODERATOR = 'Moderator'
FORUM_ROLE_COMMUNITY_TA = 'Community TA'
FORUM_ROLE_STUDENT = 'Student'
@receiver(post_save, sender=CourseEnrollment)
def assign_default_role(sender, instance, **kwargs):
if instance.user.is_staff:
role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0]
else:
role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0]
logging.info("assign_default_role: adding %s as %s" % (instance.user, role))
instance.user.roles.add(role)
class Role(models.Model):
name = models.CharField(max_length=30, null=False, blank=False)
users = models.ManyToManyField(User, related_name="roles")
course_id = models.CharField(max_length=255, blank=True, db_index=True)
def __unicode__(self):
return self.name + " for " + (self.course_id if self.course_id else "all courses")
def inherit_permissions(self, role): # TODO the name of this method is a little bit confusing,
# since it's one-off and doesn't handle inheritance later
if role.course_id and role.course_id != self.course_id:
logging.warning("%s cannot inherit permissions from %s due to course_id inconsistency", \
self, role)
for per in role.permissions.all():
self.add_permission(per)
def add_permission(self, permission):
self.permissions.add(Permission.objects.get_or_create(name=permission)[0])
def has_permission(self, permission):
course = get_course_by_id(self.course_id)
if self.name == FORUM_ROLE_STUDENT and \
(permission.startswith('edit') or permission.startswith('update') or permission.startswith('create')) and \
(not course.forum_posts_allowed):
return False
return self.permissions.filter(name=permission).exists()
class Permission(models.Model):
name = models.CharField(max_length=30, null=False, blank=False, primary_key=True)
roles = models.ManyToManyField(Role, related_name="permissions")
def __unicode__(self):
return self.name
# This file is intentionally blank. It has been moved to common/djangoapps/django_comment_common

View File

@@ -1,4 +1,4 @@
from .models import Role, Permission
from django_comment_common.models import Role, Permission
from django.db.models.signals import post_save
from django.dispatch import receiver
from student.models import CourseEnrollment
@@ -73,7 +73,6 @@ def check_conditions_permissions(user, permissions, course_id, **kwargs):
return True in results
elif operator == "and":
return not False in results
return test(user, permissions, operator="or")
@@ -89,6 +88,10 @@ VIEW_PERMISSIONS = {
'vote_for_comment' : [['vote', 'is_open']],
'undo_vote_for_comment': [['unvote', 'is_open']],
'vote_for_thread' : [['vote', 'is_open']],
'flag_abuse_for_thread': [['vote', 'is_open']],
'un_flag_abuse_for_thread': [['vote', 'is_open']],
'flag_abuse_for_comment': [['vote', 'is_open']],
'un_flag_abuse_for_comment': [['vote', 'is_open']],
'undo_vote_for_thread': [['unvote', 'is_open']],
'pin_thread': ['create_comment'],
'un_pin_thread': ['create_comment'],

View File

@@ -6,7 +6,7 @@ from django.test import TestCase
from student.models import CourseEnrollment
from django_comment_client.permissions import has_permission
from django_comment_client.models import Role
from django_comment_common.models import Role
class PermissionsTestCase(TestCase):
@@ -21,9 +21,9 @@ class PermissionsTestCase(TestCase):
self.student_role = Role.objects.get_or_create(name="Student", course_id=self.course_id)[0]
self.student = User.objects.create(username=self.random_str(),
password="123456", email="john@yahoo.com")
password="123456", email="john@yahoo.com")
self.moderator = User.objects.create(username=self.random_str(),
password="123456", email="staff@edx.org")
password="123456", email="staff@edx.org")
self.moderator.is_staff = True
self.moderator.save()
self.student_enrollment = CourseEnrollment.objects.create(user=self.student, course_id=self.course_id)

View File

@@ -0,0 +1,13 @@
from factory import DjangoModelFactory
from django_comment_common.models import Role, Permission
class RoleFactory(DjangoModelFactory):
FACTORY_FOR = Role
name = 'Student'
course_id = 'edX/toy/2012_Fall'
class PermissionFactory(DjangoModelFactory):
FACTORY_FOR = Permission
name = 'create_comment'

View File

@@ -45,6 +45,41 @@ class MockCommentServiceRequestHandler(BaseHTTPRequestHandler):
self.end_headers()
return False
def do_PUT(self):
'''
Handle a PUT request from the client
Used by the APIs for comment threads, commentables, comments,
subscriptions, commentables, users
'''
# Retrieve the PUT data into a dict.
# It should have been sent in json format
length = int(self.headers.getheader('content-length'))
data_string = self.rfile.read(length)
post_dict = json.loads(data_string)
# Log the request
logger.debug("Comment Service received PUT request %s to path %s" %
(json.dumps(post_dict), self.path))
# Every good post has at least an API key
if 'api_key' in post_dict:
response = self.server._response_str
# Log the response
logger.debug("Comment Service: sending response %s" % json.dumps(response))
# Send a response back to the client
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(response)
else:
# Respond with failure
self.send_response(500, 'Bad Request: does not contain API key')
self.send_header('Content-type', 'text/plain')
self.end_headers()
return False
class MockCommentServiceServer(HTTPServer):
'''

View File

@@ -1,7 +1,3 @@
import string
import random
import collections
from django.test import TestCase
from django_comment_client.helpers import pluralize

View File

@@ -1,4 +1,4 @@
import django_comment_client.models as models
import django_comment_common.models as models
import django_comment_client.permissions as permissions
from django.test import TestCase
@@ -9,24 +9,20 @@ class RoleClassTestCase(TestCase):
# because xmodel.course_module.id_to_location looks for a string to split
self.course_id = "edX/toy/2012_Fall"
self.student_role = models.Role.objects.get_or_create(name="Student", \
course_id=self.course_id)[0]
self.student_role = models.Role.objects.get_or_create(name="Student",
course_id=self.course_id)[0]
self.student_role.add_permission("delete_thread")
self.student_2_role = models.Role.objects.get_or_create(name="Student", \
self.student_2_role = models.Role.objects.get_or_create(name="Student",
course_id=self.course_id)[0]
self.TA_role = models.Role.objects.get_or_create(name="Community TA",
course_id=self.course_id)[0]
self.TA_role = models.Role.objects.get_or_create(name="Community TA",\
course_id=self.course_id)[0]
self.course_id_2 = "edx/6.002x/2012_Fall"
self.TA_role_2 = models.Role.objects.get_or_create(name="Community TA",\
course_id=self.course_id_2)[0]
self.TA_role_2 = models.Role.objects.get_or_create(name="Community TA",
course_id=self.course_id_2)[0]
class Dummy():
def render_template():
pass
d = {"data": {
"textbooks": [],
'wiki_slug': True,
}
}
def testHasPermission(self):
# Whenever you add a permission to student_role,
@@ -47,7 +43,6 @@ class RoleClassTestCase(TestCase):
class PermissionClassTestCase(TestCase):
def setUp(self):
self.permission = permissions.Permission.objects.get_or_create(name="test")[0]

View File

@@ -1,19 +1,8 @@
import string
import random
import collections
from django.test import TestCase
from mock import MagicMock
from django.test.utils import override_settings
import django.core.urlresolvers as urlresolvers
import django_comment_client.mustache_helpers as mustache_helpers
#########################################################################################
class PluralizeTest(TestCase):
def setUp(self):
self.text1 = '0 goat'
self.text2 = '1 goat'
@@ -25,11 +14,8 @@ class PluralizeTest(TestCase):
self.assertEqual(mustache_helpers.pluralize(self.content, self.text2), 'goat')
self.assertEqual(mustache_helpers.pluralize(self.content, self.text3), 'goats')
#########################################################################################
class CloseThreadTextTest(TestCase):
def setUp(self):
self.contentClosed = {'closed': True}
self.contentOpen = {'closed': False}
@@ -37,6 +23,3 @@ class CloseThreadTextTest(TestCase):
def test_close_thread_text(self):
self.assertEqual(mustache_helpers.close_thread_text(self.contentClosed), 'Re-open thread')
self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread')
#########################################################################################

View File

@@ -1,22 +1,10 @@
from django.test import TestCase
from factory import DjangoModelFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from django_comment_client.models import Role, Permission
from django_comment_common.models import Role, Permission
from factories import RoleFactory
import django_comment_client.utils as utils
class RoleFactory(DjangoModelFactory):
FACTORY_FOR = Role
name = 'Student'
course_id = 'edX/toy/2012_Fall'
class PermissionFactory(DjangoModelFactory):
FACTORY_FOR = Permission
name = 'create_comment'
class DictionaryTestCase(TestCase):
def test_extract(self):
d = {'cats': 'meow', 'dogs': 'woof'}

View File

@@ -1,27 +1,22 @@
from collections import defaultdict
import logging
import time
import urllib
from datetime import datetime
from courseware.module_render import get_module
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.search import path_to_location
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.db import connection
from django.http import HttpResponse
from django.utils import simplejson
from django_comment_client.models import Role
from django_comment_common.models import Role
from django_comment_client.permissions import check_permissions_by_view
from xmodule.modulestore.exceptions import NoPathToItem
from mitxmako import middleware
import pystache_custom as pystache
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from django.utils.timezone import UTC
log = logging.getLogger(__name__)
@@ -99,17 +94,17 @@ def get_discussion_category_map(course):
def filter_unstarted_categories(category_map):
now = time.gmtime()
now = datetime.now(UTC())
result_map = {}
unfiltered_queue = [category_map]
filtered_queue = [result_map]
filtered_queue = [result_map]
while len(unfiltered_queue) > 0:
unfiltered_map = unfiltered_queue.pop()
filtered_map = filtered_queue.pop()
filtered_map = filtered_queue.pop()
filtered_map["children"] = []
filtered_map["entries"] = {}
@@ -155,7 +150,7 @@ def initialize_discussion_info(course):
# get all discussion models within this course_id
all_modules = modulestore().get_items(['i4x', course.location.org, course.location.course,
'discussion', None], course_id=course_id)
'discussion', None], course_id=course_id)
for module in all_modules:
skip_module = False
@@ -174,8 +169,7 @@ def initialize_discussion_info(course):
category = " / ".join([x.strip() for x in category.split("/")])
last_category = category.split("/")[-1]
discussion_id_map[id] = {"location": module.location, "title": last_category + " / " + title}
unexpanded_category_map[category].append({"title": title, "id": id,
"sort_key": sort_key, "start_date": module.lms.start})
unexpanded_category_map[category].append({"title": title, "id": id, "sort_key": sort_key, "start_date": module.lms.start})
category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
for category_path, entries in unexpanded_category_map.items():
@@ -202,9 +196,9 @@ def initialize_discussion_info(course):
level = path[-1]
if level not in node:
node[level] = {"subcategories": defaultdict(dict),
"entries": defaultdict(dict),
"sort_key": level,
"start_date": category_start_date}
"entries": defaultdict(dict),
"sort_key": level,
"start_date": category_start_date}
else:
if node[level]["start_date"] > category_start_date:
node[level]["start_date"] = category_start_date
@@ -220,12 +214,12 @@ def initialize_discussion_info(course):
for topic, entry in course.discussion_topics.items():
category_map['entries'][topic] = {"id": entry["id"],
"sort_key": entry.get("sort_key", topic),
"start_date": time.gmtime()}
"start_date": datetime.now(UTC())}
sort_map_entries(category_map)
_DISCUSSIONINFO[course.id]['id_map'] = discussion_id_map
_DISCUSSIONINFO[course.id]['category_map'] = category_map
_DISCUSSIONINFO[course.id]['timestamp'] = datetime.now()
_DISCUSSIONINFO[course.id]['timestamp'] = datetime.now(UTC())
class JsonResponse(HttpResponse):
@@ -284,15 +278,15 @@ class QueryCountDebugMiddleware(object):
def get_ability(course_id, content, user):
return {
'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"),
'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"),
'can_endorse': check_permissions_by_view(user, course_id, content, "endorse_comment") if content['type'] == 'comment' else False,
'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"),
'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False,
'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"),
'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"),
'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"),
'can_endorse': check_permissions_by_view(user, course_id, content, "endorse_comment") if content['type'] == 'comment' else False,
'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"),
'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False,
'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"),
}
#TODO: RENAME
# TODO: RENAME
def get_annotated_content_info(course_id, content, user, user_info):
@@ -310,7 +304,7 @@ def get_annotated_content_info(course_id, content, user, user_info):
'ability': get_ability(course_id, content, user),
}
#TODO: RENAME
# TODO: RENAME
def get_annotated_content_infos(course_id, thread, user, user_info):
@@ -318,6 +312,7 @@ def get_annotated_content_infos(course_id, thread, user, user_info):
Get metadata for a thread and its children
"""
infos = {}
def annotate(content):
infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info)
for child in content.get('children', []):
@@ -382,8 +377,8 @@ def get_courseware_context(content, course):
location = id_map[id]["location"].url()
title = id_map[id]["title"]
url = reverse('jump_to', kwargs={"course_id":course.location.course_id,
"location": location})
url = reverse('jump_to', kwargs={"course_id": course.location.course_id,
"location": location})
content_info = {"courseware_url": url, "courseware_title": title}
return content_info
@@ -396,7 +391,8 @@ def safe_content(content):
'updated_at', 'depth', 'type', 'commentable_id', 'comments_count',
'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
'courseware_title', 'courseware_url', 'tags', 'unread_comments_count',
'read', 'group_id', 'group_name', 'group_string', 'pinned'
'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers'
]
if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False):

View File

@@ -13,6 +13,7 @@ from foldit.models import PuzzleComplete, Score
from student.models import UserProfile, unique_id_for_user
from datetime import datetime, timedelta
from pytz import UTC
log = logging.getLogger(__name__)
@@ -28,7 +29,7 @@ class FolditTestCase(TestCase):
self.user2 = User.objects.create_user('testuser2', 'test2@test.com', pwd)
self.unique_user_id = unique_id_for_user(self.user)
self.unique_user_id2 = unique_id_for_user(self.user2)
now = datetime.now()
now = datetime.now(UTC)
self.tomorrow = now + timedelta(days=1)
self.yesterday = now - timedelta(days=1)
@@ -222,7 +223,7 @@ class FolditTestCase(TestCase):
verify = {"Verify": verify_code(self.user.email, puzzles_str),
"VerifyMethod":"FoldItVerify"}
data = {'SetPuzzlesCompleteVerify': json.dumps(verify),
data = {'SetPuzzlesCompleteVerify': json.dumps(verify),
'SetPuzzlesComplete': puzzles_str}
request = self.make_request(data)

View File

@@ -0,0 +1,178 @@
'''
Unit tests for enrollment methods in views.py
'''
from django.test.utils import override_settings
from django.contrib.auth.models import Group, User
from django.core.urlresolvers import reverse
from courseware.access import _course_staff_group_name
from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user
from xmodule.modulestore.django import modulestore
import xmodule.modulestore.django
from student.models import CourseEnrollment, CourseEnrollmentAllowed
from instructor.views import get_and_clean_student_list
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestInstructorEnrollsStudent(LoginEnrollmentTestCase):
'''
Check Enrollment/Unenrollment with/without auto-enrollment on activation
'''
def setUp(self):
self.full = modulestore().get_course("edX/full/6.002_Spring_2012")
self.toy = modulestore().get_course("edX/toy/2012_Fall")
#Create instructor and student accounts
self.instructor = 'instructor1@test.com'
self.student1 = 'student1@test.com'
self.student2 = 'student2@test.com'
self.password = 'foo'
self.create_account('it1', self.instructor, self.password)
self.create_account('st1', self.student1, self.password)
self.create_account('st2', self.student2, self.password)
self.activate_user(self.instructor)
self.activate_user(self.student1)
self.activate_user(self.student2)
def make_instructor(course):
group_name = _course_staff_group_name(course.location)
g = Group.objects.create(name=group_name)
g.user_set.add(get_user(self.instructor))
make_instructor(self.toy)
#Enroll Students
self.logout()
self.login(self.student1, self.password)
self.enroll(self.toy)
self.logout()
self.login(self.student2, self.password)
self.enroll(self.toy)
#Enroll Instructor
self.logout()
self.login(self.instructor, self.password)
self.enroll(self.toy)
def test_unenrollment(self):
'''
Do un-enrollment test
'''
course = self.toy
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student1@test.com, student2@test.com'})
#Check the page output
self.assertContains(response, '<td>student1@test.com</td>')
self.assertContains(response, '<td>student2@test.com</td>')
self.assertContains(response, '<td>un-enrolled</td>')
#Check the enrollment table
user = User.objects.get(email='student1@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
user = User.objects.get(email='student2@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
def test_enrollment_new_student_autoenroll_on(self):
'''
Do auto-enroll on test
'''
#Run the Enroll students command
course = self.toy
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'test1_1@student.com, test1_2@student.com', 'auto_enroll': 'on'})
#Check the page output
self.assertContains(response, '<td>test1_1@student.com</td>')
self.assertContains(response, '<td>test1_2@student.com</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on</td>')
#Check the enrollmentallowed db entries
cea = CourseEnrollmentAllowed.objects.filter(email='test1_1@student.com', course_id=course.id)
self.assertEqual(1, cea[0].auto_enroll)
cea = CourseEnrollmentAllowed.objects.filter(email='test1_2@student.com', course_id=course.id)
self.assertEqual(1, cea[0].auto_enroll)
#Check there is no enrollment db entry other than for the setup instructor and students
ce = CourseEnrollment.objects.filter(course_id=course.id)
self.assertEqual(3, len(ce))
#Create and activate student accounts with same email
self.student1 = 'test1_1@student.com'
self.password = 'bar'
self.create_account('s1_1', self.student1, self.password)
self.activate_user(self.student1)
self.student2 = 'test1_2@student.com'
self.create_account('s1_2', self.student2, self.password)
self.activate_user(self.student2)
#Check students are enrolled
user = User.objects.get(email='test1_1@student.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(1, len(ce))
user = User.objects.get(email='test1_2@student.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(1, len(ce))
def test_enrollmemt_new_student_autoenroll_off(self):
'''
Do auto-enroll off test
'''
#Run the Enroll students command
course = self.toy
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'test2_1@student.com, test2_2@student.com'})
#Check the page output
self.assertContains(response, '<td>test2_1@student.com</td>')
self.assertContains(response, '<td>test2_2@student.com</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment off</td>')
#Check the enrollmentallowed db entries
cea = CourseEnrollmentAllowed.objects.filter(email='test2_1@student.com', course_id=course.id)
self.assertEqual(0, cea[0].auto_enroll)
cea = CourseEnrollmentAllowed.objects.filter(email='test2_2@student.com', course_id=course.id)
self.assertEqual(0, cea[0].auto_enroll)
#Check there is no enrollment db entry other than for the setup instructor and students
ce = CourseEnrollment.objects.filter(course_id=course.id)
self.assertEqual(3, len(ce))
#Create and activate student accounts with same email
self.student = 'test2_1@student.com'
self.password = 'bar'
self.create_account('s2_1', self.student, self.password)
self.activate_user(self.student)
self.student = 'test2_2@student.com'
self.create_account('s2_2', self.student, self.password)
self.activate_user(self.student)
#Check students are not enrolled
user = User.objects.get(email='test2_1@student.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
user = User.objects.get(email='test2_2@student.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
def test_get_and_clean_student_list(self):
'''
Clean user input test
'''
string = "abc@test.com, def@test.com ghi@test.com \n \n jkl@test.com "
cleaned_string, cleaned_string_lc = get_and_clean_student_list(string)
self.assertEqual(cleaned_string, ['abc@test.com', 'def@test.com', 'ghi@test.com', 'jkl@test.com'])

View File

@@ -9,7 +9,7 @@ from django.test.utils import override_settings
from django.contrib.auth.models import Group
from django.core.urlresolvers import reverse
from django_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, \
from django_comment_common.models import Role, FORUM_ROLE_ADMINISTRATOR, \
FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT
from django_comment_client.utils import has_forum_access

View File

@@ -0,0 +1,63 @@
"""
Tests of various instructor dashboard features that include lists of students
"""
from django.conf import settings
from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings
from markupsafe import escape
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from instructor import views
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestXss(ModuleStoreTestCase):
def setUp(self):
self._request_factory = RequestFactory()
self._course = CourseFactory.create()
self._evil_student = UserFactory.create(
email="robot+evil@edx.org",
username="evil-robot",
profile__name='<span id="evil">Evil Robot</span>',
)
self._instructor = UserFactory.create(
email="robot+instructor@edx.org",
username="instructor",
is_staff=True
)
CourseEnrollmentFactory.create(
user=self._evil_student,
course_id=self._course.id
)
def _test_action(self, action):
"""
Test for XSS vulnerability in the given action
Build a request with the given action, call the instructor dashboard
view, and check that HTML code in a user's name is properly escaped.
"""
req = self._request_factory.post(
"dummy_url",
data={"action": action}
)
req.user = self._instructor
req.session = {}
resp = views.instructor_dashboard(req, self._course.id)
respUnicode = resp.content.decode(settings.DEFAULT_CHARSET)
self.assertNotIn(self._evil_student.profile.name, respUnicode)
self.assertIn(escape(self._evil_student.profile.name), respUnicode)
def test_list_enrolled(self):
self._test_action("List enrolled students")
def test_dump_list_of_enrolled(self):
self._test_action("Dump list of enrolled students")
def test_dump_grades(self):
self._test_action("Dump Grades for all students in this course")

View File

@@ -5,6 +5,7 @@ from collections import defaultdict
import csv
import json
import logging
from markupsafe import escape
import os
import re
import requests
@@ -27,7 +28,7 @@ from courseware.access import (has_access, get_access_group_name,
course_beta_test_group_name)
from courseware.courses import get_course_with_access
from courseware.models import StudentModule
from django_comment_client.models import (Role,
from django_comment_common.models import (Role,
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA)
@@ -76,10 +77,6 @@ def instructor_dashboard(request, course_id):
else:
idash_mode = request.session.get('idash_mode', 'Grades')
def escape(s):
"""escape HTML special characters in string"""
return str(s).replace('<', '&lt;').replace('>', '&gt;')
# assemble some course statistics for output to instructor
datatable = {'header': ['Statistic', 'Value'],
'title': 'Course Statistics At A Glance',
@@ -230,13 +227,13 @@ def instructor_dashboard(request, course_id):
if student_to_reset is not None:
# find the module in question
if '/' not in problem_to_reset: # allow state of modules other than problem to be reset
problem_to_reset = "problem/" + problem_to_reset # but problem is the default
problem_to_reset = "problem/" + problem_to_reset # but problem is the default
try:
(org, course_name, _) = course_id.split("/")
module_state_key = "i4x://" + org + "/" + course_name + "/" + problem_to_reset
module_to_reset = StudentModule.objects.get(student_id=student_to_reset.id,
course_id=course_id,
module_state_key=module_state_key)
course_id=course_id,
module_state_key=module_state_key)
msg += "Found module to reset. "
except Exception:
msg += "<font color='red'>Couldn't find module with that urlname. </font>"
@@ -260,19 +257,18 @@ def instructor_dashboard(request, course_id):
module_to_reset.state = json.dumps(problem_state)
module_to_reset.save()
track.views.server_track(request,
'{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format(
old_attempts=old_number_of_attempts,
student=student_to_reset,
problem=module_to_reset.module_state_key,
instructor=request.user,
course=course_id),
{},
page='idashboard')
'{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format(
old_attempts=old_number_of_attempts,
student=student_to_reset,
problem=module_to_reset.module_state_key,
instructor=request.user,
course=course_id),
{},
page='idashboard')
msg += "<font color='green'>Module state successfully reset!</font>"
except:
msg += "<font color='red'>Couldn't reset module state. </font>"
elif "Get link to student's progress page" in action:
unique_student_identifier = request.POST.get('unique_student_identifier', '')
try:
@@ -282,12 +278,12 @@ def instructor_dashboard(request, course_id):
student_to_reset = User.objects.get(username=unique_student_identifier)
progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': student_to_reset.id})
track.views.server_track(request,
'{instructor} requested progress page for {student} in {course}'.format(
student=student_to_reset,
instructor=request.user,
course=course_id),
{},
page='idashboard')
'{instructor} requested progress page for {student} in {course}'.format(
student=student_to_reset,
instructor=request.user,
course=course_id),
{},
page='idashboard')
msg += "<a href='{0}' target='_blank'> Progress page for username: {1} with email address: {2}</a>.".format(progress_url, student_to_reset.username, student_to_reset.email)
except:
msg += "<font color='red'>Couldn't find student with that username. </font>"
@@ -315,8 +311,9 @@ def instructor_dashboard(request, course_id):
msg2, rg_stud_data = _do_remote_gradebook(request.user, course, 'get-membership')
datatable = {'header': ['Student email', 'Match?']}
rg_students = [x['email'] for x in rg_stud_data['retdata']]
def domatch(x):
return '<font color="green">yes</font>' if x.email in rg_students else '<font color="red">No</font>'
return 'yes' if x.email in rg_students else 'No'
datatable['data'] = [[x.email, domatch(x)] for x in stud_data['students']]
datatable['title'] = action
@@ -350,7 +347,6 @@ def instructor_dashboard(request, course_id):
msg2, _ = _do_remote_gradebook(request.user, course, 'post-grades', files=files)
msg += msg2
#----------------------------------------
# Admin
@@ -416,6 +412,7 @@ def instructor_dashboard(request, course_id):
profkeys = ['name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education',
'mailing_address', 'goals']
datatable = {'header': ['username', 'email'] + profkeys}
def getdat(u):
p = u.profile
return [u.username, u.email] + [getattr(p, x, '') for x in profkeys]
@@ -424,9 +421,8 @@ def instructor_dashboard(request, course_id):
datatable['title'] = 'Student profile data for course %s' % course_id
return return_csv('profiledata_%s.csv' % course_id, datatable)
elif 'Download CSV of all responses to problem' in action:
problem_to_dump = request.POST.get('problem_to_dump','')
problem_to_dump = request.POST.get('problem_to_dump', '')
if problem_to_dump[-4:] == ".xml":
problem_to_dump = problem_to_dump[:-4]
@@ -444,7 +440,7 @@ def instructor_dashboard(request, course_id):
if smdat:
datatable = {'header': ['username', 'state']}
datatable['data'] = [ [x.student.username, x.state] for x in smdat ]
datatable['data'] = [[x.student.username, x.state] for x in smdat]
datatable['title'] = 'Student state for problem %s' % problem_to_dump
return return_csv('student_state_from_%s.csv' % problem_to_dump, datatable)
@@ -481,7 +477,6 @@ def instructor_dashboard(request, course_id):
msg += _list_course_forum_members(course_id, rolename, datatable)
track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard')
elif action == 'Remove forum admin':
uname = request.POST['forumadmin']
msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_REMOVE)
@@ -539,35 +534,17 @@ def instructor_dashboard(request, course_id):
datatable['data'] = [[x.email] for x in ceaset]
datatable['title'] = action
elif action == 'Enroll student':
student = request.POST.get('enstudent', '')
ret = _do_enroll_students(course, course_id, student)
datatable = ret['datatable']
elif action == 'Un-enroll student':
student = request.POST.get('enstudent', '')
datatable = {}
isok = False
cea = CourseEnrollmentAllowed.objects.filter(course_id=course_id, email=student)
if cea:
cea.delete()
msg += "Un-enrolled student with email '%s'" % student
isok = True
try:
nce = CourseEnrollment.objects.get(user=User.objects.get(email=student), course_id=course_id)
nce.delete()
msg += "Un-enrolled student with email '%s'" % student
except Exception as err:
if not isok:
msg += "Error! Failed to un-enroll student with email '%s'\n" % student
msg += str(err) + '\n'
elif action == 'Enroll multiple students':
students = request.POST.get('enroll_multiple', '')
ret = _do_enroll_students(course, course_id, students)
students = request.POST.get('multiple_students', '')
auto_enroll = bool(request.POST.get('auto_enroll'))
ret = _do_enroll_students(course, course_id, students, auto_enroll=auto_enroll)
datatable = ret['datatable']
elif action == 'Unenroll multiple students':
students = request.POST.get('multiple_students', '')
ret = _do_unenroll_students(course_id, students)
datatable = ret['datatable']
elif action == 'List sections available in remote gradebook':
@@ -589,7 +566,6 @@ def instructor_dashboard(request, course_id):
ret = _do_enroll_students(course, course_id, students, overload=overload)
datatable = ret['datatable']
#----------------------------------------
# psychometrics
@@ -609,9 +585,9 @@ def instructor_dashboard(request, course_id):
logs and swallows errors.
"""
url = settings.ANALYTICS_SERVER_URL + \
"get?aname={}&course_id={}&apikey={}".format(analytics_name,
course_id,
settings.ANALYTICS_API_KEY)
"get?aname={}&course_id={}&apikey={}".format(analytics_name,
course_id,
settings.ANALYTICS_API_KEY)
try:
res = requests.get(url)
except Exception:
@@ -670,7 +646,7 @@ def instructor_dashboard(request, course_id):
'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}),
'analytics_results': analytics_results,
}
}
return render_to_response('courseware/instructor_dashboard.html', context)
@@ -833,7 +809,7 @@ def _add_or_remove_user_group(request, username_or_email, group, group_title, ev
action = "Added" if do_add else "Removed"
prep = "to" if do_add else "from"
msg = '<font color="green">{action} {0} {prep} {1} group = {2}</font>'.format(user, group_title, group.name,
action=action, prep=prep)
action=action, prep=prep)
if do_add:
user.groups.add(group)
else:
@@ -959,7 +935,7 @@ def gradebook(request, course_id):
'grade_summary': student_grades(student, request, course),
'realname': student.profile.name,
}
for student in enrolled_students]
for student in enrolled_students]
return render_to_response('courseware/gradebook.html', {
'students': student_info,
@@ -985,17 +961,11 @@ def grade_summary(request, course_id):
#-----------------------------------------------------------------------------
# enrollment
def _do_enroll_students(course, course_id, students, overload=False):
def _do_enroll_students(course, course_id, students, overload=False, auto_enroll=False):
"""Do the actual work of enrolling multiple students, presented as a string
of emails separated by commas or returns"""
new_students = split_by_comma_and_whitespace(students)
new_students = [str(s.strip()) for s in new_students]
new_students_lc = [x.lower() for x in new_students]
if '' in new_students:
new_students.remove('')
new_students, new_students_lc = get_and_clean_student_list(students)
status = dict([x, 'unprocessed'] for x in new_students)
if overload: # delete all but staff
@@ -1015,27 +985,35 @@ def _do_enroll_students(course, course_id, students, overload=False):
try:
user = User.objects.get(email=student)
except User.DoesNotExist:
# user not signed up yet, put in pending enrollment allowed table
if CourseEnrollmentAllowed.objects.filter(email=student, course_id=course_id):
status[student] = 'user does not exist, enrollment already allowed, pending'
#User not signed up yet, put in pending enrollment allowed table
cea = CourseEnrollmentAllowed.objects.filter(email=student, course_id=course_id)
#If enrollmentallowed already exists, update auto_enroll flag to however it was set in UI
#Will be 0 or 1 records as there is a unique key on email + course_id
if cea:
cea[0].auto_enroll = auto_enroll
cea[0].save()
status[student] = 'user does not exist, enrollment already allowed, pending with auto enrollment ' \
+ ('on' if auto_enroll else 'off')
continue
cea = CourseEnrollmentAllowed(email=student, course_id=course_id)
cea = CourseEnrollmentAllowed(email=student, course_id=course_id, auto_enroll=auto_enroll)
cea.save()
status[student] = 'user does not exist, enrollment allowed, pending'
status[student] = 'user does not exist, enrollment allowed, pending with auto enrollment ' + ('on' if auto_enroll else 'off')
continue
if CourseEnrollment.objects.filter(user=user, course_id=course_id):
status[student] = 'already enrolled'
continue
try:
nce = CourseEnrollment(user=user, course_id=course_id)
nce.save()
ce = CourseEnrollment(user=user, course_id=course_id)
ce.save()
status[student] = 'added'
except:
status[student] = 'rejected'
datatable = {'header': ['StudentEmail', 'action']}
datatable['data'] = [[x, status[x]] for x in status]
datatable['data'] = [[x, status[x]] for x in sorted(status)]
datatable['title'] = 'Enrollment of students'
def sf(stat):
@@ -1047,39 +1025,69 @@ def _do_enroll_students(course, course_id, students, overload=False):
return data
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def enroll_students(request, course_id):
"""Allows a staff member to enroll students in a course.
#Unenrollment
def _do_unenroll_students(course_id, students):
"""Do the actual work of un-enrolling multiple students, presented as a string
of emails separated by commas or returns"""
This is a short-term hack for Berkeley courses launching fall
2012. In the long term, we would like functionality like this, but
we would like both the instructor and the student to agree. Right
now, this allows any instructor to add students to their course,
which we do not want.
old_students, old_students_lc = get_and_clean_student_list(students)
status = dict([x, 'unprocessed'] for x in old_students)
It is poorly written and poorly tested, but it's designed to be
stripped out.
for student in old_students:
isok = False
cea = CourseEnrollmentAllowed.objects.filter(course_id=course_id, email=student)
#Will be 0 or 1 records as there is a unique key on email + course_id
if cea:
cea[0].delete()
status[student] = "un-enrolled"
isok = True
try:
user = User.objects.get(email=student)
except User.DoesNotExist:
continue
ce = CourseEnrollment.objects.filter(user=user, course_id=course_id)
#Will be 0 or 1 records as there is a unique key on user + course_id
if ce:
try:
ce[0].delete()
status[student] = "un-enrolled"
except Exception as err:
if not isok:
status[student] = "Error! Failed to un-enroll"
datatable = {'header': ['StudentEmail', 'action']}
datatable['data'] = [[x, status[x]] for x in sorted(status)]
datatable['title'] = 'Un-enrollment of students'
data = dict(datatable=datatable)
return data
def get_and_clean_student_list(students):
"""
Separate out individual student email from the comma, or space separated string.
In:
students: string coming from the input text area
Return:
students: list of cleaned student emails
students_lc: list of lower case cleaned student emails
"""
course = get_course_with_access(request.user, course_id, 'staff')
existing_students = [ce.user.email for ce in CourseEnrollment.objects.filter(course_id=course_id)]
new_students = request.POST.get('new_students')
ret = _do_enroll_students(course, course_id, new_students)
added_students = ret['added']
rejected_students = ret['rejected']
return render_to_response("enroll_students.html", {'course': course_id,
'existing_students': existing_students,
'added_students': added_students,
'rejected_students': rejected_students,
'debug': new_students})
students = split_by_comma_and_whitespace(students)
students = [str(s.strip()) for s in students]
students = [s for s in students if s != '']
students_lc = [x.lower() for x in students]
return students, students_lc
#-----------------------------------------------------------------------------
# answer distribution
def get_answers_distribution(request, course_id):
"""
Get the distribution of answers for all graded problems in the course.
@@ -1171,5 +1179,5 @@ def dump_grading_context(course):
msg += " %s (format=%s, Assignment=%s%s)\n" % (s.display_name, format, aname, notes)
msg += "all descriptors:\n"
msg += "length=%d\n" % len(gc['all_descriptors'])
msg = '<pre>%s</pre>' % msg.replace('<','&lt;')
msg = '<pre>%s</pre>' % msg.replace('<', '&lt;')
return msg

View File

@@ -18,6 +18,7 @@ from django.core.management.base import BaseCommand
from student.models import UserProfile, Registration
from external_auth.models import ExternalAuthMap
from django.contrib.auth.models import User, Group
from pytz import UTC
class MyCompleter(object): # Custom completer
@@ -124,7 +125,7 @@ class Command(BaseCommand):
external_credentials=json.dumps(credentials),
)
eamap.user = user
eamap.dtsignup = datetime.datetime.now()
eamap.dtsignup = datetime.datetime.now(UTC)
eamap.save()
print "User %s created successfully!" % user

View File

@@ -0,0 +1,57 @@
Notes Django App
================
This is a django application that stores and displays notes that students make while reading static HTML book(s) in their courseware. Note taking functionality in the static HTML book(s) is handled by a wrapper script around [annotator.js](http://okfnlabs.org/annotator/), which interfaces with the API provided by this application to store and retrieve notes.
Usage
-----
To use this application, course staff must opt-in by doing the following:
* Login to [Studio](http://studio.edx.org/).
* Go to *Course Settings* -> *Advanced Settings*
* Find the ```advanced_modules``` policy key and in the policy value field, add ```"notes"``` to the list.
* Save the course settings.
The result of following these steps is that you should see a new tab appear in the courseware named *My Notes*. This will display a journal of notes that the student has created in the static HTML book(s). Second, when you highlight text in the static HTML book(s), a dialog will appear. You can enter some notes and tags and save it. The note will appear highlighted in the text and will also be saved to the journal.
To disable the *My Notes* tab and notes in the static HTML book(s), simply reverse the above steps (i.e. remove ```"notes"``` from the ```advanced_modules``` policy setting).
### Caveats and Limitations
* Notes are private to each student.
* Sharing and replying to notes is not supported.
* The student *My Notes* interface is very limited.
* There is no instructor interface to view student notes.
Developer Overview
------------------
### Quickstart
```
$ rake django-admin[syncdb]
$ rake django-admin[migrate]
```
Then follow the steps above to enable the *My Notes* tab or manually add a tab to the policy tab configuration with ```{"type": "notes", "name": "My Notes"}```.
### App Directory Structure:
lms/djangoapps/notes:
* api.py - API used by annotator.js on the frontend
* models.py - Contains note model for storing notes
* tests.py - Unit tests
* views.py - View to display the journal of notes (i.e. *My Notes* tab)
* urls.py - Maps the API and View routes.
* utils.py - Contains method for checking if the course has this app enabled. Intended to be public to other modules.
Also requires:
* lms/static/coffee/src/notes.coffee -- wrapper around annotator.js
* lms/templates/notes.html -- used by views.py to display the notes
Interacts with:
* lms/djangoapps/staticbook - the html static book checks to see if notes is enabled and has some logic to enable/disable accordingly

View File

251
lms/djangoapps/notes/api.py Normal file
View File

@@ -0,0 +1,251 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, Http404
from django.core.exceptions import ValidationError
from notes.models import Note
from notes.utils import notes_enabled_for_course
from courseware.courses import get_course_with_access
import json
import logging
import collections
log = logging.getLogger(__name__)
API_SETTINGS = {
'META': {'name': 'Notes API', 'version': 1},
# Maps resources to HTTP methods and actions
'RESOURCE_MAP': {
'root': {'GET': 'root'},
'notes': {'GET': 'index', 'POST': 'create'},
'note': {'GET': 'read', 'PUT': 'update', 'DELETE': 'delete'},
'search': {'GET': 'search'},
},
# Cap the number of notes that can be returned in one request
'MAX_NOTE_LIMIT': 1000,
}
# Wrapper class for HTTP response and data. All API actions are expected to return this.
ApiResponse = collections.namedtuple('ApiResponse', ['http_response', 'data'])
#----------------------------------------------------------------------#
# API requests are routed through api_request() using the resource map.
def api_enabled(request, course_id):
'''
Returns True if the api is enabled for the course, otherwise False.
'''
course = _get_course(request, course_id)
return notes_enabled_for_course(course)
@login_required
def api_request(request, course_id, **kwargs):
'''
Routes API requests to the appropriate action method and returns JSON.
Raises a 404 if the requested resource does not exist or notes are
disabled for the course.
'''
# Verify that the api should be accessible to this course
if not api_enabled(request, course_id):
log.debug('Notes are disabled for course: {0}'.format(course_id))
raise Http404
# Locate the requested resource
resource_map = API_SETTINGS.get('RESOURCE_MAP', {})
resource_name = kwargs.pop('resource')
resource_method = request.method
resource = resource_map.get(resource_name)
if resource is None:
log.debug('Resource "{0}" does not exist'.format(resource_name))
raise Http404
if resource_method not in resource.keys():
log.debug('Resource "{0}" does not support method "{1}"'.format(resource_name, resource_method))
raise Http404
# Execute the action associated with the resource
func = resource.get(resource_method)
module = globals()
if func not in module:
log.debug('Function "{0}" does not exist for request {1} {2}'.format(func, resource_method, resource_name))
raise Http404
log.debug('API request: {0} {1}'.format(resource_method, resource_name))
api_response = module[func](request, course_id, **kwargs)
http_response = api_format(api_response)
return http_response
def api_format(api_response):
'''
Takes an ApiResponse and returns an HttpResponse.
'''
http_response = api_response.http_response
content_type = 'application/json'
content = ''
# not doing a strict boolean check on data becuase it could be an empty list
if api_response.data is not None and api_response.data != '':
content = json.dumps(api_response.data)
http_response['Content-type'] = content_type
http_response.content = content
log.debug('API response type: {0} content: {1}'.format(content_type, content))
return http_response
def _get_course(request, course_id):
'''
Helper function to load and return a user's course.
'''
return get_course_with_access(request.user, course_id, 'load')
#----------------------------------------------------------------------#
# API actions exposed via the resource map.
def index(request, course_id):
'''
Returns a list of annotation objects.
'''
MAX_LIMIT = API_SETTINGS.get('MAX_NOTE_LIMIT')
notes = Note.objects.order_by('id').filter(course_id=course_id,
user=request.user)[:MAX_LIMIT]
return ApiResponse(http_response=HttpResponse(), data=[note.as_dict() for note in notes])
def create(request, course_id):
'''
Receives an annotation object to create and returns a 303 with the read location.
'''
note = Note(course_id=course_id, user=request.user)
try:
note.clean(request.body)
except ValidationError as e:
log.debug(e)
return ApiResponse(http_response=HttpResponse('', status=400), data=None)
note.save()
response = HttpResponse('', status=303)
response['Location'] = note.get_absolute_url()
return ApiResponse(http_response=response, data=None)
def read(request, course_id, note_id):
'''
Returns a single annotation object.
'''
try:
note = Note.objects.get(id=note_id)
except Note.DoesNotExist:
return ApiResponse(http_response=HttpResponse('', status=404), data=None)
if note.user.id != request.user.id:
return ApiResponse(http_response=HttpResponse('', status=403), data=None)
return ApiResponse(http_response=HttpResponse(), data=note.as_dict())
def update(request, course_id, note_id):
'''
Updates an annotation object and returns a 303 with the read location.
'''
try:
note = Note.objects.get(id=note_id)
except Note.DoesNotExist:
return ApiResponse(http_response=HttpResponse('', status=404), data=None)
if note.user.id != request.user.id:
return ApiResponse(http_response=HttpResponse('', status=403), data=None)
try:
note.clean(request.body)
except ValidationError as e:
log.debug(e)
return ApiResponse(http_response=HttpResponse('', status=400), data=None)
note.save()
response = HttpResponse('', status=303)
response['Location'] = note.get_absolute_url()
return ApiResponse(http_response=response, data=None)
def delete(request, course_id, note_id):
'''
Deletes the annotation object and returns a 204 with no content.
'''
try:
note = Note.objects.get(id=note_id)
except Note.DoesNotExist:
return ApiResponse(http_response=HttpResponse('', status=404), data=None)
if note.user.id != request.user.id:
return ApiResponse(http_response=HttpResponse('', status=403), data=None)
note.delete()
return ApiResponse(http_response=HttpResponse('', status=204), data=None)
def search(request, course_id):
'''
Returns a subset of annotation objects based on a search query.
'''
MAX_LIMIT = API_SETTINGS.get('MAX_NOTE_LIMIT')
# search parameters
offset = request.GET.get('offset', '')
limit = request.GET.get('limit', '')
uri = request.GET.get('uri', '')
# validate search parameters
if offset.isdigit():
offset = int(offset)
else:
offset = 0
if limit.isdigit():
limit = int(limit)
if limit == 0 or limit > MAX_LIMIT:
limit = MAX_LIMIT
else:
limit = MAX_LIMIT
# set filters
filters = {'course_id': course_id, 'user': request.user}
if uri != '':
filters['uri'] = uri
# retrieve notes
notes = Note.objects.order_by('id').filter(**filters)
total = notes.count()
rows = notes[offset:offset + limit]
result = {
'total': total,
'rows': [note.as_dict() for note in rows]
}
return ApiResponse(http_response=HttpResponse(), data=result)
def root(request, course_id):
'''
Returns version information about the API.
'''
return ApiResponse(http_response=HttpResponse(), data=API_SETTINGS.get('META'))

View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'Note'
db.create_table('notes_note', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('uri', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('text', self.gf('django.db.models.fields.TextField')(default='')),
('quote', self.gf('django.db.models.fields.TextField')(default='')),
('range_start', self.gf('django.db.models.fields.CharField')(max_length=2048)),
('range_start_offset', self.gf('django.db.models.fields.IntegerField')()),
('range_end', self.gf('django.db.models.fields.CharField')(max_length=2048)),
('range_end_offset', self.gf('django.db.models.fields.IntegerField')()),
('tags', self.gf('django.db.models.fields.TextField')(default='')),
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)),
('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
))
db.send_create_signal('notes', ['Note'])
def backwards(self, orm):
# Deleting model 'Note'
db.delete_table('notes_note')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'notes.note': {
'Meta': {'object_name': 'Note'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'quote': ('django.db.models.fields.TextField', [], {'default': "''"}),
'range_end': ('django.db.models.fields.CharField', [], {'max_length': '2048'}),
'range_end_offset': ('django.db.models.fields.IntegerField', [], {}),
'range_start': ('django.db.models.fields.CharField', [], {'max_length': '2048'}),
'range_start_offset': ('django.db.models.fields.IntegerField', [], {}),
'tags': ('django.db.models.fields.TextField', [], {'default': "''"}),
'text': ('django.db.models.fields.TextField', [], {'default': "''"}),
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'uri': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['notes']

View File

@@ -0,0 +1,81 @@
from django.db import models
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.core.exceptions import ValidationError
from django.utils.html import strip_tags
import json
class Note(models.Model):
user = models.ForeignKey(User, db_index=True)
course_id = models.CharField(max_length=255, db_index=True)
uri = models.CharField(max_length=255, db_index=True)
text = models.TextField(default="")
quote = models.TextField(default="")
range_start = models.CharField(max_length=2048) # xpath string
range_start_offset = models.IntegerField()
range_end = models.CharField(max_length=2048) # xpath string
range_end_offset = models.IntegerField()
tags = models.TextField(default="") # comma-separated string
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
updated = models.DateTimeField(auto_now=True, db_index=True)
def clean(self, json_body):
"""
Cleans the note object or raises a ValidationError.
"""
if json_body is None:
raise ValidationError('Note must have a body.')
body = json.loads(json_body)
if not type(body) is dict:
raise ValidationError('Note body must be a dictionary.')
# NOTE: all three of these fields should be considered user input
# and may be output back to the user, so we need to sanitize them.
# These fields should only contain _plain text_.
self.uri = strip_tags(body.get('uri', ''))
self.text = strip_tags(body.get('text', ''))
self.quote = strip_tags(body.get('quote', ''))
ranges = body.get('ranges')
if ranges is None or len(ranges) != 1:
raise ValidationError('Note must contain exactly one range.')
self.range_start = ranges[0]['start']
self.range_start_offset = ranges[0]['startOffset']
self.range_end = ranges[0]['end']
self.range_end_offset = ranges[0]['endOffset']
self.tags = ""
tags = [strip_tags(tag) for tag in body.get('tags', [])]
if len(tags) > 0:
self.tags = ",".join(tags)
def get_absolute_url(self):
"""
Returns the absolute url for the note object.
"""
kwargs = {'course_id': self.course_id, 'note_id': str(self.pk)}
return reverse('notes_api_note', kwargs=kwargs)
def as_dict(self):
"""
Returns the note object as a dictionary.
"""
return {
'id': self.pk,
'user_id': self.user.pk,
'uri': self.uri,
'text': self.text,
'quote': self.quote,
'ranges': [{
'start': self.range_start,
'startOffset': self.range_start_offset,
'end': self.range_end,
'endOffset': self.range_end_offset
}],
'tags': self.tags.split(","),
'created': str(self.created),
'updated': str(self.updated)
}

View File

@@ -0,0 +1,398 @@
"""
Unit tests for the notes app.
"""
from django.test import TestCase
from django.test.client import Client
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
import collections
import unittest
import json
import logging
from . import utils, api, models
class UtilsTest(TestCase):
def setUp(self):
'''
Setup a dummy course-like object with a tabs field that can be
accessed via attribute lookup.
'''
self.course = collections.namedtuple('DummyCourse', ['tabs'])
self.course.tabs = []
def test_notes_not_enabled(self):
'''
Tests that notes are disabled when the course tab configuration does NOT
contain a tab with type "notes."
'''
self.assertFalse(utils.notes_enabled_for_course(self.course))
def test_notes_enabled(self):
'''
Tests that notes are enabled when the course tab configuration contains
a tab with type "notes."
'''
self.course.tabs = [{'type': 'foo'},
{'name': 'My Notes', 'type': 'notes'},
{'type': 'bar'}]
self.assertTrue(utils.notes_enabled_for_course(self.course))
class ApiTest(TestCase):
def setUp(self):
self.client = Client()
# Mocks
api.api_enabled = self.mock_api_enabled(True)
# Create two accounts
self.password = 'abc'
self.student = User.objects.create_user('student', 'student@test.com', self.password)
self.student2 = User.objects.create_user('student2', 'student2@test.com', self.password)
self.instructor = User.objects.create_user('instructor', 'instructor@test.com', self.password)
self.course_id = 'HarvardX/CB22x/The_Ancient_Greek_Hero'
self.note = {
'user': self.student,
'course_id': self.course_id,
'uri': '/',
'text': 'foo',
'quote': 'bar',
'range_start': 0,
'range_start_offset': 0,
'range_end': 100,
'range_end_offset': 0,
'tags': 'a,b,c'
}
# Make sure no note with this ID ever exists for testing purposes
self.NOTE_ID_DOES_NOT_EXIST = 99999
def mock_api_enabled(self, is_enabled):
return (lambda request, course_id: is_enabled)
def login(self, as_student=None):
username = None
password = self.password
if as_student is None:
username = self.student.username
else:
username = as_student.username
self.client.login(username=username, password=password)
def url(self, name, args={}):
args.update({'course_id': self.course_id})
return reverse(name, kwargs=args)
def create_notes(self, num_notes, create=True):
notes = []
for n in range(num_notes):
note = models.Note(**self.note)
if create:
note.save()
notes.append(note)
return notes
def test_root(self):
self.login()
resp = self.client.get(self.url('notes_api_root'))
self.assertEqual(resp.status_code, 200)
self.assertNotEqual(resp.content, '')
content = json.loads(resp.content)
self.assertEqual(set(('name', 'version')), set(content.keys()))
self.assertIsInstance(content['version'], int)
self.assertEqual(content['name'], 'Notes API')
def test_index_empty(self):
self.login()
resp = self.client.get(self.url('notes_api_notes'))
self.assertEqual(resp.status_code, 200)
self.assertNotEqual(resp.content, '')
content = json.loads(resp.content)
self.assertEqual(len(content), 0)
def test_index_with_notes(self):
num_notes = 3
self.login()
self.create_notes(num_notes)
resp = self.client.get(self.url('notes_api_notes'))
self.assertEqual(resp.status_code, 200)
self.assertNotEqual(resp.content, '')
content = json.loads(resp.content)
self.assertIsInstance(content, list)
self.assertEqual(len(content), num_notes)
def test_index_max_notes(self):
self.login()
MAX_LIMIT = api.API_SETTINGS.get('MAX_NOTE_LIMIT')
num_notes = MAX_LIMIT + 1
self.create_notes(num_notes)
resp = self.client.get(self.url('notes_api_notes'))
self.assertEqual(resp.status_code, 200)
self.assertNotEqual(resp.content, '')
content = json.loads(resp.content)
self.assertIsInstance(content, list)
self.assertEqual(len(content), MAX_LIMIT)
def test_create_note(self):
self.login()
notes = self.create_notes(1)
self.assertEqual(len(notes), 1)
note_dict = notes[0].as_dict()
excluded_fields = ['id', 'user_id', 'created', 'updated']
note = dict([(k, v) for k, v in note_dict.items() if k not in excluded_fields])
resp = self.client.post(self.url('notes_api_notes'),
json.dumps(note),
content_type='application/json',
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(resp.status_code, 303)
self.assertEqual(len(resp.content), 0)
def test_create_empty_notes(self):
self.login()
for empty_test in [None, [], '']:
resp = self.client.post(self.url('notes_api_notes'),
json.dumps(empty_test),
content_type='application/json',
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(resp.status_code, 400)
def test_create_note_missing_ranges(self):
self.login()
notes = self.create_notes(1)
self.assertEqual(len(notes), 1)
note_dict = notes[0].as_dict()
excluded_fields = ['id', 'user_id', 'created', 'updated'] + ['ranges']
note = dict([(k, v) for k, v in note_dict.items() if k not in excluded_fields])
resp = self.client.post(self.url('notes_api_notes'),
json.dumps(note),
content_type='application/json',
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(resp.status_code, 400)
def test_read_note(self):
self.login()
notes = self.create_notes(3)
self.assertEqual(len(notes), 3)
for note in notes:
resp = self.client.get(self.url('notes_api_note', {'note_id': note.pk}))
self.assertEqual(resp.status_code, 200)
self.assertNotEqual(resp.content, '')
content = json.loads(resp.content)
self.assertEqual(content['id'], note.pk)
self.assertEqual(content['user_id'], note.user_id)
def test_note_doesnt_exist_to_read(self):
self.login()
resp = self.client.get(self.url('notes_api_note', {
'note_id': self.NOTE_ID_DOES_NOT_EXIST
}))
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.content, '')
def test_student_doesnt_have_permission_to_read_note(self):
notes = self.create_notes(1)
self.assertEqual(len(notes), 1)
note = notes[0]
# set the student id to a different student (not the one that created the notes)
self.login(as_student=self.student2)
resp = self.client.get(self.url('notes_api_note', {'note_id': note.pk}))
self.assertEqual(resp.status_code, 403)
self.assertEqual(resp.content, '')
def test_delete_note(self):
self.login()
notes = self.create_notes(1)
self.assertEqual(len(notes), 1)
note = notes[0]
resp = self.client.delete(self.url('notes_api_note', {
'note_id': note.pk
}))
self.assertEqual(resp.status_code, 204)
self.assertEqual(resp.content, '')
with self.assertRaises(models.Note.DoesNotExist):
models.Note.objects.get(pk=note.pk)
def test_note_does_not_exist_to_delete(self):
self.login()
resp = self.client.delete(self.url('notes_api_note', {
'note_id': self.NOTE_ID_DOES_NOT_EXIST
}))
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.content, '')
def test_student_doesnt_have_permission_to_delete_note(self):
notes = self.create_notes(1)
self.assertEqual(len(notes), 1)
note = notes[0]
self.login(as_student=self.student2)
resp = self.client.delete(self.url('notes_api_note', {
'note_id': note.pk
}))
self.assertEqual(resp.status_code, 403)
self.assertEqual(resp.content, '')
try:
models.Note.objects.get(pk=note.pk)
except models.Note.DoesNotExist:
self.fail('note should exist and not be deleted because the student does not have permission to do so')
def test_update_note(self):
notes = self.create_notes(1)
note = notes[0]
updated_dict = note.as_dict()
updated_dict.update({
'text': 'itchy and scratchy',
'tags': ['simpsons', 'cartoons', 'animation']
})
self.login()
resp = self.client.put(self.url('notes_api_note', {'note_id': note.pk}),
json.dumps(updated_dict),
content_type='application/json',
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(resp.status_code, 303)
self.assertEqual(resp.content, '')
actual = models.Note.objects.get(pk=note.pk)
actual_dict = actual.as_dict()
for field in ['text', 'tags']:
self.assertEqual(actual_dict[field], updated_dict[field])
def test_search_note_params(self):
self.login()
total = 3
notes = self.create_notes(total)
invalid_uri = ''.join([note.uri for note in notes])
tests = [{'limit': 0, 'offset': 0, 'expected_rows': total},
{'limit': 0, 'offset': 2, 'expected_rows': total - 2},
{'limit': 0, 'offset': total, 'expected_rows': 0},
{'limit': 1, 'offset': 0, 'expected_rows': 1},
{'limit': 2, 'offset': 0, 'expected_rows': 2},
{'limit': total, 'offset': 2, 'expected_rows': 1},
{'limit': total, 'offset': total, 'expected_rows': 0},
{'limit': total + 1, 'offset': total + 1, 'expected_rows': 0},
{'limit': total + 1, 'offset': 0, 'expected_rows': total},
{'limit': 0, 'offset': 0, 'uri': invalid_uri, 'expected_rows': 0, 'expected_total': 0}]
for test in tests:
params = dict([(k, str(test[k]))
for k in ('limit', 'offset', 'uri')
if k in test])
resp = self.client.get(self.url('notes_api_search'),
params,
content_type='application/json',
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(resp.status_code, 200)
self.assertNotEqual(resp.content, '')
content = json.loads(resp.content)
for expected_key in ('total', 'rows'):
self.assertTrue(expected_key in content)
if 'expected_total' in test:
self.assertEqual(content['total'], test['expected_total'])
else:
self.assertEqual(content['total'], total)
self.assertEqual(len(content['rows']), test['expected_rows'])
for row in content['rows']:
self.assertTrue('id' in row)
class NoteTest(TestCase):
def setUp(self):
self.password = 'abc'
self.student = User.objects.create_user('student', 'student@test.com', self.password)
self.course_id = 'HarvardX/CB22x/The_Ancient_Greek_Hero'
self.note = {
'user': self.student,
'course_id': self.course_id,
'uri': '/',
'text': 'foo',
'quote': 'bar',
'range_start': 0,
'range_start_offset': 0,
'range_end': 100,
'range_end_offset': 0,
'tags': 'a,b,c'
}
def test_clean_valid_note(self):
reference_note = models.Note(**self.note)
body = reference_note.as_dict()
note = models.Note(course_id=self.course_id, user=self.student)
try:
note.clean(json.dumps(body))
self.assertEqual(note.uri, body['uri'])
self.assertEqual(note.text, body['text'])
self.assertEqual(note.quote, body['quote'])
self.assertEqual(note.range_start, body['ranges'][0]['start'])
self.assertEqual(note.range_start_offset, body['ranges'][0]['startOffset'])
self.assertEqual(note.range_end, body['ranges'][0]['end'])
self.assertEqual(note.range_end_offset, body['ranges'][0]['endOffset'])
self.assertEqual(note.tags, ','.join(body['tags']))
except ValidationError:
self.fail('a valid note should not raise an exception')
def test_clean_invalid_note(self):
note = models.Note(course_id=self.course_id, user=self.student)
for empty_type in (None, '', 0, []):
with self.assertRaises(ValidationError):
note.clean(None)
with self.assertRaises(ValidationError):
note.clean(json.dumps({
'text': 'foo',
'quote': 'bar',
'ranges': [{} for i in range(10)] # too many ranges
}))
def test_as_dict(self):
note = models.Note(course_id=self.course_id, user=self.student)
d = note.as_dict()
self.assertNotIsInstance(d, basestring)
self.assertEqual(d['user_id'], self.student.id)
self.assertTrue('course_id' not in d)

View File

@@ -0,0 +1,10 @@
from django.conf.urls import patterns, url
id_regex = r"(?P<note_id>[0-9A-Fa-f]+)"
urlpatterns = patterns('notes.api',
url(r'^api$', 'api_request', {'resource': 'root'}, name='notes_api_root'),
url(r'^api/annotations$', 'api_request', {'resource': 'notes'}, name='notes_api_notes'),
url(r'^api/annotations/' + id_regex + r'$', 'api_request', {'resource': 'note'}, name='notes_api_note'),
url(r'^api/search', 'api_request', {'resource': 'search'}, name='notes_api_search')
)

View File

@@ -0,0 +1,17 @@
from django.conf import settings
def notes_enabled_for_course(course):
'''
Returns True if the notes app is enabled for the course, False otherwise.
In order for the app to be enabled it must be:
1) enabled globally via MITX_FEATURES.
2) present in the course tab configuration.
'''
tab_found = next((True for t in course.tabs if t['type'] == 'notes'), False)
feature_enabled = settings.MITX_FEATURES.get('ENABLE_STUDENT_NOTES')
return feature_enabled and tab_found

View File

@@ -0,0 +1,24 @@
from django.contrib.auth.decorators import login_required
from django.http import Http404
from mitxmako.shortcuts import render_to_response
from courseware.courses import get_course_with_access
from notes.models import Note
from notes.utils import notes_enabled_for_course
import json
@login_required
def notes(request, course_id):
''' Displays the student's notes. '''
course = get_course_with_access(request.user, course_id, 'load')
if not notes_enabled_for_course(course):
raise Http404
notes = Note.objects.filter(course_id=course_id, user=request.user).order_by('-created', 'uri')
context = {
'course': course,
'notes': notes
}
return render_to_response('notes.html', context)

View File

@@ -5,19 +5,21 @@ django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/open
"""
import json
from mock import MagicMock
from mock import MagicMock, patch, Mock
from django.core.urlresolvers import reverse
from django.contrib.auth.models import Group
from django.http import HttpResponse
from django.conf import settings
from mitxmako.shortcuts import render_to_string
from xmodule.open_ended_grading_classes import peer_grading_service
from xmodule.open_ended_grading_classes import peer_grading_service, controller_query_service
from xmodule import peer_grading_module
from xmodule.modulestore.django import modulestore
import xmodule.modulestore.django
from xmodule.x_module import ModuleSystem
from open_ended_grading import staff_grading_service
from open_ended_grading import staff_grading_service, views
from courseware.access import _course_staff_group_name
from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user
@@ -25,10 +27,10 @@ import logging
log = logging.getLogger(__name__)
from django.test.utils import override_settings
from django.http import QueryDict
from xmodule.tests import test_util_open_ended
from courseware.tests import factories
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestStaffGradingService(LoginEnrollmentTestCase):
@@ -55,8 +57,8 @@ class TestStaffGradingService(LoginEnrollmentTestCase):
def make_instructor(course):
group_name = _course_staff_group_name(course.location)
g = Group.objects.create(name=group_name)
g.user_set.add(get_user(self.instructor))
group = Group.objects.create(name=group_name)
group.user_set.add(get_user(self.instructor))
make_instructor(self.toy)
@@ -76,28 +78,28 @@ class TestStaffGradingService(LoginEnrollmentTestCase):
self.check_for_get_code(404, url)
self.check_for_post_code(404, url)
def test_get_next(self):
self.login(self.instructor, self.password)
url = reverse('staff_grading_get_next', kwargs={'course_id': self.course_id})
data = {'location': self.location}
r = self.check_for_post_code(200, url, data)
d = json.loads(r.content)
self.assertTrue(d['success'])
self.assertEquals(d['submission_id'], self.mock_service.cnt)
self.assertIsNotNone(d['submission'])
self.assertIsNotNone(d['num_graded'])
self.assertIsNotNone(d['min_for_ml'])
self.assertIsNotNone(d['num_pending'])
self.assertIsNotNone(d['prompt'])
self.assertIsNotNone(d['ml_error_info'])
self.assertIsNotNone(d['max_score'])
self.assertIsNotNone(d['rubric'])
response = self.check_for_post_code(200, url, data)
content = json.loads(response.content)
def save_grade_base(self,skip=False):
self.assertTrue(content['success'])
self.assertEquals(content['submission_id'], self.mock_service.cnt)
self.assertIsNotNone(content['submission'])
self.assertIsNotNone(content['num_graded'])
self.assertIsNotNone(content['min_for_ml'])
self.assertIsNotNone(content['num_pending'])
self.assertIsNotNone(content['prompt'])
self.assertIsNotNone(content['ml_error_info'])
self.assertIsNotNone(content['max_score'])
self.assertIsNotNone(content['rubric'])
def save_grade_base(self, skip=False):
self.login(self.instructor, self.password)
url = reverse('staff_grading_save_grade', kwargs={'course_id': self.course_id})
@@ -109,12 +111,12 @@ class TestStaffGradingService(LoginEnrollmentTestCase):
'submission_flagged': "true",
'rubric_scores[]': ['1', '2']}
if skip:
data.update({'skipped' : True})
data.update({'skipped': True})
r = self.check_for_post_code(200, url, data)
d = json.loads(r.content)
self.assertTrue(d['success'], str(d))
self.assertEquals(d['submission_id'], self.mock_service.cnt)
response = self.check_for_post_code(200, url, data)
content = json.loads(response.content)
self.assertTrue(content['success'], str(content))
self.assertEquals(content['submission_id'], self.mock_service.cnt)
def test_save_grade(self):
self.save_grade_base(skip=False)
@@ -128,10 +130,11 @@ class TestStaffGradingService(LoginEnrollmentTestCase):
url = reverse('staff_grading_get_problem_list', kwargs={'course_id': self.course_id})
data = {}
r = self.check_for_post_code(200, url, data)
d = json.loads(r.content)
self.assertTrue(d['success'], str(d))
self.assertIsNotNone(d['problem_list'])
response = self.check_for_post_code(200, url, data)
content = json.loads(response.content)
self.assertTrue(content['success'], str(content))
self.assertIsNotNone(content['problem_list'])
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
@@ -178,13 +181,14 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
def test_get_next_submission_success(self):
data = {'location': self.location}
r = self.peer_module.get_next_submission(data)
d = json.loads(r)
self.assertTrue(d['success'])
self.assertIsNotNone(d['submission_id'])
self.assertIsNotNone(d['prompt'])
self.assertIsNotNone(d['submission_key'])
self.assertIsNotNone(d['max_score'])
response = self.peer_module.get_next_submission(data)
content = response
self.assertTrue(content['success'])
self.assertIsNotNone(content['submission_id'])
self.assertIsNotNone(content['prompt'])
self.assertIsNotNone(content['submission_key'])
self.assertIsNotNone(content['max_score'])
def test_get_next_submission_missing_location(self):
data = {}
@@ -212,9 +216,9 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
qdict.getlist = fake_get_item
qdict.keys = data.keys
r = self.peer_module.save_grade(qdict)
d = json.loads(r)
self.assertTrue(d['success'])
response = self.peer_module.save_grade(qdict)
self.assertTrue(response['success'])
def test_save_grade_missing_keys(self):
data = {}
@@ -224,37 +228,35 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
def test_is_calibrated_success(self):
data = {'location': self.location}
r = self.peer_module.is_student_calibrated(data)
d = json.loads(r)
self.assertTrue(d['success'])
self.assertTrue('calibrated' in d)
response = self.peer_module.is_student_calibrated(data)
self.assertTrue(response['success'])
self.assertTrue('calibrated' in response)
def test_is_calibrated_failure(self):
data = {}
d = self.peer_module.is_student_calibrated(data)
self.assertFalse(d['success'])
self.assertFalse('calibrated' in d)
response = self.peer_module.is_student_calibrated(data)
self.assertFalse(response['success'])
self.assertFalse('calibrated' in response)
def test_show_calibration_essay_success(self):
data = {'location': self.location}
r = self.peer_module.show_calibration_essay(data)
d = json.loads(r)
log.debug(d)
log.debug(type(d))
self.assertTrue(d['success'])
self.assertIsNotNone(d['submission_id'])
self.assertIsNotNone(d['prompt'])
self.assertIsNotNone(d['submission_key'])
self.assertIsNotNone(d['max_score'])
response = self.peer_module.show_calibration_essay(data)
self.assertTrue(response['success'])
self.assertIsNotNone(response['submission_id'])
self.assertIsNotNone(response['prompt'])
self.assertIsNotNone(response['submission_key'])
self.assertIsNotNone(response['max_score'])
def test_show_calibration_essay_missing_key(self):
data = {}
d = self.peer_module.show_calibration_essay(data)
response = self.peer_module.show_calibration_essay(data)
self.assertFalse(d['success'])
self.assertEqual(d['error'], "Missing required keys: location")
self.assertFalse(response['success'])
self.assertEqual(response['error'], "Missing required keys: location")
def test_save_calibration_essay_success(self):
data = {
@@ -276,13 +278,44 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
qdict.getlist = fake_get_item
qdict.keys = data.keys
d = self.peer_module.save_calibration_essay(qdict)
self.assertTrue(d['success'])
self.assertTrue('actual_score' in d)
response = self.peer_module.save_calibration_essay(qdict)
self.assertTrue(response['success'])
self.assertTrue('actual_score' in response)
def test_save_calibration_essay_missing_keys(self):
data = {}
d = self.peer_module.save_calibration_essay(data)
self.assertFalse(d['success'])
self.assertTrue(d['error'].find('Missing required keys:') > -1)
self.assertFalse('actual_score' in d)
response = self.peer_module.save_calibration_essay(data)
self.assertFalse(response['success'])
self.assertTrue(response['error'].find('Missing required keys:') > -1)
self.assertFalse('actual_score' in response)
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestPanel(LoginEnrollmentTestCase):
"""
Run tests on the open ended panel
"""
def setUp(self):
# Toy courses should be loaded
self.course_name = 'edX/open_ended/2012_Fall'
self.course = modulestore().get_course(self.course_name)
self.user = factories.UserFactory()
def test_open_ended_panel(self):
"""
Test to see if the peer grading module in the demo course is found
@return:
"""
found_module, peer_grading_module = views.find_peer_grading_module(self.course)
self.assertTrue(found_module)
@patch('open_ended_grading.views.controller_qs', controller_query_service.MockControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, views.system))
def test_problem_list(self):
"""
Ensure that the problem list from the grading controller server can be rendered properly locally
@return:
"""
request = Mock(user=self.user)
response = views.student_problem_list(request, self.course.id)
self.assertRegexpMatches(response.content, "Here are a list of open ended problems for this course.")

View File

@@ -21,6 +21,7 @@ import open_ended_notifications
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import search
from xmodule.modulestore.exceptions import ItemNotFoundError
from django.http import HttpResponse, Http404, HttpResponseRedirect
from mitxmako.shortcuts import render_to_string
@@ -30,11 +31,12 @@ log = logging.getLogger(__name__)
system = ModuleSystem(
ajax_url=None,
track_function=None,
get_module = None,
get_module=None,
render_template=render_to_string,
replace_urls = None,
xblock_model_data= {}
replace_urls=None,
xblock_model_data={}
)
controller_qs = ControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, system)
"""
@@ -90,40 +92,61 @@ def staff_grading(request, course_id):
'staff_access': True, })
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def peer_grading(request, course_id):
'''
Show a peer grading interface
'''
#Get the current course
course = get_course_with_access(request.user, course_id, 'load')
course_id_parts = course.id.split("/")
false_dict = [False, "False", "false", "FALSE"]
def find_peer_grading_module(course):
"""
Given a course, finds the first peer grading module in it.
@param course: A course object.
@return: boolean found_module, string problem_url
"""
#Reverse the base course url
base_course_url = reverse('courses')
try:
#TODO: This will not work with multiple runs of a course. Make it work. The last key in the Location passed
#to get_items is called revision. Is this the same as run?
#Get the peer grading modules currently in the course
items = modulestore().get_items(['i4x', None, course_id_parts[1], 'peergrading', None])
#See if any of the modules are centralized modules (ie display info from multiple problems)
items = [i for i in items if getattr(i,"use_for_single_location", True) in false_dict]
#Get the first one
found_module = False
problem_url = ""
#Get the course id and split it
course_id_parts = course.id.split("/")
log.info("COURSE ID PARTS")
log.info(course_id_parts)
#Get the peer grading modules currently in the course. Explicitly specify the course id to avoid issues with different runs.
items = modulestore().get_items(['i4x', course_id_parts[0], course_id_parts[1], 'peergrading', None],
course_id=course.id)
#See if any of the modules are centralized modules (ie display info from multiple problems)
items = [i for i in items if not getattr(i, "use_for_single_location", True)]
#Get the first one
if len(items) > 0:
item_location = items[0].location
#Generate a url for the first module and redirect the user to it
problem_url_parts = search.path_to_location(modulestore(), course.id, item_location)
problem_url = generate_problem_url(problem_url_parts, base_course_url)
found_module = True
return HttpResponseRedirect(problem_url)
except:
return found_module, problem_url
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def peer_grading(request, course_id):
'''
When a student clicks on the "peer grading" button in the open ended interface, link them to a peer grading
xmodule in the course.
'''
#Get the current course
course = get_course_with_access(request.user, course_id, 'load')
found_module, problem_url = find_peer_grading_module(course)
if not found_module:
#This is a student_facing_error
error_message = "Error with initializing peer grading. Centralized module does not exist. Please contact course staff."
error_message = """
Error with initializing peer grading.
There has not been a peer grading module created in the courseware that would allow you to grade others.
Please check back later for this.
"""
#This is a dev_facing_error
log.exception(error_message + "Current course is: {0}".format(course_id))
return HttpResponse(error_message)
return HttpResponseRedirect(problem_url)
def generate_problem_url(problem_url_parts, base_course_url):
"""
@@ -145,7 +168,8 @@ def generate_problem_url(problem_url_parts, base_course_url):
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def student_problem_list(request, course_id):
'''
Show a student problem list
Show a student problem list to a student. Fetch the list from the grading controller server, get some metadata,
and then show it to the student.
'''
course = get_course_with_access(request.user, course_id, 'load')
student_id = unique_id_for_user(request.user)
@@ -157,6 +181,7 @@ def student_problem_list(request, course_id):
base_course_url = reverse('courses')
try:
#Get list of all open ended problems that the grading server knows about
problem_list_json = controller_qs.get_grading_status_list(course_id, unique_id_for_user(request.user))
problem_list_dict = json.loads(problem_list_json)
success = problem_list_dict['success']
@@ -166,8 +191,22 @@ def student_problem_list(request, course_id):
else:
problem_list = problem_list_dict['problem_list']
#A list of problems to remove (problems that can't be found in the course)
list_to_remove = []
for i in xrange(0, len(problem_list)):
problem_url_parts = search.path_to_location(modulestore(), course.id, problem_list[i]['location'])
try:
#Try to load each problem in the courseware to get links to them
problem_url_parts = search.path_to_location(modulestore(), course.id, problem_list[i]['location'])
except ItemNotFoundError:
#If the problem cannot be found at the location received from the grading controller server, it has been deleted by the course author.
#Continue with the rest of the location to construct the list
error_message = "Could not find module for course {0} at location {1}".format(course.id,
problem_list[i][
'location'])
log.error(error_message)
#Mark the problem for removal from the list
list_to_remove.append(i)
continue
problem_url = generate_problem_url(problem_url_parts, base_course_url)
problem_list[i].update({'actual_url': problem_url})
eta_available = problem_list[i]['eta_available']
@@ -197,6 +236,8 @@ def student_problem_list(request, course_id):
log.error("Problem with results from external grading service for open ended.")
success = False
#Remove problems that cannot be found in the courseware from the list
problem_list = [problem_list[i] for i in xrange(0, len(problem_list)) if i not in list_to_remove]
ajax_url = _reverse_with_slash('open_ended_problems', course_id)
return render_to_response('open_ended_problems/open_ended_problems.html', {
@@ -300,7 +341,16 @@ def combined_notifications(request, course_id):
'description': description,
'alert_message': alert_message
}
notification_list.append(notification_item)
#The open ended panel will need to link the "peer grading" button in the panel to a peer grading
#xmodule defined in the course. This checks to see if the human name of the server notification
#that we are currently processing is "peer grading". If it is, it looks for a peer grading
#module in the course. If none exists, it removes the peer grading item from the panel.
if human_name == "Peer Grading":
found_module, problem_url = find_peer_grading_module(course)
if found_module:
notification_list.append(notification_item)
else:
notification_list.append(notification_item)
ajax_url = _reverse_with_slash('open_ended_notifications', course_id)
combined_dict = {
@@ -311,9 +361,7 @@ def combined_notifications(request, course_id):
'ajax_url': ajax_url,
}
return render_to_response('open_ended_problems/combined_notifications.html',
combined_dict
)
return render_to_response('open_ended_problems/combined_notifications.html', combined_dict)
@cache_control(no_cache=True, no_store=True, must_revalidate=True)

View File

@@ -15,6 +15,7 @@ from scipy.optimize import curve_fit
from django.conf import settings
from django.db.models import Sum, Max
from psychometrics.models import *
from pytz import UTC
log = logging.getLogger("mitx.psychometrics")
@@ -110,7 +111,7 @@ def make_histogram(ydata, bins=None):
nbins = len(bins)
hist = dict(zip(bins, [0] * nbins))
for y in ydata:
for b in bins[::-1]: # in reverse order
for b in bins[::-1]: # in reverse order
if y > b:
hist[b] += 1
break
@@ -149,7 +150,7 @@ def generate_plots_for_problem(problem):
agdat = pmdset.aggregate(Sum('attempts'), Max('attempts'))
max_attempts = agdat['attempts__max']
total_attempts = agdat['attempts__sum'] # not used yet
total_attempts = agdat['attempts__sum'] # not used yet
msg += "max attempts = %d" % max_attempts
@@ -200,7 +201,7 @@ def generate_plots_for_problem(problem):
dtsv = StatVar()
for pmd in pmdset:
try:
checktimes = eval(pmd.checktimes) # update log of attempt timestamps
checktimes = eval(pmd.checktimes) # update log of attempt timestamps
except:
continue
if len(checktimes) < 2:
@@ -208,7 +209,7 @@ def generate_plots_for_problem(problem):
ct0 = checktimes[0]
for ct in checktimes[1:]:
dt = (ct - ct0).total_seconds() / 60.0
if dt < 20: # ignore if dt too long
if dt < 20: # ignore if dt too long
dtset.append(dt)
dtsv += dt
ct0 = ct
@@ -244,7 +245,7 @@ def generate_plots_for_problem(problem):
ylast = y + ylast
yset['ydat'] = ydat
if len(ydat) > 3: # try to fit to logistic function if enough data points
if len(ydat) > 3: # try to fit to logistic function if enough data points
try:
cfp = curve_fit(func_2pl, xdat, ydat, [1.0, max_attempts / 2.0])
yset['fitparam'] = cfp
@@ -337,10 +338,10 @@ def make_psychometrics_data_update_handler(course_id, user, module_state_key):
log.exception("no attempts for %s (state=%s)" % (sm, sm.state))
try:
checktimes = eval(pmd.checktimes) # update log of attempt timestamps
checktimes = eval(pmd.checktimes) # update log of attempt timestamps
except:
checktimes = []
checktimes.append(datetime.datetime.now())
checktimes.append(datetime.datetime.now(UTC))
pmd.checktimes = checktimes
try:
pmd.save()

View File

@@ -11,6 +11,7 @@ from markdown import markdown
from .wiki_settings import *
from util.cache import cache
from pytz import UTC
class ShouldHaveExactlyOneRootSlug(Exception):
@@ -265,7 +266,7 @@ class Revision(models.Model):
return
else:
import datetime
self.article.modified_on = datetime.datetime.now()
self.article.modified_on = datetime.datetime.now(UTC)
self.article.save()
# Increment counter according to previous revision

View File

@@ -1,9 +1,11 @@
from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.core.urlresolvers import reverse
from mitxmako.shortcuts import render_to_response
from courseware.access import has_access
from courseware.courses import get_course_with_access
from notes.utils import notes_enabled_for_course
from static_replace import replace_static_urls
@@ -23,7 +25,8 @@ def index(request, course_id, book_index, page=None):
return render_to_response('staticbook.html',
{'book_index': book_index, 'page': int(page),
'course': course, 'book_url': textbook.book_url,
'course': course,
'book_url': textbook.book_url,
'table_of_contents': table_of_contents,
'start_page': textbook.start_page,
'end_page': textbook.end_page,
@@ -100,6 +103,7 @@ def html_index(request, course_id, book_index, chapter=None):
"""
course = get_course_with_access(request.user, course_id, 'load')
staff_access = has_access(request.user, course, 'staff')
notes_enabled = notes_enabled_for_course(course)
book_index = int(book_index)
if book_index < 0 or book_index >= len(course.html_textbooks):
@@ -128,4 +132,5 @@ def html_index(request, course_id, book_index, chapter=None):
'course': course,
'textbook': textbook,
'chapter': chapter,
'staff_access': staff_access})
'staff_access': staff_access,
'notes_enabled': notes_enabled})

View File

@@ -2,20 +2,29 @@
This config file extends the test environment configuration
so that we can run the lettuce acceptance tests.
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .test import *
# You need to start the server in debug mode,
# otherwise the browser will not render the pages correctly
DEBUG = True
# Disable warnings for acceptance tests, to make the logs readable
import logging
logging.disable(logging.ERROR)
# Use the mongo store for acceptance tests
modulestore_options = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'test_xmodule',
'collection': 'modulestore',
'fs_root': GITHUB_REPO_ROOT,
'render_template': 'mitxmako.shortcuts.render_to_string',
'collection': 'acceptance_modulestore',
'fs_root': TEST_ROOT / "data",
'render_template': 'mitxmako.shortcuts.render_to_string'
}
MODULESTORE = {
@@ -33,7 +42,7 @@ CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': {
'host': 'localhost',
'db': 'test_xcontent',
'db': 'test_xmodule',
}
}
@@ -43,8 +52,8 @@ CONTENTSTORE = {
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ENV_ROOT / "db" / "test_mitx.db",
'TEST_NAME': ENV_ROOT / "db" / "test_mitx.db",
'NAME': TEST_ROOT / "db" / "test_mitx.db",
'TEST_NAME': TEST_ROOT / "db" / "test_mitx.db",
}
}

View File

@@ -1,49 +0,0 @@
"""
This config file is used to host an analytics server. The edX codebase
is fairly monolithic, and expensive to import from within the
analytics framework. It also mixes up Django authentication databases,
etc. The analytics framework is fairly modular, and easy to import
from within edX. With this configuration, we can import analytics as a
library into the core edX platform, and use it to expose data through
the standard analytics protocols. The main analytics servers can then
use this to pull data out.
This should configuration should never be enabled on a production LMS.
This configuration should also never be used as the main analytics
server. It should only be used as a thin layer to allow access to edX
data in a way that can use the edX libraries. When used in this mode,
it should only be granted access to read replicas of the databases.
"""
ROOT_URLCONF = 'lms.urls'
from .dev import *
MITX_FEATURES['RUN_AS_ANALYTICS_SERVER_ENABLED'] = True
INSTALLED_APPS = INSTALLED_APPS + (
'djeventstream.httphandler',
'djcelery',
'south',
'edinsights.core',
'edinsights.modulefs',
)
if os.path.isfile("../analytics_modules.txt"):
INSTALLED_ANALYTICS_MODULES = open("../analytics_modules.txt").readlines()
INSTALLED_ANALYTICS_MODULES = [x.strip() for x in INSTALLED_ANALYTICS_MODULES if x and len(x) > 1]
else:
INSTALLED_ANALYTICS_MODULES = ['edxdataanalytic.edxdataanalytic']
DJFS = {
'type': 'osfs',
'directory_root': '/tmp/djfsmodule',
'url_root': 'file:///tmp/'
}
import djcelery
djcelery.setup_loader()
default_optional_kwargs = ['fs', 'db', 'query']

View File

@@ -6,6 +6,11 @@ Common traits:
* Use memcached, and cache-backed sessions
* Use a MySQL 5.1 database
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
import json
from .common import *
@@ -26,7 +31,8 @@ if SERVICE_VARIANT:
CONFIG_PREFIX = SERVICE_VARIANT + "."
################### ALWAYS THE SAME ################################
################################ ALWAYS THE SAME ##############################
DEBUG = False
TEMPLATE_DEBUG = False
@@ -45,15 +51,66 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
# for other warnings.
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
################# NON-SECURE ENV CONFIG ##############################
###################################### CELERY ################################
# Don't use a connection pool, since connections are dropped by ELB.
BROKER_POOL_LIMIT = 0
BROKER_CONNECTION_TIMEOUT = 1
# For the Result Store, use the django cache named 'celery'
CELERY_RESULT_BACKEND = 'cache'
CELERY_CACHE_BACKEND = 'celery'
# When the broker is behind an ELB, use a heartbeat to refresh the
# connection and to detect if it has been dropped.
BROKER_HEARTBEAT = 10.0
BROKER_HEARTBEAT_CHECKRATE = 2
# Each worker should only fetch one message at a time
CELERYD_PREFETCH_MULTIPLIER = 1
# Skip djcelery migrations, since we don't use the database as the broker
SOUTH_MIGRATION_MODULES = {
'djcelery': 'ignore',
}
# Rename the exchange and queues for each variant
QUEUE_VARIANT = CONFIG_PREFIX.lower()
CELERY_DEFAULT_EXCHANGE = 'edx.{0}core'.format(QUEUE_VARIANT)
HIGH_PRIORITY_QUEUE = 'edx.{0}core.high'.format(QUEUE_VARIANT)
DEFAULT_PRIORITY_QUEUE = 'edx.{0}core.default'.format(QUEUE_VARIANT)
LOW_PRIORITY_QUEUE = 'edx.{0}core.low'.format(QUEUE_VARIANT)
CELERY_DEFAULT_QUEUE = DEFAULT_PRIORITY_QUEUE
CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE
CELERY_QUEUES = {
HIGH_PRIORITY_QUEUE: {},
LOW_PRIORITY_QUEUE: {},
DEFAULT_PRIORITY_QUEUE: {}
}
########################## NON-SECURE ENV CONFIG ##############################
# Things like server locations, ports, etc.
with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
ENV_TOKENS = json.load(env_file)
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME)
SITE_NAME = ENV_TOKENS['SITE_NAME']
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
# allow for environments to specify what cookie name our login subsystem should use
# this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can
# happen with some browsers (e.g. Firefox)
if ENV_TOKENS.get('SESSION_COOKIE_NAME', None):
# NOTE, there's a bug in Django (http://bugs.python.org/issue18012) which necessitates this being a str()
SESSION_COOKIE_NAME = str(ENV_TOKENS.get('SESSION_COOKIE_NAME'))
BOOK_URL = ENV_TOKENS['BOOK_URL']
MEDIA_URL = ENV_TOKENS['MEDIA_URL']
LOG_DIR = ENV_TOKENS['LOG_DIR']
@@ -65,6 +122,18 @@ DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL)
DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL)
ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL)
CONTACT_EMAIL = ENV_TOKENS.get('CONTACT_EMAIL', CONTACT_EMAIL)
BUGS_EMAIL = ENV_TOKENS.get('BUGS_EMAIL', BUGS_EMAIL)
#Theme overrides
THEME_NAME = ENV_TOKENS.get('THEME_NAME', None)
if not THEME_NAME is None:
enable_theme(THEME_NAME)
FAVICON_PATH = 'themes/%s/images/favicon.ico' % THEME_NAME
# Marketing link overrides
MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {}))
#Timezone overrides
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
@@ -91,9 +160,26 @@ COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '')
CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull')
ZENDESK_URL = ENV_TOKENS.get("ZENDESK_URL")
FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL")
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
for name, value in ENV_TOKENS.get("CODE_JAIL", {}).items():
oldvalue = CODE_JAIL.get(name)
if isinstance(oldvalue, dict):
for subname, subvalue in value.items():
oldvalue[subname] = subvalue
else:
CODE_JAIL[name] = value
COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", [])
# If segment.io key specified, load it and turn on segment IO if the feature flag is set
SEGMENT_IO_LMS_KEY = ENV_TOKENS.get('SEGMENT_IO_LMS_KEY')
if SEGMENT_IO_LMS_KEY:
MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False)
############################## SECURE AUTH ITEMS ###############
# Secret things: passwords, access keys, etc.
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
AUTH_TOKENS = json.load(auth_file)
@@ -112,7 +198,8 @@ XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE']
MODULESTORE = AUTH_TOKENS.get('MODULESTORE', MODULESTORE)
CONTENTSTORE = AUTH_TOKENS.get('CONTENTSTORE', CONTENTSTORE)
OPEN_ENDED_GRADING_INTERFACE = AUTH_TOKENS.get('OPEN_ENDED_GRADING_INTERFACE', OPEN_ENDED_GRADING_INTERFACE)
OPEN_ENDED_GRADING_INTERFACE = AUTH_TOKENS.get('OPEN_ENDED_GRADING_INTERFACE',
OPEN_ENDED_GRADING_INTERFACE)
PEARSON_TEST_USER = "pearsontest"
PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD")
@@ -127,5 +214,17 @@ DATADOG_API = AUTH_TOKENS.get("DATADOG_API")
ANALYTICS_SERVER_URL = ENV_TOKENS.get("ANALYTICS_SERVER_URL")
ANALYTICS_API_KEY = AUTH_TOKENS.get("ANALYTICS_API_KEY", "")
# Zendesk
ZENDESK_USER = AUTH_TOKENS.get("ZENDESK_USER")
ZENDESK_API_KEY = AUTH_TOKENS.get("ZENDESK_API_KEY")
# Celery Broker
CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "")
CELERY_BROKER_HOSTNAME = ENV_TOKENS.get("CELERY_BROKER_HOSTNAME", "")
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)

24
lms/envs/aws_migrate.py Normal file
View File

@@ -0,0 +1,24 @@
"""
A Django settings file for use on AWS while running
database migrations, since we don't want to normally run the
LMS with enough privileges to modify the database schema.
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
# Import everything from .aws so that our settings are based on those.
from .aws import *
import os
from django.core.exceptions import ImproperlyConfigured
USER = os.environ.get('DB_MIGRATION_USER', 'root')
PASSWORD = os.environ.get('DB_MIGRATION_PASS', None)
if not PASSWORD:
raise ImproperlyConfigured("No database password was provided for running "
"migrations. This is fatal.")
DATABASES['default']['USER'] = USER
DATABASES['default']['PASSWORD'] = PASSWORD

View File

@@ -3,6 +3,11 @@ This config file is a copy of dev environment without the Debug
Toolbar. I it suitable to run against acceptance tests.
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .dev import *
# REMOVE DEBUG TOOLBAR

View File

@@ -2,6 +2,10 @@
Settings for the LMS that runs alongside the CMS on AWS
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from ..aws import *
with open(ENV_ROOT / "cms.auth.json") as auth_file:

View File

@@ -2,6 +2,10 @@
Settings for the LMS that runs alongside the CMS on AWS
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from ..dev import *
MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = False
@@ -17,7 +21,7 @@ modulestore_options = {
'db': 'xmodule',
'collection': 'modulestore',
'fs_root': DATA_DIR,
'render_template': 'mitxmako.shortcuts.render_to_string',
'render_template': 'mitxmako.shortcuts.render_to_string'
}
MODULESTORE = {

View File

@@ -2,6 +2,10 @@
Settings for the LMS that runs alongside the CMS on AWS
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .dev import *
MODULESTORE = {

View File

@@ -18,6 +18,11 @@ Longer TODO:
3. We need to handle configuration for multiple courses. This could be as
multiple sites, but we do need a way to map their data assets.
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
import sys
import os
@@ -26,6 +31,9 @@ from path import path
from .discussionsettings import *
################################### FEATURES ###################################
# The display name of the platform to be used in templates/emails/etc.
PLATFORM_NAME = "edX"
COURSEWARE_ENABLED = True
ENABLE_JASMINE = False
@@ -36,13 +44,14 @@ DISCUSSION_SETTINGS = {
'MAX_COMMENT_DEPTH': 2,
}
# Features
MITX_FEATURES = {
'SAMPLE': False,
'USE_DJANGO_PIPELINE': True,
'DISPLAY_HISTOGRAMS_TO_STAFF': True,
'REROUTE_ACTIVATION_EMAIL': False, # nonempty string = address for all activation emails
'DEBUG_LEVEL': 0, # 0 = lowest level, least verbose, 255 = max level, most verbose
'REROUTE_ACTIVATION_EMAIL': False, # nonempty string = address for all activation emails
'DEBUG_LEVEL': 0, # 0 = lowest level, least verbose, 255 = max level, most verbose
## DO NOT SET TO True IN THIS FILE
## Doing so will cause all courses to be released on production
@@ -58,14 +67,15 @@ MITX_FEATURES = {
# university to use for branding purposes
'SUBDOMAIN_BRANDING': False,
'FORCE_UNIVERSITY_DOMAIN': False, # set this to the university domain to use, as an override to HTTP_HOST
'FORCE_UNIVERSITY_DOMAIN': False, # set this to the university domain to use, as an override to HTTP_HOST
# set to None to do no university selection
'ENABLE_TEXTBOOK': True,
'ENABLE_DISCUSSION_SERVICE': True,
'ENABLE_PSYCHOMETRICS': False, # real-time psychometrics (eg item response theory analysis in instructor dashboard)
'ENABLE_PSYCHOMETRICS': False, # real-time psychometrics (eg item response theory analysis in instructor dashboard)
'ENABLE_DJANGO_ADMIN_SITE': False, # set true to enable django's admin site, even on prod (e.g. for course ops)
'ENABLE_SQL_TRACKING_LOGS': False,
'ENABLE_LMS_MIGRATION': False,
'ENABLE_MANUAL_GIT_RELOAD': False,
@@ -74,7 +84,7 @@ MITX_FEATURES = {
'DISABLE_LOGIN_BUTTON': False, # used in systems where login is automatic, eg MIT SSL
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
# extrernal access methods
'ACCESS_REQUIRE_STAFF_FOR_COURSE': False,
@@ -99,8 +109,27 @@ MITX_FEATURES = {
# Staff Debug tool.
'ENABLE_STUDENT_HISTORY_VIEW': True,
# segment.io for LMS--need to explicitly turn it on on production.
'SEGMENT_IO_LMS': False,
# Enables the student notes API and UI.
'ENABLE_STUDENT_NOTES': True,
# Provide a UI to allow users to submit feedback from the LMS
'ENABLE_FEEDBACK_SUBMISSION': False,
# Turn on a page that lets staff enter Python code to be run in the
# sandbox, for testing whether it's enabled properly.
'ENABLE_DEBUG_RUN_PYTHON': False,
# Enable URL that shows information about the status of variuous services
'ENABLE_SERVICE_STATUS': False,
# Toggle to indicate use of a custom theme
'USE_CUSTOM_THEME': False,
# Do autoplay videos for students
'AUTOPLAY_VIDEOS': True
}
# Used for A/B testing
@@ -110,7 +139,7 @@ DEFAULT_GROUPS = []
GENERATE_PROFILE_SCORES = False
# Used with XQueue
XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds
XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds
############################# SET PATH INFORMATION #############################
@@ -130,9 +159,7 @@ sys.path.append(COMMON_ROOT / 'lib')
# For Node.js
system_node_path = os.environ.get("NODE_PATH", None)
if system_node_path is None:
system_node_path = "/usr/local/lib/node_modules"
system_node_path = os.environ.get("NODE_PATH", REPO_ROOT / 'node_modules')
node_paths = [COMMON_ROOT / "static/js/vendor",
COMMON_ROOT / "static/coffee/src",
@@ -160,20 +187,20 @@ MAKO_TEMPLATES['main'] = [PROJECT_ROOT / 'templates',
# This is where Django Template lookup is defined. There are a few of these
# still left lying around.
TEMPLATE_DIRS = (
TEMPLATE_DIRS = [
PROJECT_ROOT / "templates",
COMMON_ROOT / 'templates',
COMMON_ROOT / 'lib' / 'capa' / 'capa' / 'templates',
COMMON_ROOT / 'djangoapps' / 'pipeline_mako' / 'templates',
)
]
TEMPLATE_CONTEXT_PROCESSORS = (
'django.core.context_processors.request',
'django.core.context_processors.static',
'django.contrib.messages.context_processors.messages',
#'django.core.context_processors.i18n',
'django.contrib.auth.context_processors.auth', # this is required for admin
'django.core.context_processors.csrf', # necessary for csrf protection
'django.contrib.auth.context_processors.auth', # this is required for admin
'django.core.context_processors.csrf', # necessary for csrf protection
# Added for django-wiki
'django.core.context_processors.media',
@@ -181,9 +208,12 @@ TEMPLATE_CONTEXT_PROCESSORS = (
'django.contrib.messages.context_processors.messages',
'sekizai.context_processors.sekizai',
'course_wiki.course_nav.context_processor',
# Hack to get required link URLs to password reset templates
'mitxmako.shortcuts.marketing_link_context_processor',
)
STUDENT_FILEUPLOAD_MAX_SIZE = 4 * 1000 * 1000 # 4 MB
STUDENT_FILEUPLOAD_MAX_SIZE = 4 * 1000 * 1000 # 4 MB
MAX_FILEUPLOADS_PER_INPUT = 20
# FIXME:
@@ -193,7 +223,7 @@ LIB_URL = '/static/js/'
# Dev machines shouldn't need the book
# BOOK_URL = '/static/book/'
BOOK_URL = 'https://mitxstatic.s3.amazonaws.com/book_images/' # For AWS deploys
BOOK_URL = 'https://mitxstatic.s3.amazonaws.com/book_images/' # For AWS deploys
# RSS_URL = r'lms/templates/feed.rss'
# PRESS_URL = r''
RSS_TIMEOUT = 600
@@ -217,14 +247,14 @@ COURSE_TITLE = "Circuits and Electronics"
### Dark code. Should be enabled in local settings for devel.
ENABLE_MULTICOURSE = False # set to False to disable multicourse display (see lib.util.views.mitxhome)
ENABLE_MULTICOURSE = False # set to False to disable multicourse display (see lib.util.views.mitxhome)
WIKI_ENABLED = False
###
COURSE_DEFAULT = '6.002x_Fall_2012'
COURSE_SETTINGS = {'6.002x_Fall_2012': {'number': '6.002x',
COURSE_SETTINGS = {'6.002x_Fall_2012': {'number': '6.002x',
'title': 'Circuits and Electronics',
'xmlpath': '6002x/',
'location': 'i4x://edx/6002xs12/course/6.002x_Fall_2012',
@@ -252,6 +282,31 @@ MODULESTORE = {
}
CONTENTSTORE = None
#################### Python sandbox ############################################
CODE_JAIL = {
# Path to a sandboxed Python executable. None means don't bother.
'python_bin': None,
# User to run as in the sandbox.
'user': 'sandbox',
# Configurable limits.
'limits': {
# How many CPU seconds can jailed code use?
'CPU': 1,
},
}
# Some courses are allowed to run unsafe code. This is a list of regexes, one
# of them must match the course id for that course to run unsafe code.
#
# For example:
#
# COURSES_WITH_UNSAFE_CODE = [
# r"Harvard/XY123.1/.*"
# ]
COURSES_WITH_UNSAFE_CODE = []
############################ SIGNAL HANDLERS ################################
# This is imported to register the exception signal handling that logs exceptions
import monitoring.exceptions # noqa
@@ -260,6 +315,7 @@ import monitoring.exceptions # noqa
# Change DEBUG/TEMPLATE_DEBUG in your environment settings files, not here
DEBUG = False
TEMPLATE_DEBUG = False
USE_TZ = True
# Site info
SITE_ID = 1
@@ -273,6 +329,9 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEFAULT_FROM_EMAIL = 'registration@edx.org'
DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org'
SERVER_EMAIL = 'devops@edx.org'
TECH_SUPPORT_EMAIL = 'technical@edx.org'
CONTACT_EMAIL = 'info@edx.org'
BUGS_EMAIL = 'bugs@edx.org'
ADMINS = (
('edX Admins', 'admin@edx.org'),
)
@@ -288,9 +347,11 @@ STATICFILES_DIRS = [
PROJECT_ROOT / "static",
]
FAVICON_PATH = 'images/favicon.ico'
# Locale/Internationalization
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
USE_I18N = True
USE_L10N = True
@@ -311,7 +372,7 @@ ALLOWED_GITRELOAD_IPS = ['207.97.227.253', '50.57.128.197', '108.171.174.178']
# setting is, I'm just bumping the expiration time to something absurd (100
# years). This is only used if DEFAULT_FILE_STORAGE is overriden to use S3
# in the global settings.py
AWS_QUERYSTRING_EXPIRE = 10 * 365 * 24 * 60 * 60 # 10 years
AWS_QUERYSTRING_EXPIRE = 10 * 365 * 24 * 60 * 60 # 10 years
################################# SIMPLEWIKI ###################################
SIMPLE_WIKI_REQUIRE_LOGIN_EDIT = True
@@ -320,8 +381,8 @@ SIMPLE_WIKI_REQUIRE_LOGIN_VIEW = False
################################# WIKI ###################################
WIKI_ACCOUNT_HANDLING = False
WIKI_EDITOR = 'course_wiki.editors.CodeMirror'
WIKI_SHOW_MAX_CHILDREN = 0 # We don't use the little menu that shows children of an article in the breadcrumb
WIKI_ANONYMOUS = False # Don't allow anonymous access until the styling is figured out
WIKI_SHOW_MAX_CHILDREN = 0 # We don't use the little menu that shows children of an article in the breadcrumb
WIKI_ANONYMOUS = False # Don't allow anonymous access until the styling is figured out
WIKI_CAN_CHANGE_PERMISSIONS = lambda article, user: user.is_staff or user.is_superuser
WIKI_CAN_ASSIGN = lambda article, user: user.is_staff or user.is_superuser
@@ -404,6 +465,7 @@ MIDDLEWARE_CLASSES = (
# 'debug_toolbar.middleware.DebugToolbarMiddleware',
'django_comment_client.utils.ViewNameMiddleware',
'codejail.django_integration.ConfigureCodeJailMiddleware',
)
############################### Pipeline #######################################
@@ -431,11 +493,15 @@ main_vendor_js = [
'js/vendor/jquery.qtip.min.js',
'js/vendor/swfobject/swfobject.js',
'js/vendor/jquery.ba-bbq.min.js',
'js/vendor/annotator.min.js',
'js/vendor/annotator.store.min.js',
'js/vendor/annotator.tags.min.js'
]
discussion_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/discussion/**/*.js'))
staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.js'))
open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/open_ended/**/*.js'))
notes_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/notes/**/*.coffee'))
PIPELINE_CSS = {
'application': {
@@ -448,6 +514,7 @@ PIPELINE_CSS = {
'css/vendor/jquery.treeview.css',
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
'css/vendor/jquery.qtip.min.css',
'css/vendor/annotator.min.css',
'sass/course.css',
'xmodule/modules.css',
],
@@ -469,7 +536,7 @@ PIPELINE_JS = {
'source_filenames': sorted(
set(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/**/*.js') +
rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/**/*.js')) -
set(courseware_js + discussion_js + staff_grading_js + open_ended_js)
set(courseware_js + discussion_js + staff_grading_js + open_ended_js + notes_js)
) + [
'js/form.ext.js',
'js/my_courses_dropdown.js',
@@ -510,7 +577,12 @@ PIPELINE_JS = {
'source_filenames': open_ended_js,
'output_filename': 'js/open_ended.js',
'test_order': 6,
}
},
'notes': {
'source_filenames': notes_js,
'output_filename': 'js/notes.js',
'test_order': 7
},
}
PIPELINE_DISABLE_WRAPPER = True
@@ -528,7 +600,7 @@ if os.path.isdir(DATA_DIR):
new_filename = os.path.splitext(filename)[0] + ".js"
if os.path.exists(js_dir / new_filename):
coffee_timestamp = os.stat(js_dir / filename).st_mtime
js_timestamp = os.stat(js_dir / new_filename).st_mtime
js_timestamp = os.stat(js_dir / new_filename).st_mtime
if coffee_timestamp <= js_timestamp:
continue
os.system("rm %s" % (js_dir / new_filename))
@@ -548,7 +620,52 @@ PIPELINE_YUI_BINARY = 'yui-compressor'
# Setting that will only affect the MITx version of django-pipeline until our changes are merged upstream
PIPELINE_COMPILE_INPLACE = True
################################### APPS #######################################
################################# CELERY ######################################
# Message configuration
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_MESSAGE_COMPRESSION = 'gzip'
# Results configuration
CELERY_IGNORE_RESULT = False
CELERY_STORE_ERRORS_EVEN_IF_IGNORED = True
# Events configuration
CELERY_TRACK_STARTED = True
CELERY_SEND_EVENTS = True
CELERY_SEND_TASK_SENT_EVENT = True
# Exchange configuration
CELERY_DEFAULT_EXCHANGE = 'edx.core'
CELERY_DEFAULT_EXCHANGE_TYPE = 'direct'
# Queues configuration
HIGH_PRIORITY_QUEUE = 'edx.core.high'
DEFAULT_PRIORITY_QUEUE = 'edx.core.default'
LOW_PRIORITY_QUEUE = 'edx.core.low'
CELERY_QUEUE_HA_POLICY = 'all'
CELERY_CREATE_MISSING_QUEUES = True
CELERY_DEFAULT_QUEUE = DEFAULT_PRIORITY_QUEUE
CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE
CELERY_QUEUES = {
HIGH_PRIORITY_QUEUE: {},
LOW_PRIORITY_QUEUE: {},
DEFAULT_PRIORITY_QUEUE: {}
}
################################### APPS ######################################
INSTALLED_APPS = (
# Standard ones that are always installed...
'django.contrib.auth',
@@ -557,9 +674,14 @@ INSTALLED_APPS = (
'django.contrib.messages',
'django.contrib.sessions',
'django.contrib.sites',
'djcelery',
'south',
# Monitor the status of services
'service_status',
# For asset pipelining
'mitxmako',
'pipeline',
'staticfiles',
'static_replace',
@@ -582,9 +704,9 @@ INSTALLED_APPS = (
'course_groups',
#For the wiki
'wiki', # The new django-wiki from benjaoming
'wiki', # The new django-wiki from benjaoming
'django_notify',
'course_wiki', # Our customizations
'course_wiki', # Our customizations
'mptt',
'sekizai',
#'wiki.plugins.attachments',
@@ -596,9 +718,53 @@ INSTALLED_APPS = (
'foldit',
# For testing
'django.contrib.admin', # only used in DEBUG mode
'django.contrib.admin', # only used in DEBUG mode
'debug',
# Discussion forums
'django_comment_client',
'django_comment_common',
'notes',
)
######################### MARKETING SITE ###############################
EDXMKTG_COOKIE_NAME = 'edxloggedin'
MKTG_URLS = {}
MKTG_URL_LINK_MAP = {
'ABOUT': 'about_edx',
'CONTACT': 'contact',
'FAQ': 'help_edx',
'COURSES': 'courses',
'ROOT': 'root',
'TOS': 'tos',
'HONOR': 'honor',
'PRIVACY': 'privacy_edx',
}
############################### THEME ################################
def enable_theme(theme_name):
"""
Enable the settings for a custom theme, whose files should be stored
in ENV_ROOT/themes/THEME_NAME (e.g., edx_all/themes/stanford).
The THEME_NAME setting should be configured separately since it can't
be set here (this function closes too early). An idiom for doing this
is:
THEME_NAME = "stanford"
enable_theme(THEME_NAME)
"""
MITX_FEATURES['USE_CUSTOM_THEME'] = True
# Calculate the location of the theme's files
theme_root = ENV_ROOT / "themes" / theme_name
# Include the theme's templates in the template search paths
TEMPLATE_DIRS.append(theme_root / 'templates')
MAKO_TEMPLATES['main'].append(theme_root / 'templates')
# Namespace the theme's static files to 'themes/<theme_name>' to
# avoid collisions with default edX static files
STATICFILES_DIRS.append((u'themes/%s' % theme_name,
theme_root / 'static'))

View File

@@ -2,6 +2,11 @@
These are debug machines used for content creators, so they're kind of a cross
between dev machines and AWS machines.
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .aws import *
DEBUG = True

View File

@@ -7,6 +7,11 @@ sessions. Assumes structure:
/mitx # The location of this repo
/log # Where we're going to write log files
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .common import *
from logsettings import get_logger_config
@@ -22,8 +27,7 @@ MITX_FEATURES['FORCE_UNIVERSITY_DOMAIN'] = None # show all university courses i
MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True
MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard)
MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
WIKI_ENABLED = True
@@ -143,7 +147,7 @@ if os.path.isdir(DATA_DIR):
MITX_VERSION_STRING = os.popen('cd %s; git describe' % REPO_ROOT).read().strip()
################################# Open ended grading config #####################
############################ Open ended grading config #####################
OPEN_ENDED_GRADING_INTERFACE = {
'url' : 'http://127.0.0.1:3033/',
@@ -154,7 +158,7 @@ OPEN_ENDED_GRADING_INTERFACE = {
'grading_controller' : 'grading_controller'
}
################################ LMS Migration #################################
############################## LMS Migration ##################################
MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True
MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = False # require that user be in the staff_* group to be able to enroll
MITX_FEATURES['USE_XQA_SERVER'] = 'http://xqa:server@content-qa.mitx.mit.edu/xqa'
@@ -164,6 +168,7 @@ INSTALLED_APPS += ('lms_migration',)
LMS_MIGRATION_ALLOWED_IPS = ['127.0.0.1']
################################ OpenID Auth #################################
MITX_FEATURES['AUTH_USE_OPENID'] = True
MITX_FEATURES['AUTH_USE_OPENID_PROVIDER'] = True
MITX_FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] = True
@@ -173,16 +178,22 @@ INSTALLED_APPS += ('django_openid_auth',)
OPENID_CREATE_USERS = False
OPENID_UPDATE_DETAILS_FROM_SREG = True
OPENID_SSO_SERVER_URL = 'https://www.google.com/accounts/o8/id' # TODO: accept more endpoints
OPENID_SSO_SERVER_URL = 'https://www.google.com/accounts/o8/id' # TODO: accept more endpoints
OPENID_USE_AS_ADMIN_LOGIN = False
OPENID_PROVIDER_TRUSTED_ROOTS = ['*']
################################ MIT Certificates SSL Auth #################################
######################## MIT Certificates SSL Auth ############################
MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
################################ DEBUG TOOLBAR #################################
################################# CELERY ######################################
# By default don't use a worker, execute tasks as if they were local functions
CELERY_ALWAYS_EAGER = True
################################ DEBUG TOOLBAR ################################
INSTALLED_APPS += ('debug_toolbar',)
MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware',
'debug_toolbar.middleware.DebugToolbarMiddleware',)
@@ -208,7 +219,9 @@ DEBUG_TOOLBAR_PANELS = (
DEBUG_TOOLBAR_CONFIG = {
'INTERCEPT_REDIRECTS': False
}
############################ FILE UPLOADS (for discussion forums) #############################
#################### FILE UPLOADS (for discussion forums) #####################
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
MEDIA_ROOT = ENV_ROOT / "uploads"
MEDIA_URL = "/static/uploads/"
@@ -230,3 +243,18 @@ MITX_FEATURES['ENABLE_PEARSON_LOGIN'] = False
ANALYTICS_SERVER_URL = "http://127.0.0.1:9000/"
ANALYTICS_API_KEY = ""
##### segment-io ######
# If there's an environment variable set, grab it and turn on segment io
SEGMENT_IO_LMS_KEY = os.environ.get('SEGMENT_IO_LMS_KEY')
if SEGMENT_IO_LMS_KEY:
MITX_FEATURES['SEGMENT_IO_LMS'] = True
#####################################################################
# Lastly, see if the developer has any local overrides.
try:
from .private import *
except ImportError:
pass

View File

@@ -8,6 +8,10 @@ sessions. Assumes structure:
/log # Where we're going to write log files
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
import socket
if 'eecs1' in socket.gethostname():

View File

@@ -7,6 +7,11 @@ sessions. Assumes structure:
/mitx # The location of this repo
/log # Where we're going to write log files
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .common import *
from logsettings import get_logger_config
from .dev import *

View File

@@ -9,6 +9,11 @@ following domains to 127.0.0.1 in your /etc/hosts file:
Note that OS X has a bug where using *.local domains is excruciatingly slow, so
use *.dev domains instead for local testing.
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .dev import *
MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = True

View File

@@ -1,6 +1,11 @@
"""
This config file runs the dev environment, but with mongo as the datastore
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .dev import *
GITHUB_REPO_ROOT = ENV_ROOT / "data"
@@ -14,7 +19,7 @@ MODULESTORE = {
'db': 'xmodule',
'collection': 'modulestore',
'fs_root': GITHUB_REPO_ROOT,
'render_template': 'mitxmako.shortcuts.render_to_string',
'render_template': 'mitxmako.shortcuts.render_to_string'
}
}
}

View File

@@ -0,0 +1,39 @@
"""
This config file follows the dev enviroment, but adds the
requirement of a celery worker running in the background to process
celery tasks.
The worker can be executed using:
django_admin.py celery worker
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from dev import *
################################# CELERY ######################################
# Requires a separate celery worker
CELERY_ALWAYS_EAGER = False
# Use django db as the broker and result store
BROKER_URL = 'django://'
INSTALLED_APPS += ('djcelery.transport', )
CELERY_RESULT_BACKEND = 'database'
DJKOMBU_POLLING_INTERVAL = 1.0
# Disable transaction management because we are using a worker. Views
# that request a task and wait for the result will deadlock otherwise.
MIDDLEWARE_CLASSES = tuple(
c for c in MIDDLEWARE_CLASSES
if c != 'django.middleware.transaction.TransactionMiddleware')
# Note: other alternatives for disabling transactions don't work in 1.4
# https://code.djangoproject.com/ticket/2304
# https://code.djangoproject.com/ticket/16039

View File

@@ -1,3 +1,8 @@
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from ..dev import *
CLASSES_TO_DBS = {

View File

@@ -1,3 +1,8 @@
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .courses import *
DATABASES = course_db_for('HarvardX/CS50x/2012')

View File

@@ -1,3 +1,8 @@
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .courses import *
DATABASES = course_db_for('MITx/6.002x/2012_Fall')

View File

@@ -2,6 +2,11 @@
Note that for this to work at all, you must have memcached running (or you won't
get shared sessions)
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from courses import *
# Move this to a shared file later:

View File

@@ -13,6 +13,11 @@ Dir structure:
/log # Where we're going to write log files
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .dev import *
WIKI_ENABLED = True

View File

@@ -1 +1,5 @@
# We intentionally define variables that aren't used
# pylint: disable=W0614
DISCUSSION_ALLOWED_UPLOAD_FILE_TYPES = ('.jpg', '.jpeg', '.gif', '.bmp', '.png', '.tiff')

Some files were not shown because too many files have changed in this diff Show More