Merge branch 'master' into feature/christina/metadata-ui
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,6 +4,7 @@
|
||||
*.swp
|
||||
*.orig
|
||||
*.DS_Store
|
||||
*.mo
|
||||
:2e_*
|
||||
:2e#
|
||||
.AppleDouble
|
||||
@@ -22,6 +23,8 @@ reports/
|
||||
*.egg-info
|
||||
Gemfile.lock
|
||||
.env/
|
||||
conf/locale/en/LC_MESSAGES/*.po
|
||||
!messages.po
|
||||
lms/static/sass/*.css
|
||||
cms/static/sass/*.css
|
||||
lms/lib/comment_client/python
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "common/test/phantom-jasmine"]
|
||||
path = common/test/phantom-jasmine
|
||||
url = https://github.com/jcarver989/phantom-jasmine.git
|
||||
26
.tx/config
Normal file
26
.tx/config
Normal file
@@ -0,0 +1,26 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-studio.django-partial]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/django-partial.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/django-partial.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-studio.djangojs]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-studio.mako]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/mako.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/mako.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-studio.messages]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/messages.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/messages.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
@@ -220,6 +220,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
num_drafts = self._get_draft_counts(course)
|
||||
self.assertEqual(num_drafts, 1)
|
||||
|
||||
def test_import_textbook_as_content_element(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
|
||||
module_store = modulestore('direct')
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
|
||||
self.assertGreater(len(course.textbooks), 0)
|
||||
|
||||
def test_static_tab_reordering(self):
|
||||
import_from_xml(modulestore(), 'common/test/data/', ['full'])
|
||||
|
||||
@@ -293,7 +301,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# make sure the parent no longer points to the child object which was deleted
|
||||
self.assertFalse(sequential.location.url() in chapter.children)
|
||||
|
||||
|
||||
def test_about_overrides(self):
|
||||
'''
|
||||
This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html
|
||||
@@ -490,6 +497,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertTrue(getattr(test_private_vertical, 'is_draft', False))
|
||||
|
||||
# make sure the textbook survived the export/import
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
|
||||
self.assertGreater(len(course.textbooks), 0)
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def test_course_handouts_rewrites(self):
|
||||
|
||||
@@ -10,9 +10,9 @@ class CourseUpdateTest(CourseTestCase):
|
||||
'''Go through each interface and ensure it works.'''
|
||||
# first get the update to force the creation
|
||||
url = reverse('course_info',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'name': self.course_location.name})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'name': self.course_location.name})
|
||||
self.client.get(url)
|
||||
|
||||
init_content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0">'
|
||||
@@ -20,9 +20,9 @@ class CourseUpdateTest(CourseTestCase):
|
||||
payload = {'content': content,
|
||||
'date': 'January 8, 2013'}
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
|
||||
@@ -31,25 +31,25 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(payload['content'], content)
|
||||
|
||||
first_update_url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': payload['id']})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': payload['id']})
|
||||
content += '<div>div <p>p<br/></p></div>'
|
||||
payload['content'] = content
|
||||
resp = self.client.post(first_update_url, json.dumps(payload),
|
||||
"application/json")
|
||||
"application/json")
|
||||
|
||||
self.assertHTMLEqual(content, json.loads(resp.content)['content'],
|
||||
"iframe w/ div")
|
||||
"iframe w/ div")
|
||||
|
||||
# now put in an evil update
|
||||
content = '<ol/>'
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
|
||||
@@ -58,25 +58,24 @@ class CourseUpdateTest(CourseTestCase):
|
||||
self.assertHTMLEqual(content, payload['content'], "self closing ol")
|
||||
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.get(url)
|
||||
payload = json.loads(resp.content)
|
||||
self.assertTrue(len(payload) == 2)
|
||||
|
||||
# can't test non-json paylod b/c expect_json throws error
|
||||
# try json w/o required fields
|
||||
self.assertContains(
|
||||
self.client.post(url, json.dumps({'garbage': 1}),
|
||||
"application/json"),
|
||||
'Failed to save', status_code=400)
|
||||
self.assertContains(self.client.post(url, json.dumps({'garbage': 1}),
|
||||
"application/json"),
|
||||
'Failed to save', status_code=400)
|
||||
|
||||
# now try to update a non-existent update
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': '9'})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': '9'})
|
||||
content = 'blah blah'
|
||||
payload = {'content': content,
|
||||
'date': 'January 21, 2013'}
|
||||
@@ -89,8 +88,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
self.assertContains(
|
||||
self.client.post(url, json.dumps(payload), "application/json"),
|
||||
@@ -101,8 +100,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
payload = {'content': content,
|
||||
'date': 'January 11, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
payload = json.loads(resp.content)
|
||||
@@ -110,8 +109,8 @@ class CourseUpdateTest(CourseTestCase):
|
||||
|
||||
# now try to delete a non-existent update
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': '19'})
|
||||
'course': self.course_location.course,
|
||||
'provided_id': '19'})
|
||||
payload = {'content': content,
|
||||
'date': 'January 21, 2013'}
|
||||
self.assertContains(self.client.delete(url), "delete", status_code=400)
|
||||
@@ -121,25 +120,25 @@ class CourseUpdateTest(CourseTestCase):
|
||||
payload = {'content': content,
|
||||
'date': 'January 28, 2013'}
|
||||
url = reverse('course_info_json', kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
payload = json.loads(resp.content)
|
||||
this_id = payload['id']
|
||||
self.assertHTMLEqual(content, payload['content'], "single iframe")
|
||||
# first count the entries
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': ''})
|
||||
resp = self.client.get(url)
|
||||
payload = json.loads(resp.content)
|
||||
before_delete = len(payload)
|
||||
|
||||
url = reverse('course_info_json',
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': this_id})
|
||||
kwargs={'org': self.course_location.org,
|
||||
'course': self.course_location.course,
|
||||
'provided_id': this_id})
|
||||
resp = self.client.delete(url)
|
||||
payload = json.loads(resp.content)
|
||||
self.assertTrue(len(payload) == before_delete - 1)
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.test.client import Client
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
class InternationalizationTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests to validate Internationalization.
|
||||
@@ -38,34 +39,33 @@ class InternationalizationTest(ModuleStoreTestCase):
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
}
|
||||
}
|
||||
|
||||
def test_course_plain_english(self):
|
||||
"""Test viewing the index page with no courses"""
|
||||
self.client = Client()
|
||||
self.client.login(username=self.uname, password=self.password)
|
||||
|
||||
|
||||
resp = self.client.get(reverse('index'))
|
||||
self.assertContains(resp,
|
||||
'<h1 class="title-1">My Courses</h1>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
'<h1 class="title-1">My Courses</h1>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
|
||||
def test_course_explicit_english(self):
|
||||
"""Test viewing the index page with no courses"""
|
||||
self.client = Client()
|
||||
self.client.login(username=self.uname, password=self.password)
|
||||
|
||||
|
||||
resp = self.client.get(reverse('index'),
|
||||
{},
|
||||
HTTP_ACCEPT_LANGUAGE='en'
|
||||
)
|
||||
|
||||
self.assertContains(resp,
|
||||
'<h1 class="title-1">My Courses</h1>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
|
||||
'<h1 class="title-1">My Courses</h1>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
|
||||
# ****
|
||||
# NOTE:
|
||||
@@ -74,14 +74,13 @@ class InternationalizationTest(ModuleStoreTestCase):
|
||||
# This test will break when we replace this fake 'test' language
|
||||
# with actual French. This test will need to be updated with
|
||||
# actual French at that time.
|
||||
|
||||
# Test temporarily disable since it depends on creation of dummy strings
|
||||
@skip
|
||||
def test_course_with_accents (self):
|
||||
def test_course_with_accents(self):
|
||||
"""Test viewing the index page with no courses"""
|
||||
self.client = Client()
|
||||
self.client.login(username=self.uname, password=self.password)
|
||||
|
||||
|
||||
resp = self.client.get(reverse('index'),
|
||||
{},
|
||||
HTTP_ACCEPT_LANGUAGE='fr'
|
||||
@@ -90,8 +89,8 @@ class InternationalizationTest(ModuleStoreTestCase):
|
||||
TEST_STRING = u'<h1 class="title-1">' \
|
||||
+ u'My \xc7\xf6\xfcrs\xe9s L#' \
|
||||
+ u'</h1>'
|
||||
|
||||
|
||||
self.assertContains(resp,
|
||||
TEST_STRING,
|
||||
status_code=200,
|
||||
html=True)
|
||||
html=True)
|
||||
|
||||
@@ -1,1685 +0,0 @@
|
||||
from util.json_request import expect_json
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import tarfile
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from uuid import uuid4
|
||||
from path import path
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from tempfile import mkdtemp
|
||||
from django.core.servers.basehttp import FileWrapper
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
|
||||
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.context_processors import csrf
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xblock.core import Scope
|
||||
from xblock.runtime import KeyValueStore, DbModel, InvalidScopeError
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
import static_replace
|
||||
from external_auth.views import ssl_login_shortcut
|
||||
from xmodule.modulestore.mongo import MongoUsage
|
||||
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xmodule
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from functools import partial
|
||||
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
|
||||
from auth.authz import is_user_in_course_group_role, get_users_in_course_group_by_role
|
||||
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
|
||||
from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups
|
||||
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, \
|
||||
UnitState, get_course_for_item, get_url_reverse, add_open_ended_panel_tab, \
|
||||
remove_open_ended_panel_tab
|
||||
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from contentstore.course_info_model import get_course_updates, \
|
||||
update_course_updates, delete_course_update
|
||||
from cache_toolbox.core import del_cached_content
|
||||
from contentstore.module_info_model import get_module_info, set_module_info
|
||||
from models.settings.course_details import CourseDetails, \
|
||||
CourseSettingsEncoder
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from contentstore.utils import get_modulestore
|
||||
from django.shortcuts import redirect
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
|
||||
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
|
||||
|
||||
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
|
||||
ADVANCED_COMPONENT_TYPES = ['annotatable', 'word_cloud'] + OPEN_ENDED_COMPONENT_TYPES
|
||||
ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
|
||||
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
|
||||
# ==== Public views ==================================================
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def signup(request):
|
||||
"""
|
||||
Display the signup form.
|
||||
"""
|
||||
csrf_token = csrf(request)['csrf_token']
|
||||
return render_to_response('signup.html', {'csrf': csrf_token})
|
||||
|
||||
|
||||
def old_login_redirect(request):
|
||||
'''
|
||||
Redirect to the active login url.
|
||||
'''
|
||||
return redirect('login', permanent=True)
|
||||
|
||||
|
||||
@ssl_login_shortcut
|
||||
@ensure_csrf_cookie
|
||||
def login_page(request):
|
||||
"""
|
||||
Display the login form.
|
||||
"""
|
||||
csrf_token = csrf(request)['csrf_token']
|
||||
return render_to_response('login.html', {
|
||||
'csrf': csrf_token,
|
||||
'forgot_password_link': "//{base}/#forgot-password-modal".format(base=settings.LMS_BASE),
|
||||
})
|
||||
|
||||
|
||||
def howitworks(request):
|
||||
if request.user.is_authenticated():
|
||||
return index(request)
|
||||
else:
|
||||
return render_to_response('howitworks.html', {})
|
||||
|
||||
|
||||
# static/proof-of-concept views
|
||||
def ux_alerts(request):
|
||||
return render_to_response('ux-alerts.html', {})
|
||||
|
||||
|
||||
# ==== Views for any logged-in user ==================================
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def index(request):
|
||||
"""
|
||||
List all courses available to the logged in user
|
||||
"""
|
||||
courses = modulestore('direct').get_items(['i4x', None, None, 'course', None])
|
||||
|
||||
# filter out courses that we don't have access too
|
||||
def course_filter(course):
|
||||
return (has_access(request.user, course.location)
|
||||
and course.location.course != 'templates'
|
||||
and course.location.org != ''
|
||||
and course.location.course != ''
|
||||
and course.location.name != '')
|
||||
courses = filter(course_filter, courses)
|
||||
|
||||
return render_to_response('index.html', {
|
||||
'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'),
|
||||
'courses': [(course.display_name,
|
||||
get_url_reverse('CourseOutline', course),
|
||||
get_lms_link_for_item(course.location, course_id=course.location.course_id))
|
||||
for course in courses],
|
||||
'user': request.user,
|
||||
'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff
|
||||
})
|
||||
|
||||
|
||||
# ==== Views with per-item permissions================================
|
||||
|
||||
|
||||
def has_access(user, location, role=STAFF_ROLE_NAME):
|
||||
'''
|
||||
Return True if user allowed to access this piece of data
|
||||
Note that the CMS permissions model is with respect to courses
|
||||
There is a super-admin permissions if user.is_staff is set
|
||||
Also, since we're unifying the user database between LMS and CAS,
|
||||
I'm presuming that the course instructor (formally known as admin)
|
||||
will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR
|
||||
has all the rights that STAFF do
|
||||
'''
|
||||
course_location = get_course_location_for_item(location)
|
||||
_has_access = is_user_in_course_group_role(user, course_location, role)
|
||||
# if we're not in STAFF, perhaps we're in INSTRUCTOR groups
|
||||
if not _has_access and role == STAFF_ROLE_NAME:
|
||||
_has_access = is_user_in_course_group_role(user, course_location, INSTRUCTOR_ROLE_NAME)
|
||||
return _has_access
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_index(request, org, course, name):
|
||||
"""
|
||||
Display an editable course overview.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
lms_link = get_lms_link_for_item(location)
|
||||
|
||||
upload_asset_callback_url = reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
|
||||
course = modulestore().get_item(location, depth=3)
|
||||
sections = course.get_children()
|
||||
|
||||
return render_to_response('overview.html', {
|
||||
'active_tab': 'courseware',
|
||||
'context_course': course,
|
||||
'lms_link': lms_link,
|
||||
'sections': sections,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'parent_location': course.location,
|
||||
'new_section_template': Location('i4x', 'edx', 'templates', 'chapter', 'Empty'),
|
||||
'new_subsection_template': Location('i4x', 'edx', 'templates', 'sequential', 'Empty'), # for now they are the same, but the could be different at some point...
|
||||
'upload_asset_callback_url': upload_asset_callback_url,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty')
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_subsection(request, location):
|
||||
# check that we have permissions to edit this item
|
||||
course = get_course_for_item(location)
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
|
||||
lms_link = get_lms_link_for_item(location, course_id=course.location.course_id)
|
||||
preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True)
|
||||
|
||||
# make sure that location references a 'sequential', otherwise return BadRequest
|
||||
if item.location.category != 'sequential':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
parent_locs = modulestore().get_parent_locations(location, None)
|
||||
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error('Multiple (or none) parents have been found for {0}'.format(location))
|
||||
|
||||
# this should blow up if we don't find any parents, which would be erroneous
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a more normalized UI
|
||||
|
||||
policy_metadata = dict(
|
||||
(field.name, field.read_from(item))
|
||||
for field
|
||||
in item.fields
|
||||
if field.name not in ['display_name', 'start', 'due', 'format'] and field.scope == Scope.settings
|
||||
)
|
||||
|
||||
can_view_live = False
|
||||
subsection_units = item.get_children()
|
||||
for unit in subsection_units:
|
||||
state = compute_unit_state(unit)
|
||||
if state == UnitState.public or state == UnitState.draft:
|
||||
can_view_live = True
|
||||
break
|
||||
|
||||
return render_to_response('edit_subsection.html',
|
||||
{'subsection': item,
|
||||
'context_course': course,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'parent_location': course.location,
|
||||
'parent_item': parent,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_unit(request, location):
|
||||
"""
|
||||
Display an editing page for the specified module.
|
||||
|
||||
Expects a GET request with the parameter 'id'.
|
||||
|
||||
id: A Location URL
|
||||
"""
|
||||
course = get_course_for_item(location)
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
|
||||
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
|
||||
# Check if there are any advanced modules specified in the course policy. These modules
|
||||
# should be specified as a list of strings, where the strings are the names of the modules
|
||||
# in ADVANCED_COMPONENT_TYPES that should be enabled for the course.
|
||||
course_advanced_keys = course.advanced_modules
|
||||
|
||||
# Set component types according to course policy file
|
||||
component_types = list(COMPONENT_TYPES)
|
||||
if isinstance(course_advanced_keys, list):
|
||||
course_advanced_keys = [c for c in course_advanced_keys if c in ADVANCED_COMPONENT_TYPES]
|
||||
if len(course_advanced_keys) > 0:
|
||||
component_types.append(ADVANCED_COMPONENT_CATEGORY)
|
||||
else:
|
||||
log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys))
|
||||
|
||||
templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
|
||||
for template in templates:
|
||||
category = template.location.category
|
||||
|
||||
if category in course_advanced_keys:
|
||||
category = ADVANCED_COMPONENT_CATEGORY
|
||||
|
||||
if category in component_types:
|
||||
# This is a hack to create categories for different xmodules
|
||||
component_templates[category].append((
|
||||
template.display_name_with_default,
|
||||
template.location.url(),
|
||||
hasattr(template, 'markdown') and template.markdown is not None,
|
||||
template.cms.empty,
|
||||
))
|
||||
|
||||
components = [
|
||||
component.location.url()
|
||||
for component
|
||||
in item.get_children()
|
||||
]
|
||||
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
|
||||
containing_subsection_locs = modulestore().get_parent_locations(location, None)
|
||||
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
|
||||
|
||||
containing_section_locs = modulestore().get_parent_locations(containing_subsection.location, None)
|
||||
containing_section = modulestore().get_item(containing_section_locs[0])
|
||||
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
|
||||
# need to figure out where this item is in the list of children as the preview will need this
|
||||
index = 1
|
||||
for child in containing_subsection.get_children():
|
||||
if child.location == item.location:
|
||||
break
|
||||
index = index + 1
|
||||
|
||||
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE',
|
||||
'preview.' + settings.LMS_BASE)
|
||||
|
||||
preview_lms_link = '//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
|
||||
preview_lms_base=preview_lms_base,
|
||||
lms_base=settings.LMS_BASE,
|
||||
org=course.location.org,
|
||||
course=course.location.course,
|
||||
course_name=course.location.name,
|
||||
section=containing_section.location.name,
|
||||
subsection=containing_subsection.location.name,
|
||||
index=index)
|
||||
|
||||
unit_state = compute_unit_state(item)
|
||||
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'active_tab': 'courseware',
|
||||
'unit': item,
|
||||
'unit_location': location,
|
||||
'components': components,
|
||||
'component_templates': component_templates,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None,
|
||||
'section': containing_section,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'unit_state': unit_state,
|
||||
'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def preview_component(request, location):
|
||||
# TODO (vshnayder): change name from id to location in coffee+html as well.
|
||||
if not has_access(request.user, location):
|
||||
raise HttpResponseForbidden()
|
||||
|
||||
component = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('component.html', {
|
||||
'preview': get_module_previews(request, component)[0],
|
||||
'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(),
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def assignment_type_update(request, org, course, category, name):
|
||||
'''
|
||||
CRUD operations on assignment types for sections and subsections and anything else gradable.
|
||||
'''
|
||||
location = Location(['i4x', org, course, category, name])
|
||||
if not has_access(request.user, location):
|
||||
raise HttpResponseForbidden()
|
||||
|
||||
if request.method == 'GET':
|
||||
return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)),
|
||||
mimetype="application/json")
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)),
|
||||
mimetype="application/json")
|
||||
|
||||
|
||||
def user_author_string(user):
|
||||
'''Get an author string for commits by this user. Format:
|
||||
first last <email@email.com>.
|
||||
|
||||
If the first and last names are blank, uses the username instead.
|
||||
Assumes that the email is not blank.
|
||||
'''
|
||||
f = user.first_name
|
||||
l = user.last_name
|
||||
if f == '' and l == '':
|
||||
f = user.username
|
||||
return '{first} {last} <{email}>'.format(first=f,
|
||||
last=l,
|
||||
email=user.email)
|
||||
|
||||
|
||||
@login_required
|
||||
def preview_dispatch(request, preview_id, location, dispatch=None):
|
||||
"""
|
||||
Dispatch an AJAX action to a preview XModule
|
||||
|
||||
Expects a POST request, and passes the arguments to the module
|
||||
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
location: The Location of the module to dispatch to
|
||||
dispatch: The action to execute
|
||||
"""
|
||||
|
||||
descriptor = modulestore().get_item(location)
|
||||
instance = load_preview_module(request, preview_id, descriptor)
|
||||
# Let the module handle the AJAX
|
||||
try:
|
||||
ajax_return = instance.handle_ajax(dispatch, request.POST)
|
||||
|
||||
except NotFoundError:
|
||||
log.exception("Module indicating to user that request doesn't exist")
|
||||
raise Http404
|
||||
|
||||
except ProcessingError:
|
||||
log.warning("Module raised an error while processing AJAX request",
|
||||
exc_info=True)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
except:
|
||||
log.exception("error processing ajax call")
|
||||
raise
|
||||
|
||||
return HttpResponse(ajax_return)
|
||||
|
||||
|
||||
def render_from_lms(template_name, dictionary, context=None, namespace='main'):
|
||||
"""
|
||||
Render a template using the LMS MAKO_TEMPLATES
|
||||
"""
|
||||
return render_to_string(template_name, dictionary, context, namespace="lms." + namespace)
|
||||
|
||||
|
||||
class SessionKeyValueStore(KeyValueStore):
|
||||
def __init__(self, request, model_data):
|
||||
self._model_data = model_data
|
||||
self._session = request.session
|
||||
|
||||
def get(self, key):
|
||||
try:
|
||||
return self._model_data[key.field_name]
|
||||
except (KeyError, InvalidScopeError):
|
||||
return self._session[tuple(key)]
|
||||
|
||||
def set(self, key, value):
|
||||
try:
|
||||
self._model_data[key.field_name] = value
|
||||
except (KeyError, InvalidScopeError):
|
||||
self._session[tuple(key)] = value
|
||||
|
||||
def delete(self, key):
|
||||
try:
|
||||
del self._model_data[key.field_name]
|
||||
except (KeyError, InvalidScopeError):
|
||||
del self._session[tuple(key)]
|
||||
|
||||
def has(self, key):
|
||||
return key in self._model_data or key in self._session
|
||||
|
||||
|
||||
def preview_module_system(request, preview_id, descriptor):
|
||||
"""
|
||||
Returns a ModuleSystem for the specified descriptor that is specialized for
|
||||
rendering module previews.
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
descriptor: An XModuleDescriptor
|
||||
"""
|
||||
|
||||
def preview_model_data(descriptor):
|
||||
return DbModel(
|
||||
SessionKeyValueStore(request, descriptor._model_data),
|
||||
descriptor.module_class,
|
||||
preview_id,
|
||||
MongoUsage(preview_id, descriptor.location.url()),
|
||||
)
|
||||
|
||||
return ModuleSystem(
|
||||
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
|
||||
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
|
||||
track_function=lambda type, event: None,
|
||||
filestore=descriptor.system.resources_fs,
|
||||
get_module=partial(get_preview_module, request, preview_id),
|
||||
render_template=render_from_lms,
|
||||
debug=True,
|
||||
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
|
||||
user=request.user,
|
||||
xblock_model_data=preview_model_data,
|
||||
)
|
||||
|
||||
|
||||
def get_preview_module(request, preview_id, descriptor):
|
||||
"""
|
||||
Returns a preview XModule at the specified location. The preview_data is chosen arbitrarily
|
||||
from the set of preview data for the descriptor specified by Location
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
location: A Location
|
||||
"""
|
||||
|
||||
return load_preview_module(request, preview_id, descriptor)
|
||||
|
||||
|
||||
def load_preview_module(request, preview_id, descriptor):
|
||||
"""
|
||||
Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
descriptor: An XModuleDescriptor
|
||||
instance_state: An instance state string
|
||||
shared_state: A shared state string
|
||||
"""
|
||||
system = preview_module_system(request, preview_id, descriptor)
|
||||
try:
|
||||
module = descriptor.xmodule(system)
|
||||
except:
|
||||
log.debug("Unable to load preview module", exc_info=True)
|
||||
module = ErrorDescriptor.from_descriptor(
|
||||
descriptor,
|
||||
error_msg=exc_info_to_str(sys.exc_info())
|
||||
).xmodule(system)
|
||||
|
||||
# cdodge: Special case
|
||||
if module.location.category == 'static_tab':
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_tab_display.html",
|
||||
)
|
||||
else:
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_display.html",
|
||||
)
|
||||
|
||||
module.get_html = replace_static_urls(
|
||||
module.get_html,
|
||||
getattr(module, 'data_dir', module.location.course),
|
||||
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
|
||||
)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def get_module_previews(request, descriptor):
|
||||
"""
|
||||
Returns a list of preview XModule html contents. One preview is returned for each
|
||||
pair of states returned by get_sample_state() for the supplied descriptor.
|
||||
|
||||
descriptor: An XModuleDescriptor
|
||||
"""
|
||||
preview_html = []
|
||||
for idx, (instance_state, shared_state) in enumerate(descriptor.get_sample_state()):
|
||||
module = load_preview_module(request, str(idx), descriptor)
|
||||
preview_html.append(module.get_html())
|
||||
return preview_html
|
||||
|
||||
|
||||
def _xmodule_recurse(item, action):
|
||||
for child in item.get_children():
|
||||
_xmodule_recurse(child, action)
|
||||
|
||||
action(item)
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def delete_item(request):
|
||||
item_location = request.POST['id']
|
||||
item_loc = Location(item_location)
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# optional parameter to delete all children (default False)
|
||||
delete_children = request.POST.get('delete_children', False)
|
||||
delete_all_versions = request.POST.get('delete_all_versions', False)
|
||||
|
||||
store = modulestore()
|
||||
|
||||
item = store.get_item(item_location)
|
||||
|
||||
if delete_children:
|
||||
_xmodule_recurse(item, lambda i: store.delete_item(i.location, delete_all_versions))
|
||||
else:
|
||||
store.delete_item(item.location, delete_all_versions)
|
||||
|
||||
# cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
|
||||
if delete_all_versions:
|
||||
parent_locs = modulestore('direct').get_parent_locations(item_loc, None)
|
||||
|
||||
for parent_loc in parent_locs:
|
||||
parent = modulestore('direct').get_item(parent_loc)
|
||||
item_url = item_loc.url()
|
||||
if item_url in parent.children:
|
||||
children = parent.children
|
||||
children.remove(item_url)
|
||||
parent.children = children
|
||||
modulestore('direct').update_children(parent.location, parent.children)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def save_item(request):
|
||||
item_location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
store = get_modulestore(Location(item_location))
|
||||
|
||||
if request.POST.get('data') is not None:
|
||||
data = request.POST['data']
|
||||
store.update_item(item_location, data)
|
||||
|
||||
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
|
||||
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
|
||||
# deleting the children object from the children collection
|
||||
if 'children' in request.POST and request.POST['children'] is not None:
|
||||
children = request.POST['children']
|
||||
store.update_children(item_location, children)
|
||||
|
||||
# cdodge: also commit any metadata which might have been passed along in the
|
||||
# POST from the client, if it is there
|
||||
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
|
||||
# not presented to the end-user for editing. So let's fetch the original and
|
||||
# 'apply' the submitted metadata, so we don't end up deleting system metadata
|
||||
if request.POST.get('metadata') is not None:
|
||||
posted_metadata = request.POST['metadata']
|
||||
# fetch original
|
||||
existing_item = modulestore().get_item(item_location)
|
||||
|
||||
# update existing metadata with submitted metadata (which can be partial)
|
||||
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
|
||||
for metadata_key, value in posted_metadata.items():
|
||||
|
||||
if posted_metadata[metadata_key] is None:
|
||||
# remove both from passed in collection as well as the collection read in from the modulestore
|
||||
if metadata_key in existing_item._model_data:
|
||||
del existing_item._model_data[metadata_key]
|
||||
del posted_metadata[metadata_key]
|
||||
else:
|
||||
existing_item._model_data[metadata_key] = value
|
||||
|
||||
# commit to datastore
|
||||
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
|
||||
store.update_metadata(item_location, own_metadata(existing_item))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_draft(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# This clones the existing item location to a draft location (the draft is implicit,
|
||||
# because modulestore is a Draft modulestore)
|
||||
modulestore().clone_item(location, location)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def publish_draft(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().publish(i.location, request.user.id))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def unpublish_unit(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().unpublish(i.location))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def clone_item(request):
|
||||
parent_location = Location(request.POST['parent_location'])
|
||||
template = Location(request.POST['template'])
|
||||
|
||||
display_name = request.POST.get('display_name')
|
||||
|
||||
if not has_access(request.user, parent_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
parent = get_modulestore(template).get_item(parent_location)
|
||||
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
|
||||
|
||||
new_item = get_modulestore(template).clone_item(template, dest_location)
|
||||
|
||||
# replace the display name with an optional parameter passed in from the caller
|
||||
if display_name is not None:
|
||||
new_item.display_name = display_name
|
||||
|
||||
get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item))
|
||||
|
||||
if new_item.location.category not in DETACHED_CATEGORIES:
|
||||
get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()])
|
||||
|
||||
return HttpResponse(json.dumps({'id': dest_location.url()}))
|
||||
|
||||
|
||||
def upload_asset(request, org, course, coursename):
|
||||
'''
|
||||
cdodge: this method allows for POST uploading of files into the course asset library, which will
|
||||
be supported by GridFS in MongoDB.
|
||||
'''
|
||||
if request.method != 'POST':
|
||||
# (cdodge) @todo: Is there a way to do a - say - 'raise Http400'?
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# construct a location from the passed in path
|
||||
location = get_location_and_verify_access(request, org, course, coursename)
|
||||
|
||||
# Does the course actually exist?!? Get anything from it to prove its existance
|
||||
|
||||
try:
|
||||
modulestore().get_item(location)
|
||||
except:
|
||||
# no return it as a Bad Request response
|
||||
logging.error('Could not find course' + location)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# compute a 'filename' which is similar to the location formatting, we're using the 'filename'
|
||||
# nomenclature since we're using a FileSystem paradigm here. We're just imposing
|
||||
# the Location string formatting expectations to keep things a bit more consistent
|
||||
|
||||
filename = request.FILES['file'].name
|
||||
mime_type = request.FILES['file'].content_type
|
||||
filedata = request.FILES['file'].read()
|
||||
|
||||
content_loc = StaticContent.compute_location(org, course, filename)
|
||||
content = StaticContent(content_loc, filename, mime_type, filedata)
|
||||
|
||||
# first let's see if a thumbnail can be created
|
||||
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content)
|
||||
|
||||
# delete cached thumbnail even if one couldn't be created this time (else the old thumbnail will continue to show)
|
||||
del_cached_content(thumbnail_location)
|
||||
# now store thumbnail location only if we could create it
|
||||
if thumbnail_content is not None:
|
||||
content.thumbnail_location = thumbnail_location
|
||||
|
||||
# then commit the content
|
||||
contentstore().save(content)
|
||||
del_cached_content(content.location)
|
||||
|
||||
# readback the saved content - we need the database timestamp
|
||||
readback = contentstore().find(content.location)
|
||||
|
||||
response_payload = {'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at.timetuple()),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
}
|
||||
|
||||
response = HttpResponse(json.dumps(response_payload))
|
||||
response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def manage_users(request, location):
|
||||
'''
|
||||
This view will return all CMS users who are editors for the specified course
|
||||
'''
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('manage_users.html', {
|
||||
'active_tab': 'users',
|
||||
'context_course': course_module,
|
||||
'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME),
|
||||
'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'),
|
||||
'remove_user_postback_url': reverse('remove_user', args=[location]).rstrip('/'),
|
||||
'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME),
|
||||
'request_user_id': request.user.id
|
||||
})
|
||||
|
||||
|
||||
def create_json_response(errmsg=None):
|
||||
if errmsg is not None:
|
||||
resp = HttpResponse(json.dumps({'Status': 'Failed', 'ErrMsg': errmsg}))
|
||||
else:
|
||||
resp = HttpResponse(json.dumps({'Status': 'OK'}))
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def add_user(request, location):
|
||||
'''
|
||||
This POST-back view will add a user - specified by email - to the list of editors for
|
||||
the specified course
|
||||
'''
|
||||
email = request.POST["email"]
|
||||
|
||||
if email == '':
|
||||
return create_json_response('Please specify an email address.')
|
||||
|
||||
# check that logged in user has admin permissions to this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
user = get_user_by_email(email)
|
||||
|
||||
# user doesn't exist?!? Return error.
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
|
||||
# user exists, but hasn't activated account?!?
|
||||
if not user.is_active:
|
||||
return create_json_response('User {0} has registered but has not yet activated his/her account.'.format(email))
|
||||
|
||||
# ok, we're cool to add to the course group
|
||||
add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
|
||||
return create_json_response()
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def remove_user(request, location):
|
||||
'''
|
||||
This POST-back view will remove a user - specified by email - from the list of editors for
|
||||
the specified course
|
||||
'''
|
||||
|
||||
email = request.POST["email"]
|
||||
|
||||
# check that logged in user has admin permissions on this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
user = get_user_by_email(email)
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
|
||||
# make sure we're not removing ourselves
|
||||
if user.id == request.user.id:
|
||||
raise PermissionDenied()
|
||||
|
||||
remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
|
||||
return create_json_response()
|
||||
|
||||
|
||||
# points to the temporary course landing page with log in and sign up
|
||||
def landing(request, org, course, coursename):
|
||||
return render_to_response('temp-course-landing.html', {})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def static_pages(request, org, course, coursename):
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, coursename)
|
||||
|
||||
course = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('static-pages.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course': course,
|
||||
})
|
||||
|
||||
|
||||
def edit_static(request, org, course, coursename):
|
||||
return render_to_response('edit-static-page.html', {})
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def reorder_static_tabs(request):
|
||||
tabs = request.POST['tabs']
|
||||
course = get_course_for_item(tabs[0])
|
||||
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# get list of existing static tabs in course
|
||||
# make sure they are the same lengths (i.e. the number of passed in tabs equals the number
|
||||
# that we know about) otherwise we can drop some!
|
||||
|
||||
existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab']
|
||||
if len(existing_static_tabs) != len(tabs):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# load all reference tabs, return BadRequest if we can't find any of them
|
||||
tab_items = []
|
||||
for tab in tabs:
|
||||
item = modulestore('direct').get_item(Location(tab))
|
||||
if item is None:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
tab_items.append(item)
|
||||
|
||||
# now just go through the existing course_tabs and re-order the static tabs
|
||||
reordered_tabs = []
|
||||
static_tab_idx = 0
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reordered_tabs.append({'type': 'static_tab',
|
||||
'name': tab_items[static_tab_idx].display_name,
|
||||
'url_slug': tab_items[static_tab_idx].location.name})
|
||||
static_tab_idx += 1
|
||||
else:
|
||||
reordered_tabs.append(tab)
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course.tabs = reordered_tabs
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def edit_tabs(request, org, course, coursename):
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
course_item = modulestore().get_item(location)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# see tabs have been uninitialized (e.g. supporing courses created before tab support in studio)
|
||||
if course_item.tabs is None or len(course_item.tabs) == 0:
|
||||
initialize_course_tabs(course_item)
|
||||
|
||||
# first get all static tabs from the tabs list
|
||||
# we do this because this is also the order in which items are displayed in the LMS
|
||||
static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab']
|
||||
|
||||
static_tabs = []
|
||||
for static_tab_ref in static_tabs_refs:
|
||||
static_tab_loc = Location(location)._replace(category='static_tab', name=static_tab_ref['url_slug'])
|
||||
static_tabs.append(modulestore('direct').get_item(static_tab_loc))
|
||||
|
||||
components = [
|
||||
static_tab.location.url()
|
||||
for static_tab
|
||||
in static_tabs
|
||||
]
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course': course_item,
|
||||
'components': components
|
||||
})
|
||||
|
||||
|
||||
def not_found(request):
|
||||
return render_to_response('error.html', {'error': '404'})
|
||||
|
||||
|
||||
def server_error(request):
|
||||
return render_to_response('error.html', {'error': '500'})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info(request, org, course, name, provided_id=None):
|
||||
"""
|
||||
Send models and views as well as html for editing the course info to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
# get current updates
|
||||
location = ['i4x', org, course, 'course_info', "updates"]
|
||||
|
||||
return render_to_response('course_info.html', {
|
||||
'active_tab': 'courseinfo-tab',
|
||||
'context_course': course_module,
|
||||
'url_base': "/" + org + "/" + course + "/",
|
||||
'course_updates': json.dumps(get_course_updates(location)),
|
||||
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info_updates(request, org, course, provided_id=None):
|
||||
"""
|
||||
restful CRUD operations on course_info updates.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
provided_id should be none if it's new (create) and a composite of the update db id + index otherwise.
|
||||
"""
|
||||
# ??? No way to check for access permission afaik
|
||||
# get current updates
|
||||
location = ['i4x', org, course, 'course_info', "updates"]
|
||||
|
||||
# Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-(
|
||||
# Possibly due to my removing the seemingly redundant pattern in urls.py
|
||||
if provided_id == '':
|
||||
provided_id = None
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if request.method == 'GET':
|
||||
return HttpResponse(json.dumps(get_course_updates(location)),
|
||||
mimetype="application/json")
|
||||
elif real_method == 'DELETE':
|
||||
try:
|
||||
return HttpResponse(json.dumps(delete_course_update(location,
|
||||
request.POST, provided_id)), mimetype="application/json")
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to delete",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'POST':
|
||||
try:
|
||||
return HttpResponse(json.dumps(update_course_updates(location,
|
||||
request.POST, provided_id)), mimetype="application/json")
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to save",
|
||||
content_type="text/plain")
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def module_info(request, module_location):
|
||||
location = Location(module_location)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true']
|
||||
logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links))
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
if real_method == 'GET':
|
||||
return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links)), mimetype="application/json")
|
||||
elif real_method == 'POST' or real_method == 'PUT':
|
||||
return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def get_course_settings(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'details_url': reverse(course_settings_updates,
|
||||
kwargs={"org": org,
|
||||
"course": course,
|
||||
"name": name,
|
||||
"section": "details"})
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_graders_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
course_details = CourseGradingModel.fetch(location)
|
||||
|
||||
return render_to_response('settings_graders.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_advanced_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the advanced course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings_advanced.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'advanced_dict': json.dumps(CourseMetadata.fetch(location)),
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_settings_updates(request, org, course, name, section):
|
||||
"""
|
||||
restful CRUD operations on course settings. This differs from get_course_settings by communicating purely
|
||||
through json (not rendering any html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
section: one of details, faculty, grading, problems, discussions
|
||||
"""
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if section == 'details':
|
||||
manager = CourseDetails
|
||||
elif section == 'grading':
|
||||
manager = CourseGradingModel
|
||||
else:
|
||||
return
|
||||
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course', name])), cls=CourseSettingsEncoder),
|
||||
mimetype="application/json")
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder),
|
||||
mimetype="application/json")
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
"""
|
||||
restful CRUD operations on course_info updates. This differs from get_course_settings by communicating purely
|
||||
through json (not rendering any html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if real_method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(location), grader_index)),
|
||||
mimetype="application/json")
|
||||
elif real_method == "DELETE":
|
||||
# ??? Should this return anything? Perhaps success fail?
|
||||
CourseGradingModel.delete_grader(Location(location), grader_index)
|
||||
return HttpResponse()
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(location), request.POST)),
|
||||
mimetype="application/json")
|
||||
|
||||
|
||||
# # NB: expect_json failed on ["key", "key2"] and json payload
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_advanced_updates(request, org, course, name):
|
||||
"""
|
||||
restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh,
|
||||
the payload is either a key or a list of keys to delete.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if real_method == 'GET':
|
||||
return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json")
|
||||
elif real_method == 'DELETE':
|
||||
return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))),
|
||||
mimetype="application/json")
|
||||
elif real_method == 'POST' or real_method == 'PUT':
|
||||
# NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
|
||||
request_body = json.loads(request.body)
|
||||
#Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = True
|
||||
#Check to see if the user instantiated any advanced components. This is a hack to add the open ended panel tab
|
||||
#to a course automatically if the user has indicated that they want to edit the combinedopenended or peergrading
|
||||
#module, and to remove it if they have removed the open ended elements.
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request_body:
|
||||
#Check to see if the user instantiated any open ended components
|
||||
found_oe_type = False
|
||||
#Get the course so that we can scrape current tabs
|
||||
course_module = modulestore().get_item(location)
|
||||
for oe_type in OPEN_ENDED_COMPONENT_TYPES:
|
||||
if oe_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
#Add an open ended tab to the course if needed
|
||||
changed, new_tabs = add_open_ended_panel_tab(course_module)
|
||||
#If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
#Set this flag to avoid the open ended tab removal code below.
|
||||
found_oe_type = True
|
||||
break
|
||||
#If we did not find an open ended module type in the advanced settings,
|
||||
# we may need to remove the open ended tab from the course.
|
||||
if not found_oe_type:
|
||||
#Remove open ended tab to the course if needed
|
||||
changed, new_tabs = remove_open_ended_panel_tab(course_module)
|
||||
if changed:
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs))
|
||||
return HttpResponse(response_json, mimetype="application/json")
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def get_checklists(request, org, course, name):
|
||||
"""
|
||||
Send models, views, and html for displaying the course checklists.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
modulestore = get_modulestore(location)
|
||||
course_module = modulestore.get_item(location)
|
||||
new_course_template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
|
||||
template_module = modulestore.get_item(new_course_template)
|
||||
|
||||
# If course was created before checklists were introduced, copy them over from the template.
|
||||
copied = False
|
||||
if not course_module.checklists:
|
||||
course_module.checklists = template_module.checklists
|
||||
copied = True
|
||||
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if copied or modified:
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return render_to_response('checklists.html',
|
||||
{
|
||||
'context_course': course_module,
|
||||
'checklists': checklists
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def update_checklist(request, org, course, name, checklist_index=None):
|
||||
"""
|
||||
restful CRUD operations on course checklists. The payload is a json rep of
|
||||
the modified checklist. For PUT or POST requests, the index of the
|
||||
checklist being modified must be included; the returned payload will
|
||||
be just that one checklist. For GET requests, the returned payload
|
||||
is a json representation of the list of all checklists.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
modulestore = get_modulestore(location)
|
||||
course_module = modulestore.get_item(location)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
if real_method == 'POST' or real_method == 'PUT':
|
||||
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
|
||||
index = int(checklist_index)
|
||||
course_module.checklists[index] = json.loads(request.body)
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return HttpResponse(json.dumps(checklists[index]), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
"Could not save checklist state because the checklist index was out of range or unspecified.",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'GET':
|
||||
# In the JavaScript view initialize method, we do a fetch to get all the checklists.
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if modified:
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return HttpResponse(json.dumps(checklists), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest("Unsupported request.", content_type="text/plain")
|
||||
|
||||
|
||||
def expand_checklist_action_urls(course_module):
|
||||
"""
|
||||
Gets the checklists out of the course module and expands their action urls
|
||||
if they have not yet been expanded.
|
||||
|
||||
Returns the checklists with modified urls, as well as a boolean
|
||||
indicating whether or not the checklists were modified.
|
||||
"""
|
||||
checklists = course_module.checklists
|
||||
modified = False
|
||||
for checklist in checklists:
|
||||
if not checklist.get('action_urls_expanded', False):
|
||||
for item in checklist.get('items'):
|
||||
item['action_url'] = get_url_reverse(item.get('action_url'), course_module)
|
||||
checklist['action_urls_expanded'] = True
|
||||
modified = True
|
||||
|
||||
return checklists, modified
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def asset_index(request, org, course, name):
|
||||
"""
|
||||
Display an editable asset library
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
upload_asset_callback_url = reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
course_reference = StaticContent.compute_location(org, course, name)
|
||||
assets = contentstore().get_all_content_for_course(course_reference)
|
||||
|
||||
# sort in reverse upload date order
|
||||
assets = sorted(assets, key=lambda asset: asset['uploadDate'], reverse=True)
|
||||
|
||||
asset_display = []
|
||||
for asset in assets:
|
||||
id = asset['_id']
|
||||
display_info = {}
|
||||
display_info['displayname'] = asset['displayname']
|
||||
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'].timetuple())
|
||||
|
||||
asset_location = StaticContent.compute_location(id['org'], id['course'], id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
|
||||
# note, due to the schema change we may not have a 'thumbnail_location' in the result set
|
||||
_thumbnail_location = asset.get('thumbnail_location', None)
|
||||
thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else None
|
||||
display_info['thumb_url'] = StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None
|
||||
|
||||
asset_display.append(display_info)
|
||||
|
||||
return render_to_response('asset_index.html', {
|
||||
'active_tab': 'assets',
|
||||
'context_course': course_module,
|
||||
'assets': asset_display,
|
||||
'upload_asset_callback_url': upload_asset_callback_url
|
||||
})
|
||||
|
||||
|
||||
# points to the temporary edge page
|
||||
def edge(request):
|
||||
return render_to_response('university_profiles/edge.html', {})
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_new_course(request):
|
||||
|
||||
if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff:
|
||||
raise PermissionDenied()
|
||||
|
||||
# This logic is repeated in xmodule/modulestore/tests/factories.py
|
||||
# so if you change anything here, you need to also change it there.
|
||||
# TODO: write a test that creates two courses, one with the factory and
|
||||
# the other with this method, then compare them to make sure they are
|
||||
# equivalent.
|
||||
template = Location(request.POST['template'])
|
||||
org = request.POST.get('org')
|
||||
number = request.POST.get('number')
|
||||
display_name = request.POST.get('display_name')
|
||||
|
||||
try:
|
||||
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
|
||||
except InvalidLocationError as e:
|
||||
return HttpResponse(json.dumps({'ErrMsg': "Unable to create course '" + display_name + "'.\n\n" + e.message}))
|
||||
|
||||
# see if the course already exists
|
||||
existing_course = None
|
||||
try:
|
||||
existing_course = modulestore('direct').get_item(dest_location)
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
|
||||
if existing_course is not None:
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'There is already a course defined with this name.'}))
|
||||
|
||||
course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None]
|
||||
courses = modulestore().get_items(course_search_location)
|
||||
|
||||
if len(courses) > 0:
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'There is already a course defined with the same organization and course number.'}))
|
||||
|
||||
new_course = modulestore('direct').clone_item(template, dest_location)
|
||||
|
||||
# clone a default 'about' module as well
|
||||
|
||||
about_template_location = Location(['i4x', 'edx', 'templates', 'about', 'overview'])
|
||||
dest_about_location = dest_location._replace(category='about', name='overview')
|
||||
modulestore('direct').clone_item(about_template_location, dest_about_location)
|
||||
|
||||
if display_name is not None:
|
||||
new_course.display_name = display_name
|
||||
|
||||
# set a default start date to now
|
||||
new_course.start = time.gmtime()
|
||||
|
||||
initialize_course_tabs(new_course)
|
||||
|
||||
create_all_course_groups(request.user, new_course.location)
|
||||
|
||||
return HttpResponse(json.dumps({'id': new_course.location.url()}))
|
||||
|
||||
|
||||
def initialize_course_tabs(course):
|
||||
# set up the default tabs
|
||||
# I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
|
||||
# at least a list populated with the minimal times
|
||||
# @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
|
||||
# place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
|
||||
|
||||
# This logic is repeated in xmodule/modulestore/tests/factories.py
|
||||
# so if you change anything here, you need to also change it there.
|
||||
course.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"},
|
||||
{"type": "progress", "name": "Progress"}]
|
||||
|
||||
modulestore('direct').update_metadata(course.location.url(), own_metadata(course))
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if request.method == 'POST':
|
||||
filename = request.FILES['course-data'].name
|
||||
|
||||
if not filename.endswith('.tar.gz'):
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'We only support uploading a .tar.gz file.'}))
|
||||
|
||||
data_root = path(settings.GITHUB_REPO_ROOT)
|
||||
|
||||
course_subdir = "{0}-{1}-{2}".format(org, course, name)
|
||||
course_dir = data_root / course_subdir
|
||||
if not course_dir.isdir():
|
||||
os.mkdir(course_dir)
|
||||
|
||||
temp_filepath = course_dir / filename
|
||||
|
||||
logging.debug('importing course to {0}'.format(temp_filepath))
|
||||
|
||||
# stream out the uploaded files in chunks to disk
|
||||
temp_file = open(temp_filepath, 'wb+')
|
||||
for chunk in request.FILES['course-data'].chunks():
|
||||
temp_file.write(chunk)
|
||||
temp_file.close()
|
||||
|
||||
tf = tarfile.open(temp_filepath)
|
||||
tf.extractall(course_dir + '/')
|
||||
|
||||
# find the 'course.xml' file
|
||||
|
||||
for r, d, f in os.walk(course_dir):
|
||||
for files in f:
|
||||
if files == 'course.xml':
|
||||
break
|
||||
if files == 'course.xml':
|
||||
break
|
||||
|
||||
if files != 'course.xml':
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'Could not find the course.xml file in the package.'}))
|
||||
|
||||
logging.debug('found course.xml at {0}'.format(r))
|
||||
|
||||
if r != course_dir:
|
||||
for fname in os.listdir(r):
|
||||
shutil.move(r / fname, course_dir)
|
||||
|
||||
module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT,
|
||||
[course_subdir], load_error_modules=False,
|
||||
static_content_store=contentstore(),
|
||||
target_location_namespace=Location(location),
|
||||
draft_store=modulestore())
|
||||
|
||||
# we can blow this away when we're done importing.
|
||||
shutil.rmtree(course_dir)
|
||||
|
||||
logging.debug('new course at {0}'.format(course_items[0].location))
|
||||
|
||||
create_all_course_groups(request.user, course_items[0].location)
|
||||
|
||||
return HttpResponse(json.dumps({'Status': 'OK'}))
|
||||
else:
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('import.html', {
|
||||
'context_course': course_module,
|
||||
'active_tab': 'import',
|
||||
'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module)
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def generate_export_course(request, org, course, name):
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
loc = Location(location)
|
||||
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
|
||||
|
||||
root_dir = path(mkdtemp())
|
||||
|
||||
# export out to a tempdir
|
||||
|
||||
logging.debug('root = {0}'.format(root_dir))
|
||||
|
||||
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
|
||||
#filename = root_dir / name + '.tar.gz'
|
||||
|
||||
logging.debug('tar file being generated at {0}'.format(export_file.name))
|
||||
tf = tarfile.open(name=export_file.name, mode='w:gz')
|
||||
tf.add(root_dir / name, arcname=name)
|
||||
tf.close()
|
||||
|
||||
# remove temp dir
|
||||
shutil.rmtree(root_dir / name)
|
||||
|
||||
wrapper = FileWrapper(export_file)
|
||||
response = HttpResponse(wrapper, content_type='application/x-tgz')
|
||||
response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name)
|
||||
response['Content-Length'] = os.path.getsize(export_file.name)
|
||||
return response
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def export_course(request, org, course, name):
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('export.html', {
|
||||
'context_course': course_module,
|
||||
'active_tab': 'export',
|
||||
'successful_import_redirect_url': ''
|
||||
})
|
||||
|
||||
|
||||
def event(request):
|
||||
'''
|
||||
A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at
|
||||
console logs don't get distracted :-)
|
||||
'''
|
||||
return HttpResponse(True)
|
||||
|
||||
|
||||
def render_404(request):
|
||||
return HttpResponseNotFound(render_to_string('404.html', {}))
|
||||
|
||||
|
||||
def render_500(request):
|
||||
return HttpResponseServerError(render_to_string('500.html', {}))
|
||||
|
||||
|
||||
def get_location_and_verify_access(request, org, course, name):
|
||||
"""
|
||||
Create the location tuple verify that the user has permissions
|
||||
to view the location. Returns the location.
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
return location
|
||||
|
||||
|
||||
def get_request_method(request):
|
||||
"""
|
||||
Using HTTP_X_HTTP_METHOD_OVERRIDE, in the request metadata, determine
|
||||
what type of request came from the client, and return it.
|
||||
"""
|
||||
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
|
||||
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
|
||||
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
|
||||
else:
|
||||
real_method = request.method
|
||||
|
||||
return real_method
|
||||
19
cms/djangoapps/contentstore/views/__init__.py
Normal file
19
cms/djangoapps/contentstore/views/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# pylint: disable=W0401, W0511
|
||||
|
||||
# TODO: component.py should explicitly enumerate exports with __all__
|
||||
from .component import *
|
||||
|
||||
# TODO: course.py should explicitly enumerate exports with __all__
|
||||
from .course import *
|
||||
|
||||
# Disable warnings about import from wildcard
|
||||
# All files below declare exports with __all__
|
||||
from .assets import *
|
||||
from .checklist import *
|
||||
from .error import *
|
||||
from .item import *
|
||||
from .preview import *
|
||||
from .public import *
|
||||
from .user import *
|
||||
from .tabs import *
|
||||
from .requests import *
|
||||
36
cms/djangoapps/contentstore/views/access.py
Normal file
36
cms/djangoapps/contentstore/views/access.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME
|
||||
from auth.authz import is_user_in_course_group_role
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from ..utils import get_course_location_for_item
|
||||
|
||||
|
||||
def get_location_and_verify_access(request, org, course, name):
|
||||
"""
|
||||
Create the location tuple verify that the user has permissions
|
||||
to view the location. Returns the location.
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
return location
|
||||
|
||||
|
||||
def has_access(user, location, role=STAFF_ROLE_NAME):
|
||||
'''
|
||||
Return True if user allowed to access this piece of data
|
||||
Note that the CMS permissions model is with respect to courses
|
||||
There is a super-admin permissions if user.is_staff is set
|
||||
Also, since we're unifying the user database between LMS and CAS,
|
||||
I'm presuming that the course instructor (formally known as admin)
|
||||
will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR
|
||||
has all the rights that STAFF do
|
||||
'''
|
||||
course_location = get_course_location_for_item(location)
|
||||
_has_access = is_user_in_course_group_role(user, course_location, role)
|
||||
# if we're not in STAFF, perhaps we're in INSTRUCTOR groups
|
||||
if not _has_access and role == STAFF_ROLE_NAME:
|
||||
_has_access = is_user_in_course_group_role(user, course_location, INSTRUCTOR_ROLE_NAME)
|
||||
return _has_access
|
||||
263
cms/djangoapps/contentstore/views/assets.py
Normal file
263
cms/djangoapps/contentstore/views/assets.py
Normal file
@@ -0,0 +1,263 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import shutil
|
||||
from tempfile import mkdtemp
|
||||
from path import path
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.servers.basehttp import FileWrapper
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from cache_toolbox.core import del_cached_content
|
||||
from auth.authz import create_all_course_groups
|
||||
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
|
||||
from ..utils import get_url_reverse
|
||||
from .access import get_location_and_verify_access
|
||||
|
||||
|
||||
__all__ = ['asset_index', 'upload_asset', 'import_course', 'generate_export_course', 'export_course']
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def asset_index(request, org, course, name):
|
||||
"""
|
||||
Display an editable asset library
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
upload_asset_callback_url = reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
course_reference = StaticContent.compute_location(org, course, name)
|
||||
assets = contentstore().get_all_content_for_course(course_reference)
|
||||
|
||||
# sort in reverse upload date order
|
||||
assets = sorted(assets, key=lambda asset: asset['uploadDate'], reverse=True)
|
||||
|
||||
asset_display = []
|
||||
for asset in assets:
|
||||
id = asset['_id']
|
||||
display_info = {}
|
||||
display_info['displayname'] = asset['displayname']
|
||||
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'].timetuple())
|
||||
|
||||
asset_location = StaticContent.compute_location(id['org'], id['course'], id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
|
||||
# note, due to the schema change we may not have a 'thumbnail_location' in the result set
|
||||
_thumbnail_location = asset.get('thumbnail_location', None)
|
||||
thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else None
|
||||
display_info['thumb_url'] = StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None
|
||||
|
||||
asset_display.append(display_info)
|
||||
|
||||
return render_to_response('asset_index.html', {
|
||||
'active_tab': 'assets',
|
||||
'context_course': course_module,
|
||||
'assets': asset_display,
|
||||
'upload_asset_callback_url': upload_asset_callback_url
|
||||
})
|
||||
|
||||
|
||||
def upload_asset(request, org, course, coursename):
|
||||
'''
|
||||
cdodge: this method allows for POST uploading of files into the course asset library, which will
|
||||
be supported by GridFS in MongoDB.
|
||||
'''
|
||||
if request.method != 'POST':
|
||||
# (cdodge) @todo: Is there a way to do a - say - 'raise Http400'?
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# construct a location from the passed in path
|
||||
location = get_location_and_verify_access(request, org, course, coursename)
|
||||
|
||||
# Does the course actually exist?!? Get anything from it to prove its existance
|
||||
|
||||
try:
|
||||
modulestore().get_item(location)
|
||||
except:
|
||||
# no return it as a Bad Request response
|
||||
logging.error('Could not find course' + location)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# compute a 'filename' which is similar to the location formatting, we're using the 'filename'
|
||||
# nomenclature since we're using a FileSystem paradigm here. We're just imposing
|
||||
# the Location string formatting expectations to keep things a bit more consistent
|
||||
|
||||
filename = request.FILES['file'].name
|
||||
mime_type = request.FILES['file'].content_type
|
||||
filedata = request.FILES['file'].read()
|
||||
|
||||
content_loc = StaticContent.compute_location(org, course, filename)
|
||||
content = StaticContent(content_loc, filename, mime_type, filedata)
|
||||
|
||||
# first let's see if a thumbnail can be created
|
||||
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content)
|
||||
|
||||
# delete cached thumbnail even if one couldn't be created this time (else the old thumbnail will continue to show)
|
||||
del_cached_content(thumbnail_location)
|
||||
# now store thumbnail location only if we could create it
|
||||
if thumbnail_content is not None:
|
||||
content.thumbnail_location = thumbnail_location
|
||||
|
||||
# then commit the content
|
||||
contentstore().save(content)
|
||||
del_cached_content(content.location)
|
||||
|
||||
# readback the saved content - we need the database timestamp
|
||||
readback = contentstore().find(content.location)
|
||||
|
||||
response_payload = {'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at.timetuple()),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
}
|
||||
|
||||
response = HttpResponse(json.dumps(response_payload))
|
||||
response['asset_url'] = StaticContent.get_url_path_from_location(content.location)
|
||||
return response
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if request.method == 'POST':
|
||||
filename = request.FILES['course-data'].name
|
||||
|
||||
if not filename.endswith('.tar.gz'):
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'We only support uploading a .tar.gz file.'}))
|
||||
|
||||
data_root = path(settings.GITHUB_REPO_ROOT)
|
||||
|
||||
course_subdir = "{0}-{1}-{2}".format(org, course, name)
|
||||
course_dir = data_root / course_subdir
|
||||
if not course_dir.isdir():
|
||||
os.mkdir(course_dir)
|
||||
|
||||
temp_filepath = course_dir / filename
|
||||
|
||||
logging.debug('importing course to {0}'.format(temp_filepath))
|
||||
|
||||
# stream out the uploaded files in chunks to disk
|
||||
temp_file = open(temp_filepath, 'wb+')
|
||||
for chunk in request.FILES['course-data'].chunks():
|
||||
temp_file.write(chunk)
|
||||
temp_file.close()
|
||||
|
||||
tf = tarfile.open(temp_filepath)
|
||||
tf.extractall(course_dir + '/')
|
||||
|
||||
# find the 'course.xml' file
|
||||
|
||||
for r, d, f in os.walk(course_dir):
|
||||
for files in f:
|
||||
if files == 'course.xml':
|
||||
break
|
||||
if files == 'course.xml':
|
||||
break
|
||||
|
||||
if files != 'course.xml':
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'Could not find the course.xml file in the package.'}))
|
||||
|
||||
logging.debug('found course.xml at {0}'.format(r))
|
||||
|
||||
if r != course_dir:
|
||||
for fname in os.listdir(r):
|
||||
shutil.move(r / fname, course_dir)
|
||||
|
||||
module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT,
|
||||
[course_subdir], load_error_modules=False,
|
||||
static_content_store=contentstore(),
|
||||
target_location_namespace=Location(location),
|
||||
draft_store=modulestore())
|
||||
|
||||
# we can blow this away when we're done importing.
|
||||
shutil.rmtree(course_dir)
|
||||
|
||||
logging.debug('new course at {0}'.format(course_items[0].location))
|
||||
|
||||
create_all_course_groups(request.user, course_items[0].location)
|
||||
|
||||
return HttpResponse(json.dumps({'Status': 'OK'}))
|
||||
else:
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('import.html', {
|
||||
'context_course': course_module,
|
||||
'active_tab': 'import',
|
||||
'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module)
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def generate_export_course(request, org, course, name):
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
loc = Location(location)
|
||||
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
|
||||
|
||||
root_dir = path(mkdtemp())
|
||||
|
||||
# export out to a tempdir
|
||||
|
||||
logging.debug('root = {0}'.format(root_dir))
|
||||
|
||||
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
|
||||
#filename = root_dir / name + '.tar.gz'
|
||||
|
||||
logging.debug('tar file being generated at {0}'.format(export_file.name))
|
||||
tf = tarfile.open(name=export_file.name, mode='w:gz')
|
||||
tf.add(root_dir / name, arcname=name)
|
||||
tf.close()
|
||||
|
||||
# remove temp dir
|
||||
shutil.rmtree(root_dir / name)
|
||||
|
||||
wrapper = FileWrapper(export_file)
|
||||
response = HttpResponse(wrapper, content_type='application/x-tgz')
|
||||
response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name)
|
||||
response['Content-Length'] = os.path.getsize(export_file.name)
|
||||
return response
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def export_course(request, org, course, name):
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('export.html', {
|
||||
'context_course': course_module,
|
||||
'active_tab': 'export',
|
||||
'successful_import_redirect_url': ''
|
||||
})
|
||||
104
cms/djangoapps/contentstore/views/checklist.py
Normal file
104
cms/djangoapps/contentstore/views/checklist.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import json
|
||||
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from ..utils import get_modulestore, get_url_reverse
|
||||
from .requests import get_request_method
|
||||
from .access import get_location_and_verify_access
|
||||
|
||||
__all__ = ['get_checklists', 'update_checklist']
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def get_checklists(request, org, course, name):
|
||||
"""
|
||||
Send models, views, and html for displaying the course checklists.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
modulestore = get_modulestore(location)
|
||||
course_module = modulestore.get_item(location)
|
||||
new_course_template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
|
||||
template_module = modulestore.get_item(new_course_template)
|
||||
|
||||
# If course was created before checklists were introduced, copy them over from the template.
|
||||
copied = False
|
||||
if not course_module.checklists:
|
||||
course_module.checklists = template_module.checklists
|
||||
copied = True
|
||||
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if copied or modified:
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return render_to_response('checklists.html',
|
||||
{
|
||||
'context_course': course_module,
|
||||
'checklists': checklists
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def update_checklist(request, org, course, name, checklist_index=None):
|
||||
"""
|
||||
restful CRUD operations on course checklists. The payload is a json rep of
|
||||
the modified checklist. For PUT or POST requests, the index of the
|
||||
checklist being modified must be included; the returned payload will
|
||||
be just that one checklist. For GET requests, the returned payload
|
||||
is a json representation of the list of all checklists.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
modulestore = get_modulestore(location)
|
||||
course_module = modulestore.get_item(location)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
if real_method == 'POST' or real_method == 'PUT':
|
||||
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
|
||||
index = int(checklist_index)
|
||||
course_module.checklists[index] = json.loads(request.body)
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return HttpResponse(json.dumps(checklists[index]), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
"Could not save checklist state because the checklist index was out of range or unspecified.",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'GET':
|
||||
# In the JavaScript view initialize method, we do a fetch to get all the checklists.
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if modified:
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return HttpResponse(json.dumps(checklists), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest("Unsupported request.", content_type="text/plain")
|
||||
|
||||
|
||||
def expand_checklist_action_urls(course_module):
|
||||
"""
|
||||
Gets the checklists out of the course module and expands their action urls
|
||||
if they have not yet been expanded.
|
||||
|
||||
Returns the checklists with modified urls, as well as a boolean
|
||||
indicating whether or not the checklists were modified.
|
||||
"""
|
||||
checklists = course_module.checklists
|
||||
modified = False
|
||||
for checklist in checklists:
|
||||
if not checklist.get('action_urls_expanded', False):
|
||||
for item in checklist.get('items'):
|
||||
item['action_url'] = get_url_reverse(item.get('action_url'), course_module)
|
||||
checklist['action_urls_expanded'] = True
|
||||
modified = True
|
||||
|
||||
return checklists, modified
|
||||
297
cms/djangoapps/contentstore/views/component.py
Normal file
297
cms/djangoapps/contentstore/views/component.py
Normal file
@@ -0,0 +1,297 @@
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.conf import settings
|
||||
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
|
||||
from xblock.core import Scope
|
||||
from util.json_request import expect_json
|
||||
|
||||
from contentstore.module_info_model import get_module_info, set_module_info
|
||||
from contentstore.utils import get_modulestore, get_lms_link_for_item, \
|
||||
compute_unit_state, UnitState, get_course_for_item
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
|
||||
from .requests import get_request_method, _xmodule_recurse
|
||||
from .access import has_access
|
||||
|
||||
# TODO: should explicitly enumerate exports with __all__
|
||||
|
||||
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
|
||||
|
||||
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
|
||||
ADVANCED_COMPONENT_TYPES = ['annotatable' + 'word_cloud'] + OPEN_ENDED_COMPONENT_TYPES
|
||||
ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_subsection(request, location):
|
||||
# check that we have permissions to edit this item
|
||||
course = get_course_for_item(location)
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
|
||||
lms_link = get_lms_link_for_item(location, course_id=course.location.course_id)
|
||||
preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True)
|
||||
|
||||
# make sure that location references a 'sequential', otherwise return BadRequest
|
||||
if item.location.category != 'sequential':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
parent_locs = modulestore().get_parent_locations(location, None)
|
||||
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error('Multiple (or none) parents have been found for {0}'.format(location))
|
||||
|
||||
# this should blow up if we don't find any parents, which would be erroneous
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a more normalized UI
|
||||
|
||||
policy_metadata = dict(
|
||||
(field.name, field.read_from(item))
|
||||
for field
|
||||
in item.fields
|
||||
if field.name not in ['display_name', 'start', 'due', 'format'] and field.scope == Scope.settings
|
||||
)
|
||||
|
||||
can_view_live = False
|
||||
subsection_units = item.get_children()
|
||||
for unit in subsection_units:
|
||||
state = compute_unit_state(unit)
|
||||
if state == UnitState.public or state == UnitState.draft:
|
||||
can_view_live = True
|
||||
break
|
||||
|
||||
return render_to_response('edit_subsection.html',
|
||||
{'subsection': item,
|
||||
'context_course': course,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'parent_location': course.location,
|
||||
'parent_item': parent,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_unit(request, location):
|
||||
"""
|
||||
Display an editing page for the specified module.
|
||||
|
||||
Expects a GET request with the parameter 'id'.
|
||||
|
||||
id: A Location URL
|
||||
"""
|
||||
course = get_course_for_item(location)
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
|
||||
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
|
||||
# Check if there are any advanced modules specified in the course policy. These modules
|
||||
# should be specified as a list of strings, where the strings are the names of the modules
|
||||
# in ADVANCED_COMPONENT_TYPES that should be enabled for the course.
|
||||
course_advanced_keys = course.advanced_modules
|
||||
|
||||
# Set component types according to course policy file
|
||||
component_types = list(COMPONENT_TYPES)
|
||||
if isinstance(course_advanced_keys, list):
|
||||
course_advanced_keys = [c for c in course_advanced_keys if c in ADVANCED_COMPONENT_TYPES]
|
||||
if len(course_advanced_keys) > 0:
|
||||
component_types.append(ADVANCED_COMPONENT_CATEGORY)
|
||||
else:
|
||||
log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys))
|
||||
|
||||
templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
|
||||
for template in templates:
|
||||
category = template.location.category
|
||||
|
||||
if category in course_advanced_keys:
|
||||
category = ADVANCED_COMPONENT_CATEGORY
|
||||
|
||||
if category in component_types:
|
||||
# This is a hack to create categories for different xmodules
|
||||
component_templates[category].append((
|
||||
template.display_name_with_default,
|
||||
template.location.url(),
|
||||
hasattr(template, 'markdown') and template.markdown is not None,
|
||||
template.cms.empty,
|
||||
))
|
||||
|
||||
components = [
|
||||
component.location.url()
|
||||
for component
|
||||
in item.get_children()
|
||||
]
|
||||
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
|
||||
containing_subsection_locs = modulestore().get_parent_locations(location, None)
|
||||
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
|
||||
|
||||
containing_section_locs = modulestore().get_parent_locations(containing_subsection.location, None)
|
||||
containing_section = modulestore().get_item(containing_section_locs[0])
|
||||
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
|
||||
# need to figure out where this item is in the list of children as the preview will need this
|
||||
index = 1
|
||||
for child in containing_subsection.get_children():
|
||||
if child.location == item.location:
|
||||
break
|
||||
index = index + 1
|
||||
|
||||
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE',
|
||||
'preview.' + settings.LMS_BASE)
|
||||
|
||||
preview_lms_link = '//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
|
||||
preview_lms_base=preview_lms_base,
|
||||
lms_base=settings.LMS_BASE,
|
||||
org=course.location.org,
|
||||
course=course.location.course,
|
||||
course_name=course.location.name,
|
||||
section=containing_section.location.name,
|
||||
subsection=containing_subsection.location.name,
|
||||
index=index)
|
||||
|
||||
unit_state = compute_unit_state(item)
|
||||
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'active_tab': 'courseware',
|
||||
'unit': item,
|
||||
'unit_location': location,
|
||||
'components': components,
|
||||
'component_templates': component_templates,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None,
|
||||
'section': containing_section,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'unit_state': unit_state,
|
||||
'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None,
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def assignment_type_update(request, org, course, category, name):
|
||||
'''
|
||||
CRUD operations on assignment types for sections and subsections and anything else gradable.
|
||||
'''
|
||||
location = Location(['i4x', org, course, category, name])
|
||||
if not has_access(request.user, location):
|
||||
raise HttpResponseForbidden()
|
||||
|
||||
if request.method == 'GET':
|
||||
return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)),
|
||||
mimetype="application/json")
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)),
|
||||
mimetype="application/json")
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_draft(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# This clones the existing item location to a draft location (the draft is implicit,
|
||||
# because modulestore is a Draft modulestore)
|
||||
modulestore().clone_item(location, location)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def publish_draft(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().publish(i.location, request.user.id))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def unpublish_unit(request):
|
||||
location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().unpublish(i.location))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def module_info(request, module_location):
|
||||
location = Location(module_location)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true']
|
||||
logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links))
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
if real_method == 'GET':
|
||||
return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links)), mimetype="application/json")
|
||||
elif real_method == 'POST' or real_method == 'PUT':
|
||||
return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json")
|
||||
else:
|
||||
return HttpResponseBadRequest()
|
||||
379
cms/djangoapps/contentstore/views/course.py
Normal file
379
cms/djangoapps/contentstore/views/course.py
Normal file
@@ -0,0 +1,379 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.core.urlresolvers import reverse
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, \
|
||||
InvalidLocationError
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from contentstore.course_info_model import get_course_updates, \
|
||||
update_course_updates, delete_course_update
|
||||
from contentstore.utils import get_lms_link_for_item, \
|
||||
add_open_ended_panel_tab, remove_open_ended_panel_tab
|
||||
from models.settings.course_details import CourseDetails, \
|
||||
CourseSettingsEncoder
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from auth.authz import create_all_course_groups
|
||||
from util.json_request import expect_json
|
||||
|
||||
from .access import has_access, get_location_and_verify_access
|
||||
from .requests import get_request_method
|
||||
from .tabs import initialize_course_tabs
|
||||
from .component import OPEN_ENDED_COMPONENT_TYPES, ADVANCED_COMPONENT_POLICY_KEY
|
||||
|
||||
# TODO: should explicitly enumerate exports with __all__
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_index(request, org, course, name):
|
||||
"""
|
||||
Display an editable course overview.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
lms_link = get_lms_link_for_item(location)
|
||||
|
||||
upload_asset_callback_url = reverse('upload_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'coursename': name
|
||||
})
|
||||
|
||||
course = modulestore().get_item(location, depth=3)
|
||||
sections = course.get_children()
|
||||
|
||||
return render_to_response('overview.html', {
|
||||
'active_tab': 'courseware',
|
||||
'context_course': course,
|
||||
'lms_link': lms_link,
|
||||
'sections': sections,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'parent_location': course.location,
|
||||
'new_section_template': Location('i4x', 'edx', 'templates', 'chapter', 'Empty'),
|
||||
'new_subsection_template': Location('i4x', 'edx', 'templates', 'sequential', 'Empty'), # for now they are the same, but the could be different at some point...
|
||||
'upload_asset_callback_url': upload_asset_callback_url,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty')
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_new_course(request):
|
||||
|
||||
if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff:
|
||||
raise PermissionDenied()
|
||||
|
||||
# This logic is repeated in xmodule/modulestore/tests/factories.py
|
||||
# so if you change anything here, you need to also change it there.
|
||||
# TODO: write a test that creates two courses, one with the factory and
|
||||
# the other with this method, then compare them to make sure they are
|
||||
# equivalent.
|
||||
template = Location(request.POST['template'])
|
||||
org = request.POST.get('org')
|
||||
number = request.POST.get('number')
|
||||
display_name = request.POST.get('display_name')
|
||||
|
||||
try:
|
||||
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
|
||||
except InvalidLocationError as e:
|
||||
return HttpResponse(json.dumps({'ErrMsg': "Unable to create course '" + display_name + "'.\n\n" + e.message}))
|
||||
|
||||
# see if the course already exists
|
||||
existing_course = None
|
||||
try:
|
||||
existing_course = modulestore('direct').get_item(dest_location)
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
|
||||
if existing_course is not None:
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'There is already a course defined with this name.'}))
|
||||
|
||||
course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None]
|
||||
courses = modulestore().get_items(course_search_location)
|
||||
|
||||
if len(courses) > 0:
|
||||
return HttpResponse(json.dumps({'ErrMsg': 'There is already a course defined with the same organization and course number.'}))
|
||||
|
||||
new_course = modulestore('direct').clone_item(template, dest_location)
|
||||
|
||||
# clone a default 'about' module as well
|
||||
|
||||
about_template_location = Location(['i4x', 'edx', 'templates', 'about', 'overview'])
|
||||
dest_about_location = dest_location._replace(category='about', name='overview')
|
||||
modulestore('direct').clone_item(about_template_location, dest_about_location)
|
||||
|
||||
if display_name is not None:
|
||||
new_course.display_name = display_name
|
||||
|
||||
# set a default start date to now
|
||||
new_course.start = time.gmtime()
|
||||
|
||||
initialize_course_tabs(new_course)
|
||||
|
||||
create_all_course_groups(request.user, new_course.location)
|
||||
|
||||
return HttpResponse(json.dumps({'id': new_course.location.url()}))
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info(request, org, course, name, provided_id=None):
|
||||
"""
|
||||
Send models and views as well as html for editing the course info to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
# get current updates
|
||||
location = ['i4x', org, course, 'course_info', "updates"]
|
||||
|
||||
return render_to_response('course_info.html', {
|
||||
'active_tab': 'courseinfo-tab',
|
||||
'context_course': course_module,
|
||||
'url_base': "/" + org + "/" + course + "/",
|
||||
'course_updates': json.dumps(get_course_updates(location)),
|
||||
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info_updates(request, org, course, provided_id=None):
|
||||
"""
|
||||
restful CRUD operations on course_info updates.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
provided_id should be none if it's new (create) and a composite of the update db id + index otherwise.
|
||||
"""
|
||||
# ??? No way to check for access permission afaik
|
||||
# get current updates
|
||||
location = ['i4x', org, course, 'course_info', "updates"]
|
||||
|
||||
# Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-(
|
||||
# Possibly due to my removing the seemingly redundant pattern in urls.py
|
||||
if provided_id == '':
|
||||
provided_id = None
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if request.method == 'GET':
|
||||
return HttpResponse(json.dumps(get_course_updates(location)),
|
||||
mimetype="application/json")
|
||||
elif real_method == 'DELETE':
|
||||
try:
|
||||
return HttpResponse(json.dumps(delete_course_update(location,
|
||||
request.POST, provided_id)), mimetype="application/json")
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to delete",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'POST':
|
||||
try:
|
||||
return HttpResponse(json.dumps(update_course_updates(location,
|
||||
request.POST, provided_id)), mimetype="application/json")
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to save",
|
||||
content_type="text/plain")
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def get_course_settings(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'details_url': reverse(course_settings_updates,
|
||||
kwargs={"org": org,
|
||||
"course": course,
|
||||
"name": name,
|
||||
"section": "details"})
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_graders_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
course_details = CourseGradingModel.fetch(location)
|
||||
|
||||
return render_to_response('settings_graders.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_advanced_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the advanced course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings_advanced.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'advanced_dict': json.dumps(CourseMetadata.fetch(location)),
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_settings_updates(request, org, course, name, section):
|
||||
"""
|
||||
restful CRUD operations on course settings. This differs from get_course_settings by communicating purely
|
||||
through json (not rendering any html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
section: one of details, faculty, grading, problems, discussions
|
||||
"""
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if section == 'details':
|
||||
manager = CourseDetails
|
||||
elif section == 'grading':
|
||||
manager = CourseGradingModel
|
||||
else:
|
||||
return
|
||||
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course', name])), cls=CourseSettingsEncoder),
|
||||
mimetype="application/json")
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder),
|
||||
mimetype="application/json")
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
"""
|
||||
restful CRUD operations on course_info updates. This differs from get_course_settings by communicating purely
|
||||
through json (not rendering any html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if real_method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(location), grader_index)),
|
||||
mimetype="application/json")
|
||||
elif real_method == "DELETE":
|
||||
# ??? Should this return anything? Perhaps success fail?
|
||||
CourseGradingModel.delete_grader(Location(location), grader_index)
|
||||
return HttpResponse()
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(location), request.POST)),
|
||||
mimetype="application/json")
|
||||
|
||||
|
||||
# # NB: expect_json failed on ["key", "key2"] and json payload
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_advanced_updates(request, org, course, name):
|
||||
"""
|
||||
restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh,
|
||||
the payload is either a key or a list of keys to delete.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
real_method = get_request_method(request)
|
||||
|
||||
if real_method == 'GET':
|
||||
return HttpResponse(json.dumps(CourseMetadata.fetch(location)),
|
||||
mimetype="application/json")
|
||||
elif real_method == 'DELETE':
|
||||
return HttpResponse(json.dumps(CourseMetadata.delete_key(location,
|
||||
json.loads(request.body))),
|
||||
mimetype="application/json")
|
||||
elif real_method == 'POST' or real_method == 'PUT':
|
||||
# NOTE: request.POST is messed up because expect_json
|
||||
# cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
|
||||
request_body = json.loads(request.body)
|
||||
# Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = True
|
||||
# Check to see if the user instantiated any advanced components.
|
||||
# This is a hack to add the open ended panel tab
|
||||
# to a course automatically if the user has indicated that they want
|
||||
# to edit the combinedopenended or peergrading
|
||||
# module, and to remove it if they have removed the open ended elements.
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request_body:
|
||||
# Check to see if the user instantiated any open ended components
|
||||
found_oe_type = False
|
||||
# Get the course so that we can scrape current tabs
|
||||
course_module = modulestore().get_item(location)
|
||||
for oe_type in OPEN_ENDED_COMPONENT_TYPES:
|
||||
if oe_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
# Add an open ended tab to the course if needed
|
||||
changed, new_tabs = add_open_ended_panel_tab(course_module)
|
||||
# If a tab has been added to the course, then send the
|
||||
# metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
request_body.update({'tabs': new_tabs})
|
||||
# Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
# Set this flag to avoid the open ended tab removal code below.
|
||||
found_oe_type = True
|
||||
break
|
||||
# If we did not find an open ended module type in the advanced settings,
|
||||
# we may need to remove the open ended tab from the course.
|
||||
if not found_oe_type:
|
||||
# Remove open ended tab to the course if needed
|
||||
changed, new_tabs = remove_open_ended_panel_tab(course_module)
|
||||
if changed:
|
||||
request_body.update({'tabs': new_tabs})
|
||||
# Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
response_json = json.dumps(CourseMetadata.update_from_json(location,
|
||||
request_body,
|
||||
filter_tabs=filter_tabs))
|
||||
return HttpResponse(response_json, mimetype="application/json")
|
||||
20
cms/djangoapps/contentstore/views/error.py
Normal file
20
cms/djangoapps/contentstore/views/error.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.http import HttpResponseServerError, HttpResponseNotFound
|
||||
from mitxmako.shortcuts import render_to_string, render_to_response
|
||||
|
||||
__all__ = ['not_found', 'server_error', 'render_404', 'render_500']
|
||||
|
||||
|
||||
def not_found(request):
|
||||
return render_to_response('error.html', {'error': '404'})
|
||||
|
||||
|
||||
def server_error(request):
|
||||
return render_to_response('error.html', {'error': '500'})
|
||||
|
||||
|
||||
def render_404(request):
|
||||
return HttpResponseNotFound(render_to_string('404.html', {}))
|
||||
|
||||
|
||||
def render_500(request):
|
||||
return HttpResponseServerError(render_to_string('500.html', {}))
|
||||
138
cms/djangoapps/contentstore/views/item.py
Normal file
138
cms/djangoapps/contentstore/views/item.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from util.json_request import expect_json
|
||||
from ..utils import get_modulestore
|
||||
from .access import has_access
|
||||
from .requests import _xmodule_recurse
|
||||
|
||||
__all__ = ['save_item', 'clone_item', 'delete_item']
|
||||
|
||||
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def save_item(request):
|
||||
item_location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
store = get_modulestore(Location(item_location))
|
||||
|
||||
if request.POST.get('data') is not None:
|
||||
data = request.POST['data']
|
||||
store.update_item(item_location, data)
|
||||
|
||||
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
|
||||
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
|
||||
# deleting the children object from the children collection
|
||||
if 'children' in request.POST and request.POST['children'] is not None:
|
||||
children = request.POST['children']
|
||||
store.update_children(item_location, children)
|
||||
|
||||
# cdodge: also commit any metadata which might have been passed along in the
|
||||
# POST from the client, if it is there
|
||||
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
|
||||
# not presented to the end-user for editing. So let's fetch the original and
|
||||
# 'apply' the submitted metadata, so we don't end up deleting system metadata
|
||||
if request.POST.get('metadata') is not None:
|
||||
posted_metadata = request.POST['metadata']
|
||||
# fetch original
|
||||
existing_item = modulestore().get_item(item_location)
|
||||
|
||||
# update existing metadata with submitted metadata (which can be partial)
|
||||
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
|
||||
for metadata_key, value in posted_metadata.items():
|
||||
|
||||
if posted_metadata[metadata_key] is None:
|
||||
# remove both from passed in collection as well as the collection read in from the modulestore
|
||||
if metadata_key in existing_item._model_data:
|
||||
del existing_item._model_data[metadata_key]
|
||||
del posted_metadata[metadata_key]
|
||||
else:
|
||||
existing_item._model_data[metadata_key] = value
|
||||
|
||||
# commit to datastore
|
||||
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
|
||||
store.update_metadata(item_location, own_metadata(existing_item))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def clone_item(request):
|
||||
parent_location = Location(request.POST['parent_location'])
|
||||
template = Location(request.POST['template'])
|
||||
|
||||
display_name = request.POST.get('display_name')
|
||||
|
||||
if not has_access(request.user, parent_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
parent = get_modulestore(template).get_item(parent_location)
|
||||
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
|
||||
|
||||
new_item = get_modulestore(template).clone_item(template, dest_location)
|
||||
|
||||
# replace the display name with an optional parameter passed in from the caller
|
||||
if display_name is not None:
|
||||
new_item.display_name = display_name
|
||||
|
||||
get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item))
|
||||
|
||||
if new_item.location.category not in DETACHED_CATEGORIES:
|
||||
get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()])
|
||||
|
||||
return HttpResponse(json.dumps({'id': dest_location.url()}))
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def delete_item(request):
|
||||
item_location = request.POST['id']
|
||||
item_loc = Location(item_location)
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# optional parameter to delete all children (default False)
|
||||
delete_children = request.POST.get('delete_children', False)
|
||||
delete_all_versions = request.POST.get('delete_all_versions', False)
|
||||
|
||||
store = modulestore()
|
||||
|
||||
item = store.get_item(item_location)
|
||||
|
||||
if delete_children:
|
||||
_xmodule_recurse(item, lambda i: store.delete_item(i.location, delete_all_versions))
|
||||
else:
|
||||
store.delete_item(item.location, delete_all_versions)
|
||||
|
||||
# cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
|
||||
if delete_all_versions:
|
||||
parent_locs = modulestore('direct').get_parent_locations(item_loc, None)
|
||||
|
||||
for parent_loc in parent_locs:
|
||||
parent = modulestore('direct').get_item(parent_loc)
|
||||
item_url = item_loc.url()
|
||||
if item_url in parent.children:
|
||||
children = parent.children
|
||||
children.remove(item_url)
|
||||
parent.children = children
|
||||
modulestore('direct').update_children(parent.location, parent.children)
|
||||
|
||||
return HttpResponse()
|
||||
177
cms/djangoapps/contentstore/views/preview.py
Normal file
177
cms/djangoapps/contentstore/views/preview.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import logging
|
||||
import sys
|
||||
from functools import partial
|
||||
|
||||
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xmodule
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.mongo import MongoUsage
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xblock.runtime import DbModel
|
||||
|
||||
import static_replace
|
||||
from .session_kv_store import SessionKeyValueStore
|
||||
from .requests import render_from_lms
|
||||
from .access import has_access
|
||||
|
||||
__all__ = ['preview_dispatch', 'preview_component']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@login_required
|
||||
def preview_dispatch(request, preview_id, location, dispatch=None):
|
||||
"""
|
||||
Dispatch an AJAX action to a preview XModule
|
||||
|
||||
Expects a POST request, and passes the arguments to the module
|
||||
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
location: The Location of the module to dispatch to
|
||||
dispatch: The action to execute
|
||||
"""
|
||||
|
||||
descriptor = modulestore().get_item(location)
|
||||
instance = load_preview_module(request, preview_id, descriptor)
|
||||
# Let the module handle the AJAX
|
||||
try:
|
||||
ajax_return = instance.handle_ajax(dispatch, request.POST)
|
||||
|
||||
except NotFoundError:
|
||||
log.exception("Module indicating to user that request doesn't exist")
|
||||
raise Http404
|
||||
|
||||
except ProcessingError:
|
||||
log.warning("Module raised an error while processing AJAX request",
|
||||
exc_info=True)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
except:
|
||||
log.exception("error processing ajax call")
|
||||
raise
|
||||
|
||||
return HttpResponse(ajax_return)
|
||||
|
||||
|
||||
@login_required
|
||||
def preview_component(request, location):
|
||||
# TODO (vshnayder): change name from id to location in coffee+html as well.
|
||||
if not has_access(request.user, location):
|
||||
raise HttpResponseForbidden()
|
||||
|
||||
component = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('component.html', {
|
||||
'preview': get_module_previews(request, component)[0],
|
||||
'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(),
|
||||
})
|
||||
|
||||
|
||||
def preview_module_system(request, preview_id, descriptor):
|
||||
"""
|
||||
Returns a ModuleSystem for the specified descriptor that is specialized for
|
||||
rendering module previews.
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
descriptor: An XModuleDescriptor
|
||||
"""
|
||||
|
||||
def preview_model_data(descriptor):
|
||||
return DbModel(
|
||||
SessionKeyValueStore(request, descriptor._model_data),
|
||||
descriptor.module_class,
|
||||
preview_id,
|
||||
MongoUsage(preview_id, descriptor.location.url()),
|
||||
)
|
||||
|
||||
return ModuleSystem(
|
||||
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
|
||||
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
|
||||
track_function=lambda type, event: None,
|
||||
filestore=descriptor.system.resources_fs,
|
||||
get_module=partial(get_preview_module, request, preview_id),
|
||||
render_template=render_from_lms,
|
||||
debug=True,
|
||||
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
|
||||
user=request.user,
|
||||
xblock_model_data=preview_model_data,
|
||||
)
|
||||
|
||||
|
||||
def get_preview_module(request, preview_id, descriptor):
|
||||
"""
|
||||
Returns a preview XModule at the specified location. The preview_data is chosen arbitrarily
|
||||
from the set of preview data for the descriptor specified by Location
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
location: A Location
|
||||
"""
|
||||
|
||||
return load_preview_module(request, preview_id, descriptor)
|
||||
|
||||
|
||||
def load_preview_module(request, preview_id, descriptor):
|
||||
"""
|
||||
Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state
|
||||
|
||||
request: The active django request
|
||||
preview_id (str): An identifier specifying which preview this module is used for
|
||||
descriptor: An XModuleDescriptor
|
||||
instance_state: An instance state string
|
||||
shared_state: A shared state string
|
||||
"""
|
||||
system = preview_module_system(request, preview_id, descriptor)
|
||||
try:
|
||||
module = descriptor.xmodule(system)
|
||||
except:
|
||||
log.debug("Unable to load preview module", exc_info=True)
|
||||
module = ErrorDescriptor.from_descriptor(
|
||||
descriptor,
|
||||
error_msg=exc_info_to_str(sys.exc_info())
|
||||
).xmodule(system)
|
||||
|
||||
# cdodge: Special case
|
||||
if module.location.category == 'static_tab':
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_tab_display.html",
|
||||
)
|
||||
else:
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_display.html",
|
||||
)
|
||||
|
||||
module.get_html = replace_static_urls(
|
||||
module.get_html,
|
||||
getattr(module, 'data_dir', module.location.course),
|
||||
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
|
||||
)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def get_module_previews(request, descriptor):
|
||||
"""
|
||||
Returns a list of preview XModule html contents. One preview is returned for each
|
||||
pair of states returned by get_sample_state() for the supplied descriptor.
|
||||
|
||||
descriptor: An XModuleDescriptor
|
||||
"""
|
||||
preview_html = []
|
||||
for idx, (instance_state, shared_state) in enumerate(descriptor.get_sample_state()):
|
||||
module = load_preview_module(request, str(idx), descriptor)
|
||||
preview_html.append(module.get_html())
|
||||
return preview_html
|
||||
58
cms/djangoapps/contentstore/views/public.py
Normal file
58
cms/djangoapps/contentstore/views/public.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.context_processors import csrf
|
||||
from django.shortcuts import redirect
|
||||
from django.conf import settings
|
||||
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from external_auth.views import ssl_login_shortcut
|
||||
from .user import index
|
||||
|
||||
__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks', 'ux_alerts']
|
||||
|
||||
"""
|
||||
Public views
|
||||
"""
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def signup(request):
|
||||
"""
|
||||
Display the signup form.
|
||||
"""
|
||||
csrf_token = csrf(request)['csrf_token']
|
||||
return render_to_response('signup.html', {'csrf': csrf_token})
|
||||
|
||||
|
||||
def old_login_redirect(request):
|
||||
'''
|
||||
Redirect to the active login url.
|
||||
'''
|
||||
return redirect('login', permanent=True)
|
||||
|
||||
|
||||
@ssl_login_shortcut
|
||||
@ensure_csrf_cookie
|
||||
def login_page(request):
|
||||
"""
|
||||
Display the login form.
|
||||
"""
|
||||
csrf_token = csrf(request)['csrf_token']
|
||||
return render_to_response('login.html', {
|
||||
'csrf': csrf_token,
|
||||
'forgot_password_link': "//{base}/#forgot-password-modal".format(base=settings.LMS_BASE),
|
||||
})
|
||||
|
||||
|
||||
def howitworks(request):
|
||||
if request.user.is_authenticated():
|
||||
return index(request)
|
||||
else:
|
||||
return render_to_response('howitworks.html', {})
|
||||
|
||||
|
||||
def ux_alerts(request):
|
||||
"""
|
||||
static/proof-of-concept views
|
||||
"""
|
||||
return render_to_response('ux-alerts.html', {})
|
||||
60
cms/djangoapps/contentstore/views/requests.py
Normal file
60
cms/djangoapps/contentstore/views/requests.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
|
||||
from django.http import HttpResponse
|
||||
from mitxmako.shortcuts import render_to_string, render_to_response
|
||||
|
||||
__all__ = ['edge', 'event', 'landing']
|
||||
|
||||
|
||||
# points to the temporary course landing page with log in and sign up
|
||||
def landing(request, org, course, coursename):
|
||||
return render_to_response('temp-course-landing.html', {})
|
||||
|
||||
|
||||
# points to the temporary edge page
|
||||
def edge(request):
|
||||
return render_to_response('university_profiles/edge.html', {})
|
||||
|
||||
|
||||
def event(request):
|
||||
'''
|
||||
A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at
|
||||
console logs don't get distracted :-)
|
||||
'''
|
||||
return HttpResponse(True)
|
||||
|
||||
|
||||
def get_request_method(request):
|
||||
"""
|
||||
Using HTTP_X_HTTP_METHOD_OVERRIDE, in the request metadata, determine
|
||||
what type of request came from the client, and return it.
|
||||
"""
|
||||
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
|
||||
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
|
||||
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
|
||||
else:
|
||||
real_method = request.method
|
||||
|
||||
return real_method
|
||||
|
||||
|
||||
def create_json_response(errmsg=None):
|
||||
if errmsg is not None:
|
||||
resp = HttpResponse(json.dumps({'Status': 'Failed', 'ErrMsg': errmsg}))
|
||||
else:
|
||||
resp = HttpResponse(json.dumps({'Status': 'OK'}))
|
||||
return resp
|
||||
|
||||
|
||||
def render_from_lms(template_name, dictionary, context=None, namespace='main'):
|
||||
"""
|
||||
Render a template using the LMS MAKO_TEMPLATES
|
||||
"""
|
||||
return render_to_string(template_name, dictionary, context, namespace="lms." + namespace)
|
||||
|
||||
|
||||
def _xmodule_recurse(item, action):
|
||||
for child in item.get_children():
|
||||
_xmodule_recurse(child, action)
|
||||
|
||||
action(item)
|
||||
28
cms/djangoapps/contentstore/views/session_kv_store.py
Normal file
28
cms/djangoapps/contentstore/views/session_kv_store.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from xblock.runtime import KeyValueStore, InvalidScopeError
|
||||
|
||||
|
||||
class SessionKeyValueStore(KeyValueStore):
|
||||
def __init__(self, request, model_data):
|
||||
self._model_data = model_data
|
||||
self._session = request.session
|
||||
|
||||
def get(self, key):
|
||||
try:
|
||||
return self._model_data[key.field_name]
|
||||
except (KeyError, InvalidScopeError):
|
||||
return self._session[tuple(key)]
|
||||
|
||||
def set(self, key, value):
|
||||
try:
|
||||
self._model_data[key.field_name] = value
|
||||
except (KeyError, InvalidScopeError):
|
||||
self._session[tuple(key)] = value
|
||||
|
||||
def delete(self, key):
|
||||
try:
|
||||
del self._model_data[key.field_name]
|
||||
except (KeyError, InvalidScopeError):
|
||||
del self._session[tuple(key)]
|
||||
|
||||
def has(self, key):
|
||||
return key in self._model_data or key in self._session
|
||||
132
cms/djangoapps/contentstore/views/tabs.py
Normal file
132
cms/djangoapps/contentstore/views/tabs.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from access import has_access
|
||||
from util.json_request import expect_json
|
||||
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from ..utils import get_course_for_item
|
||||
from .access import get_location_and_verify_access
|
||||
|
||||
__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages', 'edit_static']
|
||||
|
||||
|
||||
def initialize_course_tabs(course):
|
||||
# set up the default tabs
|
||||
# I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
|
||||
# at least a list populated with the minimal times
|
||||
# @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
|
||||
# place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
|
||||
|
||||
# This logic is repeated in xmodule/modulestore/tests/factories.py
|
||||
# so if you change anything here, you need to also change it there.
|
||||
course.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"},
|
||||
{"type": "progress", "name": "Progress"}]
|
||||
|
||||
modulestore('direct').update_metadata(course.location.url(), own_metadata(course))
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def reorder_static_tabs(request):
|
||||
tabs = request.POST['tabs']
|
||||
course = get_course_for_item(tabs[0])
|
||||
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# get list of existing static tabs in course
|
||||
# make sure they are the same lengths (i.e. the number of passed in tabs equals the number
|
||||
# that we know about) otherwise we can drop some!
|
||||
|
||||
existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab']
|
||||
if len(existing_static_tabs) != len(tabs):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# load all reference tabs, return BadRequest if we can't find any of them
|
||||
tab_items = []
|
||||
for tab in tabs:
|
||||
item = modulestore('direct').get_item(Location(tab))
|
||||
if item is None:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
tab_items.append(item)
|
||||
|
||||
# now just go through the existing course_tabs and re-order the static tabs
|
||||
reordered_tabs = []
|
||||
static_tab_idx = 0
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reordered_tabs.append({'type': 'static_tab',
|
||||
'name': tab_items[static_tab_idx].display_name,
|
||||
'url_slug': tab_items[static_tab_idx].location.name})
|
||||
static_tab_idx += 1
|
||||
else:
|
||||
reordered_tabs.append(tab)
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course.tabs = reordered_tabs
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def edit_tabs(request, org, course, coursename):
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
course_item = modulestore().get_item(location)
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# see tabs have been uninitialized (e.g. supporing courses created before tab support in studio)
|
||||
if course_item.tabs is None or len(course_item.tabs) == 0:
|
||||
initialize_course_tabs(course_item)
|
||||
|
||||
# first get all static tabs from the tabs list
|
||||
# we do this because this is also the order in which items are displayed in the LMS
|
||||
static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab']
|
||||
|
||||
static_tabs = []
|
||||
for static_tab_ref in static_tabs_refs:
|
||||
static_tab_loc = Location(location)._replace(category='static_tab', name=static_tab_ref['url_slug'])
|
||||
static_tabs.append(modulestore('direct').get_item(static_tab_loc))
|
||||
|
||||
components = [
|
||||
static_tab.location.url()
|
||||
for static_tab
|
||||
in static_tabs
|
||||
]
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course': course_item,
|
||||
'components': components
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def static_pages(request, org, course, coursename):
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, coursename)
|
||||
|
||||
course = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('static-pages.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course': course,
|
||||
})
|
||||
|
||||
|
||||
def edit_static(request, org, course, coursename):
|
||||
return render_to_response('edit-static-page.html', {})
|
||||
144
cms/djangoapps/contentstore/views/user.py
Normal file
144
cms/djangoapps/contentstore/views/user.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from contentstore.utils import get_url_reverse, get_lms_link_for_item
|
||||
from util.json_request import expect_json
|
||||
from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_users_in_course_group_by_role
|
||||
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
|
||||
|
||||
from .access import has_access
|
||||
from .requests import create_json_response
|
||||
|
||||
|
||||
def user_author_string(user):
|
||||
'''Get an author string for commits by this user. Format:
|
||||
first last <email@email.com>.
|
||||
|
||||
If the first and last names are blank, uses the username instead.
|
||||
Assumes that the email is not blank.
|
||||
'''
|
||||
f = user.first_name
|
||||
l = user.last_name
|
||||
if f == '' and l == '':
|
||||
f = user.username
|
||||
return '{first} {last} <{email}>'.format(first=f,
|
||||
last=l,
|
||||
email=user.email)
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def index(request):
|
||||
"""
|
||||
List all courses available to the logged in user
|
||||
"""
|
||||
courses = modulestore('direct').get_items(['i4x', None, None, 'course', None])
|
||||
|
||||
# filter out courses that we don't have access too
|
||||
def course_filter(course):
|
||||
return (has_access(request.user, course.location)
|
||||
and course.location.course != 'templates'
|
||||
and course.location.org != ''
|
||||
and course.location.course != ''
|
||||
and course.location.name != '')
|
||||
courses = filter(course_filter, courses)
|
||||
|
||||
return render_to_response('index.html', {
|
||||
'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'),
|
||||
'courses': [(course.display_name,
|
||||
get_url_reverse('CourseOutline', course),
|
||||
get_lms_link_for_item(course.location, course_id=course.location.course_id))
|
||||
for course in courses],
|
||||
'user': request.user,
|
||||
'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def manage_users(request, location):
|
||||
'''
|
||||
This view will return all CMS users who are editors for the specified course
|
||||
'''
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('manage_users.html', {
|
||||
'active_tab': 'users',
|
||||
'context_course': course_module,
|
||||
'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME),
|
||||
'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'),
|
||||
'remove_user_postback_url': reverse('remove_user', args=[location]).rstrip('/'),
|
||||
'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME),
|
||||
'request_user_id': request.user.id
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def add_user(request, location):
|
||||
'''
|
||||
This POST-back view will add a user - specified by email - to the list of editors for
|
||||
the specified course
|
||||
'''
|
||||
email = request.POST["email"]
|
||||
|
||||
if email == '':
|
||||
return create_json_response('Please specify an email address.')
|
||||
|
||||
# check that logged in user has admin permissions to this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
user = get_user_by_email(email)
|
||||
|
||||
# user doesn't exist?!? Return error.
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
|
||||
# user exists, but hasn't activated account?!?
|
||||
if not user.is_active:
|
||||
return create_json_response('User {0} has registered but has not yet activated his/her account.'.format(email))
|
||||
|
||||
# ok, we're cool to add to the course group
|
||||
add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
|
||||
return create_json_response()
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def remove_user(request, location):
|
||||
'''
|
||||
This POST-back view will remove a user - specified by email - from the list of editors for
|
||||
the specified course
|
||||
'''
|
||||
|
||||
email = request.POST["email"]
|
||||
|
||||
# check that logged in user has admin permissions on this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
user = get_user_by_email(email)
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
|
||||
# make sure we're not removing ourselves
|
||||
if user.id == request.user.id:
|
||||
raise PermissionDenied()
|
||||
|
||||
remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME)
|
||||
|
||||
return create_json_response()
|
||||
@@ -33,7 +33,7 @@ PIPELINE_JS['spec'] = {
|
||||
|
||||
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
|
||||
|
||||
STATICFILES_DIRS.append(COMMON_ROOT / 'test' / 'phantom-jasmine' / 'lib')
|
||||
STATICFILES_DIRS.append(REPO_ROOT/'node_modules/phantom-jasmine/lib')
|
||||
|
||||
# Remove the localization middleware class because it requires the test database
|
||||
# to be sync'd and migrated in order to run the jasmine tests interactively
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<%inherit file="base.html" />
|
||||
<%block name="title">Server Error</%block>
|
||||
|
||||
<%block name="title">Studio Server Error</%block>
|
||||
|
||||
<%block name="content">
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<h1>Currently the <em>edX</em> servers are down</h1>
|
||||
<p>Our staff is currently working to get the site back up as soon as possible. Please email us at <a href="mailto:technical@edx.org">technical@edx.org</a> to report any problems or downtime.</p>
|
||||
<h1>The <em>Studio</em> servers encountered an error</h1>
|
||||
<p>
|
||||
An error occurred in Studio and the page could not be loaded. Please try again in a few moments.
|
||||
We've logged the error and our staff is currently working to resolve this error as soon as possible.
|
||||
If the problem persists, please email us at <a href="mailto:technical@edx.org">technical@edx.org</a>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# File: courseware/capa/responsetypes.py
|
||||
#
|
||||
'''
|
||||
Problem response evaluation. Handles checking of student responses, of a variety of types.
|
||||
Problem response evaluation. Handles checking of student responses,
|
||||
of a variety of types.
|
||||
|
||||
Used by capa_problem.py
|
||||
'''
|
||||
@@ -35,7 +36,7 @@ from datetime import datetime
|
||||
from .util import *
|
||||
from lxml import etree
|
||||
from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME?
|
||||
import xqueue_interface
|
||||
import capa.xqueue_interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -300,7 +301,7 @@ class LoncapaResponse(object):
|
||||
# response
|
||||
aid = self.answer_ids[-1]
|
||||
new_cmap.set_hint_and_mode(aid, hint_text, hintmode)
|
||||
log.debug('after hint: new_cmap = %s' % new_cmap)
|
||||
log.debug('after hint: new_cmap = %s', new_cmap)
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_score(self, student_answers):
|
||||
@@ -790,6 +791,10 @@ class OptionResponse(LoncapaResponse):
|
||||
|
||||
|
||||
class NumericalResponse(LoncapaResponse):
|
||||
'''
|
||||
This response type expects a number or formulaic expression that evaluates
|
||||
to a number (e.g. `4+5/2^2`), and accepts with a tolerance.
|
||||
'''
|
||||
|
||||
response_tag = 'numericalresponse'
|
||||
hint_tag = 'numericalhint'
|
||||
@@ -806,12 +811,12 @@ class NumericalResponse(LoncapaResponse):
|
||||
'//*[@id=$id]//responseparam[@type="tolerance"]/@default',
|
||||
id=xml.get('id'))[0]
|
||||
self.tolerance = contextualize_text(self.tolerance_xml, context)
|
||||
except Exception:
|
||||
except IndexError: # xpath found an empty list, so (...)[0] is the error
|
||||
self.tolerance = '0'
|
||||
try:
|
||||
self.answer_id = xml.xpath('//*[@id=$id]//textline/@id',
|
||||
id=xml.get('id'))[0]
|
||||
except Exception:
|
||||
except IndexError: # Same as above
|
||||
self.answer_id = None
|
||||
|
||||
def get_score(self, student_answers):
|
||||
@@ -836,7 +841,6 @@ class NumericalResponse(LoncapaResponse):
|
||||
except:
|
||||
# Use the traceback-preserving version of re-raising with a
|
||||
# different type
|
||||
import sys
|
||||
type, value, traceback = sys.exc_info()
|
||||
|
||||
raise StudentInputError, ("Could not interpret '%s' as a number" %
|
||||
@@ -1869,8 +1873,6 @@ class FormulaResponse(LoncapaResponse):
|
||||
log.debug('formularesponse: error %s in formula' % err)
|
||||
raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %
|
||||
cgi.escape(given))
|
||||
if numpy.isnan(student_result) or numpy.isinf(student_result):
|
||||
return "incorrect"
|
||||
if not compare_with_tolerance(student_result, instructor_result, self.tolerance):
|
||||
return "incorrect"
|
||||
return "correct"
|
||||
|
||||
@@ -438,6 +438,43 @@ class FormulaResponseTest(ResponseTest):
|
||||
self.assert_grade(problem, incorrect, 'incorrect',
|
||||
msg="Failed on function {0}; the given, incorrect answer was {1} but graded 'correct'".format(func, incorrect))
|
||||
|
||||
def test_grade_infinity(self):
|
||||
# This resolves a bug where a problem with relative tolerance would
|
||||
# pass with any arbitrarily large student answer.
|
||||
|
||||
sample_dict = {'x': (1, 2)}
|
||||
|
||||
# Test problem
|
||||
problem = self.build_problem(sample_dict=sample_dict,
|
||||
num_samples=10,
|
||||
tolerance="1%",
|
||||
answer="x")
|
||||
# Expect such a large answer to be marked incorrect
|
||||
input_formula = "x*1e999"
|
||||
self.assert_grade(problem, input_formula, "incorrect")
|
||||
# Expect such a large negative answer to be marked incorrect
|
||||
input_formula = "-x*1e999"
|
||||
self.assert_grade(problem, input_formula, "incorrect")
|
||||
|
||||
def test_grade_nan(self):
|
||||
# Attempt to produce a value which causes the student's answer to be
|
||||
# evaluated to nan. See if this is resolved correctly.
|
||||
|
||||
sample_dict = {'x': (1, 2)}
|
||||
|
||||
# Test problem
|
||||
problem = self.build_problem(sample_dict=sample_dict,
|
||||
num_samples=10,
|
||||
tolerance="1%",
|
||||
answer="x")
|
||||
# Expect an incorrect answer (+ nan) to be marked incorrect
|
||||
# Right now this evaluates to 'nan' for a given x (Python implementation-dependent)
|
||||
input_formula = "10*x + 0*1e999"
|
||||
self.assert_grade(problem, input_formula, "incorrect")
|
||||
# Expect an correct answer (+ nan) to be marked incorrect
|
||||
input_formula = "x + 0*1e999"
|
||||
self.assert_grade(problem, input_formula, "incorrect")
|
||||
|
||||
|
||||
class StringResponseTest(ResponseTest):
|
||||
from response_xml_factory import StringResponseXMLFactory
|
||||
@@ -714,6 +751,30 @@ class NumericalResponseTest(ResponseTest):
|
||||
incorrect_responses = ["", "4.5", "3.5", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
def test_grade_infinity(self):
|
||||
# This resolves a bug where a problem with relative tolerance would
|
||||
# pass with any arbitrarily large student answer.
|
||||
problem = self.build_problem(question_text="What is 2 + 2 approximately?",
|
||||
explanation="The answer is 4",
|
||||
answer=4,
|
||||
tolerance="10%")
|
||||
correct_responses = []
|
||||
incorrect_responses = ["1e999", "-1e999"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
def test_grade_nan(self):
|
||||
# Attempt to produce a value which causes the student's answer to be
|
||||
# evaluated to nan. See if this is resolved correctly.
|
||||
problem = self.build_problem(question_text="What is 2 + 2 approximately?",
|
||||
explanation="The answer is 4",
|
||||
answer=4,
|
||||
tolerance="10%")
|
||||
correct_responses = []
|
||||
# Right now these evaluate to `nan`
|
||||
# `4 + nan` should be incorrect
|
||||
incorrect_responses = ["0*1e999", "4 + 0*1e999"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
def test_grade_with_script(self):
|
||||
script_text = "computed_response = math.sqrt(4)"
|
||||
problem = self.build_problem(question_text="What is sqrt(4)?",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .calc import evaluator, UndefinedVariable
|
||||
from cmath import isinf
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
#
|
||||
@@ -20,7 +21,14 @@ def compare_with_tolerance(v1, v2, tol):
|
||||
tolerance = tolerance_rel * max(abs(v1), abs(v2))
|
||||
else:
|
||||
tolerance = evaluator(dict(), dict(), tol)
|
||||
return abs(v1 - v2) <= tolerance
|
||||
|
||||
if isinf(v1) or isinf(v2):
|
||||
# If an input is infinite, we can end up with `abs(v1-v2)` and
|
||||
# `tolerance` both equal to infinity. Then, below we would have
|
||||
# `inf <= inf` which is a fail. Instead, compare directly.
|
||||
return v1 == v2
|
||||
else:
|
||||
return abs(v1 - v2) <= tolerance
|
||||
|
||||
|
||||
def contextualize_text(text, context): # private
|
||||
@@ -51,7 +59,8 @@ def convert_files_to_filenames(answers):
|
||||
new_answers = dict()
|
||||
for answer_id in answers.keys():
|
||||
answer = answers[answer_id]
|
||||
if is_list_of_files(answer): # Files are stored as a list, even if one file
|
||||
# Files are stored as a list, even if one file
|
||||
if is_list_of_files(answer):
|
||||
new_answers[answer_id] = [f.name for f in answer]
|
||||
else:
|
||||
new_answers[answer_id] = answers[answer_id]
|
||||
|
||||
@@ -104,11 +104,14 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
|
||||
icon_class = 'problem'
|
||||
|
||||
js = {'coffee':
|
||||
[resource_string(__name__, 'js/src/combinedopenended/display.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]}
|
||||
js = {
|
||||
'coffee':
|
||||
[
|
||||
resource_string(__name__, 'js/src/combinedopenended/display.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]
|
||||
}
|
||||
js_module_name = "CombinedOpenEnded"
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
|
||||
|
||||
@@ -382,6 +382,19 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
|
||||
return definition, children
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = super(CourseDescriptor, self).definition_to_xml(resource_fs)
|
||||
|
||||
if len(self.textbooks) > 0:
|
||||
textbook_xml_object = etree.Element('textbook')
|
||||
for textbook in self.textbooks:
|
||||
textbook_xml_object.set('title', textbook.title)
|
||||
textbook_xml_object.set('book_url', textbook.book_url)
|
||||
|
||||
xml_object.append(textbook_xml_object)
|
||||
|
||||
return xml_object
|
||||
|
||||
def has_ended(self):
|
||||
"""
|
||||
Returns True if the current time is after the specified course end date.
|
||||
|
||||
@@ -294,9 +294,8 @@ class CombinedOpenEndedV1Module():
|
||||
if self.current_task_number > 0:
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
current_response_data = self.get_current_attributes(self.current_task_number)
|
||||
|
||||
if (current_response_data['min_score_to_attempt'] > last_response_data['score']
|
||||
or current_response_data['max_score_to_attempt'] < last_response_data['score']):
|
||||
or current_response_data['max_score_to_attempt'] < last_response_data['score']):
|
||||
self.state = self.DONE
|
||||
self.ready_to_reset = True
|
||||
|
||||
@@ -662,9 +661,10 @@ class CombinedOpenEndedV1Module():
|
||||
return {
|
||||
'success': False,
|
||||
#This is a student_facing_error
|
||||
'error': ('You have attempted this question {0} times. '
|
||||
'You are only allowed to attempt it {1} times.').format(
|
||||
self.student_attempts, self.attempts)
|
||||
'error': (
|
||||
'You have attempted this question {0} times. '
|
||||
'You are only allowed to attempt it {1} times.'
|
||||
).format(self.student_attempts, self.attempts)
|
||||
}
|
||||
self.state = self.INITIAL
|
||||
self.ready_to_reset = False
|
||||
@@ -803,6 +803,17 @@ class CombinedOpenEndedV1Module():
|
||||
|
||||
return progress_object
|
||||
|
||||
def out_of_sync_error(self, get, msg=''):
|
||||
"""
|
||||
return dict out-of-sync error message, and also log.
|
||||
"""
|
||||
#This is a dev_facing_error
|
||||
log.warning("Combined module state out sync. state: %r, get: %r. %s",
|
||||
self.state, get, msg)
|
||||
#This is a student_facing_error
|
||||
return {'success': False,
|
||||
'error': 'The problem state got out-of-sync. Please try reloading the page.'}
|
||||
|
||||
|
||||
class CombinedOpenEndedV1Descriptor():
|
||||
"""
|
||||
@@ -849,7 +860,6 @@ class CombinedOpenEndedV1Descriptor():
|
||||
|
||||
return {'task_xml': parse_task('task'), 'prompt': parse('prompt'), 'rubric': parse('rubric')}
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''Return an xml element representing this definition.'''
|
||||
elt = etree.Element('combinedopenended')
|
||||
|
||||
@@ -76,7 +76,6 @@ class GradingService(object):
|
||||
|
||||
return r.text
|
||||
|
||||
|
||||
def _try_with_login(self, operation):
|
||||
"""
|
||||
Call operation(), which should return a requests response object. If
|
||||
@@ -87,7 +86,7 @@ class GradingService(object):
|
||||
"""
|
||||
response = operation()
|
||||
if (response.json
|
||||
and response.json.get('success') == False
|
||||
and response.json.get('success') is False
|
||||
and response.json.get('error') == 'login_required'):
|
||||
# apparrently we aren't logged in. Try to fix that.
|
||||
r = self._login()
|
||||
|
||||
@@ -72,7 +72,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
|
||||
self._parse(oeparam, self.child_prompt, self.child_rubric, system)
|
||||
|
||||
if self.child_created == True and self.child_state == self.ASSESSING:
|
||||
if self.child_created is True and self.child_state == self.ASSESSING:
|
||||
self.child_created = False
|
||||
self.send_to_grader(self.latest_answer(), system)
|
||||
self.child_created = False
|
||||
@@ -159,9 +159,11 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
score = int(survey_responses['score'])
|
||||
except:
|
||||
#This is a dev_facing_error
|
||||
error_message = ("Could not parse submission id, grader id, "
|
||||
"or feedback from message_post ajax call. Here is the message data: {0}".format(
|
||||
survey_responses))
|
||||
error_message = (
|
||||
"Could not parse submission id, grader id, "
|
||||
"or feedback from message_post ajax call. "
|
||||
"Here is the message data: {0}".format(survey_responses)
|
||||
)
|
||||
log.exception(error_message)
|
||||
#This is a student_facing_error
|
||||
return {'success': False, 'msg': "There was an error saving your feedback. Please contact course staff."}
|
||||
@@ -179,8 +181,9 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
queue_name=self.message_queue_name
|
||||
)
|
||||
|
||||
student_info = {'anonymous_student_id': anonymous_student_id,
|
||||
'submission_time': qtime,
|
||||
student_info = {
|
||||
'anonymous_student_id': anonymous_student_id,
|
||||
'submission_time': qtime,
|
||||
}
|
||||
contents = {
|
||||
'feedback': feedback,
|
||||
@@ -190,8 +193,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'student_info': json.dumps(student_info),
|
||||
}
|
||||
|
||||
(error, msg) = qinterface.send_to_queue(header=xheader,
|
||||
body=json.dumps(contents))
|
||||
(error, msg) = qinterface.send_to_queue(
|
||||
header=xheader,
|
||||
body=json.dumps(contents)
|
||||
)
|
||||
|
||||
#Convert error to a success value
|
||||
success = True
|
||||
@@ -224,15 +229,18 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
anonymous_student_id +
|
||||
str(len(self.child_history)))
|
||||
|
||||
xheader = xqueue_interface.make_xheader(lms_callback_url=system.xqueue['construct_callback'](),
|
||||
lms_key=queuekey,
|
||||
queue_name=self.queue_name)
|
||||
xheader = xqueue_interface.make_xheader(
|
||||
lms_callback_url=system.xqueue['construct_callback'](),
|
||||
lms_key=queuekey,
|
||||
queue_name=self.queue_name
|
||||
)
|
||||
|
||||
contents = self.payload.copy()
|
||||
|
||||
# Metadata related to the student submission revealed to the external grader
|
||||
student_info = {'anonymous_student_id': anonymous_student_id,
|
||||
'submission_time': qtime,
|
||||
student_info = {
|
||||
'anonymous_student_id': anonymous_student_id,
|
||||
'submission_time': qtime,
|
||||
}
|
||||
|
||||
#Update contents with student response and student info
|
||||
@@ -243,12 +251,16 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
})
|
||||
|
||||
# Submit request. When successful, 'msg' is the prior length of the queue
|
||||
(error, msg) = qinterface.send_to_queue(header=xheader,
|
||||
body=json.dumps(contents))
|
||||
qinterface.send_to_queue(
|
||||
header=xheader,
|
||||
body=json.dumps(contents)
|
||||
)
|
||||
|
||||
# State associated with the queueing request
|
||||
queuestate = {'key': queuekey,
|
||||
'time': qtime, }
|
||||
queuestate = {
|
||||
'key': queuekey,
|
||||
'time': qtime,
|
||||
}
|
||||
return True
|
||||
|
||||
def _update_score(self, score_msg, queuekey, system):
|
||||
@@ -302,11 +314,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
|
||||
# We want to display available feedback in a particular order.
|
||||
# This dictionary specifies which goes first--lower first.
|
||||
priorities = {# These go at the start of the feedback
|
||||
'spelling': 0,
|
||||
'grammar': 1,
|
||||
# needs to be after all the other feedback
|
||||
'markup_text': 3}
|
||||
priorities = {
|
||||
# These go at the start of the feedback
|
||||
'spelling': 0,
|
||||
'grammar': 1,
|
||||
# needs to be after all the other feedback
|
||||
'markup_text': 3
|
||||
}
|
||||
do_not_render = ['topicality', 'prompt-overlap']
|
||||
|
||||
default_priority = 2
|
||||
@@ -393,7 +407,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
rubric_feedback = ""
|
||||
feedback = self._convert_longform_feedback_to_html(response_items)
|
||||
rubric_scores = []
|
||||
if response_items['rubric_scores_complete'] == True:
|
||||
if response_items['rubric_scores_complete'] is True:
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
rubric_dict = rubric_renderer.render_rubric(response_items['rubric_xml'])
|
||||
success = rubric_dict['success']
|
||||
@@ -401,8 +415,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
rubric_scores = rubric_dict['rubric_scores']
|
||||
|
||||
if not response_items['success']:
|
||||
return system.render_template("{0}/open_ended_error.html".format(self.TEMPLATE_DIR),
|
||||
{'errors': feedback})
|
||||
return system.render_template(
|
||||
"{0}/open_ended_error.html".format(self.TEMPLATE_DIR),
|
||||
{'errors': feedback}
|
||||
)
|
||||
|
||||
feedback_template = system.render_template("{0}/open_ended_feedback.html".format(self.TEMPLATE_DIR), {
|
||||
'grader_type': response_items['grader_type'],
|
||||
@@ -496,8 +512,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
grader_types.append(score_result['grader_type'])
|
||||
try:
|
||||
feedback_dict = json.loads(score_result['feedback'][i])
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
feedback_dict = score_result['feedback'][i]
|
||||
feedback_dicts.append(feedback_dict)
|
||||
grader_ids.append(score_result['grader_id'][i])
|
||||
submission_ids.append(score_result['submission_id'])
|
||||
@@ -515,8 +531,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
feedback_items = [feedback]
|
||||
try:
|
||||
feedback_dict = json.loads(score_result['feedback'])
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
feedback_dict = score_result.get('feedback', '')
|
||||
feedback_dicts = [feedback_dict]
|
||||
grader_ids = [score_result['grader_id']]
|
||||
submission_ids = [score_result['submission_id']]
|
||||
@@ -545,8 +561,11 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
if not self.child_history:
|
||||
return ""
|
||||
|
||||
feedback_dict = self._parse_score_msg(self.child_history[-1].get('post_assessment', ""), system,
|
||||
join_feedback=join_feedback)
|
||||
feedback_dict = self._parse_score_msg(
|
||||
self.child_history[-1].get('post_assessment', ""),
|
||||
system,
|
||||
join_feedback=join_feedback
|
||||
)
|
||||
if not short_feedback:
|
||||
return feedback_dict['feedback'] if feedback_dict['valid'] else ''
|
||||
if feedback_dict['valid']:
|
||||
@@ -711,7 +730,7 @@ class OpenEndedDescriptor():
|
||||
template_dir_name = "openended"
|
||||
|
||||
def __init__(self, system):
|
||||
self.system =system
|
||||
self.system = system
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
@@ -734,8 +753,9 @@ class OpenEndedDescriptor():
|
||||
"""Assumes that xml_object has child k"""
|
||||
return xml_object.xpath(k)[0]
|
||||
|
||||
return {'oeparam': parse('openendedparam')}
|
||||
|
||||
return {
|
||||
'oeparam': parse('openendedparam')
|
||||
}
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''Return an xml element representing this definition.'''
|
||||
|
||||
@@ -101,8 +101,9 @@ class OpenEndedChild(object):
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
if system.open_ended_grading_interface:
|
||||
self.peer_gs = PeerGradingService(system.open_ended_grading_interface, system)
|
||||
self.controller_qs = controller_query_service.ControllerQueryService(system.open_ended_grading_interface,
|
||||
system)
|
||||
self.controller_qs = controller_query_service.ControllerQueryService(
|
||||
system.open_ended_grading_interface,system
|
||||
)
|
||||
else:
|
||||
self.peer_gs = MockPeerGradingService()
|
||||
self.controller_qs = None
|
||||
|
||||
@@ -37,7 +37,7 @@ class PeerGradingService(GradingService):
|
||||
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
response = self.get(self.get_next_submission_url,
|
||||
{'location': problem_location, 'grader_id': grader_id})
|
||||
{'location': problem_location, 'grader_id': grader_id})
|
||||
return self.try_to_decode(self._render_rubric(response))
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id, score, feedback, submission_key, rubric_scores,
|
||||
@@ -100,29 +100,29 @@ without making actual service calls to the grading controller
|
||||
|
||||
class MockPeerGradingService(object):
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'submission_id': 1,
|
||||
'submission_key': "",
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4})
|
||||
return {'success': True,
|
||||
'submission_id': 1,
|
||||
'submission_key': "",
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4}
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id,
|
||||
score, feedback, submission_key, rubric_scores, submission_flagged):
|
||||
return json.dumps({'success': True})
|
||||
return {'success': True}
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True, 'calibrated': True})
|
||||
return {'success': True, 'calibrated': True}
|
||||
|
||||
def show_calibration_essay(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'submission_id': 1,
|
||||
'submission_key': '',
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4})
|
||||
return {'success': True,
|
||||
'submission_id': 1,
|
||||
'submission_key': '',
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4}
|
||||
|
||||
def save_calibration_essay(self, problem_location, grader_id,
|
||||
calibration_essay_id, submission_key, score,
|
||||
@@ -130,10 +130,9 @@ class MockPeerGradingService(object):
|
||||
return {'success': True, 'actual_score': 2}
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'problem_list': [
|
||||
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo1',
|
||||
'problem_name': "Problem 1", 'num_graded': 3, 'num_pending': 5}),
|
||||
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo2',
|
||||
'problem_name': "Problem 2", 'num_graded': 1, 'num_pending': 5})
|
||||
]})
|
||||
return {'success': True,
|
||||
'problem_list': [
|
||||
]}
|
||||
|
||||
def get_data_for_location(self, problem_location, student_id):
|
||||
return {"version": 1, "count_graded": 3, "count_required": 3, "success": True, "student_sub_count": 1}
|
||||
|
||||
@@ -498,7 +498,6 @@ class PeerGradingModule(PeerGradingFields, XModule):
|
||||
log.error("Problem {0} does not exist in this course".format(location))
|
||||
raise
|
||||
|
||||
|
||||
for problem in problem_list:
|
||||
problem_location = problem['location']
|
||||
descriptor = _find_corresponding_module_for_location(problem_location)
|
||||
|
||||
@@ -20,7 +20,7 @@ from xmodule.x_module import ModuleSystem
|
||||
from mock import Mock
|
||||
|
||||
open_ended_grading_interface = {
|
||||
'url': 'http://sandbox-grader-001.m.edx.org/peer_grading',
|
||||
'url': 'blah/',
|
||||
'username': 'incorrect_user',
|
||||
'password': 'incorrect_pass',
|
||||
'staff_grading' : 'staff_grading',
|
||||
@@ -52,7 +52,7 @@ def test_system():
|
||||
user=Mock(is_staff=False),
|
||||
filestore=Mock(),
|
||||
debug=True,
|
||||
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
|
||||
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10, 'construct_callback' : Mock(side_effect="/")},
|
||||
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
|
||||
xblock_model_data=lambda descriptor: descriptor._model_data,
|
||||
anonymous_student_id='student',
|
||||
|
||||
@@ -2,12 +2,15 @@ import json
|
||||
from mock import Mock, MagicMock, ANY
|
||||
import unittest
|
||||
|
||||
from test_util_open_ended import MockQueryDict, DummyModulestore
|
||||
|
||||
from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild
|
||||
from xmodule.open_ended_grading_classes.open_ended_module import OpenEndedModule
|
||||
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module
|
||||
from xmodule.open_ended_grading_classes.grading_service_module import GradingServiceError
|
||||
from xmodule.combined_open_ended_module import CombinedOpenEndedModule
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from lxml import etree
|
||||
import capa.xqueue_interface as xqueue_interface
|
||||
from datetime import datetime
|
||||
@@ -17,6 +20,9 @@ log = logging.getLogger(__name__)
|
||||
|
||||
from . import test_system
|
||||
|
||||
ORG = 'edX'
|
||||
COURSE = 'open_ended' # name of directory with course data
|
||||
|
||||
import test_util_open_ended
|
||||
|
||||
"""
|
||||
@@ -29,6 +35,9 @@ OpenEndedModule
|
||||
|
||||
|
||||
class OpenEndedChildTest(unittest.TestCase):
|
||||
"""
|
||||
Test the open ended child class
|
||||
"""
|
||||
location = Location(["i4x", "edX", "sa_test", "selfassessment",
|
||||
"SampleQuestion"])
|
||||
|
||||
@@ -63,7 +72,6 @@ class OpenEndedChildTest(unittest.TestCase):
|
||||
self.openendedchild = OpenEndedChild(self.test_system, self.location,
|
||||
self.definition, self.descriptor, self.static_data, self.metadata)
|
||||
|
||||
|
||||
def test_latest_answer_empty(self):
|
||||
answer = self.openendedchild.latest_answer()
|
||||
self.assertEqual(answer, "")
|
||||
@@ -144,6 +152,9 @@ class OpenEndedChildTest(unittest.TestCase):
|
||||
|
||||
|
||||
class OpenEndedModuleTest(unittest.TestCase):
|
||||
"""
|
||||
Test the open ended module class
|
||||
"""
|
||||
location = Location(["i4x", "edX", "sa_test", "selfassessment",
|
||||
"SampleQuestion"])
|
||||
|
||||
@@ -285,6 +296,9 @@ class OpenEndedModuleTest(unittest.TestCase):
|
||||
|
||||
|
||||
class CombinedOpenEndedModuleTest(unittest.TestCase):
|
||||
"""
|
||||
Unit tests for the combined open ended xmodule
|
||||
"""
|
||||
location = Location(["i4x", "edX", "open_ended", "combinedopenended",
|
||||
"SampleQuestion"])
|
||||
definition_template = """
|
||||
@@ -361,7 +375,6 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
|
||||
descriptor,
|
||||
model_data={'data': full_definition, 'weight': '1'})
|
||||
|
||||
|
||||
def setUp(self):
|
||||
# TODO: this constructor call is definitely wrong, but neither branch
|
||||
# of the merge matches the module constructor. Someone (Vik?) should fix this.
|
||||
@@ -467,5 +480,148 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
|
||||
self.assertEqual(score_dict['total'], 15.0)
|
||||
|
||||
|
||||
class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
|
||||
"""
|
||||
Test the student flow in the combined open ended xmodule
|
||||
"""
|
||||
problem_location = Location(["i4x", "edX", "open_ended", "combinedopenended", "SampleQuestion"])
|
||||
answer = "blah blah"
|
||||
assessment = [0, 1]
|
||||
hint = "blah"
|
||||
|
||||
def setUp(self):
|
||||
self.test_system = test_system()
|
||||
self.test_system.xqueue['interface'] = Mock(
|
||||
send_to_queue=Mock(side_effect=[1, "queued"])
|
||||
)
|
||||
self.setup_modulestore(COURSE)
|
||||
|
||||
def test_open_ended_load_and_save(self):
|
||||
"""
|
||||
See if we can load the module and save an answer
|
||||
@return:
|
||||
"""
|
||||
#Load the module
|
||||
module = self.get_module_from_location(self.problem_location, COURSE)
|
||||
|
||||
#Try saving an answer
|
||||
module.handle_ajax("save_answer", {"student_answer": self.answer})
|
||||
task_one_json = json.loads(module.task_states[0])
|
||||
self.assertEqual(task_one_json['child_history'][0]['answer'], self.answer)
|
||||
|
||||
module = self.get_module_from_location(self.problem_location, COURSE)
|
||||
task_one_json = json.loads(module.task_states[0])
|
||||
self.assertEqual(task_one_json['child_history'][0]['answer'], self.answer)
|
||||
|
||||
def test_open_ended_flow_reset(self):
|
||||
"""
|
||||
Test the flow of the module if we complete the self assessment step and then reset
|
||||
@return:
|
||||
"""
|
||||
assessment = [0, 1]
|
||||
module = self.get_module_from_location(self.problem_location, COURSE)
|
||||
|
||||
#Simulate a student saving an answer
|
||||
module.handle_ajax("save_answer", {"student_answer": self.answer})
|
||||
status = module.handle_ajax("get_status", {})
|
||||
self.assertTrue(isinstance(status, basestring))
|
||||
|
||||
#Mock a student submitting an assessment
|
||||
assessment_dict = MockQueryDict()
|
||||
assessment_dict.update({'assessment': sum(assessment), 'score_list[]': assessment})
|
||||
module.handle_ajax("save_assessment", assessment_dict)
|
||||
task_one_json = json.loads(module.task_states[0])
|
||||
self.assertEqual(json.loads(task_one_json['child_history'][0]['post_assessment']), assessment)
|
||||
status = module.handle_ajax("get_status", {})
|
||||
self.assertTrue(isinstance(status, basestring))
|
||||
|
||||
#Move to the next step in the problem
|
||||
module.handle_ajax("next_problem", {})
|
||||
self.assertEqual(module.current_task_number, 0)
|
||||
|
||||
html = module.get_html()
|
||||
self.assertTrue(isinstance(html, basestring))
|
||||
|
||||
rubric = module.handle_ajax("get_combined_rubric", {})
|
||||
self.assertTrue(isinstance(rubric, basestring))
|
||||
self.assertEqual(module.state, "assessing")
|
||||
module.handle_ajax("reset", {})
|
||||
self.assertEqual(module.current_task_number, 0)
|
||||
|
||||
def test_open_ended_flow_correct(self):
|
||||
"""
|
||||
Test a two step problem where the student first goes through the self assessment step, and then the
|
||||
open ended step.
|
||||
@return:
|
||||
"""
|
||||
assessment = [1, 1]
|
||||
#Load the module
|
||||
module = self.get_module_from_location(self.problem_location, COURSE)
|
||||
|
||||
#Simulate a student saving an answer
|
||||
module.handle_ajax("save_answer", {"student_answer": self.answer})
|
||||
status = module.handle_ajax("get_status", {})
|
||||
self.assertTrue(isinstance(status, basestring))
|
||||
|
||||
#Mock a student submitting an assessment
|
||||
assessment_dict = MockQueryDict()
|
||||
assessment_dict.update({'assessment': sum(assessment), 'score_list[]': assessment})
|
||||
module.handle_ajax("save_assessment", assessment_dict)
|
||||
task_one_json = json.loads(module.task_states[0])
|
||||
self.assertEqual(json.loads(task_one_json['child_history'][0]['post_assessment']), assessment)
|
||||
module.handle_ajax("get_status", {})
|
||||
|
||||
#Move to the next step in the problem
|
||||
try:
|
||||
module.handle_ajax("next_problem", {})
|
||||
except GradingServiceError:
|
||||
#This error is okay. We don't have a grading service to connect to!
|
||||
pass
|
||||
self.assertEqual(module.current_task_number, 1)
|
||||
try:
|
||||
module.get_html()
|
||||
except GradingServiceError:
|
||||
#This error is okay. We don't have a grading service to connect to!
|
||||
pass
|
||||
|
||||
#Try to get the rubric from the module
|
||||
module.handle_ajax("get_combined_rubric", {})
|
||||
|
||||
#Make a fake reply from the queue
|
||||
queue_reply = {
|
||||
'queuekey': "",
|
||||
'xqueue_body': json.dumps({
|
||||
'score': 0,
|
||||
'feedback': json.dumps({"spelling": "Spelling: Ok.", "grammar": "Grammar: Ok.",
|
||||
"markup-text": " all of us can think of a book that we hope none of our children or any other children have taken off the shelf . but if i have the right to remove that book from the shelf that work i abhor then you also have exactly the same right and so does everyone else . and then we <bg>have no books left</bg> on the shelf for any of us . <bs>katherine</bs> <bs>paterson</bs> , author write a persuasive essay to a newspaper reflecting your vies on censorship <bg>in libraries . do</bg> you believe that certain materials , such as books , music , movies , magazines , <bg>etc . , should be</bg> removed from the shelves if they are found <bg>offensive ? support your</bg> position with convincing arguments from your own experience , observations <bg>, and or reading .</bg> "}),
|
||||
'grader_type': "ML",
|
||||
'success': True,
|
||||
'grader_id': 1,
|
||||
'submission_id': 1,
|
||||
'rubric_xml': "<rubric><category><description>Writing Applications</description><score>0</score><option points='0'> The essay loses focus, has little information or supporting details, and the organization makes it difficult to follow.</option><option points='1'> The essay presents a mostly unified theme, includes sufficient information to convey the theme, and is generally organized well.</option></category><category><description> Language Conventions </description><score>0</score><option points='0'> The essay demonstrates a reasonable command of proper spelling and grammar. </option><option points='1'> The essay demonstrates superior command of proper spelling and grammar.</option></category></rubric>",
|
||||
'rubric_scores_complete': True,
|
||||
})
|
||||
}
|
||||
|
||||
module.handle_ajax("check_for_score", {})
|
||||
|
||||
#Update the module with the fake queue reply
|
||||
module.handle_ajax("score_update", queue_reply)
|
||||
self.assertFalse(module.ready_to_reset)
|
||||
self.assertEqual(module.current_task_number, 1)
|
||||
|
||||
#Get html and other data client will request
|
||||
html = module.get_html()
|
||||
legend = module.handle_ajax("get_legend", {})
|
||||
self.assertTrue(isinstance(legend, basestring))
|
||||
|
||||
status = module.handle_ajax("get_status", {})
|
||||
module.handle_ajax("skip_post_assessment", {})
|
||||
self.assertTrue(isinstance(legend, basestring))
|
||||
|
||||
#Get all results
|
||||
module.handle_ajax("get_results", {})
|
||||
|
||||
#reset the problem
|
||||
module.handle_ajax("reset", {})
|
||||
self.assertEqual(module.state, "initial")
|
||||
|
||||
160
common/lib/xmodule/xmodule/tests/test_peer_grading.py
Normal file
160
common/lib/xmodule/xmodule/tests/test_peer_grading.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import unittest
|
||||
from xmodule.modulestore import Location
|
||||
from .import test_system
|
||||
from test_util_open_ended import MockQueryDict, DummyModulestore
|
||||
import json
|
||||
|
||||
from xmodule.peer_grading_module import PeerGradingModule, PeerGradingDescriptor
|
||||
from xmodule.open_ended_grading_classes.grading_service_module import GradingServiceError
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ORG = "edX"
|
||||
COURSE = "open_ended"
|
||||
|
||||
|
||||
class PeerGradingModuleTest(unittest.TestCase, DummyModulestore):
|
||||
"""
|
||||
Test peer grading xmodule at the unit level. More detailed tests are difficult, as the module relies on an
|
||||
external grading service.
|
||||
"""
|
||||
problem_location = Location(["i4x", "edX", "open_ended", "peergrading",
|
||||
"PeerGradingSample"])
|
||||
calibrated_dict = {'location': "blah"}
|
||||
save_dict = MockQueryDict()
|
||||
save_dict.update({
|
||||
'location': "blah",
|
||||
'submission_id': 1,
|
||||
'submission_key': "",
|
||||
'score': 1,
|
||||
'feedback': "",
|
||||
'rubric_scores[]': [0, 1],
|
||||
'submission_flagged': False,
|
||||
})
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a peer grading module from a test system
|
||||
@return:
|
||||
"""
|
||||
self.test_system = test_system()
|
||||
self.test_system.open_ended_grading_interface = None
|
||||
self.setup_modulestore(COURSE)
|
||||
self.peer_grading = self.get_module_from_location(self.problem_location, COURSE)
|
||||
|
||||
def test_module_closed(self):
|
||||
"""
|
||||
Test if peer grading is closed
|
||||
@return:
|
||||
"""
|
||||
closed = self.peer_grading.closed()
|
||||
self.assertEqual(closed, False)
|
||||
|
||||
def test_get_html(self):
|
||||
"""
|
||||
Test to see if the module can be rendered
|
||||
@return:
|
||||
"""
|
||||
html = self.peer_grading.get_html()
|
||||
|
||||
def test_get_data(self):
|
||||
"""
|
||||
Try getting data from the external grading service
|
||||
@return:
|
||||
"""
|
||||
success, data = self.peer_grading.query_data_for_location()
|
||||
self.assertEqual(success, True)
|
||||
|
||||
def test_get_score(self):
|
||||
"""
|
||||
Test getting the score
|
||||
@return:
|
||||
"""
|
||||
score = self.peer_grading.get_score()
|
||||
self.assertEquals(score['score'], None)
|
||||
|
||||
def test_get_max_score(self):
|
||||
"""
|
||||
Test getting the max score
|
||||
@return:
|
||||
"""
|
||||
max_score = self.peer_grading.max_score()
|
||||
self.assertEquals(max_score, None)
|
||||
|
||||
def get_next_submission(self):
|
||||
"""
|
||||
Test to see if we can get the next mock submission
|
||||
@return:
|
||||
"""
|
||||
success, next_submission = self.peer_grading.get_next_submission({'location': 'blah'})
|
||||
self.assertEqual(success, True)
|
||||
|
||||
def test_save_grade(self):
|
||||
"""
|
||||
Test if we can save the grade
|
||||
@return:
|
||||
"""
|
||||
response = self.peer_grading.save_grade(self.save_dict)
|
||||
self.assertEqual(response['success'], True)
|
||||
|
||||
def test_is_student_calibrated(self):
|
||||
"""
|
||||
Check to see if the student has calibrated yet
|
||||
@return:
|
||||
"""
|
||||
calibrated_dict = {'location': "blah"}
|
||||
response = self.peer_grading.is_student_calibrated(self.calibrated_dict)
|
||||
self.assertEqual(response['success'], True)
|
||||
|
||||
def test_show_calibration_essay(self):
|
||||
"""
|
||||
Test showing the calibration essay
|
||||
@return:
|
||||
"""
|
||||
response = self.peer_grading.show_calibration_essay(self.calibrated_dict)
|
||||
self.assertEqual(response['success'], True)
|
||||
|
||||
def test_save_calibration_essay(self):
|
||||
"""
|
||||
Test saving the calibration essay
|
||||
@return:
|
||||
"""
|
||||
response = self.peer_grading.save_calibration_essay(self.save_dict)
|
||||
self.assertEqual(response['success'], True)
|
||||
|
||||
def test_peer_grading_problem(self):
|
||||
"""
|
||||
See if we can render a single problem
|
||||
@return:
|
||||
"""
|
||||
response = self.peer_grading.peer_grading_problem(self.calibrated_dict)
|
||||
self.assertEqual(response['success'], True)
|
||||
|
||||
def test_get_instance_state(self):
|
||||
"""
|
||||
Get the instance state dict
|
||||
@return:
|
||||
"""
|
||||
self.peer_grading.get_instance_state()
|
||||
|
||||
class PeerGradingModuleScoredTest(unittest.TestCase, DummyModulestore):
|
||||
"""
|
||||
Test peer grading xmodule at the unit level. More detailed tests are difficult, as the module relies on an
|
||||
external grading service.
|
||||
"""
|
||||
problem_location = Location(["i4x", "edX", "open_ended", "peergrading",
|
||||
"PeerGradingScored"])
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a peer grading module from a test system
|
||||
@return:
|
||||
"""
|
||||
self.test_system = test_system()
|
||||
self.test_system.open_ended_grading_interface = None
|
||||
self.setup_modulestore(COURSE)
|
||||
|
||||
def test_metadata_load(self):
|
||||
peer_grading = self.get_module_from_location(self.problem_location, COURSE)
|
||||
self.assertEqual(peer_grading.closed(), False)
|
||||
@@ -1,5 +1,10 @@
|
||||
from .import test_system
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
|
||||
from xmodule.tests.test_export import DATA_DIR
|
||||
|
||||
OPEN_ENDED_GRADING_INTERFACE = {
|
||||
'url': 'http://127.0.0.1:3033/',
|
||||
'url': 'blah/',
|
||||
'username': 'incorrect',
|
||||
'password': 'incorrect',
|
||||
'staff_grading': 'staff_grading',
|
||||
@@ -11,4 +16,40 @@ S3_INTERFACE = {
|
||||
'aws_access_key': "",
|
||||
'aws_secret_key': "",
|
||||
"aws_bucket_name": "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MockQueryDict(dict):
|
||||
"""
|
||||
Mock a query dict so that it can be used in test classes. This will only work with the combinedopenended tests,
|
||||
and does not mock the full query dict, only the behavior that is needed there (namely get_list).
|
||||
"""
|
||||
def getlist(self, key, default=None):
|
||||
try:
|
||||
return super(MockQueryDict, self).__getitem__(key)
|
||||
except KeyError:
|
||||
if default is None:
|
||||
return []
|
||||
return default
|
||||
|
||||
|
||||
class DummyModulestore(object):
|
||||
"""
|
||||
A mixin that allows test classes to have convenience functions to get a module given a location
|
||||
"""
|
||||
test_system = test_system()
|
||||
|
||||
def setup_modulestore(self, name):
|
||||
self.modulestore = XMLModuleStore(DATA_DIR, course_dirs=[name])
|
||||
|
||||
def get_course(self, name):
|
||||
"""Get a test course by directory name. If there's more than one, error."""
|
||||
courses = self.modulestore.get_courses()
|
||||
return courses[0]
|
||||
|
||||
def get_module_from_location(self, location, course):
|
||||
course = self.get_course(course)
|
||||
if not isinstance(location, Location):
|
||||
location = Location(location)
|
||||
descriptor = self.modulestore.get_instance(course.id, location, depth=None)
|
||||
return descriptor.xmodule(self.test_system)
|
||||
|
||||
@@ -1 +1 @@
|
||||
<course filename="6.002_Spring_2012" slug="6.002_Spring_2012" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="6.002 Spring 2012" start="2015-07-17T12:00" course="full" org="edX"/>
|
||||
<course filename="6.002_Spring_2012" slug="6.002_Spring_2012" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="6.002 Spring 2012" start="2015-07-17T12:00" course="full" org="edX" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<sequential>
|
||||
<course>
|
||||
<textbook title="Textbook" book_url="https://s3.amazonaws.com/edx-textbooks/guttag_computation_v3/"/>
|
||||
<chapter filename="Overview" slug="Overview" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="Overview"/>
|
||||
<chapter filename="Week_1" slug="Week_1" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="Week 1"/>
|
||||
<chapter slug="Midterm_Exam" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="Midterm Exam">
|
||||
@@ -9,4 +10,4 @@
|
||||
<vertical filename="vertical_98" slug="vertical_1124" graceperiod="0 day 0 hours 5 minutes 0 seconds" showanswer="attempted" rerandomize="per_student" due="April 30, 12:00" graded="true"/>
|
||||
</sequential>
|
||||
</chapter>
|
||||
</sequential>
|
||||
</course>
|
||||
|
||||
1
common/test/data/open_ended/README.md
Normal file
1
common/test/data/open_ended/README.md
Normal file
@@ -0,0 +1 @@
|
||||
This is a very very simple course, useful for debugging open ended grading code.
|
||||
@@ -0,0 +1,33 @@
|
||||
<combinedopenended attempts="10000" display_name = "Humanities Question -- Machine Assessed">
|
||||
<rubric>
|
||||
<rubric>
|
||||
<category>
|
||||
<description>Writing Applications</description>
|
||||
<option> The essay loses focus, has little information or supporting details, and the organization makes it difficult to follow.</option>
|
||||
<option> The essay presents a mostly unified theme, includes sufficient information to convey the theme, and is generally organized well.</option>
|
||||
</category>
|
||||
<category>
|
||||
<description> Language Conventions </description>
|
||||
<option> The essay demonstrates a reasonable command of proper spelling and grammar. </option>
|
||||
<option> The essay demonstrates superior command of proper spelling and grammar.</option>
|
||||
</category>
|
||||
</rubric>
|
||||
</rubric>
|
||||
<prompt>
|
||||
<h4>Censorship in the Libraries</h4>
|
||||
<p>"All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us." --Katherine Paterson, Author</p>
|
||||
<p>Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.</p>
|
||||
</prompt>
|
||||
<task>
|
||||
<selfassessment/>
|
||||
</task>
|
||||
<task>
|
||||
<openended min_score_to_attempt="2" max_score_to_attempt="3">
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>
|
||||
</task>
|
||||
</combinedopenended>
|
||||
1
common/test/data/open_ended/course.xml
Normal file
1
common/test/data/open_ended/course.xml
Normal file
@@ -0,0 +1 @@
|
||||
<course org="edX" course="open_ended" url_name="2012_Fall"/>
|
||||
7
common/test/data/open_ended/course/2012_Fall.xml
Normal file
7
common/test/data/open_ended/course/2012_Fall.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<course>
|
||||
<chapter url_name="Overview">
|
||||
<combinedopenended url_name="SampleQuestion"/>
|
||||
<peergrading url_name="PeerGradingSample"/>
|
||||
<peergrading url_name="PeerGradingScored"/>
|
||||
</chapter>
|
||||
</course>
|
||||
@@ -0,0 +1 @@
|
||||
<peergrading/>
|
||||
@@ -0,0 +1 @@
|
||||
<peergrading is_graded="True" max_grade="1" use_for_single_location="False" link_to_location="i4x://edX/open_ended/combinedopenended/SampleQuestion"/>
|
||||
17
common/test/data/open_ended/policies/2012_Fall.json
Normal file
17
common/test/data/open_ended/policies/2012_Fall.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"course/2012_Fall": {
|
||||
"graceperiod": "2 days 5 hours 59 minutes 59 seconds",
|
||||
"start": "2015-07-17T12:00",
|
||||
"display_name": "Self Assessment Test",
|
||||
"graded": "true"
|
||||
},
|
||||
"chapter/Overview": {
|
||||
"display_name": "Overview"
|
||||
},
|
||||
"combinedopenended/SampleQuestion": {
|
||||
"display_name": "Sample Question"
|
||||
},
|
||||
"peergrading/PeerGradingSample": {
|
||||
"display_name": "Sample Question"
|
||||
}
|
||||
}
|
||||
1
common/test/data/open_ended/roots/2012_Fall.xml
Normal file
1
common/test/data/open_ended/roots/2012_Fall.xml
Normal file
@@ -0,0 +1 @@
|
||||
<course org="edX" course="sa_test" url_name="2012_Fall"/>
|
||||
@@ -1,14 +1,14 @@
|
||||
<selfassessment attempts='10'>
|
||||
<prompt>
|
||||
What is the meaning of life?
|
||||
</prompt>
|
||||
<rubric>
|
||||
This is a rubric.
|
||||
</rubric>
|
||||
<submitmessage>
|
||||
Thanks for your submission!
|
||||
</submitmessage>
|
||||
<hintprompt>
|
||||
Enter a hint below:
|
||||
</hintprompt>
|
||||
</selfassessment>
|
||||
<prompt>
|
||||
What is the meaning of life?
|
||||
</prompt>
|
||||
<rubric>
|
||||
This is a rubric.
|
||||
</rubric>
|
||||
<submitmessage>
|
||||
Thanks for your submission!
|
||||
</submitmessage>
|
||||
<hintprompt>
|
||||
Enter a hint below:
|
||||
</hintprompt>
|
||||
</selfassessment>
|
||||
@@ -1 +1,4 @@
|
||||
{"locales" : ["en"]}
|
||||
{
|
||||
"locales" : ["en", "es"],
|
||||
"dummy-locale" : "fr"
|
||||
}
|
||||
|
||||
@@ -1 +1,20 @@
|
||||
# edX translation file
|
||||
# Copyright (C) 2013 edX
|
||||
# This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: EdX Studio\n"
|
||||
"Report-Msgid-Bugs-To: translation_team@edx.org\n"
|
||||
"POT-Creation-Date: 2013-05-02 13:13-0400\n"
|
||||
"PO-Revision-Date: 2013-05-02 13:27-0400\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: translation team <translation_team@edx.org>\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: en\n"
|
||||
|
||||
# empty
|
||||
msgid "This is a key string."
|
||||
msgstr ""
|
||||
|
||||
77
i18n/config.py
Normal file
77
i18n/config.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import os, json
|
||||
from path import path
|
||||
|
||||
# BASE_DIR is the working directory to execute django-admin commands from.
|
||||
# Typically this should be the 'mitx' directory.
|
||||
BASE_DIR = path(__file__).abspath().dirname().joinpath('..').normpath()
|
||||
|
||||
# LOCALE_DIR contains the locale files.
|
||||
# Typically this should be 'mitx/conf/locale'
|
||||
LOCALE_DIR = BASE_DIR.joinpath('conf', 'locale')
|
||||
|
||||
class Configuration:
|
||||
"""
|
||||
# Reads localization configuration in json format
|
||||
|
||||
"""
|
||||
_source_locale = 'en'
|
||||
|
||||
def __init__(self, filename):
|
||||
self._filename = filename
|
||||
self._config = self.read_config(filename)
|
||||
|
||||
def read_config(self, filename):
|
||||
"""
|
||||
Returns data found in config file (as dict), or raises exception if file not found
|
||||
"""
|
||||
if not os.path.exists(filename):
|
||||
raise Exception("Configuration file cannot be found: %s" % filename)
|
||||
with open(filename) as stream:
|
||||
return json.load(stream)
|
||||
|
||||
@property
|
||||
def locales(self):
|
||||
"""
|
||||
Returns a list of locales declared in the configuration file,
|
||||
e.g. ['en', 'fr', 'es']
|
||||
Each locale is a string.
|
||||
"""
|
||||
return self._config['locales']
|
||||
|
||||
@property
|
||||
def source_locale(self):
|
||||
"""
|
||||
Returns source language.
|
||||
Source language is English.
|
||||
"""
|
||||
return self._source_locale
|
||||
|
||||
@property
|
||||
def dummy_locale(self):
|
||||
"""
|
||||
Returns a locale to use for the dummy text, e.g. 'fr'.
|
||||
Throws exception if no dummy-locale is declared.
|
||||
The locale is a string.
|
||||
"""
|
||||
dummy = self._config.get('dummy-locale', None)
|
||||
if not dummy:
|
||||
raise Exception('Could not read dummy-locale from configuration file.')
|
||||
return dummy
|
||||
|
||||
def get_messages_dir(self, locale):
|
||||
"""
|
||||
Returns the name of the directory holding the po files for locale.
|
||||
Example: mitx/conf/locale/fr/LC_MESSAGES
|
||||
"""
|
||||
return LOCALE_DIR.joinpath(locale, 'LC_MESSAGES')
|
||||
|
||||
@property
|
||||
def source_messages_dir(self):
|
||||
"""
|
||||
Returns the name of the directory holding the source-language po files (English).
|
||||
Example: mitx/conf/locale/en/LC_MESSAGES
|
||||
"""
|
||||
return self.get_messages_dir(self.source_locale)
|
||||
|
||||
|
||||
CONFIGURATION = Configuration(LOCALE_DIR.joinpath('config').normpath())
|
||||
@@ -1,69 +1,30 @@
|
||||
import os, subprocess, logging, json
|
||||
import os, subprocess, logging
|
||||
|
||||
def init_module():
|
||||
"""
|
||||
Initializes module parameters
|
||||
"""
|
||||
global BASE_DIR, LOCALE_DIR, CONFIG_FILENAME, SOURCE_MSGS_DIR, SOURCE_LOCALE, LOG
|
||||
from config import CONFIGURATION, BASE_DIR
|
||||
|
||||
# BASE_DIR is the working directory to execute django-admin commands from.
|
||||
# Typically this should be the 'mitx' directory.
|
||||
BASE_DIR = os.path.normpath(os.path.dirname(os.path.abspath(__file__))+'/..')
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# Source language is English
|
||||
SOURCE_LOCALE = 'en'
|
||||
|
||||
# LOCALE_DIR contains the locale files.
|
||||
# Typically this should be 'mitx/conf/locale'
|
||||
LOCALE_DIR = BASE_DIR + '/conf/locale'
|
||||
|
||||
# CONFIG_FILENAME contains localization configuration in json format
|
||||
CONFIG_FILENAME = LOCALE_DIR + '/config'
|
||||
|
||||
# SOURCE_MSGS_DIR contains the English po files.
|
||||
SOURCE_MSGS_DIR = messages_dir(SOURCE_LOCALE)
|
||||
|
||||
# Default logger.
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def messages_dir(locale):
|
||||
"""
|
||||
Returns the name of the directory holding the po files for locale.
|
||||
Example: mitx/conf/locale/en/LC_MESSAGES
|
||||
"""
|
||||
return os.path.join(LOCALE_DIR, locale, 'LC_MESSAGES')
|
||||
|
||||
def get_logger():
|
||||
"""Returns a default logger"""
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(logging.INFO)
|
||||
log_handler = logging.StreamHandler()
|
||||
log_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
|
||||
log.addHandler(log_handler)
|
||||
return log
|
||||
|
||||
# Run this after defining messages_dir and get_logger, because it depends on these.
|
||||
init_module()
|
||||
|
||||
def execute (command, working_directory=BASE_DIR, log=LOG):
|
||||
def execute(command, working_directory=BASE_DIR):
|
||||
"""
|
||||
Executes shell command in a given working_directory.
|
||||
Command is a string to pass to the shell.
|
||||
Output is logged to log.
|
||||
Output is ignored.
|
||||
"""
|
||||
log.info(command)
|
||||
LOG.info(command)
|
||||
subprocess.call(command.split(' '), cwd=working_directory)
|
||||
|
||||
def get_config():
|
||||
"""Returns data found in config file, or returns None if file not found"""
|
||||
config_path = os.path.abspath(CONFIG_FILENAME)
|
||||
if not os.path.exists(config_path):
|
||||
log.warn("Configuration file cannot be found: %s" % \
|
||||
os.path.relpath(config_path, BASE_DIR))
|
||||
return None
|
||||
with open(config_path) as stream:
|
||||
return json.load(stream)
|
||||
|
||||
|
||||
def call(command, working_directory=BASE_DIR):
|
||||
"""
|
||||
Executes shell command in a given working_directory.
|
||||
Command is a string to pass to the shell.
|
||||
Returns a tuple of two strings: (stdout, stderr)
|
||||
|
||||
"""
|
||||
LOG.info(command)
|
||||
p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=working_directory)
|
||||
out, err = p.communicate()
|
||||
return (out, err)
|
||||
|
||||
def create_dir_if_necessary(pathname):
|
||||
dirname = os.path.dirname(pathname)
|
||||
@@ -71,16 +32,16 @@ def create_dir_if_necessary(pathname):
|
||||
os.makedirs(dirname)
|
||||
|
||||
|
||||
def remove_file(filename, log=LOG, verbose=True):
|
||||
def remove_file(filename, verbose=True):
|
||||
"""
|
||||
Attempt to delete filename.
|
||||
log is boolean. If true, removal is logged.
|
||||
Log a warning if file does not exist.
|
||||
Logging filenames are releative to BASE_DIR to cut down on noise in output.
|
||||
"""
|
||||
if verbose:
|
||||
log.info('Deleting file %s' % os.path.relpath(filename, BASE_DIR))
|
||||
LOG.info('Deleting file %s' % os.path.relpath(filename, BASE_DIR))
|
||||
if not os.path.exists(filename):
|
||||
log.warn("File does not exist: %s" % os.path.relpath(filename, BASE_DIR))
|
||||
LOG.warn("File does not exist: %s" % os.path.relpath(filename, BASE_DIR))
|
||||
else:
|
||||
os.remove(filename)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/python
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow
|
||||
@@ -15,28 +15,35 @@ See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
import os, sys, logging
|
||||
from datetime import datetime
|
||||
from polib import pofile
|
||||
from execute import execute, create_dir_if_necessary, remove_file, \
|
||||
BASE_DIR, LOCALE_DIR, SOURCE_MSGS_DIR, LOG
|
||||
|
||||
from config import BASE_DIR, LOCALE_DIR, CONFIGURATION
|
||||
from execute import execute, create_dir_if_necessary, remove_file
|
||||
|
||||
# BABEL_CONFIG contains declarations for Babel to extract strings from mako template files
|
||||
# Use relpath to reduce noise in logs
|
||||
BABEL_CONFIG = os.path.relpath(LOCALE_DIR + '/babel.cfg', BASE_DIR)
|
||||
BABEL_CONFIG = BASE_DIR.relpathto(LOCALE_DIR.joinpath('babel.cfg'))
|
||||
|
||||
# Strings from mako template files are written to BABEL_OUT
|
||||
# Use relpath to reduce noise in logs
|
||||
BABEL_OUT = os.path.relpath(SOURCE_MSGS_DIR + '/mako.po', BASE_DIR)
|
||||
BABEL_OUT = BASE_DIR.relpathto(CONFIGURATION.source_messages_dir.joinpath('mako.po'))
|
||||
|
||||
SOURCE_WARN = 'This English source file is machine-generated. Do not check it into github'
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
def main ():
|
||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
|
||||
create_dir_if_necessary(LOCALE_DIR)
|
||||
generated_files = ('django-partial.po', 'djangojs.po', 'mako.po')
|
||||
source_msgs_dir = CONFIGURATION.source_messages_dir
|
||||
|
||||
remove_file(source_msgs_dir.joinpath('django.po'))
|
||||
generated_files = ('django-partial.po', 'djangojs.po', 'mako.po')
|
||||
for filename in generated_files:
|
||||
remove_file(os.path.join(SOURCE_MSGS_DIR, filename))
|
||||
remove_file(source_msgs_dir.joinpath(filename))
|
||||
|
||||
|
||||
# Extract strings from mako templates
|
||||
babel_mako_cmd = 'pybabel extract -F %s -c "TRANSLATORS:" . -o %s' % (BABEL_CONFIG, BABEL_OUT)
|
||||
@@ -52,13 +59,13 @@ def main ():
|
||||
execute(make_django_cmd, working_directory=BASE_DIR)
|
||||
# makemessages creates 'django.po'. This filename is hardcoded.
|
||||
# Rename it to django-partial.po to enable merging into django.po later.
|
||||
os.rename(os.path.join(SOURCE_MSGS_DIR, 'django.po'),
|
||||
os.path.join(SOURCE_MSGS_DIR, 'django-partial.po'))
|
||||
os.rename(source_msgs_dir.joinpath('django.po'),
|
||||
source_msgs_dir.joinpath('django-partial.po'))
|
||||
execute(make_djangojs_cmd, working_directory=BASE_DIR)
|
||||
|
||||
for filename in generated_files:
|
||||
LOG.info('Cleaning %s' % filename)
|
||||
po = pofile(os.path.join(SOURCE_MSGS_DIR, filename))
|
||||
po = pofile(source_msgs_dir.joinpath(filename))
|
||||
# replace default headers with edX headers
|
||||
fix_header(po)
|
||||
# replace default metadata with edX metadata
|
||||
@@ -79,10 +86,11 @@ def fix_header(po):
|
||||
"""
|
||||
Replace default headers with edX headers
|
||||
"""
|
||||
po.metadata_is_fuzzy = [] # remove [u'fuzzy']
|
||||
header = po.header
|
||||
fixes = (
|
||||
('SOME DESCRIPTIVE TITLE', 'edX translation file'),
|
||||
('Translations template for PROJECT.', 'edX translation file'),
|
||||
('SOME DESCRIPTIVE TITLE', 'edX translation file\n' + SOURCE_WARN),
|
||||
('Translations template for PROJECT.', 'edX translation file\n' + SOURCE_WARN),
|
||||
('YEAR', '%s' % datetime.utcnow().year),
|
||||
('ORGANIZATION', 'edX'),
|
||||
("THE PACKAGE'S COPYRIGHT HOLDER", "EdX"),
|
||||
@@ -119,10 +127,9 @@ def fix_metadata(po):
|
||||
'Report-Msgid-Bugs-To': 'translation_team@edx.org',
|
||||
'Project-Id-Version': '0.1a',
|
||||
'Language' : 'en',
|
||||
'Last-Translator' : '',
|
||||
'Language-Team': 'translation team <translation_team@edx.org>',
|
||||
}
|
||||
if po.metadata.has_key('Last-Translator'):
|
||||
del po.metadata['Last-Translator']
|
||||
po.metadata.update(fixes)
|
||||
|
||||
def strip_key_strings(po):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/python
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow
|
||||
@@ -13,50 +13,71 @@
|
||||
languages to generate.
|
||||
"""
|
||||
|
||||
import os
|
||||
from execute import execute, get_config, messages_dir, remove_file, \
|
||||
BASE_DIR, LOG, SOURCE_LOCALE
|
||||
import os, sys, logging
|
||||
from polib import pofile
|
||||
|
||||
def merge(locale, target='django.po'):
|
||||
from config import BASE_DIR, CONFIGURATION
|
||||
from execute import execute
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
def merge(locale, target='django.po', fail_if_missing=True):
|
||||
"""
|
||||
For the given locale, merge django-partial.po, messages.po, mako.po -> django.po
|
||||
target is the resulting filename
|
||||
If fail_if_missing is True, and the files to be merged are missing,
|
||||
throw an Exception.
|
||||
If fail_if_missing is False, and the files to be merged are missing,
|
||||
just return silently.
|
||||
"""
|
||||
LOG.info('Merging locale={0}'.format(locale))
|
||||
locale_directory = messages_dir(locale)
|
||||
locale_directory = CONFIGURATION.get_messages_dir(locale)
|
||||
files_to_merge = ('django-partial.po', 'messages.po', 'mako.po')
|
||||
validate_files(locale_directory, files_to_merge)
|
||||
try:
|
||||
validate_files(locale_directory, files_to_merge)
|
||||
except Exception, e:
|
||||
if not fail_if_missing:
|
||||
return
|
||||
raise e
|
||||
|
||||
# merged file is merged.po
|
||||
merge_cmd = 'msgcat -o merged.po ' + ' '.join(files_to_merge)
|
||||
execute(merge_cmd, working_directory=locale_directory)
|
||||
|
||||
# clean up redunancies in the metadata
|
||||
merged_filename = locale_directory.joinpath('merged.po')
|
||||
clean_metadata(merged_filename)
|
||||
|
||||
# rename merged.po -> django.po (default)
|
||||
merged_filename = os.path.join(locale_directory, 'merged.po')
|
||||
django_filename = os.path.join(locale_directory, target)
|
||||
django_filename = locale_directory.joinpath(target)
|
||||
os.rename(merged_filename, django_filename) # can't overwrite file on Windows
|
||||
|
||||
def clean_metadata(file):
|
||||
"""
|
||||
Clean up redundancies in the metadata caused by merging.
|
||||
This reads in a PO file and simply saves it back out again.
|
||||
"""
|
||||
pofile(file).save()
|
||||
|
||||
def validate_files(dir, files_to_merge):
|
||||
"""
|
||||
Asserts that the given files exist.
|
||||
files_to_merge is a list of file names (no directories).
|
||||
dir is the directory in which the files should appear.
|
||||
dir is the directory (a path object from path.py) in which the files should appear.
|
||||
raises an Exception if any of the files are not in dir.
|
||||
"""
|
||||
for path in files_to_merge:
|
||||
pathname = os.path.join(dir, path)
|
||||
if not os.path.exists(pathname):
|
||||
raise Exception("File not found: {0}".format(pathname))
|
||||
pathname = dir.joinpath(path)
|
||||
if not pathname.exists():
|
||||
raise Exception("I18N: Cannot generate because file not found: {0}".format(pathname))
|
||||
|
||||
def main ():
|
||||
configuration = get_config()
|
||||
if configuration == None:
|
||||
LOG.warn('Configuration file not found, using only English.')
|
||||
locales = (SOURCE_LOCALE,)
|
||||
else:
|
||||
locales = configuration['locales']
|
||||
for locale in locales:
|
||||
merge(locale)
|
||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
|
||||
|
||||
for locale in CONFIGURATION.locales:
|
||||
merge(locale)
|
||||
# Dummy text is not required. Don't raise exception if files are missing.
|
||||
merge(CONFIGURATION.dummy_locale, fail_if_missing=False)
|
||||
compile_cmd = 'django-admin.py compilemessages'
|
||||
execute(compile_cmd, working_directory=BASE_DIR)
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
#!/usr/bin/python
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Generate test translation files from human-readable po files.
|
||||
#
|
||||
# Dummy language is specified in configuration file (see config.py)
|
||||
# two letter language codes reference:
|
||||
# see http://www.loc.gov/standards/iso639-2/php/code_list.php
|
||||
#
|
||||
# Django will not localize in languages that django itself has not been
|
||||
# localized for. So we are using a well-known language (default='fr').
|
||||
#
|
||||
# po files can be generated with this:
|
||||
# django-admin.py makemessages --all --extension html -l en
|
||||
@@ -10,14 +16,15 @@
|
||||
#
|
||||
# $ ./make_dummy.py <sourcefile>
|
||||
#
|
||||
# $ ./make_dummy.py mitx/conf/locale/en/LC_MESSAGES/django.po
|
||||
# $ ./make_dummy.py ../conf/locale/en/LC_MESSAGES/django.po
|
||||
#
|
||||
# generates output to
|
||||
# mitx/conf/locale/vr/LC_MESSAGES/django.po
|
||||
# mitx/conf/locale/fr/LC_MESSAGES/django.po
|
||||
|
||||
import os, sys
|
||||
import polib
|
||||
from dummy import Dummy
|
||||
from config import CONFIGURATION
|
||||
from execute import create_dir_if_necessary
|
||||
|
||||
def main(file, locale):
|
||||
@@ -41,27 +48,19 @@ def new_filename(original_filename, new_locale):
|
||||
orig_dir = os.path.dirname(original_filename)
|
||||
msgs_dir = os.path.basename(orig_dir)
|
||||
orig_file = os.path.basename(original_filename)
|
||||
return os.path.join(orig_dir,
|
||||
'/../..',
|
||||
new_locale,
|
||||
msgs_dir,
|
||||
orig_file)
|
||||
|
||||
|
||||
# Dummy language
|
||||
# two letter language codes reference:
|
||||
# see http://www.loc.gov/standards/iso639-2/php/code_list.php
|
||||
#
|
||||
# Django will not localize in languages that django itself has not been
|
||||
# localized for. So we are using a well-known language: 'fr'.
|
||||
|
||||
DEFAULT_LOCALE = 'fr'
|
||||
return os.path.abspath(os.path.join(orig_dir,
|
||||
'../..',
|
||||
new_locale,
|
||||
msgs_dir,
|
||||
orig_file))
|
||||
|
||||
if __name__ == '__main__':
|
||||
# required arg: file
|
||||
if len(sys.argv)<2:
|
||||
raise Exception("missing file argument")
|
||||
if len(sys.argv)<2:
|
||||
locale = DEFAULT_LOCALE
|
||||
# optional arg: locale
|
||||
if len(sys.argv)<3:
|
||||
locale = CONFIGURATION.get_dummy_locale()
|
||||
else:
|
||||
locale = sys.argv[2]
|
||||
main(sys.argv[1], locale)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from test_config import TestConfiguration
|
||||
from test_extract import TestExtract
|
||||
from test_generate import TestGenerate
|
||||
from test_converter import TestConverter
|
||||
from test_dummy import TestDummy
|
||||
import test_validate
|
||||
|
||||
33
i18n/tests/test_config.py
Normal file
33
i18n/tests/test_config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import os
|
||||
from unittest import TestCase
|
||||
|
||||
from config import Configuration, LOCALE_DIR, CONFIGURATION
|
||||
|
||||
class TestConfiguration(TestCase):
|
||||
"""
|
||||
Tests functionality of i18n/config.py
|
||||
"""
|
||||
|
||||
def test_config(self):
|
||||
config_filename = os.path.normpath(os.path.join(LOCALE_DIR, 'config'))
|
||||
config = Configuration(config_filename)
|
||||
self.assertEqual(config.source_locale, 'en')
|
||||
|
||||
def test_no_config(self):
|
||||
config_filename = os.path.normpath(os.path.join(LOCALE_DIR, 'no_such_file'))
|
||||
with self.assertRaises(Exception):
|
||||
Configuration(config_filename)
|
||||
|
||||
def test_valid_configuration(self):
|
||||
"""
|
||||
Make sure we have a valid configuration file,
|
||||
and that it contains an 'en' locale.
|
||||
Also check values of dummy_locale and source_locale.
|
||||
"""
|
||||
self.assertIsNotNone(CONFIGURATION)
|
||||
locales = CONFIGURATION.locales
|
||||
self.assertIsNotNone(locales)
|
||||
self.assertIsInstance(locales, list)
|
||||
self.assertIn('en', locales)
|
||||
self.assertEqual('fr', CONFIGURATION.dummy_locale)
|
||||
self.assertEqual('en', CONFIGURATION.source_locale)
|
||||
@@ -4,7 +4,7 @@ from nose.plugins.skip import SkipTest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import extract
|
||||
from execute import SOURCE_MSGS_DIR
|
||||
from config import CONFIGURATION
|
||||
|
||||
# Make sure setup runs only once
|
||||
SETUP_HAS_RUN = False
|
||||
@@ -39,7 +39,7 @@ class TestExtract(TestCase):
|
||||
Fails assertion if one of the files doesn't exist.
|
||||
"""
|
||||
for filename in self.generated_files:
|
||||
path = os.path.join(SOURCE_MSGS_DIR, filename)
|
||||
path = os.path.join(CONFIGURATION.source_messages_dir, filename)
|
||||
exists = os.path.exists(path)
|
||||
self.assertTrue(exists, msg='Missing file: %s' % filename)
|
||||
if exists:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import os, string, random
|
||||
import os, string, random, re
|
||||
from polib import pofile
|
||||
from unittest import TestCase
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import generate
|
||||
from execute import get_config, messages_dir, SOURCE_MSGS_DIR, SOURCE_LOCALE
|
||||
from config import CONFIGURATION
|
||||
|
||||
class TestGenerate(TestCase):
|
||||
"""
|
||||
@@ -12,29 +13,16 @@ class TestGenerate(TestCase):
|
||||
generated_files = ('django-partial.po', 'djangojs.po', 'mako.po')
|
||||
|
||||
def setUp(self):
|
||||
self.configuration = get_config()
|
||||
|
||||
# Subtract 1 second to help comparisons with file-modify time succeed,
|
||||
# since os.path.getmtime() is not millisecond-accurate
|
||||
self.start_time = datetime.now() - timedelta(seconds=1)
|
||||
|
||||
def test_configuration(self):
|
||||
"""
|
||||
Make sure we have a valid configuration file,
|
||||
and that it contains an 'en' locale.
|
||||
"""
|
||||
self.assertIsNotNone(self.configuration)
|
||||
locales = self.configuration['locales']
|
||||
self.assertIsNotNone(locales)
|
||||
self.assertIsInstance(locales, list)
|
||||
self.assertIn('en', locales)
|
||||
|
||||
def test_merge(self):
|
||||
"""
|
||||
Tests merge script on English source files.
|
||||
"""
|
||||
filename = os.path.join(SOURCE_MSGS_DIR, random_name())
|
||||
generate.merge(SOURCE_LOCALE, target=filename)
|
||||
filename = os.path.join(CONFIGURATION.source_messages_dir, random_name())
|
||||
generate.merge(CONFIGURATION.source_locale, target=filename)
|
||||
self.assertTrue(os.path.exists(filename))
|
||||
os.remove(filename)
|
||||
|
||||
@@ -47,13 +35,35 @@ class TestGenerate(TestCase):
|
||||
after start of test suite)
|
||||
"""
|
||||
generate.main()
|
||||
for locale in self.configuration['locales']:
|
||||
for filename in ('django.mo', 'djangojs.mo'):
|
||||
path = os.path.join(messages_dir(locale), filename)
|
||||
for locale in CONFIGURATION.locales:
|
||||
for filename in ('django', 'djangojs'):
|
||||
mofile = filename+'.mo'
|
||||
path = os.path.join(CONFIGURATION.get_messages_dir(locale), mofile)
|
||||
exists = os.path.exists(path)
|
||||
self.assertTrue(exists, msg='Missing file in locale %s: %s' % (locale, filename))
|
||||
self.assertTrue(exists, msg='Missing file in locale %s: %s' % (locale, mofile))
|
||||
self.assertTrue(datetime.fromtimestamp(os.path.getmtime(path)) >= self.start_time,
|
||||
msg='File not recently modified: %s' % path)
|
||||
self.assert_merge_headers(locale)
|
||||
|
||||
def assert_merge_headers(self, locale):
|
||||
"""
|
||||
This is invoked by test_main to ensure that it runs after
|
||||
calling generate.main().
|
||||
|
||||
There should be exactly three merge comment headers
|
||||
in our merged .po file. This counts them to be sure.
|
||||
A merge comment looks like this:
|
||||
# #-#-#-#-# django-partial.po (0.1a) #-#-#-#-#
|
||||
|
||||
"""
|
||||
path = os.path.join(CONFIGURATION.get_messages_dir(locale), 'django.po')
|
||||
po = pofile(path)
|
||||
pattern = re.compile('^#-#-#-#-#', re.M)
|
||||
match = pattern.findall(po.header)
|
||||
self.assertEqual(len(match), 3,
|
||||
msg="Found %s (should be 3) merge comments in the header for %s" % \
|
||||
(len(match), path))
|
||||
|
||||
|
||||
def random_name(size=6):
|
||||
"""Returns random filename as string, like test-4BZ81W"""
|
||||
|
||||
34
i18n/tests/test_validate.py
Normal file
34
i18n/tests/test_validate.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import os, sys, logging
|
||||
from unittest import TestCase
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
from config import LOCALE_DIR
|
||||
from execute import call
|
||||
|
||||
def test_po_files(root=LOCALE_DIR):
|
||||
"""
|
||||
This is a generator. It yields all of the .po files under root, and tests each one.
|
||||
"""
|
||||
log = logging.getLogger(__name__)
|
||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
|
||||
|
||||
for (dirpath, dirnames, filenames) in os.walk(root):
|
||||
for name in filenames:
|
||||
(base, ext) = os.path.splitext(name)
|
||||
if ext.lower() == '.po':
|
||||
yield validate_po_file, os.path.join(dirpath, name), log
|
||||
|
||||
|
||||
def validate_po_file(filename, log):
|
||||
"""
|
||||
Call GNU msgfmt -c on each .po file to validate its format.
|
||||
Any errors caught by msgfmt are logged to log.
|
||||
"""
|
||||
# Skip this test for now because it's very noisy
|
||||
raise SkipTest()
|
||||
# Use relative paths to make output less noisy.
|
||||
rfile = os.path.relpath(filename, LOCALE_DIR)
|
||||
(out, err) = call(['msgfmt','-c', rfile], working_directory=LOCALE_DIR)
|
||||
if err != '':
|
||||
log.warn('\n'+err)
|
||||
|
||||
70
i18n/transifex.py
Executable file
70
i18n/transifex.py
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import os, sys
|
||||
from polib import pofile
|
||||
from config import CONFIGURATION
|
||||
from extract import SOURCE_WARN
|
||||
from execute import execute
|
||||
|
||||
TRANSIFEX_HEADER = 'Translations in this file have been downloaded from %s'
|
||||
TRANSIFEX_URL = 'https://www.transifex.com/projects/p/edx-studio/'
|
||||
|
||||
def push():
|
||||
execute('tx push -s')
|
||||
|
||||
def pull():
|
||||
for locale in CONFIGURATION.locales:
|
||||
if locale != CONFIGURATION.source_locale:
|
||||
execute('tx pull -l %s' % locale)
|
||||
clean_translated_locales()
|
||||
|
||||
|
||||
def clean_translated_locales():
|
||||
"""
|
||||
Strips out the warning from all translated po files
|
||||
about being an English source file.
|
||||
"""
|
||||
for locale in CONFIGURATION.locales:
|
||||
if locale != CONFIGURATION.source_locale:
|
||||
clean_locale(locale)
|
||||
|
||||
def clean_locale(locale):
|
||||
"""
|
||||
Strips out the warning from all of a locale's translated po files
|
||||
about being an English source file.
|
||||
Iterates over machine-generated files.
|
||||
"""
|
||||
dirname = CONFIGURATION.get_messages_dir(locale)
|
||||
for filename in ('django-partial.po', 'djangojs.po', 'mako.po'):
|
||||
clean_file(dirname.joinpath(filename))
|
||||
|
||||
def clean_file(file):
|
||||
"""
|
||||
Strips out the warning from a translated po file about being an English source file.
|
||||
Replaces warning with a note about coming from Transifex.
|
||||
"""
|
||||
po = pofile(file)
|
||||
if po.header.find(SOURCE_WARN) != -1:
|
||||
new_header = get_new_header(po)
|
||||
new = po.header.replace(SOURCE_WARN, new_header)
|
||||
po.header = new
|
||||
po.save()
|
||||
|
||||
def get_new_header(po):
|
||||
team = po.metadata.get('Language-Team', None)
|
||||
if not team:
|
||||
return TRANSIFEX_HEADER % TRANSIFEX_URL
|
||||
else:
|
||||
return TRANSIFEX_HEADER % team
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv)<2:
|
||||
raise Exception("missing argument: push or pull")
|
||||
arg = sys.argv[1]
|
||||
if arg == 'push':
|
||||
push()
|
||||
elif arg == 'pull':
|
||||
pull()
|
||||
else:
|
||||
raise Exception("unknown argument: (%s)" % arg)
|
||||
|
||||
@@ -399,6 +399,14 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase):
|
||||
import_from_xml(module_store, TEST_DATA_DIR, ['toy'])
|
||||
self.check_random_page_loads(module_store)
|
||||
|
||||
def test_full_textbooks_loads(self):
|
||||
module_store = modulestore()
|
||||
import_from_xml(module_store, TEST_DATA_DIR, ['full'])
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
|
||||
|
||||
self.assertGreater(len(course.textbooks), 0)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
|
||||
class TestNavigation(LoginEnrollmentTestCase):
|
||||
|
||||
@@ -84,7 +84,9 @@ class TestStaffGradingService(LoginEnrollmentTestCase):
|
||||
data = {'location': self.location}
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
|
||||
d = json.loads(r.content)
|
||||
|
||||
self.assertTrue(d['success'])
|
||||
self.assertEquals(d['submission_id'], self.mock_service.cnt)
|
||||
self.assertIsNotNone(d['submission'])
|
||||
@@ -130,6 +132,7 @@ class TestStaffGradingService(LoginEnrollmentTestCase):
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
|
||||
self.assertTrue(d['success'], str(d))
|
||||
self.assertIsNotNone(d['problem_list'])
|
||||
|
||||
@@ -179,7 +182,8 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
|
||||
data = {'location': self.location}
|
||||
|
||||
r = self.peer_module.get_next_submission(data)
|
||||
d = json.loads(r)
|
||||
d = r
|
||||
|
||||
self.assertTrue(d['success'])
|
||||
self.assertIsNotNone(d['submission_id'])
|
||||
self.assertIsNotNone(d['prompt'])
|
||||
@@ -213,7 +217,8 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
|
||||
qdict.keys = data.keys
|
||||
|
||||
r = self.peer_module.save_grade(qdict)
|
||||
d = json.loads(r)
|
||||
d = r
|
||||
|
||||
self.assertTrue(d['success'])
|
||||
|
||||
def test_save_grade_missing_keys(self):
|
||||
@@ -225,7 +230,8 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
|
||||
def test_is_calibrated_success(self):
|
||||
data = {'location': self.location}
|
||||
r = self.peer_module.is_student_calibrated(data)
|
||||
d = json.loads(r)
|
||||
d = r
|
||||
|
||||
self.assertTrue(d['success'])
|
||||
self.assertTrue('calibrated' in d)
|
||||
|
||||
@@ -239,9 +245,8 @@ class TestPeerGradingService(LoginEnrollmentTestCase):
|
||||
data = {'location': self.location}
|
||||
|
||||
r = self.peer_module.show_calibration_essay(data)
|
||||
d = json.loads(r)
|
||||
log.debug(d)
|
||||
log.debug(type(d))
|
||||
d = r
|
||||
|
||||
self.assertTrue(d['success'])
|
||||
self.assertIsNotNone(d['submission_id'])
|
||||
self.assertIsNotNone(d['prompt'])
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"name": "mitx",
|
||||
"version": "0.1.0",
|
||||
"dependencies": { "coffee-script": "1.6.x"}
|
||||
}
|
||||
"dependencies": {
|
||||
"coffee-script": "1.6.X",
|
||||
"phantom-jasmine": "0.3.X"
|
||||
}
|
||||
}
|
||||
|
||||
98
rakefile
98
rakefile
@@ -14,8 +14,7 @@ LMS_REPORT_DIR = File.join(REPORT_DIR, "lms")
|
||||
|
||||
# Packaging constants
|
||||
DEPLOY_DIR = "/opt/wwc"
|
||||
PACKAGE_NAME = "mitx"
|
||||
LINK_PATH = "/opt/wwc/mitx"
|
||||
PACKAGE_NAME = "edx-platform"
|
||||
PKG_VERSION = "0.1"
|
||||
COMMIT = (ENV["GIT_COMMIT"] || `git rev-parse HEAD`).chomp()[0, 10]
|
||||
BRANCH = (ENV["GIT_BRANCH"] || `git symbolic-ref -q HEAD`).chomp().gsub('refs/heads/', '').gsub('origin/', '')
|
||||
@@ -95,7 +94,7 @@ def template_jasmine_runner(lib)
|
||||
if !coffee_files.empty?
|
||||
sh("node_modules/.bin/coffee -c #{coffee_files.join(' ')}")
|
||||
end
|
||||
phantom_jasmine_path = File.expand_path("common/test/phantom-jasmine")
|
||||
phantom_jasmine_path = File.expand_path("node_modules/phantom-jasmine")
|
||||
common_js_root = File.expand_path("common/static/js")
|
||||
common_coffee_root = File.expand_path("common/static/coffee/src")
|
||||
|
||||
@@ -319,7 +318,7 @@ end
|
||||
compile_assets()
|
||||
phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs'
|
||||
django_for_jasmine(system, false) do |jasmine_url|
|
||||
sh("#{phantomjs} common/test/phantom-jasmine/lib/run_jasmine_test.coffee #{jasmine_url}")
|
||||
sh("#{phantomjs} node_modules/phantom-jasmine/lib/run_jasmine_test.coffee #{jasmine_url}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -337,12 +336,6 @@ task :migrate, [:env] do |t, args|
|
||||
sh(django_admin(:lms, args.env, 'migrate'))
|
||||
end
|
||||
|
||||
desc "Run tests for the internationalization library"
|
||||
task :test_i18n do
|
||||
test = File.join(REPO_ROOT, "i18n", "tests")
|
||||
sh("nosetests #{test}")
|
||||
end
|
||||
|
||||
Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib|
|
||||
task_name = "test_#{lib}"
|
||||
|
||||
@@ -376,7 +369,7 @@ Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib|
|
||||
task "phantomjs_jasmine_#{lib}" do
|
||||
phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs'
|
||||
template_jasmine_runner(lib) do |f|
|
||||
sh("#{phantomjs} common/test/phantom-jasmine/lib/run_jasmine_test.coffee #{f}")
|
||||
sh("#{phantomjs} node_modules/phantom-jasmine/lib/run_jasmine_test.coffee #{f}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -516,27 +509,76 @@ end
|
||||
|
||||
# --- Internationalization tasks
|
||||
|
||||
desc "Extract localizable strings from sources"
|
||||
task :extract_dev_strings do
|
||||
sh(File.join(REPO_ROOT, "i18n", "extract.py"))
|
||||
end
|
||||
namespace :i18n do
|
||||
|
||||
desc "Compile localizable strings from sources. With optional flag 'extract', will extract strings first."
|
||||
task :generate_i18n do
|
||||
if ARGV.last.downcase == 'extract'
|
||||
Rake::Task["extract_dev_strings"].execute
|
||||
desc "Extract localizable strings from sources"
|
||||
task :extract => "i18n:validate:gettext" do
|
||||
sh(File.join(REPO_ROOT, "i18n", "extract.py"))
|
||||
end
|
||||
sh(File.join(REPO_ROOT, "i18n", "generate.py"))
|
||||
end
|
||||
|
||||
desc "Simulate international translation by generating dummy strings corresponding to source strings."
|
||||
task :dummy_i18n do
|
||||
source_files = Dir["#{REPO_ROOT}/conf/locale/en/LC_MESSAGES/*.po"]
|
||||
dummy_locale = 'fr'
|
||||
cmd = File.join(REPO_ROOT, "i18n", "make_dummy.py")
|
||||
for file in source_files do
|
||||
sh("#{cmd} #{file} #{dummy_locale}")
|
||||
desc "Compile localizable strings from sources. With optional flag 'extract', will extract strings first."
|
||||
task :generate => "i18n:validate:gettext" do
|
||||
if ARGV.last.downcase == 'extract'
|
||||
Rake::Task["i18n:extract"].execute
|
||||
end
|
||||
sh(File.join(REPO_ROOT, "i18n", "generate.py"))
|
||||
end
|
||||
|
||||
desc "Simulate international translation by generating dummy strings corresponding to source strings."
|
||||
task :dummy do
|
||||
source_files = Dir["#{REPO_ROOT}/conf/locale/en/LC_MESSAGES/*.po"]
|
||||
dummy_locale = 'fr'
|
||||
cmd = File.join(REPO_ROOT, "i18n", "make_dummy.py")
|
||||
for file in source_files do
|
||||
sh("#{cmd} #{file} #{dummy_locale}")
|
||||
end
|
||||
end
|
||||
|
||||
namespace :validate do
|
||||
|
||||
desc "Make sure GNU gettext utilities are available"
|
||||
task :gettext do
|
||||
begin
|
||||
select_executable('xgettext')
|
||||
rescue
|
||||
msg = "Cannot locate GNU gettext utilities, which are required by django for internationalization.\n"
|
||||
msg += "(see https://docs.djangoproject.com/en/dev/topics/i18n/translation/#message-files)\n"
|
||||
msg += "Try downloading them from http://www.gnu.org/software/gettext/"
|
||||
abort(msg.red)
|
||||
end
|
||||
end
|
||||
|
||||
desc "Make sure config file with username/password exists"
|
||||
task :transifex_config do
|
||||
config_file = "#{Dir.home}/.transifexrc"
|
||||
if !File.file?(config_file) or File.size(config_file)==0
|
||||
msg ="Cannot connect to Transifex, config file is missing or empty: #{config_file}\n"
|
||||
msg += "See http://help.transifex.com/features/client/#transifexrc"
|
||||
abort(msg.red)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace :transifex do
|
||||
desc "Push source strings to Transifex for translation"
|
||||
task :push => "i18n:validate:transifex_config" do
|
||||
cmd = File.join(REPO_ROOT, "i18n", "transifex.py")
|
||||
sh("#{cmd} push")
|
||||
end
|
||||
|
||||
desc "Pull translated strings from Transifex"
|
||||
task :pull => "i18n:validate:transifex_config" do
|
||||
cmd = File.join(REPO_ROOT, "i18n", "transifex.py")
|
||||
sh("#{cmd} pull")
|
||||
end
|
||||
end
|
||||
|
||||
desc "Run tests for the internationalization library"
|
||||
task :test => "i18n:validate:gettext" do
|
||||
test = File.join(REPO_ROOT, "i18n", "tests")
|
||||
sh("nosetests #{test}")
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# --- Develop and public documentation ---
|
||||
|
||||
@@ -33,6 +33,7 @@ paramiko==1.9.0
|
||||
path.py==3.0.1
|
||||
Pillow==1.7.8
|
||||
pip
|
||||
polib==1.0.3
|
||||
pygments==1.5
|
||||
pygraphviz==1.1
|
||||
pymongo==2.4.1
|
||||
|
||||
Reference in New Issue
Block a user