Commit f69d38bb by cahrens

Merge branch 'master' into feature/christina/metadata

parents c0f0366b 57427f9a
...@@ -11,6 +11,7 @@ Feature: Advanced (manual) course policy ...@@ -11,6 +11,7 @@ Feature: Advanced (manual) course policy
Given I am on the Advanced Course Settings page in Studio Given I am on the Advanced Course Settings page in Studio
Then the settings are alphabetized Then the settings are alphabetized
@skip-phantom
Scenario: Test cancel editing key value Scenario: Test cancel editing key value
Given I am on the Advanced Course Settings page in Studio Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key When I edit the value of a policy key
...@@ -19,6 +20,7 @@ Feature: Advanced (manual) course policy ...@@ -19,6 +20,7 @@ Feature: Advanced (manual) course policy
And I reload the page And I reload the page
Then the policy key value is unchanged Then the policy key value is unchanged
@skip-phantom
Scenario: Test editing key value Scenario: Test editing key value
Given I am on the Advanced Course Settings page in Studio Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key and save When I edit the value of a policy key and save
...@@ -26,6 +28,7 @@ Feature: Advanced (manual) course policy ...@@ -26,6 +28,7 @@ Feature: Advanced (manual) course policy
And I reload the page And I reload the page
Then the policy key value is changed Then the policy key value is changed
@skip-phantom
Scenario: Test how multi-line input appears Scenario: Test how multi-line input appears
Given I am on the Advanced Course Settings page in Studio Given I am on the Advanced Course Settings page in Studio
When I create a JSON object as a value When I create a JSON object as a value
...@@ -33,6 +36,7 @@ Feature: Advanced (manual) course policy ...@@ -33,6 +36,7 @@ Feature: Advanced (manual) course policy
And I reload the page And I reload the page
Then it is displayed as formatted Then it is displayed as formatted
@skip-phantom
Scenario: Test automatic quoting of non-JSON values Scenario: Test automatic quoting of non-JSON values
Given I am on the Advanced Course Settings page in Studio Given I am on the Advanced Course Settings page in Studio
When I create a non-JSON value not in quotes When I create a non-JSON value not in quotes
......
...@@ -3,10 +3,7 @@ ...@@ -3,10 +3,7 @@
from lettuce import world, step from lettuce import world, step
from common import * from common import *
import time from nose.tools import assert_false, assert_equal
from terrain.steps import reload_the_page
from nose.tools import assert_true, assert_false, assert_equal
""" """
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
...@@ -18,8 +15,8 @@ VALUE_CSS = 'textarea.json' ...@@ -18,8 +15,8 @@ VALUE_CSS = 'textarea.json'
DISPLAY_NAME_KEY = "display_name" DISPLAY_NAME_KEY = "display_name"
DISPLAY_NAME_VALUE = '"Robot Super Course"' DISPLAY_NAME_VALUE = '"Robot Super Course"'
############### ACTIONS ####################
############### ACTIONS ####################
@step('I select the Advanced Settings$') @step('I select the Advanced Settings$')
def i_select_advanced_settings(step): def i_select_advanced_settings(step):
expand_icon_css = 'li.nav-course-settings i.icon-expand' expand_icon_css = 'li.nav-course-settings i.icon-expand'
...@@ -38,7 +35,7 @@ def i_am_on_advanced_course_settings(step): ...@@ -38,7 +35,7 @@ def i_am_on_advanced_course_settings(step):
@step(u'I press the "([^"]*)" notification button$') @step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name): def press_the_notification_button(step, name):
css = 'a.%s-button' % name.lower() css = 'a.%s-button' % name.lower()
world.css_click_at(css) world.css_click(css)
@step(u'I edit the value of a policy key$') @step(u'I edit the value of a policy key$')
...@@ -52,7 +49,7 @@ def edit_the_value_of_a_policy_key(step): ...@@ -52,7 +49,7 @@ def edit_the_value_of_a_policy_key(step):
@step(u'I edit the value of a policy key and save$') @step(u'I edit the value of a policy key and save$')
def edit_the_value_of_a_policy_key(step): def edit_the_value_of_a_policy_key_and_save(step):
change_display_name_value(step, '"foo"') change_display_name_value(step, '"foo"')
...@@ -90,7 +87,7 @@ def it_is_formatted(step): ...@@ -90,7 +87,7 @@ def it_is_formatted(step):
@step('it is displayed as a string') @step('it is displayed as a string')
def it_is_formatted(step): def it_is_displayed_as_string(step):
assert_policy_entries([DISPLAY_NAME_KEY], ['"quote me"']) assert_policy_entries([DISPLAY_NAME_KEY], ['"quote me"'])
......
...@@ -10,6 +10,8 @@ Feature: Course checklists ...@@ -10,6 +10,8 @@ Feature: Course checklists
Then I can check and uncheck tasks in a checklist Then I can check and uncheck tasks in a checklist
And They are correctly selected after I reload the page And They are correctly selected after I reload the page
@skip-phantom
@skip-firefox
Scenario: A task can link to a location within Studio Scenario: A task can link to a location within Studio
Given I have opened Checklists Given I have opened Checklists
When I select a link to the course outline When I select a link to the course outline
...@@ -17,8 +19,9 @@ Feature: Course checklists ...@@ -17,8 +19,9 @@ Feature: Course checklists
And I press the browser back button And I press the browser back button
Then I am brought back to the course outline in the correct state Then I am brought back to the course outline in the correct state
@skip-phantom
@skip-firefox
Scenario: A task can link to a location outside Studio Scenario: A task can link to a location outside Studio
Given I have opened Checklists Given I have opened Checklists
When I select a link to help page When I select a link to help page
Then I am brought to the help page in a new window Then I am brought to the help page in a new window
...@@ -89,8 +89,6 @@ def i_am_brought_to_help_page_in_new_window(step): ...@@ -89,8 +89,6 @@ def i_am_brought_to_help_page_in_new_window(step):
assert_equal('http://help.edge.edx.org/', world.browser.url) assert_equal('http://help.edge.edx.org/', world.browser.url)
############### HELPER METHODS #################### ############### HELPER METHODS ####################
def verifyChecklist2Status(completed, total, percentage): def verifyChecklist2Status(completed, total, percentage):
def verify_count(driver): def verify_count(driver):
...@@ -107,9 +105,11 @@ def verifyChecklist2Status(completed, total, percentage): ...@@ -107,9 +105,11 @@ def verifyChecklist2Status(completed, total, percentage):
def toggleTask(checklist, task): def toggleTask(checklist, task):
world.css_click('#course-checklist' + str(checklist) +'-task' + str(task)) world.css_click('#course-checklist' + str(checklist) + '-task' + str(task))
# TODO: figure out a way to do this in phantom and firefox
# For now we will mark the scenerios that use this method as skipped
def clickActionLink(checklist, task, actionText): def clickActionLink(checklist, task, actionText):
# toggle checklist item to make sure that the link button is showing # toggle checklist item to make sure that the link button is showing
toggleTask(checklist, task) toggleTask(checklist, task)
...@@ -121,4 +121,3 @@ def clickActionLink(checklist, task, actionText): ...@@ -121,4 +121,3 @@ def clickActionLink(checklist, task, actionText):
world.wait_for(verify_action_link_text) world.wait_for(verify_action_link_text)
action_link.click() action_link.click()
Feature: Course Settings Feature: Course Settings
As a course author, I want to be able to configure my course settings. As a course author, I want to be able to configure my course settings.
@skip-phantom
Scenario: User can set course dates Scenario: User can set course dates
Given I have opened a new course in Studio Given I have opened a new course in Studio
When I select Schedule and Details When I select Schedule and Details
And I set course dates And I set course dates
Then I see the set dates on refresh Then I see the set dates on refresh
@skip-phantom
Scenario: User can clear previously set course dates (except start date) Scenario: User can clear previously set course dates (except start date)
Given I have set course dates Given I have set course dates
And I clear all the dates except start And I clear all the dates except start
Then I see cleared dates on refresh Then I see cleared dates on refresh
@skip-phantom
Scenario: User cannot clear the course start date Scenario: User cannot clear the course start date
Given I have set course dates Given I have set course dates
And I clear the course start date And I clear the course start date
......
...@@ -3,6 +3,7 @@ Feature: Create Section ...@@ -3,6 +3,7 @@ Feature: Create Section
As a course author As a course author
I want to create and edit sections I want to create and edit sections
@skip-phantom
Scenario: Add a new section to a course Scenario: Add a new section to a course
Given I have opened a new course in Studio Given I have opened a new course in Studio
When I click the New Section link When I click the New Section link
......
...@@ -14,6 +14,7 @@ Feature: Overview Toggle Section ...@@ -14,6 +14,7 @@ Feature: Overview Toggle Section
When I navigate to the course overview page When I navigate to the course overview page
Then I do not see the "Collapse All Sections" link Then I do not see the "Collapse All Sections" link
@skip-phantom
Scenario: Collapse link appears after creating first section of a course Scenario: Collapse link appears after creating first section of a course
Given I have a course with no sections Given I have a course with no sections
When I navigate to the course overview page When I navigate to the course overview page
......
...@@ -3,12 +3,14 @@ Feature: Create Subsection ...@@ -3,12 +3,14 @@ Feature: Create Subsection
As a course author As a course author
I want to create and edit subsections I want to create and edit subsections
@skip-phantom
Scenario: Add a new subsection to a section Scenario: Add a new subsection to a section
Given I have opened a new course section in Studio Given I have opened a new course section in Studio
When I click the New Subsection link When I click the New Subsection link
And I enter the subsection name and click save And I enter the subsection name and click save
Then I see my subsection on the Courseware page Then I see my subsection on the Courseware page
@skip-phantom
Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216) Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216)
Given I have opened a new course section in Studio Given I have opened a new course section in Studio
When I click the New Subsection link When I click the New Subsection link
...@@ -25,6 +27,7 @@ Feature: Create Subsection ...@@ -25,6 +27,7 @@ Feature: Create Subsection
And I reload the page And I reload the page
Then I see it marked as Homework Then I see it marked as Homework
@skip-phantom
Scenario: Set a due date in a different year (bug #256) Scenario: Set a due date in a different year (bug #256)
Given I have opened a new subsection in Studio Given I have opened a new subsection in Studio
And I have set a release date and due date in different years And I have set a release date and due date in different years
...@@ -40,5 +43,3 @@ Feature: Create Subsection ...@@ -40,5 +43,3 @@ Feature: Create Subsection
When I press the "subsection" delete icon When I press the "subsection" delete icon
And I confirm the alert And I confirm the alert
Then the subsection does not exist Then the subsection does not exist
...@@ -9,7 +9,6 @@ from tempdir import mkdtemp_clean ...@@ -9,7 +9,6 @@ from tempdir import mkdtemp_clean
from fs.osfs import OSFS from fs.osfs import OSFS
import copy import copy
from json import loads from json import loads
import traceback
from datetime import timedelta from datetime import timedelta
from django.contrib.auth.models import User from django.contrib.auth.models import User
...@@ -478,6 +477,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -478,6 +477,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
for child in vertical.get_children(): for child in vertical.get_children():
self.assertTrue(getattr(child, 'is_draft', False)) self.assertTrue(getattr(child, 'is_draft', False))
# make sure that we don't have a sequential that is in draft mode
sequential = draft_store.get_item(Location(['i4x', 'edX', 'full',
'sequential', 'Administrivia_and_Circuit_Elements', None]))
self.assertFalse(getattr(sequential, 'is_draft', False))
# verify that we have the private vertical # verify that we have the private vertical
test_private_vertical = draft_store.get_item(Location(['i4x', 'edX', 'full', test_private_vertical = draft_store.get_item(Location(['i4x', 'edX', 'full',
'vertical', 'vertical_66', None])) 'vertical', 'vertical_66', None]))
......
...@@ -36,3 +36,4 @@ DATABASES = { ...@@ -36,3 +36,4 @@ DATABASES = {
INSTALLED_APPS += ('lettuce.django',) INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('contentstore',) LETTUCE_APPS = ('contentstore',)
LETTUCE_SERVER_PORT = 8001 LETTUCE_SERVER_PORT = 8001
LETTUCE_BROWSER = 'chrome'
...@@ -87,12 +87,12 @@ from contentstore import utils ...@@ -87,12 +87,12 @@ from contentstore import utils
<div class="note note-promotion note-promotion-courseURL has-actions"> <div class="note note-promotion note-promotion-courseURL has-actions">
<h3 class="title">Course Summary Page <span class="tip">(for student enrollment and access)</span></h3> <h3 class="title">Course Summary Page <span class="tip">(for student enrollment and access)</span></h3>
<div class="copy"> <div class="copy">
<p><a class="link-courseURL" rel="external" href="${utils.get_lms_link_for_about_page(course_location)}" />${utils.get_lms_link_for_about_page(course_location)}</a></p> <p><a class="link-courseURL" rel="external" href="https:${utils.get_lms_link_for_about_page(course_location)}" />https:${utils.get_lms_link_for_about_page(course_location)}</a></p>
</div> </div>
<ul class="list-actions"> <ul class="list-actions">
<li class="action-item"> <li class="action-item">
<a title="Send a note to students via email" href="mailto:john.doe@gmail.com?Subject=Enroll%20in%20COURSENAME&body=Hi,%20COURSENAME,%20provided%20by%20edX,%20is%20almost%20ready%20to%20begin.%20Please%20enroll%20for%20this%20course%20at%20${utils.get_lms_link_for_about_page(course_location)}." class="action action-primary"><i class="ss-icon icon ss-symbolicons-standard icon icon-inline icon-announcement">&#x2709;</i> Send an invitation to your students</a> <a title="Send a note to students via email" href="mailto:someone@domain.com?Subject=Enroll%20in%20${context_course.display_name_with_default}&body=The%20course%20&quot;${context_course.display_name_with_default}&quot;,%20provided%20by%20edX,%20is%20open%20for%20enrollment.%20Please%20navigate%20to%20this%20course%20at%20https:${utils.get_lms_link_for_about_page(course_location)}%20to%20enroll." class="action action-primary"><i class="ss-icon icon ss-symbolicons-standard icon icon-inline icon-announcement">&#x2709;</i> Invite your students</a>
</li> </li>
</ul> </ul>
</div> </div>
......
...@@ -97,7 +97,7 @@ from contentstore import utils ...@@ -97,7 +97,7 @@ from contentstore import utils
<ol class="list-input"> <ol class="list-input">
<li class="field text" id="field-course-grading-graceperiod"> <li class="field text" id="field-course-grading-graceperiod">
<label for="course-grading-graceperiod">Grace Period on Deadline:</label> <label for="course-grading-graceperiod">Grace Period on Deadline:</label>
<input type="text" class="short time" id="course-grading-graceperiod" value="0:00" placeholder="e.g. 10 minutes"> <input type="text" class="short time" id="course-grading-graceperiod" value="0:00" placeholder="HH:MM" autocomplete="off" />
<span class="tip tip-inline">Leeway on due dates</span> <span class="tip tip-inline">Leeway on due dates</span>
</li> </li>
</ol> </ol>
......
...@@ -2,17 +2,17 @@ from student.models import (User, UserProfile, Registration, ...@@ -2,17 +2,17 @@ from student.models import (User, UserProfile, Registration,
CourseEnrollmentAllowed, CourseEnrollment) CourseEnrollmentAllowed, CourseEnrollment)
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from datetime import datetime from datetime import datetime
from factory import Factory, SubFactory, post_generation from factory import DjangoModelFactory, Factory, SubFactory, PostGenerationMethodCall
from uuid import uuid4 from uuid import uuid4
class GroupFactory(Factory): class GroupFactory(DjangoModelFactory):
FACTORY_FOR = Group FACTORY_FOR = Group
name = 'staff_MITx/999/Robot_Super_Course' name = 'staff_MITx/999/Robot_Super_Course'
class UserProfileFactory(Factory): class UserProfileFactory(DjangoModelFactory):
FACTORY_FOR = UserProfile FACTORY_FOR = UserProfile
user = None user = None
...@@ -23,19 +23,20 @@ class UserProfileFactory(Factory): ...@@ -23,19 +23,20 @@ class UserProfileFactory(Factory):
goals = 'World domination' goals = 'World domination'
class RegistrationFactory(Factory): class RegistrationFactory(DjangoModelFactory):
FACTORY_FOR = Registration FACTORY_FOR = Registration
user = None user = None
activation_key = uuid4().hex activation_key = uuid4().hex
class UserFactory(Factory): class UserFactory(DjangoModelFactory):
FACTORY_FOR = User FACTORY_FOR = User
username = 'robot' username = 'robot'
email = 'robot+test@edx.org' email = 'robot+test@edx.org'
password = 'test' password = PostGenerationMethodCall('set_password',
'test')
first_name = 'Robot' first_name = 'Robot'
last_name = 'Test' last_name = 'Test'
is_staff = False is_staff = False
...@@ -44,26 +45,19 @@ class UserFactory(Factory): ...@@ -44,26 +45,19 @@ class UserFactory(Factory):
last_login = datetime(2012, 1, 1) last_login = datetime(2012, 1, 1)
date_joined = datetime(2011, 1, 1) date_joined = datetime(2011, 1, 1)
@post_generation
def set_password(self, create, extracted, **kwargs):
self._raw_password = self.password
self.set_password(self.password)
if create:
self.save()
class AdminFactory(UserFactory): class AdminFactory(UserFactory):
is_staff = True is_staff = True
class CourseEnrollmentFactory(Factory): class CourseEnrollmentFactory(DjangoModelFactory):
FACTORY_FOR = CourseEnrollment FACTORY_FOR = CourseEnrollment
user = SubFactory(UserFactory) user = SubFactory(UserFactory)
course_id = 'edX/toy/2012_Fall' course_id = 'edX/toy/2012_Fall'
class CourseEnrollmentAllowedFactory(Factory): class CourseEnrollmentAllowedFactory(DjangoModelFactory):
FACTORY_FOR = CourseEnrollmentAllowed FACTORY_FOR = CourseEnrollmentAllowed
email = 'test@edx.org' email = 'test@edx.org'
......
from lettuce import before, after, world from lettuce import before, after, world
from splinter.browser import Browser from splinter.browser import Browser
from logging import getLogger from logging import getLogger
from django.core.management import call_command
from django.conf import settings
# Let the LMS and CMS do their one-time setup # Let the LMS and CMS do their one-time setup
# For example, setting up mongo caches # For example, setting up mongo caches
...@@ -10,18 +12,14 @@ from cms import one_time_startup ...@@ -10,18 +12,14 @@ from cms import one_time_startup
logger = getLogger(__name__) logger = getLogger(__name__)
logger.info("Loading the lettuce acceptance testing terrain file...") logger.info("Loading the lettuce acceptance testing terrain file...")
from django.core.management import call_command
@before.harvest @before.harvest
def initial_setup(server): def initial_setup(server):
''' '''
Launch the browser once before executing the tests Launch the browser once before executing the tests
''' '''
# Launch the browser app (choose one of these below) browser_driver = getattr(settings, 'LETTUCE_BROWSER', 'chrome')
world.browser = Browser('chrome') world.browser = Browser(browser_driver)
# world.browser = Browser('phantomjs')
# world.browser = Browser('firefox')
@before.each_scenario @before.each_scenario
...@@ -34,6 +32,15 @@ def reset_data(scenario): ...@@ -34,6 +32,15 @@ def reset_data(scenario):
call_command('flush', interactive=False) call_command('flush', interactive=False)
@after.each_scenario
def screenshot_on_error(scenario):
'''
Save a screenshot to help with debugging
'''
if scenario.failed:
world.browser.driver.save_screenshot('/tmp/last_failed_scenario.png')
@after.all @after.all
def teardown_browser(total): def teardown_browser(total):
''' '''
......
...@@ -132,6 +132,8 @@ def i_am_logged_in(step): ...@@ -132,6 +132,8 @@ def i_am_logged_in(step):
world.create_user('robot') world.create_user('robot')
world.log_in('robot', 'test') world.log_in('robot', 'test')
world.browser.visit(django_url('/')) world.browser.visit(django_url('/'))
# You should not see the login link
assert_equals(world.browser.find_by_css('a#login'), [])
@step(u'I am an edX user$') @step(u'I am an edX user$')
......
...@@ -105,8 +105,12 @@ def add_histogram(get_html, module, user): ...@@ -105,8 +105,12 @@ def add_histogram(get_html, module, user):
return get_html() return get_html()
module_id = module.id module_id = module.id
if module.descriptor.has_score:
histogram = grade_histogram(module_id) histogram = grade_histogram(module_id)
render_histogram = len(histogram) > 0 render_histogram = len(histogram) > 0
else:
histogram = None
render_histogram = False
if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'): if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'):
[filepath, filename] = getattr(module.descriptor, 'xml_attributes', {}).get('filename', ['', None]) [filepath, filename] = getattr(module.descriptor, 'xml_attributes', {}).get('filename', ['', None])
......
...@@ -668,6 +668,8 @@ class MatlabInput(CodeInput): ...@@ -668,6 +668,8 @@ class MatlabInput(CodeInput):
# Check if problem has been queued # Check if problem has been queued
self.queuename = 'matlab' self.queuename = 'matlab'
self.queue_msg = '' self.queue_msg = ''
# this is only set if we don't have a graded response
# the graded response takes precedence
if 'queue_msg' in self.input_state and self.status in ['queued', 'incomplete', 'unsubmitted']: if 'queue_msg' in self.input_state and self.status in ['queued', 'incomplete', 'unsubmitted']:
self.queue_msg = self.input_state['queue_msg'] self.queue_msg = self.input_state['queue_msg']
if 'queuestate' in self.input_state and self.input_state['queuestate'] == 'queued': if 'queuestate' in self.input_state and self.input_state['queuestate'] == 'queued':
...@@ -712,11 +714,23 @@ class MatlabInput(CodeInput): ...@@ -712,11 +714,23 @@ class MatlabInput(CodeInput):
self.input_state['queuestate'] = None self.input_state['queuestate'] = None
self.input_state['queuekey'] = None self.input_state['queuekey'] = None
def button_enabled(self):
""" Return whether or not we want the 'Test Code' button visible
Right now, we only want this button to show up when a problem has not been
checked.
"""
if self.status in ['correct', 'incorrect']:
return False
else:
return True
def _extra_context(self): def _extra_context(self):
''' Set up additional context variables''' ''' Set up additional context variables'''
extra_context = { extra_context = {
'queue_len': str(self.queue_len), 'queue_len': str(self.queue_len),
'queue_msg': self.queue_msg 'queue_msg': self.queue_msg,
'button_enabled': self.button_enabled(),
} }
return extra_context return extra_context
...@@ -766,10 +780,6 @@ class MatlabInput(CodeInput): ...@@ -766,10 +780,6 @@ class MatlabInput(CodeInput):
lms_key=queuekey, lms_key=queuekey,
queue_name=self.queuename) queue_name=self.queuename)
# save the input state
self.input_state['queuekey'] = queuekey
self.input_state['queuestate'] = 'queued'
# construct xqueue body # construct xqueue body
student_info = {'anonymous_student_id': anonymous_student_id, student_info = {'anonymous_student_id': anonymous_student_id,
'submission_time': qtime} 'submission_time': qtime}
...@@ -779,6 +789,10 @@ class MatlabInput(CodeInput): ...@@ -779,6 +789,10 @@ class MatlabInput(CodeInput):
(error, msg) = qinterface.send_to_queue(header=xheader, (error, msg) = qinterface.send_to_queue(header=xheader,
body=json.dumps(contents)) body=json.dumps(contents))
# save the input state if successful
if error == 0:
self.input_state['queuekey'] = queuekey
self.input_state['queuestate'] = 'queued'
return {'success': error == 0, 'message': msg} return {'success': error == 0, 'message': msg}
......
...@@ -33,9 +33,11 @@ ...@@ -33,9 +33,11 @@
${queue_msg|n} ${queue_msg|n}
</div> </div>
% if button_enabled:
<div class="plot-button"> <div class="plot-button">
<input type="button" class="save" name="plot-button" id="plot_${id}" value="Plot" /> <input type="button" class="save" name="plot-button" id="plot_${id}" value="Run Code" />
</div> </div>
%endif
<script> <script>
// Note: We need to make the area follow the CodeMirror for this to work. // Note: We need to make the area follow the CodeMirror for this to work.
...@@ -91,7 +93,7 @@ ...@@ -91,7 +93,7 @@
window.location.reload(); window.location.reload();
} }
else { else {
gentle_alert(problem_elt, msg); gentle_alert(problem_elt, response.message);
} }
} }
...@@ -102,7 +104,7 @@ ...@@ -102,7 +104,7 @@
{'submission': submission}, plot_callback); {'submission': submission}, plot_callback);
} }
else { else {
gentle_alert(problem_elt, msg); gentle_alert(problem_elt, response.message);
} }
} }
......
"""Tests for the logic in input type mako templates."""
import unittest
import capa
import os.path
from lxml import etree
from mako.template import Template as MakoTemplate
from mako import exceptions
class TemplateError(Exception):
"""Error occurred while rendering a Mako template"""
pass
class TemplateTestCase(unittest.TestCase):
"""Utilitites for testing templates"""
# Subclasses override this to specify the file name of the template
# to be loaded from capa/templates.
# The template name should include the .html extension:
# for example: choicegroup.html
TEMPLATE_NAME = None
def setUp(self):
"""Load the template"""
capa_path = capa.__path__[0]
self.template_path = os.path.join(capa_path,
'templates',
self.TEMPLATE_NAME)
template_file = open(self.template_path)
self.template = MakoTemplate(template_file.read())
template_file.close()
def render_to_xml(self, context_dict):
"""Render the template using the `context_dict` dict.
Returns an `etree` XML element."""
try:
xml_str = self.template.render_unicode(**context_dict)
except:
raise TemplateError(exceptions.text_error_template().render())
return etree.fromstring(xml_str)
def assert_has_xpath(self, xml_root, xpath, context_dict, exact_num=1):
"""Asserts that the xml tree has an element satisfying `xpath`.
`xml_root` is an etree XML element
`xpath` is an XPath string, such as `'/foo/bar'`
`context` is used to print a debugging message
`exact_num` is the exact number of matches to expect.
"""
message = ("XML does not have %d match(es) for xpath '%s'\nXML: %s\nContext: %s"
% (exact_num, str(xpath), etree.tostring(xml_root), str(context_dict)))
self.assertEqual(len(xml_root.xpath(xpath)), exact_num, msg=message)
def assert_no_xpath(self, xml_root, xpath, context_dict):
"""Asserts that the xml tree does NOT have an element
satisfying `xpath`.
`xml_root` is an etree XML element
`xpath` is an XPath string, such as `'/foo/bar'`
`context` is used to print a debugging message
"""
self.assert_has_xpath(xml_root, xpath, context_dict, exact_num=0)
def assert_has_text(self, xml_root, xpath, text, exact=True):
"""Find the element at `xpath` in `xml_root` and assert
that its text is `text`.
`xml_root` is an etree XML element
`xpath` is an XPath string, such as `'/foo/bar'`
`text` is the expected text that the element should contain
If multiple elements are found, checks the first one.
If no elements are found, the assertion fails.
"""
element_list = xml_root.xpath(xpath)
self.assertTrue(len(element_list) > 0,
"Could not find element at '%s'" % str(xpath))
if exact:
self.assertEqual(text, element_list[0].text)
else:
self.assertIn(text, element_list[0].text)
class ChoiceGroupTemplateTest(TemplateTestCase):
"""Test mako template for `<choicegroup>` input"""
TEMPLATE_NAME = 'choicegroup.html'
def setUp(self):
choices = [('1', 'choice 1'), ('2', 'choice 2'), ('3', 'choice 3')]
self.context = {'id': '1',
'choices': choices,
'status': 'correct',
'input_type': 'checkbox',
'name_array_suffix': '1',
'value': '3'}
super(ChoiceGroupTemplateTest, self).setUp()
def test_problem_marked_correct(self):
"""Test conditions under which the entire problem
(not a particular option) is marked correct"""
self.context['status'] = 'correct'
self.context['input_type'] = 'checkbox'
self.context['value'] = ['1', '2']
# Should mark the entire problem correct
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='correct']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
self.assert_no_xpath(xml, "//label[@class='choicegroup_incorrect']",
self.context)
self.assert_no_xpath(xml, "//label[@class='choicegroup_correct']",
self.context)
def test_problem_marked_incorrect(self):
"""Test all conditions under which the entire problem
(not a particular option) is marked incorrect"""
conditions = [
{'status': 'incorrect', 'input_type': 'radio', 'value': ''},
{'status': 'incorrect', 'input_type': 'checkbox', 'value': []},
{'status': 'incorrect', 'input_type': 'checkbox', 'value': ['2']},
{'status': 'incorrect', 'input_type': 'checkbox', 'value': ['2', '3']},
{'status': 'incomplete', 'input_type': 'radio', 'value': ''},
{'status': 'incomplete', 'input_type': 'checkbox', 'value': []},
{'status': 'incomplete', 'input_type': 'checkbox', 'value': ['2']},
{'status': 'incomplete', 'input_type': 'checkbox', 'value': ['2', '3']}]
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='incorrect']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
self.assert_no_xpath(xml,
"//label[@class='choicegroup_incorrect']",
self.context)
self.assert_no_xpath(xml,
"//label[@class='choicegroup_correct']",
self.context)
def test_problem_marked_unsubmitted(self):
"""Test all conditions under which the entire problem
(not a particular option) is marked unanswered"""
conditions = [
{'status': 'unsubmitted', 'input_type': 'radio', 'value': ''},
{'status': 'unsubmitted', 'input_type': 'radio', 'value': []},
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': []},
{'input_type': 'radio', 'value': ''},
{'input_type': 'radio', 'value': []},
{'input_type': 'checkbox', 'value': []},
{'input_type': 'checkbox', 'value': ['1']},
{'input_type': 'checkbox', 'value': ['1', '2']}]
self.context['status'] = 'unanswered'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='unanswered']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
self.assert_no_xpath(xml,
"//label[@class='choicegroup_incorrect']",
self.context)
self.assert_no_xpath(xml,
"//label[@class='choicegroup_correct']",
self.context)
def test_option_marked_correct(self):
"""Test conditions under which a particular option
(not the entire problem) is marked correct."""
conditions = [
{'input_type': 'radio', 'value': '2'},
{'input_type': 'radio', 'value': ['2']}]
self.context['status'] = 'correct'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//label[@class='choicegroup_correct']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator_container']/span"
self.assert_no_xpath(xml, xpath, self.context)
def test_option_marked_incorrect(self):
"""Test conditions under which a particular option
(not the entire problem) is marked incorrect."""
conditions = [
{'input_type': 'radio', 'value': '2'},
{'input_type': 'radio', 'value': ['2']}]
self.context['status'] = 'incorrect'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//label[@class='choicegroup_incorrect']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator_container']/span"
self.assert_no_xpath(xml, xpath, self.context)
def test_never_show_correctness(self):
"""Test conditions under which we tell the template to
NOT show correct/incorrect, but instead show a message.
This is used, for example, by the Justice course to ask
questions without specifying a correct answer. When
the student responds, the problem displays "Thank you
for your response"
"""
conditions = [
{'input_type': 'radio', 'status': 'correct', 'value': ''},
{'input_type': 'radio', 'status': 'correct', 'value': '2'},
{'input_type': 'radio', 'status': 'correct', 'value': ['2']},
{'input_type': 'radio', 'status': 'incorrect', 'value': '2'},
{'input_type': 'radio', 'status': 'incorrect', 'value': []},
{'input_type': 'radio', 'status': 'incorrect', 'value': ['2']},
{'input_type': 'checkbox', 'status': 'correct', 'value': []},
{'input_type': 'checkbox', 'status': 'correct', 'value': ['2']},
{'input_type': 'checkbox', 'status': 'incorrect', 'value': []},
{'input_type': 'checkbox', 'status': 'incorrect', 'value': ['2']}]
self.context['show_correctness'] = 'never'
self.context['submitted_message'] = 'Test message'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
# Should NOT mark the entire problem correct/incorrect
xpath = "//div[@class='indicator_container']/span[@class='correct']"
self.assert_no_xpath(xml, xpath, self.context)
xpath = "//div[@class='indicator_container']/span[@class='incorrect']"
self.assert_no_xpath(xml, xpath, self.context)
# Should NOT mark individual options
self.assert_no_xpath(xml,
"//label[@class='choicegroup_incorrect']",
self.context)
self.assert_no_xpath(xml,
"//label[@class='choicegroup_correct']",
self.context)
# Expect to see the message
self.assert_has_text(xml, "//div[@class='capa_alert']",
self.context['submitted_message'])
def test_no_message_before_submission(self):
"""Ensure that we don't show the `submitted_message`
before submitting"""
conditions = [
{'input_type': 'radio', 'status': 'unsubmitted', 'value': ''},
{'input_type': 'radio', 'status': 'unsubmitted', 'value': []},
{'input_type': 'checkbox', 'status': 'unsubmitted', 'value': []},
# These tests expose bug #365
# When the bug is fixed, uncomment these cases.
#{'input_type': 'radio', 'status': 'unsubmitted', 'value': '2'},
#{'input_type': 'radio', 'status': 'unsubmitted', 'value': ['2']},
#{'input_type': 'radio', 'status': 'unsubmitted', 'value': '2'},
#{'input_type': 'radio', 'status': 'unsubmitted', 'value': ['2']},
#{'input_type': 'checkbox', 'status': 'unsubmitted', 'value': ['2']},
#{'input_type': 'checkbox', 'status': 'unsubmitted', 'value': ['2']}]
]
self.context['show_correctness'] = 'never'
self.context['submitted_message'] = 'Test message'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
# Expect that we do NOT see the message yet
self.assert_no_xpath(xml, "//div[@class='capa_alert']", self.context)
class TextlineTemplateTest(TemplateTestCase):
"""Test mako template for `<textline>` input"""
TEMPLATE_NAME = 'textline.html'
def setUp(self):
self.context = {'id': '1',
'status': 'correct',
'value': '3',
'preprocessor': None,
'trailing_text': None}
super(TextlineTemplateTest, self).setUp()
def test_section_class(self):
cases = [({}, ' capa_inputtype '),
({'do_math': True}, 'text-input-dynamath capa_inputtype '),
({'inline': True}, ' capa_inputtype inline'),
({'do_math': True, 'inline': True}, 'text-input-dynamath capa_inputtype inline'), ]
for (context, css_class) in cases:
base_context = self.context.copy()
base_context.update(context)
xml = self.render_to_xml(base_context)
xpath = "//section[@class='%s']" % css_class
self.assert_has_xpath(xml, xpath, self.context)
def test_status(self):
cases = [('correct', 'correct', 'correct'),
('unsubmitted', 'unanswered', 'unanswered'),
('incorrect', 'incorrect', 'incorrect'),
('incomplete', 'incorrect', 'incomplete')]
for (context_status, div_class, status_mark) in cases:
self.context['status'] = context_status
xml = self.render_to_xml(self.context)
# Expect that we get a <div> with correct class
xpath = "//div[@class='%s ']" % div_class
self.assert_has_xpath(xml, xpath, self.context)
# Expect that we get a <p> with class="status"
# (used to by CSS to draw the green check / red x)
self.assert_has_text(xml, "//p[@class='status']",
status_mark, exact=False)
def test_hidden(self):
self.context['hidden'] = True
xml = self.render_to_xml(self.context)
xpath = "//div[@style='display:none;']"
self.assert_has_xpath(xml, xpath, self.context)
xpath = "//input[@style='display:none;']"
self.assert_has_xpath(xml, xpath, self.context)
def test_do_math(self):
self.context['do_math'] = True
xml = self.render_to_xml(self.context)
xpath = "//input[@class='math']"
self.assert_has_xpath(xml, xpath, self.context)
xpath = "//div[@class='equation']"
self.assert_has_xpath(xml, xpath, self.context)
xpath = "//textarea[@id='input_1_dynamath']"
self.assert_has_xpath(xml, xpath, self.context)
def test_size(self):
self.context['size'] = '20'
xml = self.render_to_xml(self.context)
xpath = "//input[@size='20']"
self.assert_has_xpath(xml, xpath, self.context)
def test_preprocessor(self):
self.context['preprocessor'] = {'class_name': 'test_class',
'script_src': 'test_script'}
xml = self.render_to_xml(self.context)
xpath = "//div[@class='text-input-dynamath_data' and @data-preprocessor='test_class']"
self.assert_has_xpath(xml, xpath, self.context)
xpath = "//div[@class='script_placeholder' and @data-src='test_script']"
self.assert_has_xpath(xml, xpath, self.context)
def test_do_inline(self):
cases = [('correct', 'correct'),
('unsubmitted', 'unanswered'),
('incorrect', 'incorrect'),
('incomplete', 'incorrect')]
self.context['inline'] = True
for (context_status, div_class) in cases:
self.context['status'] = context_status
xml = self.render_to_xml(self.context)
# Expect that we get a <div> with correct class
xpath = "//div[@class='%s inline']" % div_class
self.assert_has_xpath(xml, xpath, self.context)
def test_message(self):
self.context['msg'] = "Test message"
xml = self.render_to_xml(self.context)
xpath = "//span[@class='message']"
self.assert_has_text(xml, xpath, self.context['msg'])
...@@ -384,6 +384,7 @@ class MatlabTest(unittest.TestCase): ...@@ -384,6 +384,7 @@ class MatlabTest(unittest.TestCase):
'linenumbers': 'true', 'linenumbers': 'true',
'hidden': '', 'hidden': '',
'tabsize': int(self.tabsize), 'tabsize': int(self.tabsize),
'button_enabled': True,
'queue_len': '3'} 'queue_len': '3'}
self.assertEqual(context, expected) self.assertEqual(context, expected)
...@@ -409,10 +410,37 @@ class MatlabTest(unittest.TestCase): ...@@ -409,10 +410,37 @@ class MatlabTest(unittest.TestCase):
'linenumbers': 'true', 'linenumbers': 'true',
'hidden': '', 'hidden': '',
'tabsize': int(self.tabsize), 'tabsize': int(self.tabsize),
'button_enabled': True,
'queue_len': '3'} 'queue_len': '3'}
self.assertEqual(context, expected) self.assertEqual(context, expected)
def test_rendering_when_completed(self):
for status in ['correct', 'incorrect']:
state = {'value': 'print "good evening"',
'status': status,
'input_state': {},
}
elt = etree.fromstring(self.xml)
the_input = self.input_class(test_system, elt, state)
context = the_input._get_render_context()
expected = {'id': 'prob_1_2',
'value': 'print "good evening"',
'status': status,
'msg': '',
'mode': self.mode,
'rows': self.rows,
'cols': self.cols,
'queue_msg': '',
'linenumbers': 'true',
'hidden': '',
'tabsize': int(self.tabsize),
'button_enabled': False,
'queue_len': '0'}
self.assertEqual(context, expected)
def test_rendering_while_queued(self): def test_rendering_while_queued(self):
state = {'value': 'print "good evening"', state = {'value': 'print "good evening"',
'status': 'incomplete', 'status': 'incomplete',
...@@ -433,6 +461,7 @@ class MatlabTest(unittest.TestCase): ...@@ -433,6 +461,7 @@ class MatlabTest(unittest.TestCase):
'linenumbers': 'true', 'linenumbers': 'true',
'hidden': '', 'hidden': '',
'tabsize': int(self.tabsize), 'tabsize': int(self.tabsize),
'button_enabled': True,
'queue_len': '1'} 'queue_len': '1'}
self.assertEqual(context, expected) self.assertEqual(context, expected)
...@@ -447,6 +476,17 @@ class MatlabTest(unittest.TestCase): ...@@ -447,6 +476,17 @@ class MatlabTest(unittest.TestCase):
self.assertTrue(self.the_input.input_state['queuekey'] is not None) self.assertTrue(self.the_input.input_state['queuekey'] is not None)
self.assertEqual(self.the_input.input_state['queuestate'], 'queued') self.assertEqual(self.the_input.input_state['queuestate'], 'queued')
def test_plot_data_failure(self):
get = {'submission': 'x = 1234;'}
error_message = 'Error message!'
test_system.xqueue['interface'].send_to_queue.return_value = (1, error_message)
response = self.the_input.handle_ajax("plot", get)
self.assertFalse(response['success'])
self.assertEqual(response['message'], error_message)
self.assertTrue('queuekey' not in self.the_input.input_state)
self.assertTrue('queuestate' not in self.the_input.input_state)
test_system.xqueue['interface'].send_to_queue.return_value = (0, 'Success!')
def test_ungraded_response_success(self): def test_ungraded_response_success(self):
queuekey = 'abcd' queuekey = 'abcd'
input_state = {'queuekey': queuekey, 'queuestate': 'queued'} input_state = {'queuekey': queuekey, 'queuestate': 'queued'}
...@@ -583,7 +623,6 @@ class ImageInputTest(unittest.TestCase): ...@@ -583,7 +623,6 @@ class ImageInputTest(unittest.TestCase):
self.check('[12 13 14]', 0, 0) self.check('[12 13 14]', 0, 0)
class CrystallographyTest(unittest.TestCase): class CrystallographyTest(unittest.TestCase):
''' '''
Check that crystallography inputs work Check that crystallography inputs work
...@@ -613,8 +652,7 @@ class CrystallographyTest(unittest.TestCase): ...@@ -613,8 +652,7 @@ class CrystallographyTest(unittest.TestCase):
'status': 'unsubmitted', 'status': 'unsubmitted',
'msg': '', 'msg': '',
'width': width, 'width': width,
'height': height, 'height': height}
}
self.assertEqual(context, expected) self.assertEqual(context, expected)
...@@ -654,13 +692,11 @@ class VseprTest(unittest.TestCase): ...@@ -654,13 +692,11 @@ class VseprTest(unittest.TestCase):
'width': width, 'width': width,
'height': height, 'height': height,
'molecules': molecules, 'molecules': molecules,
'geometries': geometries, 'geometries': geometries}
}
self.assertEqual(context, expected) self.assertEqual(context, expected)
class ChemicalEquationTest(unittest.TestCase): class ChemicalEquationTest(unittest.TestCase):
''' '''
Check that chemical equation inputs work. Check that chemical equation inputs work.
...@@ -674,7 +710,6 @@ class ChemicalEquationTest(unittest.TestCase): ...@@ -674,7 +710,6 @@ class ChemicalEquationTest(unittest.TestCase):
state = {'value': 'H2OYeah', } state = {'value': 'H2OYeah', }
self.the_input = lookup_tag('chemicalequationinput')(test_system, element, state) self.the_input = lookup_tag('chemicalequationinput')(test_system, element, state)
def test_rendering(self): def test_rendering(self):
''' Verify that the render context matches the expected render context''' ''' Verify that the render context matches the expected render context'''
context = self.the_input._get_render_context() context = self.the_input._get_render_context()
...@@ -688,10 +723,8 @@ class ChemicalEquationTest(unittest.TestCase): ...@@ -688,10 +723,8 @@ class ChemicalEquationTest(unittest.TestCase):
} }
self.assertEqual(context, expected) self.assertEqual(context, expected)
def test_chemcalc_ajax_sucess(self): def test_chemcalc_ajax_sucess(self):
''' Verify that using the correct dispatch and valid data produces a valid response''' ''' Verify that using the correct dispatch and valid data produces a valid response'''
data = {'formula': "H"} data = {'formula': "H"}
response = self.the_input.handle_ajax("preview_chemcalc", data) response = self.the_input.handle_ajax("preview_chemcalc", data)
...@@ -700,9 +733,6 @@ class ChemicalEquationTest(unittest.TestCase): ...@@ -700,9 +733,6 @@ class ChemicalEquationTest(unittest.TestCase):
self.assertEqual(response['error'], "") self.assertEqual(response['error'], "")
class DragAndDropTest(unittest.TestCase): class DragAndDropTest(unittest.TestCase):
''' '''
Check that drag and drop inputs work Check that drag and drop inputs work
......
...@@ -274,7 +274,7 @@ def import_from_xml(store, data_dir, course_dirs=None, ...@@ -274,7 +274,7 @@ def import_from_xml(store, data_dir, course_dirs=None,
# now import any 'draft' items # now import any 'draft' items
if draft_store is not None: if draft_store is not None:
import_course_draft(xml_module_store, draft_store, course_data_path, import_course_draft(xml_module_store, store, draft_store, course_data_path,
static_content_store, target_location_namespace if target_location_namespace is not None static_content_store, target_location_namespace if target_location_namespace is not None
else course_location) else course_location)
...@@ -339,7 +339,7 @@ def import_module(module, store, course_data_path, static_content_store, allow_n ...@@ -339,7 +339,7 @@ def import_module(module, store, course_data_path, static_content_store, allow_n
store.update_metadata(module.location, dict(own_metadata(module))) store.update_metadata(module.location, dict(own_metadata(module)))
def import_course_draft(xml_module_store, store, course_data_path, static_content_store, target_location_namespace): def import_course_draft(xml_module_store, store, draft_store, course_data_path, static_content_store, target_location_namespace):
''' '''
This will import all the content inside of the 'drafts' folder, if it exists This will import all the content inside of the 'drafts' folder, if it exists
NOTE: This is not a full course import, basically in our current application only verticals (and downwards) NOTE: This is not a full course import, basically in our current application only verticals (and downwards)
...@@ -396,7 +396,7 @@ def import_course_draft(xml_module_store, store, course_data_path, static_conten ...@@ -396,7 +396,7 @@ def import_course_draft(xml_module_store, store, course_data_path, static_conten
del module.xml_attributes['parent_sequential_url'] del module.xml_attributes['parent_sequential_url']
del module.xml_attributes['index_in_children_list'] del module.xml_attributes['index_in_children_list']
import_module(module, store, course_data_path, static_content_store, allow_not_found=True) import_module(module, draft_store, course_data_path, static_content_store, allow_not_found=True)
for child in module.get_children(): for child in module.get_children():
_import_module(child) _import_module(child)
......
"""Tests of the Capa XModule"""
#pylint: disable=C0111
#pylint: disable=R0904
#pylint: disable=C0103
#pylint: disable=C0302
import datetime import datetime
import json from mock import Mock, patch
from mock import Mock, MagicMock, patch
from pprint import pprint
import unittest import unittest
import random import random
import xmodule import xmodule
import capa
from capa.responsetypes import StudentInputError, \ from capa.responsetypes import StudentInputError, \
LoncapaProblemError, ResponseError LoncapaProblemError, ResponseError
from xmodule.capa_module import CapaModule from xmodule.capa_module import CapaModule
from xmodule.modulestore import Location from xmodule.modulestore import Location
from lxml import etree
from django.http import QueryDict from django.http import QueryDict
...@@ -384,7 +386,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -384,7 +386,7 @@ class CapaModuleTest(unittest.TestCase):
# what the input is, by patching CorrectMap.is_correct() # what the input is, by patching CorrectMap.is_correct()
# Also simulate rendering the HTML # Also simulate rendering the HTML
# TODO: pep8 thinks the following line has invalid syntax # TODO: pep8 thinks the following line has invalid syntax
with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct,\ with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct, \
patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html: patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html:
mock_is_correct.return_value = True mock_is_correct.return_value = True
mock_html.return_value = "Test HTML" mock_html.return_value = "Test HTML"
...@@ -435,8 +437,11 @@ class CapaModuleTest(unittest.TestCase): ...@@ -435,8 +437,11 @@ class CapaModuleTest(unittest.TestCase):
self.assertEqual(module.attempts, 3) self.assertEqual(module.attempts, 3)
def test_check_problem_resubmitted_with_randomize(self): def test_check_problem_resubmitted_with_randomize(self):
rerandomize_values = ['always', 'true']
for rerandomize in rerandomize_values:
# Randomize turned on # Randomize turned on
module = CapaFactory.create(rerandomize='always', attempts=0) module = CapaFactory.create(rerandomize=rerandomize, attempts=0)
# Simulate that the problem is completed # Simulate that the problem is completed
module.done = True module.done = True
...@@ -450,8 +455,11 @@ class CapaModuleTest(unittest.TestCase): ...@@ -450,8 +455,11 @@ class CapaModuleTest(unittest.TestCase):
self.assertEqual(module.attempts, 0) self.assertEqual(module.attempts, 0)
def test_check_problem_resubmitted_no_randomize(self): def test_check_problem_resubmitted_no_randomize(self):
rerandomize_values = ['never', 'false', 'per_student']
for rerandomize in rerandomize_values:
# Randomize turned off # Randomize turned off
module = CapaFactory.create(rerandomize='never', attempts=0, done=True) module = CapaFactory.create(rerandomize=rerandomize, attempts=0, done=True)
# Expect that we can submit successfully # Expect that we can submit successfully
get_request_dict = {CapaFactory.input_key(): '3.14'} get_request_dict = {CapaFactory.input_key(): '3.14'}
...@@ -615,7 +623,12 @@ class CapaModuleTest(unittest.TestCase): ...@@ -615,7 +623,12 @@ class CapaModuleTest(unittest.TestCase):
self.assertTrue('success' in result and not result['success']) self.assertTrue('success' in result and not result['success'])
def test_save_problem_submitted_with_randomize(self): def test_save_problem_submitted_with_randomize(self):
module = CapaFactory.create(rerandomize='always', done=True)
# Capa XModule treats 'always' and 'true' equivalently
rerandomize_values = ['always', 'true']
for rerandomize in rerandomize_values:
module = CapaFactory.create(rerandomize=rerandomize, done=True)
# Try to save # Try to save
get_request_dict = {CapaFactory.input_key(): '3.14'} get_request_dict = {CapaFactory.input_key(): '3.14'}
...@@ -625,7 +638,12 @@ class CapaModuleTest(unittest.TestCase): ...@@ -625,7 +638,12 @@ class CapaModuleTest(unittest.TestCase):
self.assertTrue('success' in result and not result['success']) self.assertTrue('success' in result and not result['success'])
def test_save_problem_submitted_no_randomize(self): def test_save_problem_submitted_no_randomize(self):
module = CapaFactory.create(rerandomize='never', done=True)
# Capa XModule treats 'false' and 'per_student' equivalently
rerandomize_values = ['never', 'false', 'per_student']
for rerandomize in rerandomize_values:
module = CapaFactory.create(rerandomize=rerandomize, done=True)
# Try to save # Try to save
get_request_dict = {CapaFactory.input_key(): '3.14'} get_request_dict = {CapaFactory.input_key(): '3.14'}
...@@ -681,21 +699,30 @@ class CapaModuleTest(unittest.TestCase): ...@@ -681,21 +699,30 @@ class CapaModuleTest(unittest.TestCase):
# If user submitted a problem but hasn't reset, # If user submitted a problem but hasn't reset,
# do NOT show the check button # do NOT show the check button
# Note: we can only reset when rerandomize="always" # Note: we can only reset when rerandomize="always" or "true"
module = CapaFactory.create(rerandomize="always", done=True) module = CapaFactory.create(rerandomize="always", done=True)
self.assertFalse(module.should_show_check_button()) self.assertFalse(module.should_show_check_button())
module = CapaFactory.create(rerandomize="true", done=True)
self.assertFalse(module.should_show_check_button())
# Otherwise, DO show the check button # Otherwise, DO show the check button
module = CapaFactory.create() module = CapaFactory.create()
self.assertTrue(module.should_show_check_button()) self.assertTrue(module.should_show_check_button())
# If the user has submitted the problem # If the user has submitted the problem
# and we do NOT have a reset button, then we can show the check button # and we do NOT have a reset button, then we can show the check button
# Setting rerandomize to "never" ensures that the reset button # Setting rerandomize to "never" or "false" ensures that the reset button
# is not shown # is not shown
module = CapaFactory.create(rerandomize="never", done=True) module = CapaFactory.create(rerandomize="never", done=True)
self.assertTrue(module.should_show_check_button()) self.assertTrue(module.should_show_check_button())
module = CapaFactory.create(rerandomize="false", done=True)
self.assertTrue(module.should_show_check_button())
module = CapaFactory.create(rerandomize="per_student", done=True)
self.assertTrue(module.should_show_check_button())
def test_should_show_reset_button(self): def test_should_show_reset_button(self):
attempts = random.randint(1, 10) attempts = random.randint(1, 10)
...@@ -712,6 +739,14 @@ class CapaModuleTest(unittest.TestCase): ...@@ -712,6 +739,14 @@ class CapaModuleTest(unittest.TestCase):
module = CapaFactory.create(rerandomize="never", done=True) module = CapaFactory.create(rerandomize="never", done=True)
self.assertFalse(module.should_show_reset_button()) self.assertFalse(module.should_show_reset_button())
# If we're NOT randomizing, then do NOT show the reset button
module = CapaFactory.create(rerandomize="per_student", done=True)
self.assertFalse(module.should_show_reset_button())
# If we're NOT randomizing, then do NOT show the reset button
module = CapaFactory.create(rerandomize="false", done=True)
self.assertFalse(module.should_show_reset_button())
# If the user hasn't submitted an answer yet, # If the user hasn't submitted an answer yet,
# then do NOT show the reset button # then do NOT show the reset button
module = CapaFactory.create(done=False) module = CapaFactory.create(done=False)
...@@ -742,13 +777,19 @@ class CapaModuleTest(unittest.TestCase): ...@@ -742,13 +777,19 @@ class CapaModuleTest(unittest.TestCase):
module = CapaFactory.create(rerandomize="always", done=True) module = CapaFactory.create(rerandomize="always", done=True)
self.assertFalse(module.should_show_save_button()) self.assertFalse(module.should_show_save_button())
module = CapaFactory.create(rerandomize="true", done=True)
self.assertFalse(module.should_show_save_button())
# If the user has unlimited attempts and we are not randomizing, # If the user has unlimited attempts and we are not randomizing,
# then do NOT show a save button # then do NOT show a save button
# because they can keep using "Check" # because they can keep using "Check"
module = CapaFactory.create(max_attempts=None, rerandomize="never", done=False) module = CapaFactory.create(max_attempts=None, rerandomize="never", done=False)
self.assertFalse(module.should_show_save_button()) self.assertFalse(module.should_show_save_button())
module = CapaFactory.create(max_attempts=None, rerandomize="never", done=True) module = CapaFactory.create(max_attempts=None, rerandomize="false", done=True)
self.assertFalse(module.should_show_save_button())
module = CapaFactory.create(max_attempts=None, rerandomize="per_student", done=True)
self.assertFalse(module.should_show_save_button()) self.assertFalse(module.should_show_save_button())
# Otherwise, DO show the save button # Otherwise, DO show the save button
...@@ -759,6 +800,12 @@ class CapaModuleTest(unittest.TestCase): ...@@ -759,6 +800,12 @@ class CapaModuleTest(unittest.TestCase):
module = CapaFactory.create(rerandomize="never", max_attempts=2, done=True) module = CapaFactory.create(rerandomize="never", max_attempts=2, done=True)
self.assertTrue(module.should_show_save_button()) self.assertTrue(module.should_show_save_button())
module = CapaFactory.create(rerandomize="false", max_attempts=2, done=True)
self.assertTrue(module.should_show_save_button())
module = CapaFactory.create(rerandomize="per_student", max_attempts=2, done=True)
self.assertTrue(module.should_show_save_button())
# If survey question for capa (max_attempts = 0), # If survey question for capa (max_attempts = 0),
# DO show the save button # DO show the save button
module = CapaFactory.create(max_attempts=0, done=False) module = CapaFactory.create(max_attempts=0, done=False)
...@@ -788,9 +835,15 @@ class CapaModuleTest(unittest.TestCase): ...@@ -788,9 +835,15 @@ class CapaModuleTest(unittest.TestCase):
done=True) done=True)
self.assertTrue(module.should_show_save_button()) self.assertTrue(module.should_show_save_button())
module = CapaFactory.create(force_save_button="true",
rerandomize="true",
done=True)
self.assertTrue(module.should_show_save_button())
def test_no_max_attempts(self): def test_no_max_attempts(self):
module = CapaFactory.create(max_attempts='') module = CapaFactory.create(max_attempts='')
html = module.get_problem_html() html = module.get_problem_html()
self.assertTrue(html is not None)
# assert that we got here without exploding # assert that we got here without exploding
def test_get_problem_html(self): def test_get_problem_html(self):
...@@ -875,6 +928,8 @@ class CapaModuleTest(unittest.TestCase): ...@@ -875,6 +928,8 @@ class CapaModuleTest(unittest.TestCase):
# Try to render the module with DEBUG turned off # Try to render the module with DEBUG turned off
html = module.get_problem_html() html = module.get_problem_html()
self.assertTrue(html is not None)
# Check the rendering context # Check the rendering context
render_args, _ = module.system.render_template.call_args render_args, _ = module.system.render_template.call_args
context = render_args[1] context = render_args[1]
...@@ -886,7 +941,9 @@ class CapaModuleTest(unittest.TestCase): ...@@ -886,7 +941,9 @@ class CapaModuleTest(unittest.TestCase):
def test_random_seed_no_change(self): def test_random_seed_no_change(self):
# Run the test for each possible rerandomize value # Run the test for each possible rerandomize value
for rerandomize in ['never', 'per_student', 'always', 'onreset']: for rerandomize in ['false', 'never',
'per_student', 'always',
'true', 'onreset']:
module = CapaFactory.create(rerandomize=rerandomize) module = CapaFactory.create(rerandomize=rerandomize)
# Get the seed # Get the seed
...@@ -896,8 +953,9 @@ class CapaModuleTest(unittest.TestCase): ...@@ -896,8 +953,9 @@ class CapaModuleTest(unittest.TestCase):
# If we're not rerandomizing, the seed is always set # If we're not rerandomizing, the seed is always set
# to the same value (1) # to the same value (1)
if rerandomize == 'never': if rerandomize in ['never']:
self.assertEqual(seed, 1) self.assertEqual(seed, 1,
msg="Seed should always be 1 when rerandomize='%s'" % rerandomize)
# Check the problem # Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'} get_request_dict = {CapaFactory.input_key(): '3.14'}
...@@ -947,7 +1005,8 @@ class CapaModuleTest(unittest.TestCase): ...@@ -947,7 +1005,8 @@ class CapaModuleTest(unittest.TestCase):
return success return success
# Run the test for each possible rerandomize value # Run the test for each possible rerandomize value
for rerandomize in ['never', 'per_student', 'always', 'onreset']: for rerandomize in ['never', 'false', 'per_student',
'always', 'true', 'onreset']:
module = CapaFactory.create(rerandomize=rerandomize) module = CapaFactory.create(rerandomize=rerandomize)
# Get the seed # Get the seed
...@@ -959,7 +1018,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -959,7 +1018,7 @@ class CapaModuleTest(unittest.TestCase):
# is set to 'never' -- it should still be 1 # is set to 'never' -- it should still be 1
# The seed also stays the same if we're randomizing # The seed also stays the same if we're randomizing
# 'per_student': the same student should see the same problem # 'per_student': the same student should see the same problem
if rerandomize in ['never', 'per_student']: if rerandomize in ['never', 'false', 'per_student']:
self.assertEqual(seed, _reset_and_get_seed(module)) self.assertEqual(seed, _reset_and_get_seed(module))
# Otherwise, we expect the seed to change # Otherwise, we expect the seed to change
...@@ -969,10 +1028,8 @@ class CapaModuleTest(unittest.TestCase): ...@@ -969,10 +1028,8 @@ class CapaModuleTest(unittest.TestCase):
# Since there's a small chance we might get the # Since there's a small chance we might get the
# same seed again, give it 5 chances # same seed again, give it 5 chances
# to generate a different seed # to generate a different seed
success = _retry_and_check(5, success = _retry_and_check(5, lambda: _reset_and_get_seed(module) != seed)
lambda: _reset_and_get_seed(module) != seed)
# TODO: change this comparison to module.seed is not None? self.assertTrue(module.seed is not None)
self.assertTrue(module.seed != None)
msg = 'Could not get a new seed from reset after 5 tries' msg = 'Could not get a new seed from reset after 5 tries'
self.assertTrue(success, msg) self.assertTrue(success, msg)
...@@ -89,6 +89,16 @@ if Backbone? ...@@ -89,6 +89,16 @@ if Backbone?
@set("pinned",pinned) @set("pinned",pinned)
@trigger "change", @ @trigger "change", @
flagAbuse: ->
temp_array = @get("abuse_flaggers")
temp_array.push(window.user.get('id'))
@set("abuse_flaggers",temp_array)
@trigger "change", @
unflagAbuse: ->
@get("abuse_flaggers").pop(window.user.get('id'))
@trigger "change", @
class @Thread extends @Content class @Thread extends @Content
urlMappers: urlMappers:
...@@ -102,6 +112,8 @@ if Backbone? ...@@ -102,6 +112,8 @@ if Backbone?
'delete' : -> DiscussionUtil.urlFor('delete_thread', @id) 'delete' : -> DiscussionUtil.urlFor('delete_thread', @id)
'follow' : -> DiscussionUtil.urlFor('follow_thread', @id) 'follow' : -> DiscussionUtil.urlFor('follow_thread', @id)
'unfollow' : -> DiscussionUtil.urlFor('unfollow_thread', @id) 'unfollow' : -> DiscussionUtil.urlFor('unfollow_thread', @id)
'flagAbuse' : -> DiscussionUtil.urlFor("flagAbuse_#{@get('type')}", @id)
'unFlagAbuse' : -> DiscussionUtil.urlFor("unFlagAbuse_#{@get('type')}", @id)
'pinThread' : -> DiscussionUtil.urlFor("pin_thread", @id) 'pinThread' : -> DiscussionUtil.urlFor("pin_thread", @id)
'unPinThread' : -> DiscussionUtil.urlFor("un_pin_thread", @id) 'unPinThread' : -> DiscussionUtil.urlFor("un_pin_thread", @id)
...@@ -157,6 +169,8 @@ if Backbone? ...@@ -157,6 +169,8 @@ if Backbone?
'endorse': -> DiscussionUtil.urlFor('endorse_comment', @id) 'endorse': -> DiscussionUtil.urlFor('endorse_comment', @id)
'update': -> DiscussionUtil.urlFor('update_comment', @id) 'update': -> DiscussionUtil.urlFor('update_comment', @id)
'delete': -> DiscussionUtil.urlFor('delete_comment', @id) 'delete': -> DiscussionUtil.urlFor('delete_comment', @id)
'flagAbuse' : -> DiscussionUtil.urlFor("flagAbuse_#{@get('type')}", @id)
'unFlagAbuse' : -> DiscussionUtil.urlFor("unFlagAbuse_#{@get('type')}", @id)
getCommentsCount: -> getCommentsCount: ->
count = 0 count = 0
......
...@@ -37,6 +37,9 @@ if Backbone? ...@@ -37,6 +37,9 @@ if Backbone?
data['commentable_ids'] = options.commentable_ids data['commentable_ids'] = options.commentable_ids
when 'all' when 'all'
url = DiscussionUtil.urlFor 'threads' url = DiscussionUtil.urlFor 'threads'
when 'flagged'
data['flagged'] = true
url = DiscussionUtil.urlFor 'search'
when 'followed' when 'followed'
url = DiscussionUtil.urlFor 'followed_threads', options.user_id url = DiscussionUtil.urlFor 'followed_threads', options.user_id
if options['group_id'] if options['group_id']
......
...@@ -18,8 +18,12 @@ class @DiscussionUtil ...@@ -18,8 +18,12 @@ class @DiscussionUtil
@loadRoles: (roles)-> @loadRoles: (roles)->
@roleIds = roles @roleIds = roles
@loadFlagModerator: (what)->
@isFlagModerator = what
@loadRolesFromContainer: -> @loadRolesFromContainer: ->
@loadRoles($("#discussion-container").data("roles")) @loadRoles($("#discussion-container").data("roles"))
@loadFlagModerator($("#discussion-container").data("flag-moderator"))
@isStaff: (user_id) -> @isStaff: (user_id) ->
staff = _.union(@roleIds['Staff'], @roleIds['Moderator'], @roleIds['Administrator']) staff = _.union(@roleIds['Staff'], @roleIds['Moderator'], @roleIds['Administrator'])
...@@ -48,6 +52,10 @@ class @DiscussionUtil ...@@ -48,6 +52,10 @@ class @DiscussionUtil
update_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/update" update_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/update"
create_comment : "/courses/#{$$course_id}/discussion/threads/#{param}/reply" create_comment : "/courses/#{$$course_id}/discussion/threads/#{param}/reply"
delete_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/delete" delete_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/delete"
flagAbuse_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/flagAbuse"
unFlagAbuse_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unFlagAbuse"
flagAbuse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/flagAbuse"
unFlagAbuse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/unFlagAbuse"
upvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/upvote" upvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/upvote"
downvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/downvote" downvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/downvote"
pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/pin" pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/pin"
......
if Backbone? if Backbone?
class @DiscussionContentView extends Backbone.View class @DiscussionContentView extends Backbone.View
events:
"click .discussion-flag-abuse": "toggleFlagAbuse"
attrRenderer: attrRenderer:
endorsed: (endorsed) -> endorsed: (endorsed) ->
if endorsed if endorsed
...@@ -95,6 +100,47 @@ if Backbone? ...@@ -95,6 +100,47 @@ if Backbone?
setWmdContent: (cls_identifier, text) => setWmdContent: (cls_identifier, text) =>
DiscussionUtil.setWmdContent @$el, $.proxy(@$, @), cls_identifier, text DiscussionUtil.setWmdContent @$el, $.proxy(@$, @), cls_identifier, text
initialize: -> initialize: ->
@initLocal() @initLocal()
@model.bind('change', @renderPartialAttrs, @) @model.bind('change', @renderPartialAttrs, @)
toggleFlagAbuse: (event) ->
event.preventDefault()
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@unFlagAbuse()
else
@flagAbuse()
flagAbuse: ->
url = @model.urlFor("flagAbuse")
DiscussionUtil.safeAjax
$elem: @$(".discussion-flag-abuse")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
###
note, we have to clone the array in order to trigger a change event
###
temp_array = _.clone(@model.get('abuse_flaggers'));
temp_array.push(window.user.id)
@model.set('abuse_flaggers', temp_array)
unFlagAbuse: ->
url = @model.urlFor("unFlagAbuse")
DiscussionUtil.safeAjax
$elem: @$(".discussion-flag-abuse")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
temp_array = _.clone(@model.get('abuse_flaggers'));
temp_array.pop(window.user.id)
# if you're an admin, clear this
if DiscussionUtil.isFlagModerator
temp_array = []
@model.set('abuse_flaggers', temp_array)
...@@ -276,6 +276,11 @@ if Backbone? ...@@ -276,6 +276,11 @@ if Backbone?
@$(".post-search-field").val("") @$(".post-search-field").val("")
@$('.cohort').show() @$('.cohort').show()
@retrieveAllThreads() @retrieveAllThreads()
else if discussionId == "#flagged"
@discussionIds = ""
@$(".post-search-field").val("")
@$('.cohort').hide()
@retrieveFlaggedThreads()
else if discussionId == "#following" else if discussionId == "#following"
@retrieveFollowed(event) @retrieveFollowed(event)
@$('.cohort').hide() @$('.cohort').hide()
...@@ -321,6 +326,12 @@ if Backbone? ...@@ -321,6 +326,12 @@ if Backbone?
@collection.reset() @collection.reset()
@loadMorePages(event) @loadMorePages(event)
retrieveFlaggedThreads: (event)->
@collection.current_page = 0
@collection.reset()
@mode = 'flagged'
@loadMorePages(event)
sortThreads: (event) -> sortThreads: (event) ->
@$(".sort-bar a").removeClass("active") @$(".sort-bar a").removeClass("active")
$(event.target).addClass("active") $(event.target).addClass("active")
......
...@@ -3,6 +3,7 @@ if Backbone? ...@@ -3,6 +3,7 @@ if Backbone?
events: events:
"click .discussion-vote": "toggleVote" "click .discussion-vote": "toggleVote"
"click .discussion-flag-abuse": "toggleFlagAbuse"
"click .admin-pin": "togglePin" "click .admin-pin": "togglePin"
"click .action-follow": "toggleFollowing" "click .action-follow": "toggleFollowing"
"click .action-edit": "edit" "click .action-edit": "edit"
...@@ -25,6 +26,7 @@ if Backbone? ...@@ -25,6 +26,7 @@ if Backbone?
@delegateEvents() @delegateEvents()
@renderDogear() @renderDogear()
@renderVoted() @renderVoted()
@renderFlagged()
@renderPinned() @renderPinned()
@renderAttrs() @renderAttrs()
@$("span.timeago").timeago() @$("span.timeago").timeago()
...@@ -43,6 +45,16 @@ if Backbone? ...@@ -43,6 +45,16 @@ if Backbone?
else else
@$("[data-role=discussion-vote]").removeClass("is-cast") @$("[data-role=discussion-vote]").removeClass("is-cast")
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse .flag-label").html("Misuse Reported")
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse .flag-label").html("Report Misuse")
renderPinned: => renderPinned: =>
if @model.get("pinned") if @model.get("pinned")
@$("[data-role=thread-pin]").addClass("pinned") @$("[data-role=thread-pin]").addClass("pinned")
...@@ -56,6 +68,7 @@ if Backbone? ...@@ -56,6 +68,7 @@ if Backbone?
updateModelDetails: => updateModelDetails: =>
@renderVoted() @renderVoted()
@renderFlagged()
@renderPinned() @renderPinned()
@$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"]) @$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"])
...@@ -96,6 +109,7 @@ if Backbone? ...@@ -96,6 +109,7 @@ if Backbone?
if textStatus == 'success' if textStatus == 'success'
@model.set(response, {silent: true}) @model.set(response, {silent: true})
unvote: -> unvote: ->
window.user.unvote(@model) window.user.unvote(@model)
url = @model.urlFor("unvote") url = @model.urlFor("unvote")
...@@ -107,6 +121,7 @@ if Backbone? ...@@ -107,6 +121,7 @@ if Backbone?
if textStatus == 'success' if textStatus == 'success'
@model.set(response, {silent: true}) @model.set(response, {silent: true})
edit: (event) -> edit: (event) ->
@trigger "thread:edit", event @trigger "thread:edit", event
......
...@@ -91,7 +91,7 @@ if Backbone? ...@@ -91,7 +91,7 @@ if Backbone?
body = @getWmdContent("reply-body") body = @getWmdContent("reply-body")
return if not body.trim().length return if not body.trim().length
@setWmdContent("reply-body", "") @setWmdContent("reply-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, endorsed: false, user_id: window.user.get("id")) comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, abuse_flaggers:[], endorsed: false, user_id: window.user.get("id"))
comment.set('thread', @model.get('thread')) comment.set('thread', @model.get('thread'))
@renderResponse(comment) @renderResponse(comment)
@model.addComment() @model.addComment()
......
if Backbone? if Backbone?
class @ResponseCommentShowView extends DiscussionContentView class @ResponseCommentShowView extends DiscussionContentView
events:
"click .discussion-flag-abuse": "toggleFlagAbuse"
tagName: "li" tagName: "li"
initialize: ->
super()
@model.on "change", @updateModelDetails
render: -> render: ->
@template = _.template($("#response-comment-show-template").html()) @template = _.template($("#response-comment-show-template").html())
params = @model.toJSON() params = @model.toJSON()
...@@ -11,6 +18,7 @@ if Backbone? ...@@ -11,6 +18,7 @@ if Backbone?
@initLocal() @initLocal()
@delegateEvents() @delegateEvents()
@renderAttrs() @renderAttrs()
@renderFlagged()
@markAsStaff() @markAsStaff()
@$el.find(".timeago").timeago() @$el.find(".timeago").timeago()
@convertMath() @convertMath()
...@@ -34,3 +42,17 @@ if Backbone? ...@@ -34,3 +42,17 @@ if Backbone?
@$el.find("a.profile-link").after('<span class="staff-label">staff</span>') @$el.find("a.profile-link").after('<span class="staff-label">staff</span>')
else if DiscussionUtil.isTA(@model.get("user_id")) else if DiscussionUtil.isTA(@model.get("user_id"))
@$el.find("a.profile-link").after('<span class="community-ta-label">Community&nbsp;&nbsp;TA</span>') @$el.find("a.profile-link").after('<span class="community-ta-label">Community&nbsp;&nbsp;TA</span>')
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
updateModelDetails: =>
@renderFlagged()
...@@ -5,6 +5,7 @@ if Backbone? ...@@ -5,6 +5,7 @@ if Backbone?
"click .action-endorse": "toggleEndorse" "click .action-endorse": "toggleEndorse"
"click .action-delete": "delete" "click .action-delete": "delete"
"click .action-edit": "edit" "click .action-edit": "edit"
"click .discussion-flag-abuse": "toggleFlagAbuse"
$: (selector) -> $: (selector) ->
@$el.find(selector) @$el.find(selector)
...@@ -23,6 +24,7 @@ if Backbone? ...@@ -23,6 +24,7 @@ if Backbone?
if window.user.voted(@model) if window.user.voted(@model)
@$(".vote-btn").addClass("is-cast") @$(".vote-btn").addClass("is-cast")
@renderAttrs() @renderAttrs()
@renderFlagged()
@$el.find(".posted-details").timeago() @$el.find(".posted-details").timeago()
@convertMath() @convertMath()
@markAsStaff() @markAsStaff()
...@@ -71,6 +73,7 @@ if Backbone? ...@@ -71,6 +73,7 @@ if Backbone?
if textStatus == 'success' if textStatus == 'success'
@model.set(response) @model.set(response)
edit: (event) -> edit: (event) ->
@trigger "response:edit", event @trigger "response:edit", event
...@@ -92,3 +95,17 @@ if Backbone? ...@@ -92,3 +95,17 @@ if Backbone?
url: url url: url
data: data data: data
type: "POST" type: "POST"
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse .flag-label").html("Misuse Reported")
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse .flag-label").html("Report Misuse")
updateModelDetails: =>
@renderFlagged()
...@@ -77,7 +77,7 @@ if Backbone? ...@@ -77,7 +77,7 @@ if Backbone?
body = @getWmdContent("comment-body") body = @getWmdContent("comment-body")
return if not body.trim().length return if not body.trim().length
@setWmdContent("comment-body", "") @setWmdContent("comment-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), user_id: window.user.get("id"), id:"unsaved") comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), abuse_flaggers:[], user_id: window.user.get("id"), id:"unsaved")
view = @renderComment(comment) view = @renderComment(comment)
@hideEditorChrome() @hideEditorChrome()
@trigger "comment:add", comment @trigger "comment:add", comment
......
...@@ -12,7 +12,6 @@ This will read the `Gemfile` and install all of the gems specified there. ...@@ -12,7 +12,6 @@ This will read the `Gemfile` and install all of the gems specified there.
Run the following:: Run the following::
pip install -r requirements.txt pip install -r requirements.txt
pip install -r test-requirements.txt
### Binaries ### Binaries
...@@ -52,77 +51,13 @@ or with additional options: ...@@ -52,77 +51,13 @@ or with additional options:
*N.B.* You may have to escape the `[` characters, depending on your shell: `rake "lms[test,5000]"` *N.B.* You may have to escape the `[` characters, depending on your shell: `rake "lms[test,5000]"`
## Running tests To get a full list of available rake tasks, use:
### Python Tests
This runs all the tests (long, uses collectstatic):
rake test
If if you aren't changing static files, can run `rake test` once, then run
rake fasttest_lms
or
rake fasttest_cms
xmodule can be tested independently, with this:
rake test_common/lib/xmodule
To run a single django test class:
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth
To run a single django test:
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth.test_dark_launch
To run a single nose test file:
nosetests common/lib/xmodule/xmodule/tests/test_stringify.py
To run a single nose test:
nosetests common/lib/xmodule/xmodule/tests/test_stringify.py:test_stringify
Very handy: if you uncomment the `pdb=1` line in `setup.cfg`, it will drop you into pdb on error. This lets you go up and down the stack and see what the values of the variables are. Check out http://docs.python.org/library/pdb.html
### Javascript Tests
These commands start a development server with jasmine testing enabled, and launch your default browser
pointing to those tests
rake browse_jasmine_{lms,cms}
To run the tests headless, you must install phantomjs (http://phantomjs.org/download.html).
rake phantomjs_jasmine_{lms,cms}
If the `phantomjs` binary is not on the path, set the `PHANTOMJS_PATH` environment variable to point to it
PHANTOMJS_PATH=/path/to/phantomjs rake phantomjs_jasmine_{lms,cms}
## Getting More Information
Run the following to see a list of all rake tasks available and their arguments
rake -T rake -T
## Testing using queue servers ## Running Tests
When testing problems that use a queue server on AWS (e.g. sandbox-xqueue.edx.org), you'll need to run your server on your public IP, like so.
`django-admin.py runserver --settings=lms.envs.dev --pythonpath=. 0.0.0.0:8000`
When you connect to the LMS, you need to use the public ip. Use `ifconfig` to figure out the numnber, and connect e.g. to `http://18.3.4.5:8000/`
See `testing.md` for instructions on running the test suite.
## Content development ## Content development
......
...@@ -387,7 +387,14 @@ Inherited ...@@ -387,7 +387,14 @@ Inherited
When this content should be shown to students. Note that anyone with staff access to the course will always see everything. When this content should be shown to students. Note that anyone with staff access to the course will always see everything.
`showanswer` `showanswer`
When to show answer. For 'attempted', will show answer after first attempt. Values: never, attempted, answered, closed. Default: closed. Optional. When to show answer. Values: never, attempted, answered, closed, finished, past_due, always. Default: closed. Optional.
- `never`: never show answer
- `attempted`: show answer after first attempt
- `answered` : this is slightly different from `attempted` -- resetting the problems makes "done" False, but leaves attempts unchanged.
- `closed` : show answer after problem is closed, ie due date is past, or maximum attempts exceeded.
- `finished` : show answer after problem closed, or is correctly answered.
- `past_due` : show answer after problem due date is past.
- `always` : always allow answer to be shown.
`graded` `graded`
Whether this section will count towards the students grade. "true" or "false". Defaults to "false". Whether this section will count towards the students grade. "true" or "false". Defaults to "false".
......
# Testing # Testing
Testing is good. Here is some useful info about how we set up tests. ## Overview
More info is [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Test+Engineering)
## Backend code We maintain three kinds of tests: unit tests, integration tests,
and acceptance tests.
- The python unit tests can be run via rake tasks. ### Unit Tests
See development.md for more info on how to do this.
## Frontend code * Each test case should be concise: setup, execute, check, and teardown.
If you find yourself writing tests with many steps, consider refactoring
the unit under tests into smaller units, and then testing those individually.
### Jasmine * As a rule of thumb, your unit tests should cover every code branch.
We're using Jasmine to unit/integration test the JavaScript files. * Mock or patch external dependencies.
More info [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Jasmine) We use [voidspace mock](http://www.voidspace.org.uk/python/mock/).
All the specs are written in CoffeeScript to be consistent with the code. * We unit test Python code (using [unittest](http://docs.python.org/2/library/unittest.html)) and
To access the test cases, start the server using the settings file **jasmine.py** using this command: Javascript (using [Jasmine](http://pivotal.github.io/jasmine/))
`rake django-admin[runserver,lms,jasmine,12345]`
Then navigate to `http://localhost:12345/_jasmine/` to see the test results. ### Integration Tests
* Test several units at the same time.
Note that you can still mock or patch dependencies
that are not under test! For example, you might test that
`LoncapaProblem`, `NumericalResponse`, and `CorrectMap` in the
`capa` package work together, while still mocking out template rendering.
All the JavaScript codes must have test coverage. Both CMS and LMS * Use integration tests to ensure that units are hooked up correctly.
has its own test directory in `{cms,lms}/static/coffee/spec` If you haven't You do not need to test every possible input--that's what unit
written a JavaScript test before, you can look at those example files as a tests are for. Instead, focus on testing the "happy path"
starting point. Also, these materials might be helpful for you: to verify that the components work together correctly.
CMS Note: For consistency, you're advised to use the same directory structure * Many of our tests use the [Django test client](https://docs.djangoproject.com/en/dev/topics/testing/overview/) to simulate
for implementation and test. For example, test for `src/views/module.coffee` HTTP requests to the server.
### UI Acceptance Tests
* Use these to test that major program features are working correctly.
* We use [lettuce](http://lettuce.it/) to write BDD-style tests. Most of
these tests simulate user interactions through the browser using
[splinter](http://splinter.cobrateam.info/).
Overall, you want to write the tests that **maximize coverage**
while **minimizing maintenance**.
In practice, this usually means investing heavily
in unit tests, which tend to be the most robust to changes in the code base.
![Test Pyramid](test_pyramid.png)
The pyramid above shows the relative number of unit tests, integration tests,
and acceptance tests. Most of our tests are unit tests or integration tests.
## Test Locations
* Python unit and integration tests: Located in
subpackages called `tests`.
For example, the tests for the `capa` package are located in
`common/lib/capa/capa/tests`.
* Javascript unit tests: Located in `spec` folders. For example,
`common/lib/xmodule/xmodule/js/spec` and `{cms,lms}/static/coffee/spec`
For consistency, you should use the same directory structure for implementation
and test. For example, the test for `src/views/module.coffee`
should be written in `spec/views/module_spec.coffee`. should be written in `spec/views/module_spec.coffee`.
* http://pivotal.github.com/jasmine * UI acceptance tests:
* http://railscasts.com/episodes/261-testing-javascript-with-jasmine?view=asciicast - Set up and helper methods: `common/djangoapps/terrain`
* http://a-developer-life.blogspot.com/2011/05/jasmine-part-1-unit-testing-javascript.html - Tests: located in `features` subpackage within a Django app.
For example: `lms/djangoapps/courseware/features`
## Factories
Many tests delegate set-up to a "factory" class. For example,
there are factories for creating courses, problems, and users.
This encapsulates set-up logic from tests.
Factories are often implemented using [FactoryBoy](https://readthedocs.org/projects/factoryboy/)
In general, factories should be located close to the code they use.
For example, the factory for creating problem XML definitions
is located in `common/lib/capa/capa/tests/response_xml_factory.py`
because the `capa` package handles problem XML.
# Running Tests
Before running tests, ensure that you have all the dependencies. You can install dependencies using:
pip install -r requirements.txt
## Running Python Unit tests
We use [nose](https://nose.readthedocs.org/en/latest/) through
the [django-nose plugin](https://pypi.python.org/pypi/django-nose)
to run the test suite.
You can run tests using `rake` commands. For example,
rake test
runs all the tests. It also runs `collectstatic`, which prepares the static files used by the site (for example, compiling Coffeescript to Javascript).
You can also run the tests without `collectstatic`, which tends to be faster:
rake fasttest_lms
or
rake fasttest_cms
xmodule can be tested independently, with this:
rake test_common/lib/xmodule
To run a single django test class:
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth
To run a single django test:
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth.test_dark_launch
If you're finishing a feature that contains JavaScript code snippets and do not To run a single nose test file:
sure how to test, please feel free to open up a pull request and asking people
for help. (However, the best way to do it would be writing your test first, then
implement your feature - Test Driven Development.)
### BDD style acceptance tests with Lettuce nosetests common/lib/xmodule/xmodule/tests/test_stringify.py
We're using Lettuce for end user acceptance testing of features. To run a single nose test:
More info [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Lettuce+Acceptance+Testing)
Lettuce is a port of Cucumber. We're using it to drive Splinter, which is a python wrapper to Selenium. nosetests common/lib/xmodule/xmodule/tests/test_stringify.py:test_stringify
To execute the automated test scripts, you'll need to start up the django server separately, then launch the tests.
Do both use the settings file named **acceptance.py**.
What this will do is to use a sqllite database named mitx_all/db/test_mitx.db.
That way it can be flushed etc. without messing up your dev db.
Note that this also means that you need to syncdb and migrate the db first before starting the server to initialize it if it does not yet exist.
1. Set up the test database (only needs to be done once): Very handy: if you uncomment the `pdb=1` line in `setup.cfg`, it will drop you into pdb on error. This lets you go up and down the stack and see what the values of the variables are. Check out [the pdb documentation](http://docs.python.org/library/pdb.html)
### Running Javascript Unit Tests
These commands start a development server with jasmine testing enabled, and launch your default browser
pointing to those tests
rake browse_jasmine_{lms,cms}
To run the tests headless, you must install [phantomjs](http://phantomjs.org/download.html), then run:
rake phantomjs_jasmine_{lms,cms}
If the `phantomjs` binary is not on the path, set the `PHANTOMJS_PATH` environment variable to point to it
PHANTOMJS_PATH=/path/to/phantomjs rake phantomjs_jasmine_{lms,cms}
Once you have run the `rake` command, your browser should open to
to `http://localhost/_jasmine/`, which displays the test results.
**Troubleshooting**: If you get an error message while running the `rake` task,
try running `bundle install` to install the required ruby gems.
### Running Acceptance Tests
We use [Lettuce](http://lettuce.it/) for acceptance testing.
Most of our tests use [Splinter](http://splinter.cobrateam.info/)
to simulate UI browser interactions. Splinter, in turn,
uses [Selenium](http://docs.seleniumhq.org/) to control the browser.
**Prerequisite**: You must have [ChromeDriver](https://code.google.com/p/selenium/wiki/ChromeDriver)
installed to run the tests in Chrome.
Before running the tests, you need to set up the test database:
rm ../db/test_mitx.db rm ../db/test_mitx.db
rake django-admin[syncdb,lms,acceptance,--noinput] rake django-admin[syncdb,lms,acceptance,--noinput]
rake django-admin[migrate,lms,acceptance,--noinput] rake django-admin[migrate,lms,acceptance,--noinput]
2. Start up the django server separately in a shell To run the acceptance tests:
1. Start the Django server locally using the settings in **acceptance.py**:
rake lms[acceptance] rake lms[acceptance]
3. Then in another shell, run the tests in different ways as below. Lettuce comes with a new django-admin command called _harvest_. See the [lettuce django docs](http://lettuce.it/recipes/django-lxml.html) for more details. 2. In another shell, run the tests:
* All tests in a specified feature folder: `django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/portal/features/`
* Only the specified feature's scenarios: `django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/courseware/features/high-level-tabs.feature` django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/portal/features/
To test only a specific feature:
django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/courseware/features/high-level-tabs.feature
**Troubleshooting**: If you get an error message that says something about harvest not being a command, you probably are missing a requirement.
Try running:
pip install -r requirements.txt
## Viewing Test Coverage
We currently collect test coverage information for Python unit/integration tests.
To view test coverage:
1. Run the test suite:
rake test
2. Generate reports:
rake coverage:html
3. HTML reports are located in the `reports` folder.
## Testing using queue servers
When testing problems that use a queue server on AWS (e.g. sandbox-xqueue.edx.org), you'll need to run your server on your public IP, like so.
`django-admin.py runserver --settings=lms.envs.dev --pythonpath=. 0.0.0.0:8000`
4. Troubleshooting When you connect to the LMS, you need to use the public ip. Use `ifconfig` to figure out the number, and connect e.g. to `http://18.3.4.5:8000/`
* If you get an error msg that says something about harvest not being a command, you probably are missing a requirement. Pip install (test-requirements.txt) and/or brew install as needed.
\ No newline at end of file
...@@ -15,6 +15,7 @@ from xmodule.modulestore import Location ...@@ -15,6 +15,7 @@ from xmodule.modulestore import Location
from xmodule.x_module import XModule, XModuleDescriptor from xmodule.x_module import XModule, XModuleDescriptor
from student.models import CourseEnrollmentAllowed from student.models import CourseEnrollmentAllowed
from courseware.masquerade import is_masquerading_as_student
DEBUG_ACCESS = False DEBUG_ACCESS = False
...@@ -235,7 +236,7 @@ def _has_access_descriptor(user, descriptor, action, course_context=None): ...@@ -235,7 +236,7 @@ def _has_access_descriptor(user, descriptor, action, course_context=None):
don't have to hit the enrollments table on every module load. don't have to hit the enrollments table on every module load.
""" """
# If start dates are off, can always load # If start dates are off, can always load
if settings.MITX_FEATURES['DISABLE_START_DATES']: if settings.MITX_FEATURES['DISABLE_START_DATES'] and not is_masquerading_as_student(user):
debug("Allow: DISABLE_START_DATES") debug("Allow: DISABLE_START_DATES")
return True return True
...@@ -543,6 +544,10 @@ def _has_access_to_location(user, location, access_level, course_context): ...@@ -543,6 +544,10 @@ def _has_access_to_location(user, location, access_level, course_context):
if user is None or (not user.is_authenticated()): if user is None or (not user.is_authenticated()):
debug("Deny: no user or anon user") debug("Deny: no user or anon user")
return False return False
if is_masquerading_as_student(user):
return False
if user.is_staff: if user.is_staff:
debug("Allow: user.is_staff") debug("Allow: user.is_staff")
return True return True
......
...@@ -15,6 +15,7 @@ Feature: Answer problems ...@@ -15,6 +15,7 @@ Feature: Answer problems
| drop down | | drop down |
| multiple choice | | multiple choice |
| checkbox | | checkbox |
| radio |
| string | | string |
| numerical | | numerical |
| formula | | formula |
...@@ -33,6 +34,7 @@ Feature: Answer problems ...@@ -33,6 +34,7 @@ Feature: Answer problems
| drop down | | drop down |
| multiple choice | | multiple choice |
| checkbox | | checkbox |
| radio |
| string | | string |
| numerical | | numerical |
| formula | | formula |
...@@ -50,6 +52,7 @@ Feature: Answer problems ...@@ -50,6 +52,7 @@ Feature: Answer problems
| drop down | | drop down |
| multiple choice | | multiple choice |
| checkbox | | checkbox |
| radio |
| string | | string |
| numerical | | numerical |
| formula | | formula |
...@@ -71,6 +74,8 @@ Feature: Answer problems ...@@ -71,6 +74,8 @@ Feature: Answer problems
| multiple choice | incorrect | | multiple choice | incorrect |
| checkbox | correct | | checkbox | correct |
| checkbox | incorrect | | checkbox | incorrect |
| radio | correct |
| radio | incorrect |
| string | correct | | string | correct |
| string | incorrect | | string | incorrect |
| numerical | correct | | numerical | correct |
......
...@@ -42,7 +42,13 @@ PROBLEM_FACTORY_DICT = { ...@@ -42,7 +42,13 @@ PROBLEM_FACTORY_DICT = {
'choice_type': 'checkbox', 'choice_type': 'checkbox',
'choices': [True, False, True, False, False], 'choices': [True, False, True, False, False],
'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}}, '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': { 'string': {
'factory': StringResponseXMLFactory(), 'factory': StringResponseXMLFactory(),
'kwargs': { 'kwargs': {
...@@ -174,6 +180,12 @@ def answer_problem(step, problem_type, correctness): ...@@ -174,6 +180,12 @@ def answer_problem(step, problem_type, correctness):
else: else:
inputfield('checkbox', choice='choice_3').check() 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': elif problem_type == 'string':
textvalue = 'correct string' if correctness == 'correct' \ textvalue = 'correct string' if correctness == 'correct' \
else 'incorrect' else 'incorrect'
...@@ -252,6 +264,14 @@ def assert_problem_has_answer(step, problem_type, answer_class): ...@@ -252,6 +264,14 @@ def assert_problem_has_answer(step, problem_type, answer_class):
else: else:
assert_checked('checkbox', []) 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': elif problem_type == 'string':
if answer_class == 'blank': if answer_class == 'blank':
expected = '' expected = ''
...@@ -298,6 +318,7 @@ CORRECTNESS_SELECTORS = { ...@@ -298,6 +318,7 @@ CORRECTNESS_SELECTORS = {
'correct': {'drop down': ['span.correct'], 'correct': {'drop down': ['span.correct'],
'multiple choice': ['label.choicegroup_correct'], 'multiple choice': ['label.choicegroup_correct'],
'checkbox': ['span.correct'], 'checkbox': ['span.correct'],
'radio': ['label.choicegroup_correct'],
'string': ['div.correct'], 'string': ['div.correct'],
'numerical': ['div.correct'], 'numerical': ['div.correct'],
'formula': ['div.correct'], 'formula': ['div.correct'],
...@@ -308,6 +329,8 @@ CORRECTNESS_SELECTORS = { ...@@ -308,6 +329,8 @@ CORRECTNESS_SELECTORS = {
'multiple choice': ['label.choicegroup_incorrect', 'multiple choice': ['label.choicegroup_incorrect',
'span.incorrect'], 'span.incorrect'],
'checkbox': ['span.incorrect'], 'checkbox': ['span.incorrect'],
'radio': ['label.choicegroup_incorrect',
'span.incorrect'],
'string': ['div.incorrect'], 'string': ['div.incorrect'],
'numerical': ['div.incorrect'], 'numerical': ['div.incorrect'],
'formula': ['div.incorrect'], 'formula': ['div.incorrect'],
...@@ -317,6 +340,7 @@ CORRECTNESS_SELECTORS = { ...@@ -317,6 +340,7 @@ CORRECTNESS_SELECTORS = {
'unanswered': {'drop down': ['span.unanswered'], 'unanswered': {'drop down': ['span.unanswered'],
'multiple choice': ['span.unanswered'], 'multiple choice': ['span.unanswered'],
'checkbox': ['span.unanswered'], 'checkbox': ['span.unanswered'],
'radio': ['span.unanswered'],
'string': ['div.unanswered'], 'string': ['div.unanswered'],
'numerical': ['div.unanswered'], 'numerical': ['div.unanswered'],
'formula': ['div.unanswered'], 'formula': ['div.unanswered'],
......
'''
---------------------------------------- Masequerade ----------------------------------------
Allow course staff to see a student or staff view of courseware.
Which kind of view has been selected is stored in the session state.
'''
import json
import logging
from django.http import HttpResponse
from django.conf import settings
log = logging.getLogger(__name__)
MASQ_KEY = 'masquerade_identity'
def handle_ajax(request, marg):
'''
Handle ajax call from "staff view" / "student view" toggle button
'''
if marg == 'toggle':
status = request.session.get(MASQ_KEY, '')
if status is None or status in ['', 'staff']:
status = 'student'
else:
status = 'staff'
request.session[MASQ_KEY] = status
return HttpResponse(json.dumps({'status': status}))
def setup_masquerade(request, staff_access=False):
'''
Setup masquerade identity (allows staff to view courseware as either staff or student)
Uses request.session[MASQ_KEY] to store status of masquerading.
Adds masquerade status to request.user, if masquerading active.
Return string version of status of view (either 'staff' or 'student')
'''
if request.user is None:
return None
if not settings.MITX_FEATURES.get('ENABLE_MASQUERADE', False):
return None
if not staff_access: # can masquerade only if user has staff access to course
return None
usertype = request.session.get(MASQ_KEY, '')
if usertype is None or not usertype:
request.session[MASQ_KEY] = 'staff'
usertype = 'staff'
if usertype == 'student':
request.user.masquerade_as_student = True
return usertype
def is_masquerading_as_student(user):
'''
Return True if user is masquerading as a student, False otherwise
'''
masq = getattr(user, 'masquerade_as_student', False)
return masq==True
...@@ -17,6 +17,7 @@ from django.views.decorators.csrf import csrf_exempt ...@@ -17,6 +17,7 @@ from django.views.decorators.csrf import csrf_exempt
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
from capa.xqueue_interface import XQueueInterface from capa.xqueue_interface import XQueueInterface
from courseware.masquerade import setup_masquerade
from courseware.access import has_access from courseware.access import has_access
from mitxmako.shortcuts import render_to_string from mitxmako.shortcuts import render_to_string
from .models import StudentModule from .models import StudentModule
...@@ -164,6 +165,10 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours ...@@ -164,6 +165,10 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours
Actually implement get_module. See docstring there for details. Actually implement get_module. See docstring there for details.
""" """
# allow course staff to masquerade as student
if has_access(user, descriptor, 'staff', course_id):
setup_masquerade(request, True)
# Short circuit--if the user shouldn't have access, bail without doing any work # Short circuit--if the user shouldn't have access, bail without doing any work
if not has_access(user, descriptor, 'load', course_id): if not has_access(user, descriptor, 'load', course_id):
return None return None
......
import factory
from student.models import (User, UserProfile, Registration,
CourseEnrollmentAllowed)
from courseware.models import StudentModule
from django.contrib.auth.models import Group
from datetime import datetime from datetime import datetime
import uuid import json
from functools import partial
from factory import DjangoModelFactory, SubFactory
from student.tests.factories import UserFactory as StudentUserFactory
from student.tests.factories import GroupFactory as StudentGroupFactory
from student.tests.factories import UserProfileFactory as StudentUserProfileFactory
from student.tests.factories import CourseEnrollmentAllowedFactory as StudentCourseEnrollmentAllowedFactory
from student.tests.factories import RegistrationFactory as StudentRegistrationFactory
from courseware.models import StudentModule, XModuleContentField, XModuleSettingsField
from courseware.models import XModuleStudentInfoField, XModuleStudentPrefsField
class UserProfileFactory(factory.Factory): from xmodule.modulestore import Location
FACTORY_FOR = UserProfile
user = None location = partial(Location, 'i4x', 'edX', 'test_course', 'problem')
name = 'Robot Studio'
courseware = 'course.xml'
class RegistrationFactory(factory.Factory): class UserProfileFactory(StudentUserProfileFactory):
FACTORY_FOR = Registration name = 'Robot Studio'
courseware = 'course.xml'
user = None
activation_key = uuid.uuid4().hex
class RegistrationFactory(StudentRegistrationFactory):
pass
class UserFactory(factory.Factory):
FACTORY_FOR = User
username = 'robot' class UserFactory(StudentUserFactory):
email = 'robot@edx.org' email = 'robot@edx.org'
password = 'test'
first_name = 'Robot'
last_name = 'Tester' last_name = 'Tester'
is_staff = False
is_active = True
is_superuser = False
last_login = datetime.now() last_login = datetime.now()
date_joined = datetime.now() date_joined = datetime.now()
class GroupFactory(factory.Factory): class GroupFactory(StudentGroupFactory):
FACTORY_FOR = Group
name = 'test_group' name = 'test_group'
class CourseEnrollmentAllowedFactory(factory.Factory): class CourseEnrollmentAllowedFactory(StudentCourseEnrollmentAllowedFactory):
FACTORY_FOR = CourseEnrollmentAllowed pass
email = 'test@edx.org'
course_id = 'edX/test/2012_Fall'
class StudentModuleFactory(DjangoModelFactory):
class StudentModuleFactory(factory.Factory):
FACTORY_FOR = StudentModule FACTORY_FOR = StudentModule
module_type = "problem" module_type = "problem"
student = factory.SubFactory(UserFactory) student = SubFactory(UserFactory)
course_id = "MITx/999/Robot_Super_Course" course_id = "MITx/999/Robot_Super_Course"
state = None state = None
grade = None grade = None
max_grade = None max_grade = None
done = 'na' done = 'na'
class ContentFactory(DjangoModelFactory):
FACTORY_FOR = XModuleContentField
field_name = 'existing_field'
value = json.dumps('old_value')
definition_id = location('def_id').url()
class SettingsFactory(DjangoModelFactory):
FACTORY_FOR = XModuleSettingsField
field_name = 'existing_field'
value = json.dumps('old_value')
usage_id = '%s-%s' % ('edX/test_course/test', location('def_id').url())
class StudentPrefsFactory(DjangoModelFactory):
FACTORY_FOR = XModuleStudentPrefsField
field_name = 'existing_field'
value = json.dumps('old_value')
student = SubFactory(UserFactory)
module_type = 'problem'
class StudentInfoFactory(DjangoModelFactory):
FACTORY_FOR = XModuleStudentInfoField
field_name = 'existing_field'
value = json.dumps('old_value')
student = SubFactory(UserFactory)
"""
Unit tests for masquerade
Based on (and depends on) unit tests for courseware.
Notes for running by hand:
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware
"""
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User, Group
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
import json
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase):
'''
Check for staff being able to masquerade as student
'''
def setUp(self):
xmodule.modulestore.django._MODULESTORES = {}
#self.full = modulestore().get_course("edX/full/6.002_Spring_2012")
#self.toy = modulestore().get_course("edX/toy/2012_Fall")
self.graded_course = modulestore().get_course("edX/graded/2012_Fall")
# Create staff account
self.instructor = 'view2@test.com'
self.password = 'foo'
self.create_account('u2', self.instructor, self.password)
self.activate_user(self.instructor)
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.graded_course)
self.logout()
self.login(self.instructor, self.password)
self.enroll(self.graded_course)
# self.factory = RequestFactory()
def get_cw_section(self):
url = reverse('courseware_section',
kwargs={'course_id': self.graded_course.id,
'chapter': 'GradedChapter',
'section': 'Homework1'})
resp = self.client.get(url)
print "url ", url
return resp
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)
def toggle_masquerade(self):
'''
Toggle masquerade state
'''
masq_url = reverse('masquerade-switch', kwargs={'marg': 'toggle'})
print "masq_url ", masq_url
resp = self.client.get(masq_url)
return resp
def test_no_staff_debug_for_student(self):
togresp = self.toggle_masquerade()
print "masq now ", togresp.content
self.assertEqual(togresp.content, '{"status": "student"}', '')
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
modx_url = reverse('modx_dispatch',
kwargs={'course_id': self.graded_course.id,
'location': problem_location,
'dispatch': 'problem_get', })
resp = self.client.get(modx_url)
print "modx_url ", modx_url
return resp
def test_showanswer_for_staff(self):
resp = self.get_problem()
html = json.loads(resp.content)['html']
print html
sabut = '<input class="show" type="button" value="Show Answer">'
self.assertTrue(sabut in html)
def test_no_showanswer_for_student(self):
togresp = self.toggle_masquerade()
print "masq now ", togresp.content
self.assertEqual(togresp.content, '{"status": "student"}', '')
resp = self.get_problem()
html = json.loads(resp.content)['html']
print html
sabut = '<input class="show" type="button" value="Show Answer">'
self.assertFalse(sabut in html)
import factory
import json import json
from mock import Mock from mock import Mock
from django.contrib.auth.models import User
from functools import partial from functools import partial
from courseware.model_data import LmsKeyValueStore, InvalidWriteError, InvalidScopeError, ModelDataCache from courseware.model_data import LmsKeyValueStore, InvalidWriteError
from courseware.models import StudentModule, XModuleContentField, XModuleSettingsField, XModuleStudentInfoField, XModuleStudentPrefsField from courseware.model_data import InvalidScopeError, ModelDataCache
from courseware.models import StudentModule, XModuleContentField, XModuleSettingsField
from courseware.models import XModuleStudentInfoField, XModuleStudentPrefsField
from student.tests.factories import UserFactory
from courseware.tests.factories import StudentModuleFactory as cmfStudentModuleFactory
from courseware.tests.factories import ContentFactory, SettingsFactory
from courseware.tests.factories import StudentPrefsFactory, StudentInfoFactory
from xblock.core import Scope, BlockScope from xblock.core import Scope, BlockScope
from xmodule.modulestore import Location from xmodule.modulestore import Location
from django.test import TestCase from django.test import TestCase
...@@ -19,6 +23,7 @@ def mock_field(scope, name): ...@@ -19,6 +23,7 @@ def mock_field(scope, name):
field.name = name field.name = name
return field return field
def mock_descriptor(fields=[], lms_fields=[]): def mock_descriptor(fields=[], lms_fields=[]):
descriptor = Mock() descriptor = Mock()
descriptor.stores_state = True descriptor.stores_state = True
...@@ -37,53 +42,9 @@ prefs_key = partial(LmsKeyValueStore.Key, Scope.preferences, 'user', 'problem') ...@@ -37,53 +42,9 @@ prefs_key = partial(LmsKeyValueStore.Key, Scope.preferences, 'user', 'problem')
user_info_key = partial(LmsKeyValueStore.Key, Scope.user_info, 'user', None) user_info_key = partial(LmsKeyValueStore.Key, Scope.user_info, 'user', None)
class UserFactory(factory.Factory): class StudentModuleFactory(cmfStudentModuleFactory):
FACTORY_FOR = User
username = 'user'
class StudentModuleFactory(factory.Factory):
FACTORY_FOR = StudentModule
module_type = 'problem'
module_state_key = location('def_id').url() module_state_key = location('def_id').url()
student = factory.SubFactory(UserFactory)
course_id = course_id course_id = course_id
state = None
class ContentFactory(factory.Factory):
FACTORY_FOR = XModuleContentField
field_name = 'existing_field'
value = json.dumps('old_value')
definition_id = location('def_id').url()
class SettingsFactory(factory.Factory):
FACTORY_FOR = XModuleSettingsField
field_name = 'existing_field'
value = json.dumps('old_value')
usage_id = '%s-%s' % (course_id, location('def_id').url())
class StudentPrefsFactory(factory.Factory):
FACTORY_FOR = XModuleStudentPrefsField
field_name = 'existing_field'
value = json.dumps('old_value')
student = factory.SubFactory(UserFactory)
module_type = 'problem'
class StudentInfoFactory(factory.Factory):
FACTORY_FOR = XModuleStudentInfoField
field_name = 'existing_field'
value = json.dumps('old_value')
student = factory.SubFactory(UserFactory)
class TestDescriptorFallback(TestCase): class TestDescriptorFallback(TestCase):
...@@ -114,7 +75,7 @@ class TestDescriptorFallback(TestCase): ...@@ -114,7 +75,7 @@ class TestDescriptorFallback(TestCase):
class TestInvalidScopes(TestCase): class TestInvalidScopes(TestCase):
def setUp(self): def setUp(self):
self.desc_md = {} self.desc_md = {}
self.user = UserFactory.create() self.user = UserFactory.create(username='user')
self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.user_state, 'a_field')])], course_id, self.user) self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.user_state, 'a_field')])], course_id, self.user)
self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) self.kvs = LmsKeyValueStore(self.desc_md, self.mdc)
...@@ -180,7 +141,7 @@ class TestStudentModuleStorage(TestCase): ...@@ -180,7 +141,7 @@ class TestStudentModuleStorage(TestCase):
class TestMissingStudentModule(TestCase): class TestMissingStudentModule(TestCase):
def setUp(self): def setUp(self):
self.user = UserFactory.create() self.user = UserFactory.create(username='user')
self.desc_md = {} self.desc_md = {}
self.mdc = ModelDataCache([mock_descriptor()], course_id, self.user) self.mdc = ModelDataCache([mock_descriptor()], course_id, self.user)
self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) self.kvs = LmsKeyValueStore(self.desc_md, self.mdc)
......
...@@ -20,6 +20,7 @@ from courseware.access import has_access ...@@ -20,6 +20,7 @@ from courseware.access import has_access
from courseware.courses import (get_courses, get_course_with_access, from courseware.courses import (get_courses, get_course_with_access,
get_courses_by_university, sort_by_announcement) get_courses_by_university, sort_by_announcement)
import courseware.tabs as tabs import courseware.tabs as tabs
from courseware.masquerade import setup_masquerade
from courseware.model_data import ModelDataCache from courseware.model_data import ModelDataCache
from .module_render import toc_for_course, get_module_for_descriptor, get_module from .module_render import toc_for_course, get_module_for_descriptor, get_module
from courseware.models import StudentModule, StudentModuleHistory from courseware.models import StudentModule, StudentModuleHistory
...@@ -89,6 +90,7 @@ def render_accordion(request, course, chapter, section, model_data_cache): ...@@ -89,6 +90,7 @@ def render_accordion(request, course, chapter, section, model_data_cache):
# grab the table of contents # grab the table of contents
user = User.objects.prefetch_related("groups").get(id=request.user.id) user = User.objects.prefetch_related("groups").get(id=request.user.id)
request.user = user # keep just one instance of User
toc = toc_for_course(user, request, course, chapter, section, model_data_cache) toc = toc_for_course(user, request, course, chapter, section, model_data_cache)
context = dict([('toc', toc), context = dict([('toc', toc),
...@@ -260,6 +262,7 @@ def index(request, course_id, chapter=None, section=None, ...@@ -260,6 +262,7 @@ def index(request, course_id, chapter=None, section=None,
- HTTPresponse - HTTPresponse
""" """
user = User.objects.prefetch_related("groups").get(id=request.user.id) user = User.objects.prefetch_related("groups").get(id=request.user.id)
request.user = user # keep just one instance of User
course = get_course_with_access(user, course_id, 'load', depth=2) course = get_course_with_access(user, course_id, 'load', depth=2)
staff_access = has_access(user, course, 'staff') staff_access = has_access(user, course, 'staff')
registered = registered_for_course(course, user) registered = registered_for_course(course, user)
...@@ -268,6 +271,8 @@ def index(request, course_id, chapter=None, section=None, ...@@ -268,6 +271,8 @@ def index(request, course_id, chapter=None, section=None,
log.debug('User %s tried to view course %s but is not enrolled' % (user, course.location.url())) log.debug('User %s tried to view course %s but is not enrolled' % (user, course.location.url()))
return redirect(reverse('about_course', args=[course.id])) return redirect(reverse('about_course', args=[course.id]))
masq = setup_masquerade(request, staff_access)
try: try:
model_data_cache = ModelDataCache.cache_for_descriptor_descendents( model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
course.id, user, course, depth=2) course.id, user, course, depth=2)
...@@ -289,6 +294,7 @@ def index(request, course_id, chapter=None, section=None, ...@@ -289,6 +294,7 @@ def index(request, course_id, chapter=None, section=None,
'init': '', 'init': '',
'content': '', 'content': '',
'staff_access': staff_access, 'staff_access': staff_access,
'masquerade': masq,
'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa') 'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa')
} }
...@@ -301,12 +307,18 @@ def index(request, course_id, chapter=None, section=None, ...@@ -301,12 +307,18 @@ def index(request, course_id, chapter=None, section=None,
chapter_module = course_module.get_child_by(lambda m: m.url_name == chapter) chapter_module = course_module.get_child_by(lambda m: m.url_name == chapter)
if chapter_module is None: if chapter_module is None:
# User may be trying to access a chapter that isn't live yet # User may be trying to access a chapter that isn't live yet
if masq=='student': # if staff is masquerading as student be kinder, don't 404
log.debug('staff masq as student: no chapter %s' % chapter)
return redirect(reverse('courseware', args=[course.id]))
raise Http404 raise Http404
if section is not None: if section is not None:
section_descriptor = chapter_descriptor.get_child_by(lambda m: m.url_name == section) section_descriptor = chapter_descriptor.get_child_by(lambda m: m.url_name == section)
if section_descriptor is None: if section_descriptor is None:
# Specifically asked-for section doesn't exist # Specifically asked-for section doesn't exist
if masq=='student': # if staff is masquerading as student be kinder, don't 404
log.debug('staff masq as student: no section %s' % section)
return redirect(reverse('courseware', args=[course.id]))
raise Http404 raise Http404
# cdodge: this looks silly, but let's refetch the section_descriptor with depth=None # cdodge: this looks silly, but let's refetch the section_descriptor with depth=None
...@@ -437,9 +449,10 @@ def course_info(request, course_id): ...@@ -437,9 +449,10 @@ def course_info(request, course_id):
""" """
course = get_course_with_access(request.user, course_id, 'load') course = get_course_with_access(request.user, course_id, 'load')
staff_access = has_access(request.user, course, 'staff') staff_access = has_access(request.user, course, 'staff')
masq = setup_masquerade(request, staff_access) # allow staff to toggle masquerade on info page
return render_to_response('courseware/info.html', {'request': request, 'course_id': course_id, 'cache': None, return render_to_response('courseware/info.html', {'request': request, 'course_id': course_id, 'cache': None,
'course': course, 'staff_access': staff_access}) 'course': course, 'staff_access': staff_access, 'masquerade': masq})
@ensure_csrf_cookie @ensure_csrf_cookie
......
...@@ -11,6 +11,8 @@ urlpatterns = patterns('django_comment_client.base.views', ...@@ -11,6 +11,8 @@ urlpatterns = patterns('django_comment_client.base.views',
url(r'threads/(?P<thread_id>[\w\-]+)/delete', 'delete_thread', name='delete_thread'), 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\-]+)/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\-]+)/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\-]+)/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\-]+)/pin$', 'pin_thread', name='pin_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/unpin$', 'un_pin_thread', name='un_pin_thread'), url(r'threads/(?P<thread_id>[\w\-]+)/unpin$', 'un_pin_thread', name='un_pin_thread'),
...@@ -25,7 +27,8 @@ urlpatterns = patterns('django_comment_client.base.views', ...@@ -25,7 +27,8 @@ urlpatterns = patterns('django_comment_client.base.views',
url(r'comments/(?P<comment_id>[\w\-]+)/upvote$', 'vote_for_comment', {'value': 'up'}, name='upvote_comment'), 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\-]+)/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\-]+)/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'), url(r'^(?P<commentable_id>[\w\-.]+)/threads/create$', 'create_thread', name='create_thread'),
# TODO should we search within the board? # TODO should we search within the board?
url(r'^(?P<commentable_id>[\w\-.]+)/threads/search_similar$', 'search_similar_threads', name='search_similar_threads'), url(r'^(?P<commentable_id>[\w\-.]+)/threads/search_similar$', 'search_similar_threads', name='search_similar_threads'),
......
...@@ -20,7 +20,7 @@ from django.utils.translation import ugettext as _ ...@@ -20,7 +20,7 @@ from django.utils.translation import ugettext as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response, render_to_string
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access, get_course_by_id
from course_groups.cohorts import get_cohort_id, is_commentable_cohorted 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.utils import JsonResponse, JsonError, extract, get_courseware_context
...@@ -287,25 +287,71 @@ def vote_for_thread(request, course_id, thread_id, value): ...@@ -287,25 +287,71 @@ def vote_for_thread(request, course_id, thread_id, value):
@require_POST @require_POST
@login_required @login_required
@permitted @permitted
def flag_abuse_for_thread(request, course_id, thread_id):
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):
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):
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):
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): def undo_vote_for_thread(request, course_id, thread_id):
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id) thread = cc.Thread.find(thread_id)
user.unvote(thread) user.unvote(thread)
return JsonResponse(utils.safe_content(thread.to_dict())) return JsonResponse(utils.safe_content(thread.to_dict()))
@require_POST @require_POST
@login_required @login_required
@permitted @permitted
def pin_thread(request, course_id, thread_id): def pin_thread(request, course_id, thread_id):
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id) thread = cc.Thread.find(thread_id)
thread.pin(user,thread_id) thread.pin(user, thread_id)
return JsonResponse(utils.safe_content(thread.to_dict())) return JsonResponse(utils.safe_content(thread.to_dict()))
def un_pin_thread(request, course_id, thread_id): def un_pin_thread(request, course_id, thread_id):
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id) 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())) return JsonResponse(utils.safe_content(thread.to_dict()))
...@@ -456,12 +502,7 @@ def upload(request, course_id): # ajax upload file to a question or answer ...@@ -456,12 +502,7 @@ def upload(request, course_id): # ajax upload file to a question or answer
raise exceptions.PermissionDenied(msg) raise exceptions.PermissionDenied(msg)
# generate new file name # generate new file name
new_file_name = str( new_file_name = str(time.time()).replace('.', str(random.randint(0, 100000))) + file_extension
time.time()
).replace(
'.',
str(random.randint(0, 100000))
) + file_extension
file_storage = get_storage_class()() file_storage = get_storage_class()()
# use default storage to store file # use default storage to store file
......
...@@ -12,6 +12,7 @@ from courseware.courses import get_course_with_access ...@@ -12,6 +12,7 @@ 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) get_cohorted_commentables, get_course_cohorts, get_cohort_by_id)
from courseware.access import has_access from courseware.access import has_access
from django_comment_client.models import Role
from django_comment_client.permissions import cached_has_permission from django_comment_client.permissions import cached_has_permission
from django_comment_client.utils import (merge_dict, extract, strip_none, get_courseware_context) from django_comment_client.utils import (merge_dict, extract, strip_none, get_courseware_context)
...@@ -79,7 +80,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG ...@@ -79,7 +80,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
strip_none(extract(request.GET, strip_none(extract(request.GET,
['page', 'sort_key', ['page', 'sort_key',
'sort_order', 'text', 'sort_order', 'text',
'tags', 'commentable_ids']))) 'tags', 'commentable_ids', 'flagged'])))
threads, page, num_pages = cc.Thread.search(query_params) threads, page, num_pages = cc.Thread.search(query_params)
...@@ -108,7 +109,6 @@ def inline_discussion(request, course_id, discussion_id): ...@@ -108,7 +109,6 @@ def inline_discussion(request, course_id, discussion_id):
""" """
Renders JSON for DiscussionModules Renders JSON for DiscussionModules
""" """
course = get_course_with_access(request.user, course_id, 'load') course = get_course_with_access(request.user, course_id, 'load')
try: try:
...@@ -219,6 +219,7 @@ def forum_form_discussion(request, course_id): ...@@ -219,6 +219,7 @@ def forum_form_discussion(request, course_id):
'threads': saxutils.escape(json.dumps(threads), escapedict), 'threads': saxutils.escape(json.dumps(threads), escapedict),
'thread_pages': query_params['num_pages'], 'thread_pages': query_params['num_pages'],
'user_info': saxutils.escape(json.dumps(user_info), escapedict), '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), 'annotated_content_info': saxutils.escape(json.dumps(annotated_content_info), escapedict),
'course_id': course.id, 'course_id': course.id,
'category_map': category_map, 'category_map': category_map,
...@@ -230,7 +231,6 @@ def forum_form_discussion(request, course_id): ...@@ -230,7 +231,6 @@ def forum_form_discussion(request, course_id):
'is_course_cohorted': is_course_cohorted(course_id) 'is_course_cohorted': is_course_cohorted(course_id)
} }
# print "start rendering.." # print "start rendering.."
return render_to_response('discussion/index.html', context) return render_to_response('discussion/index.html', context)
...@@ -242,19 +242,12 @@ def single_thread(request, course_id, discussion_id, thread_id): ...@@ -242,19 +242,12 @@ def single_thread(request, course_id, discussion_id, thread_id):
try: try:
thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id) 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: except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
log.error("Error loading single thread.") log.error("Error loading single thread.")
raise Http404 raise Http404
if request.is_ajax(): if request.is_ajax():
courseware_context = get_courseware_context(thread, course) courseware_context = get_courseware_context(thread, course)
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info) 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} context = {'thread': thread.to_dict(), 'course_id': course_id}
# TODO: Remove completely or switch back to server side rendering # TODO: Remove completely or switch back to server side rendering
...@@ -326,6 +319,7 @@ def single_thread(request, course_id, discussion_id, thread_id): ...@@ -326,6 +319,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
'thread_pages': query_params['num_pages'], 'thread_pages': query_params['num_pages'],
'is_course_cohorted': is_course_cohorted(course_id), 'is_course_cohorted': is_course_cohorted(course_id),
'is_moderator': cached_has_permission(request.user, "see_all_cohorts", 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, 'cohorts': cohorts,
'user_cohort': get_cohort_id(request.user, course_id), 'user_cohort': get_cohort_id(request.user, course_id),
'cohorted_commentables': cohorted_commentables 'cohorted_commentables': cohorted_commentables
......
...@@ -6,10 +6,11 @@ from django.core.management.base import BaseCommand, CommandError ...@@ -6,10 +6,11 @@ from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth.models import User from django.contrib.auth.models import User
import comment_client as cc import comment_client as cc
class Command(BaseCommand): class Command(BaseCommand):
help = 'Reload forum (comment client) users from existing users' help = 'Reload forum (comment client) users from existing users'
def adduser(self,user): def adduser(self, user):
print user print user
try: try:
cc_user = cc.User.from_django_user(user) cc_user = cc.User.from_django_user(user)
...@@ -26,4 +27,3 @@ class Command(BaseCommand): ...@@ -26,4 +27,3 @@ class Command(BaseCommand):
for user in uset: for user in uset:
self.adduser(user) self.adduser(user)
\ No newline at end of file
...@@ -73,7 +73,6 @@ def check_conditions_permissions(user, permissions, course_id, **kwargs): ...@@ -73,7 +73,6 @@ def check_conditions_permissions(user, permissions, course_id, **kwargs):
return True in results return True in results
elif operator == "and": elif operator == "and":
return not False in results return not False in results
return test(user, permissions, operator="or") return test(user, permissions, operator="or")
...@@ -89,6 +88,10 @@ VIEW_PERMISSIONS = { ...@@ -89,6 +88,10 @@ VIEW_PERMISSIONS = {
'vote_for_comment' : [['vote', 'is_open']], 'vote_for_comment' : [['vote', 'is_open']],
'undo_vote_for_comment': [['unvote', 'is_open']], 'undo_vote_for_comment': [['unvote', 'is_open']],
'vote_for_thread' : [['vote', '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']], 'undo_vote_for_thread': [['unvote', 'is_open']],
'pin_thread': ['create_comment'], 'pin_thread': ['create_comment'],
'un_pin_thread': ['create_comment'], 'un_pin_thread': ['create_comment'],
......
...@@ -39,4 +39,3 @@ class CloseThreadTextTest(TestCase): ...@@ -39,4 +39,3 @@ class CloseThreadTextTest(TestCase):
self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread') self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread')
######################################################################################### #########################################################################################
import string
import random
import collections
from django.test import TestCase from django.test import TestCase
from factory import DjangoModelFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory
import factory
from django.contrib.auth.models import User
from student.models import UserProfile, CourseEnrollment
from django_comment_client.models import Role, Permission from django_comment_client.models import Role, Permission
import django_comment_client.models as models
import django_comment_client.utils as utils import django_comment_client.utils as utils
import xmodule.modulestore.django as django
class UserFactory(factory.Factory):
FACTORY_FOR = User
username = 'robot'
password = '123456'
email = 'robot@edx.org'
is_active = True
is_staff = False
class CourseEnrollmentFactory(factory.Factory):
FACTORY_FOR = CourseEnrollment
user = factory.SubFactory(UserFactory)
course_id = 'edX/toy/2012_Fall'
class RoleFactory(factory.Factory): class RoleFactory(DjangoModelFactory):
FACTORY_FOR = Role FACTORY_FOR = Role
name = 'Student' name = 'Student'
course_id = 'edX/toy/2012_Fall' course_id = 'edX/toy/2012_Fall'
class PermissionFactory(factory.Factory): class PermissionFactory(DjangoModelFactory):
FACTORY_FOR = Permission FACTORY_FOR = Permission
name = 'create_comment' name = 'create_comment'
......
import time
from collections import defaultdict from collections import defaultdict
import logging import logging
import time import time
...@@ -174,8 +175,7 @@ def initialize_discussion_info(course): ...@@ -174,8 +175,7 @@ def initialize_discussion_info(course):
category = " / ".join([x.strip() for x in category.split("/")]) category = " / ".join([x.strip() for x in category.split("/")])
last_category = category.split("/")[-1] last_category = category.split("/")[-1]
discussion_id_map[id] = {"location": module.location, "title": last_category + " / " + title} discussion_id_map[id] = {"location": module.location, "title": last_category + " / " + title}
unexpanded_category_map[category].append({"title": title, "id": id, unexpanded_category_map[category].append({"title": title, "id": id, "sort_key": sort_key, "start_date": module.lms.start})
"sort_key": sort_key, "start_date": module.lms.start})
category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)} category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
for category_path, entries in unexpanded_category_map.items(): for category_path, entries in unexpanded_category_map.items():
...@@ -318,6 +318,7 @@ def get_annotated_content_infos(course_id, thread, user, user_info): ...@@ -318,6 +318,7 @@ def get_annotated_content_infos(course_id, thread, user, user_info):
Get metadata for a thread and its children Get metadata for a thread and its children
""" """
infos = {} infos = {}
def annotate(content): def annotate(content):
infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info) infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info)
for child in content.get('children', []): for child in content.get('children', []):
...@@ -382,7 +383,7 @@ def get_courseware_context(content, course): ...@@ -382,7 +383,7 @@ def get_courseware_context(content, course):
location = id_map[id]["location"].url() location = id_map[id]["location"].url()
title = id_map[id]["title"] title = id_map[id]["title"]
url = reverse('jump_to', kwargs={"course_id":course.location.course_id, url = reverse('jump_to', kwargs={"course_id": course.location.course_id,
"location": location}) "location": location})
content_info = {"courseware_url": url, "courseware_title": title} content_info = {"courseware_url": url, "courseware_title": title}
...@@ -396,7 +397,8 @@ def safe_content(content): ...@@ -396,7 +397,8 @@ def safe_content(content):
'updated_at', 'depth', 'type', 'commentable_id', 'comments_count', 'updated_at', 'depth', 'type', 'commentable_id', 'comments_count',
'at_position_list', 'children', 'highlighted_title', 'highlighted_body', 'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
'courseware_title', 'courseware_url', 'tags', 'unread_comments_count', '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): if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False):
......
...@@ -5,15 +5,19 @@ import json ...@@ -5,15 +5,19 @@ import json
from uuid import uuid4 from uuid import uuid4
from random import shuffle from random import shuffle
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from factory import Factory, SubFactory from factory import DjangoModelFactory, SubFactory
from django.test import TestCase from django.test import TestCase
from django.test.client import Client
from django.test.utils import override_settings from django.test.utils import override_settings
from django.core.management import call_command from django.core.management import call_command
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from nose.tools import assert_true
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from licenses.models import CourseSoftware, UserLicense from licenses.models import CourseSoftware, UserLicense
from courseware.tests.tests import LoginEnrollmentTestCase, get_user
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
...@@ -27,7 +31,7 @@ SERIAL_1 = '123456abcde' ...@@ -27,7 +31,7 @@ SERIAL_1 = '123456abcde'
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class CourseSoftwareFactory(Factory): class CourseSoftwareFactory(DjangoModelFactory):
'''Factory for generating CourseSoftware objects in database''' '''Factory for generating CourseSoftware objects in database'''
FACTORY_FOR = CourseSoftware FACTORY_FOR = CourseSoftware
...@@ -37,7 +41,7 @@ class CourseSoftwareFactory(Factory): ...@@ -37,7 +41,7 @@ class CourseSoftwareFactory(Factory):
course_id = COURSE_1 course_id = COURSE_1
class UserLicenseFactory(Factory): class UserLicenseFactory(DjangoModelFactory):
''' '''
Factory for generating UserLicense objects in database Factory for generating UserLicense objects in database
...@@ -46,19 +50,24 @@ class UserLicenseFactory(Factory): ...@@ -46,19 +50,24 @@ class UserLicenseFactory(Factory):
''' '''
FACTORY_FOR = UserLicense FACTORY_FOR = UserLicense
user = None
software = SubFactory(CourseSoftwareFactory) software = SubFactory(CourseSoftwareFactory)
serial = SERIAL_1 serial = SERIAL_1
class LicenseTestCase(LoginEnrollmentTestCase): class LicenseTestCase(TestCase):
'''Tests for licenses.views''' '''Tests for licenses.views'''
def setUp(self): def setUp(self):
'''creates a user and logs in''' '''creates a user and logs in'''
self.setup_viewtest_user() # self.setup_viewtest_user()
self.user = UserFactory(username='test',
email='test@edx.org', password='test_password')
self.client = Client()
assert_true(self.client.login(username='test', password='test_password'))
self.software = CourseSoftwareFactory() self.software = CourseSoftwareFactory()
def test_get_license(self): def test_get_license(self):
UserLicenseFactory(user=get_user(self.viewtest_email), software=self.software) UserLicenseFactory(user=self.user, software=self.software)
response = self.client.post(reverse('user_software_license'), response = self.client.post(reverse('user_software_license'),
{'software': SOFTWARE_1, 'generate': 'false'}, {'software': SOFTWARE_1, 'generate': 'false'},
HTTP_X_REQUESTED_WITH='XMLHttpRequest', HTTP_X_REQUESTED_WITH='XMLHttpRequest',
...@@ -125,7 +134,7 @@ class LicenseTestCase(LoginEnrollmentTestCase): ...@@ -125,7 +134,7 @@ class LicenseTestCase(LoginEnrollmentTestCase):
self.assertEqual(404, response.status_code) self.assertEqual(404, response.status_code)
def test_get_license_without_login(self): def test_get_license_without_login(self):
self.logout() self.client.logout()
response = self.client.post(reverse('user_software_license'), response = self.client.post(reverse('user_software_license'),
{'software': SOFTWARE_1, 'generate': 'false'}, {'software': SOFTWARE_1, 'generate': 'false'},
HTTP_X_REQUESTED_WITH='XMLHttpRequest', HTTP_X_REQUESTED_WITH='XMLHttpRequest',
......
"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
Replace this with more appropriate tests for your application.
"""
from django.test import TestCase from django.test import TestCase
from django.test.client import Client
class SimpleTest(TestCase): class SimpleTest(TestCase):
def test_basic_addition(self):
def setUp(self):
self.client = Client()
def test_render(self):
"""
Render a normal page, like jobs
"""
response = self.client.get("/jobs")
self.assertEquals(response.status_code, 200)
def test_render_press_release(self):
""" """
Tests that 1 + 1 always equals 2. Render press releases from generic URL match
""" """
self.assertEqual(1 + 1, 2) # since I had to remap files, pedantically test all press releases
# published to date. Decent positive test while we're at it.
all_releases = ["/press/mit-and-harvard-announce-edx",
"/press/uc-berkeley-joins-edx",
"/press/edX-announces-proctored-exam-testing",
"/press/elsevier-collaborates-with-edx",
"/press/ut-joins-edx",
"/press/cengage-to-provide-book-content",
"/press/gates-foundation-announcement",
"/press/wellesley-college-joins-edx",
"/press/georgetown-joins-edx",
"/press/spring-courses",
"/press/lewin-course-announcement",
"/press/bostonx-announcement",
"/press/eric-lander-secret-of-life",
"/press/edx-expands-internationally",
"/press/xblock_announcement",
"/press/stanford-to-work-with-edx",
]
for rel in all_releases:
response = self.client.get(rel)
self.assertNotContains(response, "PAGE NOT FOUND", status_code=200)
# should work with caps
response = self.client.get("/press/STANFORD-to-work-with-edx")
self.assertContains(response, "Stanford", status_code=200)
# negative test
response = self.client.get("/press/this-shouldnt-work")
self.assertEqual(response.status_code, 404)
# can someone do something fishy? no.
response = self.client.get("/press/../homework.html")
self.assertEqual(response.status_code, 404)
# "." in is ascii 2E
response = self.client.get("/press/%2E%2E/homework.html")
self.assertEqual(response.status_code, 404)
...@@ -4,9 +4,10 @@ ...@@ -4,9 +4,10 @@
# security reasons. # security reasons.
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response, render_to_string
from mako.exceptions import TopLevelLookupException
from django.shortcuts import redirect from django.shortcuts import redirect
from django.conf import settings from django.conf import settings
from django.http import HttpResponseNotFound, HttpResponseServerError from django.http import HttpResponseNotFound, HttpResponseServerError, Http404
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from util.cache import cache_if_anonymous from util.cache import cache_if_anonymous
...@@ -40,6 +41,25 @@ def render(request, template): ...@@ -40,6 +41,25 @@ def render(request, template):
return render_to_response('static_templates/' + template, {}) return render_to_response('static_templates/' + template, {})
@ensure_csrf_cookie
@cache_if_anonymous
def render_press_release(request, slug):
"""
Render a press release given a slug. Similar to the "render" function above,
but takes a slug and does a basic conversion to convert it to a template file.
a) all lower case,
b) convert dashes to underscores, and
c) appending ".html"
"""
template = slug.lower().replace('-', '_') + ".html"
try:
resp = render_to_response('static_templates/press_releases/' + template, {})
except TopLevelLookupException:
raise Http404
else:
return resp
def render_404(request): def render_404(request):
return HttpResponseNotFound(render_to_string('static_templates/404.html', {})) return HttpResponseNotFound(render_to_string('static_templates/404.html', {}))
......
...@@ -67,3 +67,4 @@ MITX_FEATURES['STUB_VIDEO_FOR_TESTING'] = True ...@@ -67,3 +67,4 @@ MITX_FEATURES['STUB_VIDEO_FOR_TESTING'] = True
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command # Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
INSTALLED_APPS += ('lettuce.django',) INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('courseware',) LETTUCE_APPS = ('courseware',)
LETTUCE_BROWSER = 'chrome'
...@@ -71,6 +71,8 @@ MITX_FEATURES = { ...@@ -71,6 +71,8 @@ MITX_FEATURES = {
'ENABLE_LMS_MIGRATION': False, 'ENABLE_LMS_MIGRATION': False,
'ENABLE_MANUAL_GIT_RELOAD': False, 'ENABLE_MANUAL_GIT_RELOAD': False,
'ENABLE_MASQUERADE': True, # allow course staff to change to student view of courseware
'DISABLE_LOGIN_BUTTON': False, # used in systems where login is automatic, eg MIT SSL '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
......
...@@ -11,12 +11,12 @@ class Comment(models.Model): ...@@ -11,12 +11,12 @@ class Comment(models.Model):
'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id', 'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id',
'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list',
'type', 'commentable_id', 'type', 'commentable_id', 'abuse_flaggers'
] ]
updatable_fields = [ updatable_fields = [
'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed',
'user_id', 'endorsed', 'user_id', 'endorsed'
] ]
initializable_fields = updatable_fields initializable_fields = updatable_fields
...@@ -42,6 +42,32 @@ class Comment(models.Model): ...@@ -42,6 +42,32 @@ class Comment(models.Model):
else: else:
return super(Comment, cls).url(action, params) return super(Comment, cls).url(action, params)
def flagAbuse(self, user, voteable):
if voteable.type == 'thread':
url = _url_for_flag_abuse_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_flag_abuse_comment(voteable.id)
else:
raise CommentClientError("Can only flag/unflag threads or comments")
params = {'user_id': user.id}
request = perform_request('put', url, params)
voteable.update_attributes(request)
def unFlagAbuse(self, user, voteable, removeAll):
if voteable.type == 'thread':
url = _url_for_unflag_abuse_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_unflag_abuse_comment(voteable.id)
else:
raise CommentClientError("Can flag/unflag for threads or comments")
params = {'user_id': user.id}
if removeAll:
params['all'] = True
request = perform_request('put', url, params)
voteable.update_attributes(request)
def _url_for_thread_comments(thread_id): def _url_for_thread_comments(thread_id):
return "{prefix}/threads/{thread_id}/comments".format(prefix=settings.PREFIX, thread_id=thread_id) return "{prefix}/threads/{thread_id}/comments".format(prefix=settings.PREFIX, thread_id=thread_id)
...@@ -49,3 +75,11 @@ def _url_for_thread_comments(thread_id): ...@@ -49,3 +75,11 @@ def _url_for_thread_comments(thread_id):
def _url_for_comment(comment_id): def _url_for_comment(comment_id):
return "{prefix}/comments/{comment_id}".format(prefix=settings.PREFIX, comment_id=comment_id) return "{prefix}/comments/{comment_id}".format(prefix=settings.PREFIX, comment_id=comment_id)
def _url_for_flag_abuse_comment(comment_id):
return "{prefix}/comments/{comment_id}/abuse_flag".format(prefix=settings.PREFIX, comment_id=comment_id)
def _url_for_unflag_abuse_comment(comment_id):
return "{prefix}/comments/{comment_id}/abuse_unflag".format(prefix=settings.PREFIX, comment_id=comment_id)
...@@ -29,7 +29,6 @@ def search_trending_tags(course_id, query_params={}, *args, **kwargs): ...@@ -29,7 +29,6 @@ def search_trending_tags(course_id, query_params={}, *args, **kwargs):
def tags_autocomplete(value, *args, **kwargs): def tags_autocomplete(value, *args, **kwargs):
return perform_request('get', _url_for_threads_tags_autocomplete(), {'value': value}, *args, **kwargs) return perform_request('get', _url_for_threads_tags_autocomplete(), {'value': value}, *args, **kwargs)
def _url_for_search_similar_threads(): def _url_for_search_similar_threads():
return "{prefix}/search/threads/more_like_this".format(prefix=settings.PREFIX) return "{prefix}/search/threads/more_like_this".format(prefix=settings.PREFIX)
......
from .utils import * from .utils import *
import models import models
import settings import settings
...@@ -11,7 +10,7 @@ class Thread(models.Model): ...@@ -11,7 +10,7 @@ class Thread(models.Model):
'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id', 'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id',
'created_at', 'updated_at', 'comments_count', 'unread_comments_count', 'created_at', 'updated_at', 'comments_count', 'unread_comments_count',
'at_position_list', 'children', 'type', 'highlighted_title', 'at_position_list', 'children', 'type', 'highlighted_title',
'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned' 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned', 'abuse_flaggers'
] ]
updatable_fields = [ updatable_fields = [
...@@ -27,11 +26,13 @@ class Thread(models.Model): ...@@ -27,11 +26,13 @@ class Thread(models.Model):
@classmethod @classmethod
def search(cls, query_params, *args, **kwargs): def search(cls, query_params, *args, **kwargs):
default_params = {'page': 1, default_params = {'page': 1,
'per_page': 20, 'per_page': 20,
'course_id': query_params['course_id'], 'course_id': query_params['course_id'],
'recursive': False} 'recursive': False}
params = merge_dict(default_params, strip_blank(strip_none(query_params))) params = merge_dict(default_params, strip_blank(strip_none(query_params)))
if query_params.get('text') or query_params.get('tags') or query_params.get('commentable_ids'): if query_params.get('text') or query_params.get('tags') or query_params.get('commentable_ids'):
url = cls.url(action='search') url = cls.url(action='search')
else: else:
...@@ -54,6 +55,7 @@ class Thread(models.Model): ...@@ -54,6 +55,7 @@ class Thread(models.Model):
@classmethod @classmethod
def url(cls, action, params={}): def url(cls, action, params={}):
if action in ['get_all', 'post']: if action in ['get_all', 'post']:
return cls.url_for_threads(params) return cls.url_for_threads(params)
elif action == 'search': elif action == 'search':
...@@ -66,7 +68,6 @@ class Thread(models.Model): ...@@ -66,7 +68,6 @@ class Thread(models.Model):
# that subclasses don't need to override for this. # that subclasses don't need to override for this.
def _retrieve(self, *args, **kwargs): def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes) url = self.url(action='get', params=self.attributes)
request_params = { request_params = {
'recursive': kwargs.get('recursive'), 'recursive': kwargs.get('recursive'),
'user_id': kwargs.get('user_id'), 'user_id': kwargs.get('user_id'),
...@@ -80,6 +81,32 @@ class Thread(models.Model): ...@@ -80,6 +81,32 @@ class Thread(models.Model):
response = perform_request('get', url, request_params) response = perform_request('get', url, request_params)
self.update_attributes(**response) self.update_attributes(**response)
def flagAbuse(self, user, voteable):
if voteable.type == 'thread':
url = _url_for_flag_abuse_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_flag_comment(voteable.id)
else:
raise CommentClientError("Can only flag/unflag threads or comments")
params = {'user_id': user.id}
request = perform_request('put', url, params)
voteable.update_attributes(request)
def unFlagAbuse(self, user, voteable, removeAll):
if voteable.type == 'thread':
url = _url_for_unflag_abuse_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_unflag_comment(voteable.id)
else:
raise CommentClientError("Can only flag/unflag for threads or comments")
params = {'user_id': user.id}
#if you're an admin, when you unflag, remove ALL flags
if removeAll:
params['all'] = True
request = perform_request('put', url, params)
voteable.update_attributes(request)
def pin(self, user, thread_id): def pin(self, user, thread_id):
url = _url_for_pin_thread(thread_id) url = _url_for_pin_thread(thread_id)
params = {'user_id': user.id} params = {'user_id': user.id}
...@@ -93,9 +120,17 @@ class Thread(models.Model): ...@@ -93,9 +120,17 @@ class Thread(models.Model):
self.update_attributes(request) self.update_attributes(request)
def _url_for_flag_abuse_thread(thread_id):
return "{prefix}/threads/{thread_id}/abuse_flag".format(prefix=settings.PREFIX, thread_id=thread_id)
def _url_for_unflag_abuse_thread(thread_id):
return "{prefix}/threads/{thread_id}/abuse_unflag".format(prefix=settings.PREFIX, thread_id=thread_id)
def _url_for_pin_thread(thread_id): def _url_for_pin_thread(thread_id):
return "{prefix}/threads/{thread_id}/pin".format(prefix=settings.PREFIX, thread_id=thread_id) return "{prefix}/threads/{thread_id}/pin".format(prefix=settings.PREFIX, thread_id=thread_id)
def _url_for_un_pin_thread(thread_id): def _url_for_un_pin_thread(thread_id):
return "{prefix}/threads/{thread_id}/unpin".format(prefix=settings.PREFIX, thread_id=thread_id) return "{prefix}/threads/{thread_id}/unpin".format(prefix=settings.PREFIX, thread_id=thread_id)
\ No newline at end of file
...@@ -95,6 +95,7 @@ ...@@ -95,6 +95,7 @@
body.discussion { body.discussion {
.new-post-form-errors { .new-post-form-errors {
display: none; display: none;
background: $error-red; background: $error-red;
...@@ -1334,6 +1335,9 @@ body.discussion { ...@@ -1334,6 +1335,9 @@ body.discussion {
background-position: 0 0; background-position: 0 0;
} }
} }
} }
.discussion-post { .discussion-post {
...@@ -2436,7 +2440,6 @@ body.discussion { ...@@ -2436,7 +2440,6 @@ body.discussion {
@extend .discussion-module @extend .discussion-module
} }
.group-visibility-label { .group-visibility-label {
font-size: 12px; font-size: 12px;
color:#000; color:#000;
...@@ -2451,6 +2454,15 @@ body.discussion { ...@@ -2451,6 +2454,15 @@ body.discussion {
font-style: italic; font-style: italic;
} }
.discussion-pin-inline {
font-size: 12px;
float:right;
font-style: italic;
position: relative;
right:-20px;
top:-13px;
}
.notpinned .icon .notpinned .icon
{ {
display: inline-block; display: inline-block;
...@@ -2478,3 +2490,43 @@ body.discussion { ...@@ -2478,3 +2490,43 @@ body.discussion {
color: #888; color: #888;
font-style: italic; font-style: italic;
} }
.pinned-false
{
display:none;
}
.discussion-flag-abuse {
font-size: 12px;
float:right;
padding-right: 5px;
font-style: italic;
}
.notflagged .icon
{
display: inline-block;
width: 10px;
height: 14px;
padding-right: 3px;
background: transparent url('../images/notflagged.png') no-repeat 0 0;
}
.flagged .icon
{
display: inline-block;
width: 10px;
height: 14px;
padding-right: 3px;
background: transparent url('../images/flagged.png') no-repeat 0 0;
}
.flagged span {
color: #B82066;
font-style: italic;
}
.notflagged span {
color: #888;
font-style: italic;
}
\ No newline at end of file
...@@ -27,6 +27,42 @@ def url_class(is_active): ...@@ -27,6 +27,42 @@ def url_class(is_active):
</li> </li>
% endfor % endfor
<%block name="extratabs" /> <%block name="extratabs" />
% if masquerade is not UNDEFINED:
% if staff_access and masquerade is not None:
<li style="float:right"><a href="#" id="staffstatus">Staff view</a></li>
% endif
% endif
</ol> </ol>
</div> </div>
</nav> </nav>
% if masquerade is not UNDEFINED:
% if staff_access and masquerade is not None:
<script type="text/javascript">
masq = (function(){
var el = $('#staffstatus');
var setstat = function(status){
if (status=='student'){
el.html('<font color="green">Student view</font>');
}else{
el.html('<font color="red">Staff view</font>');
}
}
setstat('${masquerade}');
el.click(function(){
$.ajax({ url: '/masquerade/toggle',
type: 'GET',
success: function(result){
setstat(result.status);
location.reload();
},
error: function() {
alert('Error: cannot connect to server');
}
});
});
}() );
</script>
% endif
% endif
...@@ -33,6 +33,14 @@ ...@@ -33,6 +33,14 @@
<span class="board-name" data-discussion_id='#all'>Show All Discussions</span> <span class="board-name" data-discussion_id='#all'>Show All Discussions</span>
</a> </a>
</li> </li>
%if flag_moderator:
<li>
<a href="#">
<span class="board-name" data-discussion_id='#flagged'>Show Flagged Discussions</span>
</a>
</li>
%endif
<li> <li>
<a href="#"> <a href="#">
<span class="board-name" data-discussion_id='#following'>Following</span> <span class="board-name" data-discussion_id='#following'>Following</span>
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
<script type="text/template" id="thread-template"> <script type="text/template" id="thread-template">
<article class="discussion-article" data-id="${'<%- id %>'}"> <article class="discussion-article" data-id="${'<%- id %>'}">
<div class="thread-content-wrapper"></div> <div class="thread-content-wrapper"></div>
<ol class="responses"> <ol class="responses">
<li class="loading"><div class="loading-animation"></div></li> <li class="loading"><div class="loading-animation"></div></li>
</ol> </ol>
...@@ -30,7 +31,8 @@ ...@@ -30,7 +31,8 @@
<div class="group-visibility-label">${"<%- obj.group_string%>"}</div> <div class="group-visibility-label">${"<%- obj.group_string%>"}</div>
${"<% } %>"} ${"<% } %>"}
<a href="#" class="vote-btn discussion-vote discussion-vote-up" data-role="discussion-vote" data-tooltip="vote"><span class="plus-icon">+</span> <span class='votes-count-number'>${'<%- votes["up_count"] %>'}</span></a> <a href="#" class="vote-btn discussion-vote discussion-vote-up" data-role="discussion-vote" data-tooltip="vote">
<span class="plus-icon">+</span> <span class='votes-count-number'>${'<%- votes["up_count"] %>'}</span></a>
<h1>${'<%- title %>'}</h1> <h1>${'<%- title %>'}</h1>
<p class="posted-details"> <p class="posted-details">
${"<% if (obj.username) { %>"} ${"<% if (obj.username) { %>"}
...@@ -45,6 +47,10 @@ ...@@ -45,6 +47,10 @@
</header> </header>
<div class="post-body">${'<%- body %>'}</div> <div class="post-body">${'<%- body %>'}</div>
<div class="discussion-flag-abuse notflagged" data-role="thread-flag" data-tooltip="report misuse">
<i class="icon"></i><span class="flag-label">Report Misuse</span></div>
% if course and has_permission(user, 'openclose_thread', course.id): % if course and has_permission(user, 'openclose_thread', course.id):
<div class="admin-pin discussion-pin notpinned" data-role="thread-pin" data-tooltip="pin this thread"> <div class="admin-pin discussion-pin notpinned" data-role="thread-pin" data-tooltip="pin this thread">
<i class="icon"></i><span class="pin-label">Pin Thread</span></div> <i class="icon"></i><span class="pin-label">Pin Thread</span></div>
...@@ -118,7 +124,10 @@ ...@@ -118,7 +124,10 @@
${"<% } else {print('<span class=\"anonymous\"><em>anonymous</em></span>');} %>"} ${"<% } else {print('<span class=\"anonymous\"><em>anonymous</em></span>');} %>"}
<p class="posted-details" title="${'<%- created_at %>'}">${'<%- created_at %>'}</p> <p class="posted-details" title="${'<%- created_at %>'}">${'<%- created_at %>'}</p>
</header> </header>
<div class="response-local"><div class="response-body">${"<%- body %>"}</div></div> <div class="response-local"><div class="response-body">${"<%- body %>"}</div>
<div class="discussion-flag-abuse notflagged" data-role="thread-flag" data-tooltip="report misuse">
<i class="icon"></i><span class="flag-label">Report Misuse</span></div>
</div>
<ul class="moderator-actions response-local"> <ul class="moderator-actions response-local">
<li style="display: none"><a class="action-edit" href="javascript:void(0)"><span class="edit-icon"></span> Edit</a></li> <li style="display: none"><a class="action-edit" href="javascript:void(0)"><span class="edit-icon"></span> Edit</a></li>
<li style="display: none"><a class="action-delete" href="javascript:void(0)"><span class="delete-icon"></span> Delete</a></li> <li style="display: none"><a class="action-delete" href="javascript:void(0)"><span class="delete-icon"></span> Delete</a></li>
...@@ -141,6 +150,8 @@ ...@@ -141,6 +150,8 @@
<script type="text/template" id="response-comment-show-template"> <script type="text/template" id="response-comment-show-template">
<div id="comment_${'<%- id %>'}"> <div id="comment_${'<%- id %>'}">
<div class="response-body">${'<%- body %>'}</div> <div class="response-body">${'<%- body %>'}</div>
<div class="discussion-flag-abuse notflagged" data-role="thread-flag" data-tooltip="report misuse">
<i class="icon"></i><span class="flag-label"></span></div>
<p class="posted-details">&ndash;posted <span class="timeago" title="${'<%- created_at %>'}">${'<%- created_at %>'}</span> by <p class="posted-details">&ndash;posted <span class="timeago" title="${'<%- created_at %>'}">${'<%- created_at %>'}</span> by
${"<% if (obj.username) { %>"} ${"<% if (obj.username) { %>"}
<a href="${'<%- user_url %>'}" class="profile-link">${'<%- username %>'}</a> <a href="${'<%- user_url %>'}" class="profile-link">${'<%- username %>'}</a>
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
<%include file="_new_post.html" /> <%include file="_new_post.html" />
<section class="discussion container" id="discussion-container" data-roles="${roles}" data-course-id="${course_id}" data-user-info="${user_info}" data-threads="${threads}" data-thread-pages="${thread_pages}" data-content-info="${annotated_content_info}"> <section class="discussion container" id="discussion-container" data-roles="${roles}" data-course-id="${course_id}" data-user-info="${user_info}" data-threads="${threads}" data-thread-pages="${thread_pages}" data-content-info="${annotated_content_info}" data-flag-moderator="${flag_moderator}">
<div class="discussion-body"> <div class="discussion-body">
<div class="sidebar"></div> <div class="sidebar"></div>
<div class="discussion-column"> <div class="discussion-column">
......
<article class="discussion-article" data-id="{{id}}"> <article class="discussion-article" data-id="{{id}}">
<div class="group-visibility-label">{{group_string}}</div> <div class="group-visibility-label">{{group_string}}</div>
<div class="thread-content-wrapper"></div> <div class="thread-content-wrapper"></div>
<ol class="responses post-extended-content"> <ol class="responses post-extended-content">
......
...@@ -3,6 +3,9 @@ ...@@ -3,6 +3,9 @@
<header> <header>
<a href="#" class="vote-btn discussion-vote discussion-vote-up" data-role="discussion-vote" data-tooltip="vote"><span class="plus-icon">+</span> <span class='votes-count-number'>{{votes.up_count}}</span></a> <a href="#" class="vote-btn discussion-vote discussion-vote-up" data-role="discussion-vote" data-tooltip="vote"><span class="plus-icon">+</span> <span class='votes-count-number'>{{votes.up_count}}</span></a>
<h3>{{title}}</h3> <h3>{{title}}</h3>
<div class="discussion-pin-inline pinned pinned-{{pinned}}" data-tooltip="This thread has been pinned by course staff.">
<i class="icon"></i><span class="pin-label">Pinned</span></div>
<p class="posted-details"> <p class="posted-details">
{{#user}} {{#user}}
<a href="{{user_url}}" class="username">{{username}}</a> <a href="{{user_url}}" class="username">{{username}}</a>
......
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
<%include file="_new_post.html" /> <%include file="_new_post.html" />
<section class="discussion container" id="discussion-container" data-roles="${roles}" data-course-id="${course_id}" data-user-info="${user_info}" data-threads="${threads}" data-content-info="${annotated_content_info}" data-thread-pages="${thread_pages}"> <section class="discussion container" id="discussion-container" data-roles="${roles}" data-course-id="${course_id}" data-user-info="${user_info}" data-threads="${threads}" data-content-info="${annotated_content_info}" data-thread-pages="${thread_pages}" data-flag-moderator="${flag_moderator}">
<div class="discussion-body"> <div class="discussion-body">
<div class="sidebar"></div> <div class="sidebar"></div>
<div class="discussion-column"></div> <div class="discussion-column"></div>
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
<id>tag:www.edx.org,2012:Post/17</id> <id>tag:www.edx.org,2012:Post/17</id>
<published>2012-12-19T14:00:00-07:00</published> <published>2012-12-19T14:00:00-07:00</published>
<updated>2012-12-19T14:00:00-07:00</updated> <updated>2012-12-19T14:00:00-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/stanford-to-work-with-edx')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['stanford-to-work-with-edx'])}"/>
<title>Stanford University to Collaborate with edX on Development of Non-Profit Open Source edX Platform</title> <title>Stanford University to Collaborate with edX on Development of Non-Profit Open Source edX Platform</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/stanford-university-m.png')}&quot; /&gt; <content type="html">&lt;img src=&quot;${static.url('images/press/releases/stanford-university-m.png')}&quot; /&gt;
&lt;p&gt;&lt;/p&gt;</content> &lt;p&gt;&lt;/p&gt;</content>
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
<id>tag:www.edx.org,2013:Post/16</id> <id>tag:www.edx.org,2013:Post/16</id>
<published>2013-03-15T10:00:00-07:00</published> <published>2013-03-15T10:00:00-07:00</published>
<updated>2013-03-15T10:00:00-07:00</updated> <updated>2013-03-15T10:00:00-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/xblock-announcement')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['xblock-announcement'])}"/>
<title>edX releases XBlock SDK, first step toward open source vision</title> <title>edX releases XBlock SDK, first step toward open source vision</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/edx-logo_240x180.png')}&quot; /&gt; <content type="html">&lt;img src=&quot;${static.url('images/press/releases/edx-logo_240x180.png')}&quot; /&gt;
&lt;p&gt;&lt;/p&gt;</content> &lt;p&gt;&lt;/p&gt;</content>
...@@ -38,7 +38,7 @@ ...@@ -38,7 +38,7 @@
<!-- <id>tag:www.edx.org,2013:Post/14</id> --> <!-- <id>tag:www.edx.org,2013:Post/14</id> -->
<!-- <published>2013-02-20T10:00:00-07:00</published> --> <!-- <published>2013-02-20T10:00:00-07:00</published> -->
<!-- <updated>2013-02-20T10:00:00-07:00</updated> --> <!-- <updated>2013-02-20T10:00:00-07:00</updated> -->
<!-- <link type="text/html" rel="alternate" href="${reverse('press/edx-expands-internationally')}"/> --> <!-- <link type="text/html" rel="alternate" href="${reverse('press_release', args=['edx-expands-internationally'])}"/> -->
<!-- <title>edX Expands Internationally and Doubles its Institutional Membership with the Addition of Six New Schools</title> --> <!-- <title>edX Expands Internationally and Doubles its Institutional Membership with the Addition of Six New Schools</title> -->
<!-- <content type="html">&lt;img src=&quot;${static.url('images/press/releases/edx-logo_240x180.png')}&quot; /&gt; --> <!-- <content type="html">&lt;img src=&quot;${static.url('images/press/releases/edx-logo_240x180.png')}&quot; /&gt; -->
<!-- &lt;p&gt;&lt;/p&gt;</content> --> <!-- &lt;p&gt;&lt;/p&gt;</content> -->
...@@ -47,7 +47,7 @@ ...@@ -47,7 +47,7 @@
<id>tag:www.edx.org,2013:Post/14</id> <id>tag:www.edx.org,2013:Post/14</id>
<published>2013-01-30T10:00:00-07:00</published> <published>2013-01-30T10:00:00-07:00</published>
<updated>2013-01-30T10:00:00-07:00</updated> <updated>2013-01-30T10:00:00-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/eric-lander-secret-of-life')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['eric-lander-secret-of-life'])}"/>
<title>New biology course from human genome pioneer Eric Lander</title> <title>New biology course from human genome pioneer Eric Lander</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/eric-lander_240x180.jpg')}&quot; /&gt; <content type="html">&lt;img src=&quot;${static.url('images/press/releases/eric-lander_240x180.jpg')}&quot; /&gt;
&lt;p&gt;&lt;/p&gt;</content> &lt;p&gt;&lt;/p&gt;</content>
...@@ -56,7 +56,7 @@ ...@@ -56,7 +56,7 @@
<id>tag:www.edx.org,2013:Post/12</id> <id>tag:www.edx.org,2013:Post/12</id>
<published>2013-01-22T10:00:00-07:00</published> <published>2013-01-22T10:00:00-07:00</published>
<updated>2013-01-22T10:00:00-07:00</updated> <updated>2013-01-22T10:00:00-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/lewin-course-announcement')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['lewin-course-announcement'])}"/>
<title>New course from legendary MIT physics professor Walter Lewin</title> <title>New course from legendary MIT physics professor Walter Lewin</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/dr-lewin-316_240x180.jpg')}&quot; /&gt; <content type="html">&lt;img src=&quot;${static.url('images/press/releases/dr-lewin-316_240x180.jpg')}&quot; /&gt;
&lt;p&gt;&lt;/p&gt;</content> &lt;p&gt;&lt;/p&gt;</content>
...@@ -65,7 +65,7 @@ ...@@ -65,7 +65,7 @@
<id>tag:www.edx.org,2013:Post/11</id> <id>tag:www.edx.org,2013:Post/11</id>
<published>2013-01-29T10:00:00-07:00</published> <published>2013-01-29T10:00:00-07:00</published>
<updated>2013-01-29T10:00:00-07:00</updated> <updated>2013-01-29T10:00:00-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/bostonx-announcement')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['bostonx-announcement'])}"/>
<title>City of Boston and edX partner to establish BostonX to improve educational access for residents</title> <title>City of Boston and edX partner to establish BostonX to improve educational access for residents</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/edx-logo_240x180.png')}&quot; /&gt; <content type="html">&lt;img src=&quot;${static.url('images/press/releases/edx-logo_240x180.png')}&quot; /&gt;
&lt;p&gt;&lt;/p&gt;</content> &lt;p&gt;&lt;/p&gt;</content>
...@@ -74,7 +74,7 @@ ...@@ -74,7 +74,7 @@
<!-- <id>tag:www.edx.org,2012:Post/10</id> --> <!-- <id>tag:www.edx.org,2012:Post/10</id> -->
<!-- <published>2012-12-19T14:00:00-07:00</published> --> <!-- <published>2012-12-19T14:00:00-07:00</published> -->
<!-- <updated>2012-12-19T14:00:00-07:00</updated> --> <!-- <updated>2012-12-19T14:00:00-07:00</updated> -->
<!-- <link type="text/html" rel="alternate" href="${reverse('press/spring-courses')}"/> --> <!-- <link type="text/html" rel="alternate" href="${reverse('press_release', args=['spring-courses'])}"/> -->
<!-- <title>edX announces first wave of new courses for Spring 2013</title> --> <!-- <title>edX announces first wave of new courses for Spring 2013</title> -->
<!-- <content type="html">&lt;img src=&quot;${static.url('images/press/releases/edx-logo_240x180.png')}&quot; /&gt; --> <!-- <content type="html">&lt;img src=&quot;${static.url('images/press/releases/edx-logo_240x180.png')}&quot; /&gt; -->
<!-- &lt;p&gt;&lt;/p&gt;</content> --> <!-- &lt;p&gt;&lt;/p&gt;</content> -->
...@@ -83,7 +83,7 @@ ...@@ -83,7 +83,7 @@
<id>tag:www.edx.org,2012:Post/9</id> <id>tag:www.edx.org,2012:Post/9</id>
<published>2012-12-10T14:00:00-07:00</published> <published>2012-12-10T14:00:00-07:00</published>
<updated>2012-12-10T14:00:00-07:00</updated> <updated>2012-12-10T14:00:00-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/georgetown-joins-edx')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['georgetown-joins-edx'])}"/>
<title>Georgetown University joins edX</title> <title>Georgetown University joins edX</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/georgetown-seal_240x180.png')}&quot; /&gt; <content type="html">&lt;img src=&quot;${static.url('images/press/releases/georgetown-seal_240x180.png')}&quot; /&gt;
&lt;p&gt;Sixth institution to join global movement in year one&lt;/p&gt;</content> &lt;p&gt;Sixth institution to join global movement in year one&lt;/p&gt;</content>
...@@ -92,7 +92,7 @@ ...@@ -92,7 +92,7 @@
<id>tag:www.edx.org,2012:Post/8</id> <id>tag:www.edx.org,2012:Post/8</id>
<published>2012-12-04T14:00:00-07:00</published> <published>2012-12-04T14:00:00-07:00</published>
<updated>2012-12-04T14:00:00-07:00</updated> <updated>2012-12-04T14:00:00-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/wellesley-college-joins-edx')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['wellesley-college-joins-edx'])}"/>
<title>Wellesley College joins edX</title> <title>Wellesley College joins edX</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/wellesley-seal_240x180.png')}&quot; /&gt; <content type="html">&lt;img src=&quot;${static.url('images/press/releases/wellesley-seal_240x180.png')}&quot; /&gt;
&lt;p&gt;First liberal arts college to join edX&lt;/p&gt;</content> &lt;p&gt;First liberal arts college to join edX&lt;/p&gt;</content>
...@@ -101,7 +101,7 @@ ...@@ -101,7 +101,7 @@
<id>tag:www.edx.org,2012:Post/7</id> <id>tag:www.edx.org,2012:Post/7</id>
<published>2012-11-12T14:00:00-07:00</published> <published>2012-11-12T14:00:00-07:00</published>
<updated>2012-11-12T14:00:00-07:00</updated> <updated>2012-11-12T14:00:00-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/gates-foundation-announcement')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['gates-foundation-announcement'])}"/>
<title>edX and Massachusetts Community Colleges join in Gates-Funded educational initiative</title> <title>edX and Massachusetts Community Colleges join in Gates-Funded educational initiative</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/mass-seal_240x180.png')}&quot; /&gt; <content type="html">&lt;img src=&quot;${static.url('images/press/releases/mass-seal_240x180.png')}&quot; /&gt;
&lt;p&gt;&lt;/p&gt;</content> &lt;p&gt;&lt;/p&gt;</content>
...@@ -110,7 +110,7 @@ ...@@ -110,7 +110,7 @@
<id>tag:www.edx.org,2012:Post/6</id> <id>tag:www.edx.org,2012:Post/6</id>
<published>2012-10-15T14:00:00-07:00</published> <published>2012-10-15T14:00:00-07:00</published>
<updated>2012-10-14T14:00:00-07:00</updated> <updated>2012-10-14T14:00:00-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/ut-joins-edx')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['ut-joins-edx'])}"/>
<title>The University of Texas System joins edX</title> <title>The University of Texas System joins edX</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/utsys-seal_240x180.png')}&quot; /&gt; <content type="html">&lt;img src=&quot;${static.url('images/press/releases/utsys-seal_240x180.png')}&quot; /&gt;
&lt;p&gt;Nine universities and six health institutions&lt;/p&gt;</content> &lt;p&gt;Nine universities and six health institutions&lt;/p&gt;</content>
...@@ -119,7 +119,7 @@ ...@@ -119,7 +119,7 @@
<!-- <id>tag:www.edx.org,2012:Post/5</id> --> <!-- <id>tag:www.edx.org,2012:Post/5</id> -->
<!-- <published>2012-09-25T14:00:00-07:00</published> --> <!-- <published>2012-09-25T14:00:00-07:00</published> -->
<!-- <updated>2012-09-25T14:00:00-07:00</updated> --> <!-- <updated>2012-09-25T14:00:00-07:00</updated> -->
<!-- <link type="text/html" rel="alternate" href="${reverse('press/elsevier-collaborates-with-edx')}"/> --> <!-- <link type="text/html" rel="alternate" href="${reverse('press_release', args=['elsevier-collaborates-with-edx'])}"/> -->
<!-- <title>Elsevier collaborates with edX</title> --> <!-- <title>Elsevier collaborates with edX</title> -->
<!-- <content type="html">&lt;img src=&quot;${static.url('images/press/releases/foundations-of-analog_240x180.jpg')}&quot; /&gt; --> <!-- <content type="html">&lt;img src=&quot;${static.url('images/press/releases/foundations-of-analog_240x180.jpg')}&quot; /&gt; -->
<!-- &lt;p&gt;Free course textbook made available to edX students&lt;/p&gt;</content> --> <!-- &lt;p&gt;Free course textbook made available to edX students&lt;/p&gt;</content> -->
...@@ -128,7 +128,7 @@ ...@@ -128,7 +128,7 @@
<id>tag:www.edx.org,2012:Post/4</id> <id>tag:www.edx.org,2012:Post/4</id>
<published>2012-09-06T14:00:00-07:00</published> <published>2012-09-06T14:00:00-07:00</published>
<updated>2012-09-06T14:00:00-07:00</updated> <updated>2012-09-06T14:00:00-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/edX-announces-proctored-exam-testing')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['edX-announces-proctored-exam-testing'])}"/>
<title>edX to offer learners option of taking proctored final exam</title> <title>edX to offer learners option of taking proctored final exam</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/diploma_240x180.jpg')}&quot; /&gt;</content> <content type="html">&lt;img src=&quot;${static.url('images/press/releases/diploma_240x180.jpg')}&quot; /&gt;</content>
</entry> </entry>
...@@ -136,7 +136,7 @@ ...@@ -136,7 +136,7 @@
<id>tag:www.edx.org,2012:Post/3</id> <id>tag:www.edx.org,2012:Post/3</id>
<published>2012-07-16T14:08:12-07:00</published> <published>2012-07-16T14:08:12-07:00</published>
<updated>2012-07-16T14:08:12-07:00</updated> <updated>2012-07-16T14:08:12-07:00</updated>
<link type="text/html" rel="alternate" href="${reverse('press/uc-berkeley-joins-edx')}"/> <link type="text/html" rel="alternate" href="${reverse('press_release', args=['uc-berkeley-joins-edx'])}"/>
<title>UC Berkeley joins edX</title> <title>UC Berkeley joins edX</title>
<content type="html">&lt;img src=&quot;${static.url('images/press/releases/edx-logo_240x180.png')}&quot; /&gt; <content type="html">&lt;img src=&quot;${static.url('images/press/releases/edx-logo_240x180.png')}&quot; /&gt;
&lt;p&gt;edX broadens course offerings&lt;/p&gt;</content> &lt;p&gt;edX broadens course offerings&lt;/p&gt;</content>
......
...@@ -117,51 +117,9 @@ urlpatterns = ('', ...@@ -117,51 +117,9 @@ urlpatterns = ('',
{'template': 'honor.html'}, name="honor"), {'template': 'honor.html'}, name="honor"),
#Press releases #Press releases
url(r'^press/mit-and-harvard-announce-edx$', 'static_template_view.views.render', url(r'^press/([_a-zA-Z0-9-]+)$', 'static_template_view.views.render_press_release', name='press_release'),
{'template': 'press_releases/MIT_and_Harvard_announce_edX.html'}, name="press/mit-and-harvard-announce-edx"),
url(r'^press/uc-berkeley-joins-edx$', 'static_template_view.views.render',
{'template': 'press_releases/UC_Berkeley_joins_edX.html'}, name="press/uc-berkeley-joins-edx"),
url(r'^press/edX-announces-proctored-exam-testing$', 'static_template_view.views.render',
{'template': 'press_releases/edX_announces_proctored_exam_testing.html'}, name="press/edX-announces-proctored-exam-testing"),
url(r'^press/elsevier-collaborates-with-edx$', 'static_template_view.views.render',
{'template': 'press_releases/Elsevier_collaborates_with_edX.html'}, name="press/elsevier-collaborates-with-edx"),
url(r'^press/ut-joins-edx$', 'static_template_view.views.render',
{'template': 'press_releases/UT_joins_edX.html'}, name="press/ut-joins-edx"),
url(r'^press/cengage-to-provide-book-content$', 'static_template_view.views.render',
{'template': 'press_releases/Cengage_to_provide_book_content.html'}, name="press/cengage-to-provide-book-content"),
url(r'^press/gates-foundation-announcement$', 'static_template_view.views.render',
{'template': 'press_releases/Gates_Foundation_announcement.html'}, name="press/gates-foundation-announcement"),
url(r'^press/wellesley-college-joins-edx$', 'static_template_view.views.render',
{'template': 'press_releases/Wellesley_College_joins_edX.html'}, name="press/wellesley-college-joins-edx"),
url(r'^press/georgetown-joins-edx$', 'static_template_view.views.render',
{'template': 'press_releases/Georgetown_joins_edX.html'}, name="press/georgetown-joins-edx"),
url(r'^press/spring-courses$', 'static_template_view.views.render',
{'template': 'press_releases/Spring_2013_course_announcements.html'},
name="press/spring-courses"),
url(r'^press/lewin-course-announcement$', 'static_template_view.views.render',
{'template': 'press_releases/Lewin_course_announcement.html'},
name="press/lewin-course-announcement"),
url(r'^press/bostonx-announcement$', 'static_template_view.views.render',
{'template': 'press_releases/bostonx_announcement.html'},
name="press/bostonx-announcement"),
url(r'^press/eric-lander-secret-of-life$', 'static_template_view.views.render',
{'template': 'press_releases/eric_lander_secret_of_life.html'},
name="press/eric-lander-secret-of-life"),
url(r'^press/edx-expands-internationally$', 'static_template_view.views.render',
{'template': 'press_releases/edx_expands_internationally.html'},
name="press/edx-expands-internationally"),
url(r'^press/xblock_announcement$', 'static_template_view.views.render',
{'template': 'press_releases/xblock_announcement.html'},
name="press/xblock-announcement"),
url(r'^press/stanford-to-work-with-edx$', 'static_template_view.views.render',
{'template': 'press_releases/stanford_announcement.html'},
name="press/stanford-to-work-with-edx"),
# Should this always update to point to the latest press release?
(r'^pressrelease$', 'django.views.generic.simple.redirect_to',
{'url': '/press/xblock-announcement'}),
# Favicon
(r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}), (r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}),
# TODO: These urls no longer work. They need to be updated before they are re-enabled # TODO: These urls no longer work. They need to be updated before they are re-enabled
...@@ -201,9 +159,6 @@ if settings.WIKI_ENABLED: ...@@ -201,9 +159,6 @@ if settings.WIKI_ENABLED:
if settings.COURSEWARE_ENABLED: if settings.COURSEWARE_ENABLED:
urlpatterns += ( urlpatterns += (
# Hook django-masquerade, allowing staff to view site as other users
url(r'^masquerade/', include('masquerade.urls')),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/jump_to/(?P<location>.*)$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/jump_to/(?P<location>.*)$',
'courseware.views.jump_to', name="jump_to"), 'courseware.views.jump_to', name="jump_to"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/modx/(?P<location>.*?)/(?P<dispatch>[^/]*)$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/modx/(?P<location>.*?)/(?P<dispatch>[^/]*)$',
...@@ -341,6 +296,13 @@ if settings.COURSEWARE_ENABLED: ...@@ -341,6 +296,13 @@ if settings.COURSEWARE_ENABLED:
'open_ended_grading.views.peer_grading', name='peer_grading'), 'open_ended_grading.views.peer_grading', name='peer_grading'),
) )
# allow course staff to change to student view of courseware
if settings.MITX_FEATURES.get('ENABLE_MASQUERADE'):
urlpatterns += (
url(r'^masquerade/(?P<marg>.*)$','courseware.masquerade.handle_ajax', name="masquerade-switch"),
)
# discussion forums live within courseware, so courseware must be enabled first # discussion forums live within courseware, so courseware must be enabled first
if settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'): if settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
urlpatterns += ( urlpatterns += (
......
...@@ -61,8 +61,8 @@ sphinx==1.1.3 ...@@ -61,8 +61,8 @@ sphinx==1.1.3
# Used for testing # Used for testing
coverage==3.6 coverage==3.6
factory_boy==1.3.0 factory_boy==2.0.2
lettuce==0.2.15 lettuce==0.2.16
mock==0.8.0 mock==0.8.0
nosexcover==1.0.7 nosexcover==1.0.7
pep8==1.4.5 pep8==1.4.5
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment