bringing up to master
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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$')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 |
|
||||
|
||||
11
lms/djangoapps/courseware/features/high-level-tabs.py
Normal file
11
lms/djangoapps/courseware/features/high-level-tabs.py
Normal 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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "/"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
325
lms/djangoapps/courseware/features/problems_setup.py
Normal file
325
lms/djangoapps/courseware/features/problems_setup.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "([^"]*)"$')
|
||||
|
||||
6
lms/djangoapps/courseware/features/video.feature
Normal file
6
lms/djangoapps/courseware/features/video.feature
Normal 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
|
||||
34
lms/djangoapps/courseware/features/video.py
Normal file
34
lms/djangoapps/courseware/features/video.py
Normal 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')
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
4
lms/djangoapps/courseware/tests/load_tests/README.md
Normal file
4
lms/djangoapps/courseware/tests/load_tests/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Load Testing
|
||||
|
||||
Scripts for load testing the courseware app,
|
||||
mostly using [multimechanize](http://testutils.org/multi-mechanize/)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
27
lms/djangoapps/courseware/tests/test_video_mongo.py
Normal file
27
lms/djangoapps/courseware/tests/test_video_mongo.py
Normal 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)
|
||||
135
lms/djangoapps/courseware/tests/test_video_xml.py
Normal file
135
lms/djangoapps/courseware/tests/test_video_xml.py
Normal 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})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
0
lms/djangoapps/debug/__init__.py
Normal file
0
lms/djangoapps/debug/__init__.py
Normal file
3
lms/djangoapps/debug/models.py
Normal file
3
lms/djangoapps/debug/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
31
lms/djangoapps/debug/views.py
Normal file
31
lms/djangoapps/debug/views.py
Normal 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)
|
||||
230
lms/djangoapps/django_comment_client/base/tests.py
Normal file
230
lms/djangoapps/django_comment_client/base/tests.py
Normal 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)
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 == '':
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
|
||||
13
lms/djangoapps/django_comment_client/tests/factories.py
Normal file
13
lms/djangoapps/django_comment_client/tests/factories.py
Normal 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'
|
||||
@@ -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):
|
||||
'''
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import string
|
||||
import random
|
||||
import collections
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from django_comment_client.helpers import pluralize
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
#########################################################################################
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
178
lms/djangoapps/instructor/tests/test_enrollment.py
Normal file
178
lms/djangoapps/instructor/tests/test_enrollment.py
Normal 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'])
|
||||
@@ -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
|
||||
|
||||
|
||||
63
lms/djangoapps/instructor/tests/test_xss.py
Normal file
63
lms/djangoapps/instructor/tests/test_xss.py
Normal 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")
|
||||
@@ -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('<', '<').replace('>', '>')
|
||||
|
||||
# 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('<','<')
|
||||
msg = '<pre>%s</pre>' % msg.replace('<', '<')
|
||||
return msg
|
||||
|
||||
@@ -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
|
||||
|
||||
57
lms/djangoapps/notes/README.md
Normal file
57
lms/djangoapps/notes/README.md
Normal 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
|
||||
0
lms/djangoapps/notes/__init__.py
Normal file
0
lms/djangoapps/notes/__init__.py
Normal file
251
lms/djangoapps/notes/api.py
Normal file
251
lms/djangoapps/notes/api.py
Normal 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'))
|
||||
90
lms/djangoapps/notes/migrations/0001_initial.py
Normal file
90
lms/djangoapps/notes/migrations/0001_initial.py
Normal 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']
|
||||
0
lms/djangoapps/notes/migrations/__init__.py
Normal file
0
lms/djangoapps/notes/migrations/__init__.py
Normal file
81
lms/djangoapps/notes/models.py
Normal file
81
lms/djangoapps/notes/models.py
Normal 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)
|
||||
}
|
||||
398
lms/djangoapps/notes/tests.py
Normal file
398
lms/djangoapps/notes/tests.py
Normal 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)
|
||||
10
lms/djangoapps/notes/urls.py
Normal file
10
lms/djangoapps/notes/urls.py
Normal 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')
|
||||
)
|
||||
17
lms/djangoapps/notes/utils.py
Normal file
17
lms/djangoapps/notes/utils.py
Normal 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
|
||||
24
lms/djangoapps/notes/views.py
Normal file
24
lms/djangoapps/notes/views.py
Normal 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)
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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']
|
||||
105
lms/envs/aws.py
105
lms/envs/aws.py
@@ -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
24
lms/envs/aws_migrate.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
lms/envs/dev_with_worker.py
Normal file
39
lms/envs/dev_with_worker.py
Normal 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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user