Created
March 18, 2014 23:39
-
-
Save name1984/9632354 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Views related to operations on course objects | |
""" | |
import json | |
import random | |
import string # pylint: disable=W0402 | |
import re | |
import bson | |
from django.db.models import Q | |
from django.utils.translation import ugettext as _ | |
from django.contrib.auth.decorators import login_required | |
from django_future.csrf import ensure_csrf_cookie | |
from django.conf import settings | |
from django.views.decorators.http import require_http_methods | |
from django.core.exceptions import PermissionDenied | |
from django.core.urlresolvers import reverse | |
from django.http import HttpResponseBadRequest, HttpResponseNotFound | |
from util.json_request import JsonResponse | |
from edxmako.shortcuts import render_to_response | |
from xmodule.error_module import ErrorDescriptor | |
from xmodule.modulestore.django import modulestore, loc_mapper | |
from xmodule.contentstore.content import StaticContent | |
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_extra_panel_tab, remove_extra_panel_tab, | |
get_modulestore) | |
from models.settings.course_details import CourseDetails, CourseSettingsEncoder | |
from models.settings.course_grading import CourseGradingModel | |
from models.settings.course_metadata import CourseMetadata | |
from util.json_request import expect_json | |
from .access import has_course_access | |
from .tabs import initialize_course_tabs | |
from .component import ( | |
OPEN_ENDED_COMPONENT_TYPES, NOTE_COMPONENT_TYPES, | |
ADVANCED_COMPONENT_POLICY_KEY) | |
from django_comment_common.models import assign_default_role | |
from django_comment_common.utils import seed_permissions_roles | |
from student.models import CourseEnrollment | |
from xmodule.html_module import AboutDescriptor | |
from xmodule.modulestore.locator import BlockUsageLocator, CourseLocator | |
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested | |
from contentstore import utils | |
from student.roles import CourseInstructorRole, CourseStaffRole, CourseCreatorRole, GlobalStaff | |
from student import auth | |
from microsite_configuration import microsite | |
__all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler', | |
'settings_handler', | |
'grading_handler', | |
'advanced_settings_handler', | |
'syllabus_list_handler', 'syllabus_detail_handler', | |
'textbooks_list_handler', 'textbooks_detail_handler'] | |
def _get_locator_and_course(package_id, branch, version_guid, block_id, user, depth=0): | |
""" | |
Internal method used to calculate and return the locator and course module | |
for the view functions in this file. | |
""" | |
locator = BlockUsageLocator(package_id=package_id, branch=branch, version_guid=version_guid, block_id=block_id) | |
if not has_course_access(user, locator): | |
raise PermissionDenied() | |
course_location = loc_mapper().translate_locator_to_location(locator) | |
course_module = modulestore().get_item(course_location, depth=depth) | |
return locator, course_module | |
# pylint: disable=unused-argument | |
@login_required | |
def course_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None): | |
""" | |
The restful handler for course specific requests. | |
It provides the course tree with the necessary information for identifying and labeling the parts. The root | |
will typically be a 'course' object but may not be especially as we support modules. | |
GET | |
html: return course listing page if not given a course id | |
html: return html page overview for the given course if given a course id | |
json: return json representing the course branch's index entry as well as dag w/ all of the children | |
replaced w/ json docs where each doc has {'_id': , 'display_name': , 'children': } | |
POST | |
json: create a course, return resulting json | |
descriptor (same as in GET course/...). Leaving off /branch/draft would imply create the course w/ default | |
branches. Cannot change the structure contents ('_id', 'display_name', 'children') but can change the | |
index entry. | |
PUT | |
json: update this course (index entry not xblock) such as repointing head, changing display name, org, | |
package_id, prettyid. Return same json as above. | |
DELETE | |
json: delete this branch from this course (leaving off /branch/draft would imply delete the course) | |
""" | |
response_format = request.REQUEST.get('format', 'html') | |
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): | |
if request.method == 'GET': | |
return JsonResponse(_course_json(request, package_id, branch, version_guid, block)) | |
elif request.method == 'POST': # not sure if this is only post. If one will have ids, it goes after access | |
return create_new_course(request) | |
elif not has_course_access( | |
request.user, | |
BlockUsageLocator(package_id=package_id, branch=branch, version_guid=version_guid, block_id=block) | |
): | |
raise PermissionDenied() | |
elif request.method == 'PUT': | |
raise NotImplementedError() | |
elif request.method == 'DELETE': | |
raise NotImplementedError() | |
else: | |
return HttpResponseBadRequest() | |
elif request.method == 'GET': # assume html | |
if package_id is None: | |
return course_listing(request) | |
else: | |
return course_index(request, package_id, branch, version_guid, block) | |
else: | |
return HttpResponseNotFound() | |
@login_required | |
def _course_json(request, package_id, branch, version_guid, block): | |
""" | |
Returns a JSON overview of a course | |
""" | |
__, course = _get_locator_and_course( | |
package_id, branch, version_guid, block, request.user, depth=None | |
) | |
return _xmodule_json(course, course.location.course_id) | |
def _xmodule_json(xmodule, course_id): | |
""" | |
Returns a JSON overview of an XModule | |
""" | |
locator = loc_mapper().translate_location( | |
course_id, xmodule.location, published=False, add_entry_if_missing=True | |
) | |
is_container = xmodule.has_children | |
result = { | |
'display_name': xmodule.display_name, | |
'id': unicode(locator), | |
'category': xmodule.category, | |
'is_draft': getattr(xmodule, 'is_draft', False), | |
'is_container': is_container, | |
} | |
if is_container: | |
result['children'] = [_xmodule_json(child, course_id) for child in xmodule.get_children()] | |
return result | |
def _accessible_courses_list(request): | |
""" | |
List all courses available to the logged in user by iterating through all the courses | |
""" | |
courses = modulestore('direct').get_courses() | |
# filter out courses that we don't have access too | |
def course_filter(course): | |
""" | |
Get courses to which this user has access | |
""" | |
if GlobalStaff().has_user(request.user): | |
return course.location.course != 'templates' | |
return (has_course_access(request.user, course.location) | |
# pylint: disable=fixme | |
# TODO remove this condition when templates purged from db | |
and course.location.course != 'templates' | |
) | |
courses = filter(course_filter, courses) | |
return courses | |
# pylint: disable=invalid-name | |
def _accessible_courses_list_from_groups(request): | |
""" | |
List all courses available to the logged in user by reversing access group names | |
""" | |
courses_list = [] | |
course_ids = set() | |
user_staff_group_names = request.user.groups.filter( | |
Q(name__startswith='instructor_') | Q(name__startswith='staff_') | |
).values_list('name', flat=True) | |
# we can only get course_ids from role names with the new format (instructor_org/number/run or | |
# instructor_org.number.run but not instructor_number). | |
for user_staff_group_name in user_staff_group_names: | |
# to avoid duplication try to convert all course_id's to format with dots e.g. "edx.course.run" | |
if user_staff_group_name.startswith("instructor_"): | |
# strip starting text "instructor_" | |
course_id = user_staff_group_name[11:] | |
else: | |
# strip starting text "staff_" | |
course_id = user_staff_group_name[6:] | |
course_ids.add(course_id.replace('/', '.').lower()) | |
for course_id in course_ids: | |
# get course_location with lowercase id | |
course_location = loc_mapper().translate_locator_to_location( | |
CourseLocator(package_id=course_id), get_course=True, lower_only=True | |
) | |
if course_location is None: | |
raise ItemNotFoundError(course_id) | |
course = modulestore('direct').get_course(course_location.course_id) | |
courses_list.append(course) | |
return courses_list | |
@login_required | |
@ensure_csrf_cookie | |
def course_listing(request): | |
""" | |
List all courses available to the logged in user | |
Try to get all courses by first reversing django groups and fallback to old method if it fails | |
Note: overhead of pymongo reads will increase if getting courses from django groups fails | |
""" | |
if GlobalStaff().has_user(request.user): | |
# user has global access so no need to get courses from django groups | |
courses = _accessible_courses_list(request) | |
else: | |
try: | |
courses = _accessible_courses_list_from_groups(request) | |
except ItemNotFoundError: | |
# user have some old groups or there was some error getting courses from django groups | |
# so fallback to iterating through all courses | |
courses = _accessible_courses_list(request) | |
# update location entry in "loc_mapper" for user courses (add keys 'lower_id' and 'lower_course_id') | |
for course in courses: | |
loc_mapper().create_map_entry(course.location) | |
def format_course_for_view(course): | |
""" | |
return tuple of the data which the view requires for each course | |
""" | |
# published = false b/c studio manipulates draft versions not b/c the course isn't pub'd | |
course_loc = loc_mapper().translate_location( | |
course.location.course_id, course.location, published=False, add_entry_if_missing=True | |
) | |
return ( | |
course.display_name, | |
# note, couldn't get django reverse to work; so, wrote workaround | |
course_loc.url_reverse('course/', ''), | |
get_lms_link_for_item(course.location), | |
course.display_org_with_default, | |
course.display_number_with_default, | |
course.location.name | |
) | |
return render_to_response('index.html', { | |
'courses': [format_course_for_view(c) for c in courses if not isinstance(c, ErrorDescriptor)], | |
'user': request.user, | |
'request_course_creator_url': reverse('contentstore.views.request_course_creator'), | |
'course_creator_status': _get_course_creator_status(request.user), | |
}) | |
@login_required | |
@ensure_csrf_cookie | |
def course_index(request, package_id, branch, version_guid, block): | |
""" | |
Display an editable course overview. | |
org, course, name: Attributes of the Location for the item to edit | |
""" | |
locator, course = _get_locator_and_course( | |
package_id, branch, version_guid, block, request.user, depth=3 | |
) | |
lms_link = get_lms_link_for_item(course.location) | |
sections = course.get_children() | |
return render_to_response('overview.html', { | |
'context_course': course, | |
'lms_link': lms_link, | |
'sections': sections, | |
'course_graders': json.dumps( | |
CourseGradingModel.fetch(locator).graders | |
), | |
'parent_locator': locator, | |
'new_section_category': 'chapter', | |
'new_subsection_category': 'sequential', | |
'new_unit_category': 'vertical', | |
'category': 'vertical' | |
}) | |
@expect_json | |
def create_new_course(request): | |
""" | |
Create a new course. | |
Returns the URL for the course overview page. | |
""" | |
if not auth.has_access(request.user, CourseCreatorRole()): | |
raise PermissionDenied() | |
org = request.json.get('org') | |
number = request.json.get('number') | |
display_name = request.json.get('display_name') | |
run = request.json.get('run') | |
level_of_education = request.json.get('level_of_education') | |
try: | |
dest_location = Location(u'i4x', org, number, u'course', run) | |
except InvalidLocationError as error: | |
return JsonResponse({ | |
"ErrMsg": _("Unable to create course '{name}'.\n\n{err}").format( | |
name=display_name, err=error.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 JsonResponse({ | |
'ErrMsg': _( | |
'There is already a course defined with the same ' | |
'organization, course number, and course run. Please ' | |
'change either organization or course number to be ' | |
'unique.' | |
), | |
'OrgErrMsg': _( | |
'Please change either the organization or ' | |
'course number so that it is unique.' | |
), | |
'CourseErrMsg': _( | |
'Please change either the organization or ' | |
'course number so that it is unique.' | |
), | |
}) | |
# dhm: this query breaks the abstraction, but I'll fix it when I do my suspended refactoring of this | |
# file for new locators. get_items should accept a query rather than requiring it be a legal location | |
course_search_location = bson.son.SON({ | |
'_id.tag': 'i4x', | |
# cannot pass regex to Location constructor; thus this hack | |
# pylint: disable=E1101 | |
'_id.org': re.compile(u'^{}$'.format(dest_location.org), re.IGNORECASE | re.UNICODE), | |
# pylint: disable=E1101 | |
'_id.course': re.compile(u'^{}$'.format(dest_location.course), re.IGNORECASE | re.UNICODE), | |
'_id.category': 'course', | |
}) | |
courses = modulestore().collection.find(course_search_location, fields=('_id')) | |
if courses.count() > 0: | |
return JsonResponse({ | |
'ErrMsg': _( | |
'There is already a course defined with the same ' | |
'organization and course number. Please ' | |
'change at least one field to be unique.'), | |
'OrgErrMsg': _( | |
'Please change either the organization or ' | |
'course number so that it is unique.'), | |
'CourseErrMsg': _( | |
'Please change either the organization or ' | |
'course number so that it is unique.'), | |
}) | |
# instantiate the CourseDescriptor and then persist it | |
# note: no system to pass | |
if display_name is None: | |
metadata = {} | |
else: | |
metadata = {'display_name': display_name, 'level_of_education': level_of_education} | |
# Set a unique wiki_slug for newly created courses. To maintain active wiki_slugs for existing xml courses this | |
# cannot be changed in CourseDescriptor. | |
wiki_slug = "{0}.{1}.{2}".format(dest_location.org, dest_location.course, dest_location.name) | |
definition_data = {'wiki_slug': wiki_slug} | |
modulestore('direct').create_and_save_xmodule( | |
dest_location, | |
definition_data=definition_data, | |
metadata=metadata | |
) | |
new_course = modulestore('direct').get_item(dest_location) | |
# clone a default 'about' overview module as well | |
dest_about_location = dest_location.replace( | |
category='about', | |
name='overview' | |
) | |
overview_template = AboutDescriptor.get_template('overview.yaml') | |
modulestore('direct').create_and_save_xmodule( | |
dest_about_location, | |
system=new_course.system, | |
definition_data=overview_template.get('data') | |
) | |
initialize_course_tabs(new_course, request.user) | |
new_location = loc_mapper().translate_location(new_course.location.course_id, new_course.location, False, True) | |
# can't use auth.add_users here b/c it requires request.user to already have Instructor perms in this course | |
# however, we can assume that b/c this user had authority to create the course, the user can add themselves | |
CourseInstructorRole(new_location).add_users(request.user) | |
auth.add_users(request.user, CourseStaffRole(new_location), request.user) | |
# seed the forums | |
seed_permissions_roles(new_course.location.course_id) | |
# auto-enroll the course creator in the course so that "View Live" will | |
# work. | |
CourseEnrollment.enroll(request.user, new_course.location.course_id) | |
_users_assign_default_role(new_course.location) | |
return JsonResponse({'url': new_location.url_reverse("course/", "")}) | |
def _users_assign_default_role(course_location): | |
""" | |
Assign 'Student' role to all previous users (if any) for this course | |
""" | |
enrollments = CourseEnrollment.objects.filter(course_id=course_location.course_id) | |
for enrollment in enrollments: | |
assign_default_role(course_location.course_id, enrollment.user) | |
# pylint: disable=unused-argument | |
@login_required | |
@ensure_csrf_cookie | |
@require_http_methods(["GET"]) | |
def course_info_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None): | |
""" | |
GET | |
html: return html for editing the course info handouts and updates. | |
""" | |
__, course_module = _get_locator_and_course( | |
package_id, branch, version_guid, block, request.user | |
) | |
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): | |
handouts_old_location = course_module.location.replace(category='course_info', name='handouts') | |
handouts_locator = loc_mapper().translate_location( | |
course_module.location.course_id, handouts_old_location, False, True | |
) | |
update_location = course_module.location.replace(category='course_info', name='updates') | |
update_locator = loc_mapper().translate_location( | |
course_module.location.course_id, update_location, False, True | |
) | |
return render_to_response( | |
'course_info.html', | |
{ | |
'context_course': course_module, | |
'updates_url': update_locator.url_reverse('course_info_update/'), | |
'handouts_locator': handouts_locator, | |
'base_asset_url': StaticContent.get_base_url_path_for_course_assets(course_module.location) + '/' | |
} | |
) | |
else: | |
return HttpResponseBadRequest("Only supports html requests") | |
# pylint: disable=unused-argument | |
@login_required | |
@ensure_csrf_cookie | |
@require_http_methods(("GET", "POST", "PUT", "DELETE")) | |
@expect_json | |
def course_info_update_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None, | |
provided_id=None): | |
""" | |
restful CRUD operations on course_info updates. | |
provided_id should be none if it's new (create) and index otherwise. | |
GET | |
json: return the course info update models | |
POST | |
json: create an update | |
PUT or DELETE | |
json: change an existing update | |
""" | |
if 'application/json' not in request.META.get('HTTP_ACCEPT', 'application/json'): | |
return HttpResponseBadRequest("Only supports json requests") | |
course_location = loc_mapper().translate_locator_to_location( | |
CourseLocator(package_id=package_id), get_course=True | |
) | |
updates_location = course_location.replace(category='course_info', name=block) | |
if provided_id == '': | |
provided_id = None | |
# check that logged in user has permissions to this item (GET shouldn't require this level?) | |
if not has_course_access(request.user, updates_location): | |
raise PermissionDenied() | |
if request.method == 'GET': | |
course_updates = get_course_updates(updates_location, provided_id) | |
if isinstance(course_updates, dict) and course_updates.get('error'): | |
return JsonResponse(get_course_updates(updates_location, provided_id), course_updates.get('status', 400)) | |
else: | |
return JsonResponse(get_course_updates(updates_location, provided_id)) | |
elif request.method == 'DELETE': | |
try: | |
return JsonResponse(delete_course_update(updates_location, request.json, provided_id, request.user)) | |
except: | |
return HttpResponseBadRequest( | |
"Failed to delete", | |
content_type="text/plain" | |
) | |
# can be either and sometimes django is rewriting one to the other: | |
elif request.method in ('POST', 'PUT'): | |
try: | |
return JsonResponse(update_course_updates(updates_location, request.json, provided_id, request.user)) | |
except: | |
return HttpResponseBadRequest( | |
"Failed to save", | |
content_type="text/plain" | |
) | |
@login_required | |
@ensure_csrf_cookie | |
@require_http_methods(("GET", "PUT", "POST")) | |
@expect_json | |
def settings_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None): | |
""" | |
Course settings for dates and about pages | |
GET | |
html: get the page | |
json: get the CourseDetails model | |
PUT | |
json: update the Course and About xblocks through the CourseDetails model | |
""" | |
locator, course_module = _get_locator_and_course( | |
package_id, branch, version_guid, block, request.user | |
) | |
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': | |
upload_asset_url = locator.url_reverse('assets/') | |
# see if the ORG of this course can be attributed to a 'Microsite'. In that case, the | |
# course about page should be editable in Studio | |
about_page_editable = not microsite.get_value_for_org( | |
course_module.location.org, | |
'ENABLE_MKTG_SITE', | |
settings.FEATURES.get('ENABLE_MKTG_SITE', False) | |
) | |
short_description_editable = settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True) | |
return render_to_response('settings.html', { | |
'context_course': course_module, | |
'course_locator': locator, | |
'lms_link_for_about_page': utils.get_lms_link_for_about_page(course_module.location), | |
'course_image_url': utils.course_image_url(course_module), | |
'details_url': locator.url_reverse('/settings/details/'), | |
'about_page_editable': about_page_editable, | |
'short_description_editable': short_description_editable, | |
'upload_asset_url': upload_asset_url | |
}) | |
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): | |
if request.method == 'GET': | |
return JsonResponse( | |
CourseDetails.fetch(locator), | |
# encoder serializes dates, old locations, and instances | |
encoder=CourseSettingsEncoder | |
) | |
else: # post or put, doesn't matter. | |
return JsonResponse( | |
CourseDetails.update_from_json(locator, request.json, request.user), | |
encoder=CourseSettingsEncoder | |
) | |
@login_required | |
@ensure_csrf_cookie | |
@require_http_methods(("GET", "POST", "PUT", "DELETE")) | |
@expect_json | |
def grading_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None, grader_index=None): | |
""" | |
Course Grading policy configuration | |
GET | |
html: get the page | |
json no grader_index: get the CourseGrading model (graceperiod, cutoffs, and graders) | |
json w/ grader_index: get the specific grader | |
PUT | |
json no grader_index: update the Course through the CourseGrading model | |
json w/ grader_index: create or update the specific grader (create if index out of range) | |
""" | |
locator, course_module = _get_locator_and_course( | |
package_id, branch, version_guid, block, request.user | |
) | |
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': | |
course_details = CourseGradingModel.fetch(locator) | |
return render_to_response('settings_graders.html', { | |
'context_course': course_module, | |
'course_locator': locator, | |
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder), | |
'grading_url': locator.url_reverse('/settings/grading/'), | |
}) | |
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): | |
if request.method == 'GET': | |
if grader_index is None: | |
return JsonResponse( | |
CourseGradingModel.fetch(locator), | |
# encoder serializes dates, old locations, and instances | |
encoder=CourseSettingsEncoder | |
) | |
else: | |
return JsonResponse(CourseGradingModel.fetch_grader(locator, grader_index)) | |
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. | |
# None implies update the whole model (cutoffs, graceperiod, and graders) not a specific grader | |
if grader_index is None: | |
return JsonResponse( | |
CourseGradingModel.update_from_json(locator, request.json, request.user), | |
encoder=CourseSettingsEncoder | |
) | |
else: | |
return JsonResponse( | |
CourseGradingModel.update_grader_from_json(locator, request.json, request.user) | |
) | |
elif request.method == "DELETE" and grader_index is not None: | |
CourseGradingModel.delete_grader(locator, grader_index, request.user) | |
return JsonResponse() | |
# pylint: disable=invalid-name | |
def _config_course_advanced_components(request, course_module): | |
""" | |
Check to see if the user instantiated any advanced components. This | |
is a hack that does the following : | |
1) adds/removes the open ended panel tab to a course automatically | |
if the user has indicated that they want to edit the | |
combinedopendended or peergrading module | |
2) adds/removes the notes panel tab to a course automatically if | |
the user has indicated that they want the notes module enabled in | |
their course | |
""" | |
# TODO refactor the above into distinct advanced policy settings | |
filter_tabs = True # Exceptional conditions will pull this to False | |
if ADVANCED_COMPONENT_POLICY_KEY in request.json: # Maps tab types to components | |
tab_component_map = { | |
'open_ended': OPEN_ENDED_COMPONENT_TYPES, | |
'notes': NOTE_COMPONENT_TYPES, | |
} | |
# Check to see if the user instantiated any notes or open ended | |
# components | |
for tab_type in tab_component_map.keys(): | |
component_types = tab_component_map.get(tab_type) | |
found_ac_type = False | |
for ac_type in component_types: | |
if ac_type in request.json[ADVANCED_COMPONENT_POLICY_KEY]: | |
# Add tab to the course if needed | |
changed, new_tabs = add_extra_panel_tab(tab_type, course_module) | |
# If a tab has been added to the course, then send the | |
# metadata along to CourseMetadata.update_from_json | |
if changed: | |
course_module.tabs = new_tabs | |
request.json.update({'tabs': new_tabs}) | |
# Indicate that tabs should not be filtered out of | |
# the metadata | |
filter_tabs = False # Set this flag to avoid the tab removal code below. | |
found_ac_type = True #break | |
# If we did not find a module type in the advanced settings, | |
# we may need to remove the tab from the course. | |
if not found_ac_type: # Remove tab from the course if needed | |
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module) | |
if changed: | |
course_module.tabs = new_tabs | |
request.json.update({'tabs':new_tabs}) | |
# Indicate that tabs should *not* be filtered out of | |
# the metadata | |
filter_tabs = False | |
return filter_tabs | |
@login_required | |
@ensure_csrf_cookie | |
@require_http_methods(("GET", "POST", "PUT")) | |
@expect_json | |
def advanced_settings_handler(request, package_id=None, branch=None, version_guid=None, block=None, tag=None): | |
""" | |
Course settings configuration | |
GET | |
html: get the page | |
json: get the model | |
PUT, POST | |
json: update the Course's settings. The payload is a json rep of the | |
metadata dicts. The dict can include a "unsetKeys" entry which is a list | |
of keys whose values to unset: i.e., revert to default | |
""" | |
locator, course_module = _get_locator_and_course( | |
package_id, branch, version_guid, block, request.user | |
) | |
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': | |
return render_to_response('settings_advanced.html', { | |
'context_course': course_module, | |
'advanced_dict': json.dumps(CourseMetadata.fetch(course_module)), | |
'advanced_settings_url': locator.url_reverse('settings/advanced') | |
}) | |
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): | |
if request.method == 'GET': | |
return JsonResponse(CourseMetadata.fetch(course_module)) | |
else: | |
# Whether or not to filter the tabs key out of the settings metadata | |
filter_tabs = _config_course_advanced_components(request, course_module) | |
try: | |
return JsonResponse(CourseMetadata.update_from_json( | |
course_module, | |
request.json, | |
filter_tabs=filter_tabs, | |
user=request.user, | |
)) | |
except (TypeError, ValueError) as err: | |
return HttpResponseBadRequest( | |
"Incorrect setting format. {}".format(err), | |
content_type="text/plain" | |
) | |
class TextbookValidationError(Exception): | |
"An error thrown when a textbook input is invalid" | |
pass | |
class SyllabusValidationError(Exception): | |
"An error thrown when a syllabus input is invalid" | |
pass | |
def validate_textbooks_json(text): | |
""" | |
Validate the given text as representing a single PDF textbook | |
""" | |
try: | |
textbooks = json.loads(text) | |
except ValueError: | |
raise TextbookValidationError("invalid JSON") | |
if not isinstance(textbooks, (list, tuple)): | |
raise TextbookValidationError("must be JSON list") | |
for textbook in textbooks: | |
validate_textbook_json(textbook) | |
# check specified IDs for uniqueness | |
all_ids = [textbook["id"] for textbook in textbooks if "id" in textbook] | |
unique_ids = set(all_ids) | |
if len(all_ids) > len(unique_ids): | |
raise TextbookValidationError("IDs must be unique") | |
return textbooks | |
def validate_textbook_json(textbook): | |
""" | |
Validate the given text as representing a list of PDF textbooks | |
""" | |
if isinstance(textbook, basestring): | |
try: | |
textbook = json.loads(textbook) | |
except ValueError: | |
raise TextbookValidationError("invalid JSON") | |
if not isinstance(textbook, dict): | |
raise TextbookValidationError("must be JSON object") | |
if not textbook.get("tab_title"): | |
raise TextbookValidationError("must have tab_title") | |
tid = unicode(textbook.get("id", "")) | |
if tid and not tid[0].isdigit(): | |
raise TextbookValidationError("textbook ID must start with a digit") | |
return textbook | |
def validate_syllabuses_json(text): | |
""" | |
Validate the given text as representing a single topic syllabus | |
""" | |
try: | |
syllabuses = json.loads(text) | |
except ValueError: | |
raise SyllabusValidationError("invalid JSON") | |
if not isinstance(syllabuses, (list, tuple)): | |
raise SyllabusValidationError("must be JSON list") | |
for syllabus in syllabuses: | |
validate_syllabus_json(syllabus) | |
# check specified IDs for uniqueness | |
all_ids = [syllabus["id"] for syllabus in syllabuses if "id" in syllabus] | |
unique_ids = set(all_ids) | |
if len(all_ids) > len(unique_ids): | |
raise SyllabusValidationError("IDs must be unique") | |
return syllabuses | |
def validate_syllabus_json(syllabus): | |
""" | |
Validate the given text as representing a list of topic_syllabuses | |
""" | |
if isinstance(syllabus, basestring): | |
try: | |
syllabus = json.loads(syllabus) | |
except ValueError: | |
raise SyllabusValidationError("invalid JSON") | |
if not isinstance(syllabus, dict): | |
raise SyllabusValidationError("must be JSON object") | |
if not syllabus.get("tab_title"): | |
raise SyllabusValidationError("must have tab_title") | |
tid = str(syllabus.get("id", "")) | |
if tid and not tid[0].isdigit(): | |
raise SyllabusValidationError("syllabus ID must start with a digit") | |
return syllabus | |
def assign_textbook_id(textbook, used_ids=()): | |
""" | |
Return an ID that can be assigned to a textbook | |
and doesn't match the used_ids | |
""" | |
tid = Location.clean(textbook["tab_title"]) | |
if not tid[0].isdigit(): | |
# stick a random digit in front | |
tid = random.choice(string.digits) + tid | |
while tid in used_ids: | |
# add a random ASCII character to the end | |
tid = tid + random.choice(string.ascii_lowercase) | |
return tid | |
def assign_syllabus_id(syllabus, used_ids=()): | |
""" | |
Return an ID that can be assigned to a syllabus | |
and doesn't match the used_ids | |
""" | |
tid = Location.clean(syllabus["tab_title"]) | |
if not tid[0].isdigit(): | |
# stick a random digit in front | |
tid = random.choice(string.digits) + tid | |
while tid in used_ids: | |
# add a random ASCII character to the end | |
tid = tid + random.choice(string.ascii_lowercase) | |
return tid | |
@require_http_methods(("GET", "POST", "PUT")) | |
@login_required | |
@ensure_csrf_cookie | |
def syllabus_list_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None): | |
""" | |
A RESTful handler for syllabus collections. | |
GET | |
html: return syllabus list page (Backbone application) | |
json: return JSON representation of all syllabus in this course | |
POST | |
json: create a new syllabus for this course | |
PUT | |
json: overwrite all syllabus in the course with the given list | |
""" | |
locator, course = _get_locator_and_course( | |
package_id, branch, version_guid, block, request.user | |
) | |
store = get_modulestore(course.location) | |
if not "application/json" in request.META.get('HTTP_ACCEPT', 'text/html'): | |
# return HTML page | |
syllabus_url = locator.url_reverse('/syllabuses') | |
return render_to_response('syllabuses.html', { | |
'context_course': course, | |
'syllabuses': course.topic_syllabuses, | |
'syllabus_url': syllabus_url, | |
}) | |
# from here on down, we know the client has requested JSON | |
if request.method == 'GET': | |
return JsonResponse(course.topic_syllabuses) | |
elif request.method == 'PUT': | |
try: | |
syllabuses = validate_syllabuses_json(request.body) | |
except SyllabusValidationError as err: | |
return JsonResponse({"error": err.message}, status=400) | |
tids = set(t["id"] for t in syllabuses if "id" in t) | |
for syllabus in syllabuses: | |
if not "id" in syllabus: | |
tid = assign_syllabus_id(syllabus, tids) | |
syllabus["id"] = tid | |
tids.add(tid) | |
if not any(tab['type'] == 'topic_syllabuses' for tab in course.tabs): | |
course.tabs.append({"type": "topic_syllabuses"}) | |
course.topic_syllabuses = syllabuses | |
store.update_item(course, request.user.id) | |
return JsonResponse(course.topic_syllabuses) | |
elif request.method == 'POST': | |
# create a new syllabus for the course | |
try: | |
syllabus = validate_syllabus_json(request.body) | |
except SyllabusValidationError as err: | |
return JsonResponse({"error": err.message}, status=400) | |
if not syllabus.get("id"): | |
tids = set(t["id"] for t in course.topic_syllabuses if "id" in t) | |
syllabus["id"] = assign_syllabus_id(syllabus, tids) | |
existing = course.topic_syllabuses | |
existing.append(syllabus) | |
course.topic_syllabuses = existing | |
if not any(tab['type'] == 'topic_syllabuses' for tab in course.tabs): | |
tabs = course.tabs | |
tabs.append({"type": "topic_syllabuses"}) | |
course.tabs = tabs | |
store.update_item(course, request.user.id) | |
resp = JsonResponse(syllabus, status=201) | |
resp["Location"] = locator.url_reverse('syllabuses', syllabus["id"]) | |
return resp | |
@require_http_methods(("GET", "POST", "PUT")) | |
@login_required | |
@ensure_csrf_cookie | |
def textbooks_list_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None): | |
""" | |
A RESTful handler for textbook collections. | |
GET | |
html: return textbook list page (Backbone application) | |
json: return JSON representation of all textbooks in this course | |
POST | |
json: create a new textbook for this course | |
PUT | |
json: overwrite all textbooks in the course with the given list | |
""" | |
locator, course = _get_locator_and_course( | |
package_id, branch, version_guid, block, request.user | |
) | |
store = get_modulestore(course.location) | |
if not "application/json" in request.META.get('HTTP_ACCEPT', 'text/html'): | |
# return HTML page | |
upload_asset_url = locator.url_reverse('assets/', '') | |
textbook_url = locator.url_reverse('/textbooks') | |
return render_to_response('textbooks.html', { | |
'context_course': course, | |
'textbooks': course.pdf_textbooks, | |
'upload_asset_url': upload_asset_url, | |
'textbook_url': textbook_url, | |
}) | |
# from here on down, we know the client has requested JSON | |
if request.method == 'GET': | |
return JsonResponse(course.pdf_textbooks) | |
elif request.method == 'PUT': | |
try: | |
textbooks = validate_textbooks_json(request.body) | |
except TextbookValidationError as err: | |
return JsonResponse({"error": err.message}, status=400) | |
tids = set(t["id"] for t in textbooks if "id" in t) | |
for textbook in textbooks: | |
if not "id" in textbook: | |
tid = assign_textbook_id(textbook, tids) | |
textbook["id"] = tid | |
tids.add(tid) | |
if not any(tab['type'] == 'pdf_textbooks' for tab in course.tabs): | |
course.tabs.append({"type": "pdf_textbooks"}) | |
course.pdf_textbooks = textbooks | |
store.update_item(course, request.user.id) | |
return JsonResponse(course.pdf_textbooks) | |
elif request.method == 'POST': | |
# create a new textbook for the course | |
try: | |
textbook = validate_textbook_json(request.body) | |
except TextbookValidationError as err: | |
return JsonResponse({"error": err.message}, status=400) | |
if not textbook.get("id"): | |
tids = set(t["id"] for t in course.pdf_textbooks if "id" in t) | |
textbook["id"] = assign_textbook_id(textbook, tids) | |
existing = course.pdf_textbooks | |
existing.append(textbook) | |
course.pdf_textbooks = existing | |
if not any(tab['type'] == 'pdf_textbooks' for tab in course.tabs): | |
tabs = course.tabs | |
tabs.append({"type": "pdf_textbooks"}) | |
course.tabs = tabs | |
store.update_item(course, request.user.id) | |
resp = JsonResponse(textbook, status=201) | |
resp["Location"] = locator.url_reverse('textbooks', textbook["id"]) | |
return resp | |
@login_required | |
@ensure_csrf_cookie | |
@require_http_methods(("GET", "POST", "PUT", "DELETE")) | |
def syllabus_detail_handler(request, tid, tag=None, package_id=None, branch=None, version_guid=None, block=None): | |
""" | |
JSON API endpoint for manipulating a syllabus via its internal ID. | |
Used by the Backbone application. | |
GET | |
json: return JSON representation of syllabus | |
POST or PUT | |
json: update syllabus based on provided information | |
DELETE | |
json: remove syllabus | |
""" | |
__, course = _get_locator_and_course( | |
package_id, branch, version_guid, block, request.user | |
) | |
store = get_modulestore(course.location) | |
matching_id = [tb for tb in course.topic_syllabuses | |
if str(tb.get("id")) == str(tid)] | |
if matching_id: | |
syllabus = matching_id[0] | |
else: | |
syllabus = None | |
if request.method == 'GET': | |
if not syllabus: | |
return JsonResponse(status=404) | |
return JsonResponse(syllabus) | |
elif request.method in ('POST', 'PUT'): # can be either and sometimes | |
# django is rewriting one to the other | |
try: | |
new_syllabus = validate_syllabus_json(request.body) | |
except SyllabusValidationError as err: | |
return JsonResponse({"error": err.message}, status=400) | |
new_syllabus["id"] = tid | |
if syllabus: | |
i = course.topic_syllabuses.index(syllabus) | |
new_syllabuses = course.topic_syllabuses[0:i] | |
new_syllabuses.append(new_syllabus) | |
new_syllabuses.extend(course.topic_syllabuses[i + 1:]) | |
course.topic_syllabuses = new_syllabuses | |
else: | |
course.topic_syllabuses.append(new_syllabus) | |
store.update_item(course, request.user.id) | |
return JsonResponse(new_syllabus, status=201) | |
elif request.method == 'DELETE': | |
if not syllabus: | |
return JsonResponse(status=404) | |
i = course.topic_syllabuses.index(syllabus) | |
new_syllabuses = course.topic_syllabuses[0:i] | |
new_syllabuses.extend(course.topic_syllabuses[i + 1:]) | |
course.topic_syllabuses = new_syllabuses | |
store.update_item(course, request.user.id) | |
return JsonResponse() | |
@login_required | |
@ensure_csrf_cookie | |
@require_http_methods(("GET", "POST", "PUT", "DELETE")) | |
def textbooks_detail_handler(request, tid, tag=None, package_id=None, branch=None, version_guid=None, block=None): | |
""" | |
JSON API endpoint for manipulating a textbook via its internal ID. | |
Used by the Backbone application. | |
GET | |
json: return JSON representation of textbook | |
POST or PUT | |
json: update textbook based on provided information | |
DELETE | |
json: remove textbook | |
""" | |
__, course = _get_locator_and_course( | |
package_id, branch, version_guid, block, request.user | |
) | |
store = get_modulestore(course.location) | |
matching_id = [tb for tb in course.pdf_textbooks | |
if unicode(tb.get("id")) == unicode(tid)] | |
if matching_id: | |
textbook = matching_id[0] | |
else: | |
textbook = None | |
if request.method == 'GET': | |
if not textbook: | |
return JsonResponse(status=404) | |
return JsonResponse(textbook) | |
elif request.method in ('POST', 'PUT'): # can be either and sometimes | |
# django is rewriting one to the other | |
try: | |
new_textbook = validate_textbook_json(request.body) | |
except TextbookValidationError as err: | |
return JsonResponse({"error": err.message}, status=400) | |
new_textbook["id"] = tid | |
if textbook: | |
i = course.pdf_textbooks.index(textbook) | |
new_textbooks = course.pdf_textbooks[0:i] | |
new_textbooks.append(new_textbook) | |
new_textbooks.extend(course.pdf_textbooks[i + 1:]) | |
course.pdf_textbooks = new_textbooks | |
else: | |
course.pdf_textbooks.append(new_textbook) | |
store.update_item(course, request.user.id) | |
return JsonResponse(new_textbook, status=201) | |
elif request.method == 'DELETE': | |
if not textbook: | |
return JsonResponse(status=404) | |
i = course.pdf_textbooks.index(textbook) | |
new_textbooks = course.pdf_textbooks[0:i] | |
new_textbooks.extend(course.pdf_textbooks[i + 1:]) | |
course.pdf_textbooks = new_textbooks | |
store.update_item(course, request.user.id) | |
return JsonResponse() | |
def _get_course_creator_status(user): | |
""" | |
Helper method for returning the course creator status for a particular user, | |
taking into account the values of DISABLE_COURSE_CREATION and ENABLE_CREATOR_GROUP. | |
If the user passed in has not previously visited the index page, it will be | |
added with status 'unrequested' if the course creator group is in use. | |
""" | |
if user.is_staff: | |
course_creator_status = 'granted' | |
elif settings.FEATURES.get('DISABLE_COURSE_CREATION', False): | |
course_creator_status = 'disallowed_for_this_site' | |
elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): | |
course_creator_status = get_course_creator_status(user) | |
if course_creator_status is None: | |
# User not grandfathered in as an existing user, has not previously visited the dashboard page. | |
# Add the user to the course creator admin table with status 'unrequested'. | |
add_user_with_status_unrequested(user) | |
course_creator_status = get_course_creator_status(user) | |
else: | |
course_creator_status = 'granted' | |
return course_creator_status |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment