""" Views related to operations on course objects """ import json import random from django.utils.translation import ugettext as _ import string # pylint: disable=W0402 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, require_POST from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.http import HttpResponseBadRequest from util.json_request import JsonResponse from mitxmako.shortcuts import render_to_response from xmodule.modulestore.django import modulestore from xmodule.modulestore.inheritance import own_metadata 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 auth.authz import create_all_course_groups, is_user_in_creator_group from util.json_request import expect_json from .access import has_access, get_location_and_verify_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.utils import seed_permissions_roles from xmodule.html_module import AboutDescriptor __all__ = ['course_index', 'create_new_course', 'course_info', 'course_info_updates', 'get_course_settings', 'course_config_graders_page', 'course_config_advanced_page', 'course_settings_updates', 'course_grader_updates', 'course_advanced_updates', 'textbook_index', 'textbook_by_id', 'create_textbook'] @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', { 'context_course': course, 'lms_link': lms_link, 'sections': sections, 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), 'parent_location': course.location, 'new_section_category': 'chapter', 'new_subsection_category': 'sequential', 'upload_asset_callback_url': upload_asset_callback_url, 'new_unit_category': 'vertical', 'category': 'vertical' }) @login_required @expect_json def create_new_course(request): """ Create a new course """ if not is_user_in_creator_group(request.user): raise PermissionDenied() org = request.POST.get('org') number = request.POST.get('number') display_name = request.POST.get('display_name') run = request.POST.get('run') try: dest_location = Location('i4x', org, number, '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.'), } ) course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None] courses = modulestore().get_items(course_search_location) if len(courses) > 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} modulestore('direct').create_and_save_xmodule(dest_location, 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) create_all_course_groups(request.user, new_course.location) # seed the forums seed_permissions_roles(new_course.location.course_id) return JsonResponse({'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 = Location(['i4x', org, course, 'course_info', "updates"]) return render_to_response('course_info.html', { '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 @require_http_methods(("GET", "POST", "PUT", "DELETE")) @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() if request.method == 'GET': return JsonResponse(get_course_updates(location)) elif request.method == 'DELETE': try: return JsonResponse(delete_course_update(location, request.POST, provided_id)) except: return HttpResponseBadRequest("Failed to delete", content_type="text/plain") elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other try: return JsonResponse(update_course_updates(location, request.POST, provided_id)) 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"}), 'about_page_editable': not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False) }) @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 JsonResponse(manager.fetch(Location(['i4x', org, course, 'course', name])), encoder=CourseSettingsEncoder) elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. return JsonResponse(manager.update_from_json(request.POST), encoder=CourseSettingsEncoder) @expect_json @require_http_methods(("GET", "POST", "PUT", "DELETE")) @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) if request.method == 'GET': # Cannot just do a get w/o knowing the course name :-( return JsonResponse(CourseGradingModel.fetch_grader(Location(location), grader_index)) elif request.method == "DELETE": # ??? Should this return anything? Perhaps success fail? CourseGradingModel.delete_grader(Location(location), grader_index) return JsonResponse() else: # post or put, doesn't matter. return JsonResponse(CourseGradingModel.update_grader_from_json(Location(location), request.POST)) # # NB: expect_json failed on ["key", "key2"] and json payload @require_http_methods(("GET", "POST", "PUT", "DELETE")) @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) if request.method == 'GET': return JsonResponse(CourseMetadata.fetch(location)) elif request.method == 'DELETE': return JsonResponse(CourseMetadata.delete_key(location, json.loads(request.body))) else: # 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 # 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 if ADVANCED_COMPONENT_POLICY_KEY in request_body: # Get the course so that we can scrape current tabs course_module = modulestore().get_item(location) # 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_body[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_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 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_body.update({'tabs': new_tabs}) # Indicate that tabs should *not* be filtered out of the metadata filter_tabs = False try: return JsonResponse(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs)) except (TypeError, ValueError) as err: return HttpResponseBadRequest("Incorrect setting format. " + str(err), content_type="text/plain") class TextbookValidationError(Exception): "An error thrown when a textbook 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 = str(textbook.get("id", "")) if tid and not tid[0].isdigit(): raise TextbookValidationError("textbook ID must start with a digit") return textbook 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 @login_required @ensure_csrf_cookie def textbook_index(request, org, course, name): """ Display an editable textbook overview. org, course, name: Attributes of the Location for the item to edit """ location = get_location_and_verify_access(request, org, course, name) store = get_modulestore(location) course_module = store.get_item(location, depth=3) if request.is_ajax(): if request.method == 'GET': return JsonResponse(course_module.pdf_textbooks) elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other 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_module.tabs): course_module.tabs.append({"type": "pdf_textbooks"}) course_module.pdf_textbooks = textbooks # Save the data that we've just changed to the underlying # MongoKeyValueStore before we update the mongo datastore. course_module.save() store.update_metadata(course_module.location, own_metadata(course_module)) return JsonResponse(course_module.pdf_textbooks) else: upload_asset_url = reverse('upload_asset', kwargs={ 'org': org, 'course': course, 'coursename': name, }) textbook_url = reverse('textbook_index', kwargs={ 'org': org, 'course': course, 'name': name, }) return render_to_response('textbooks.html', { 'context_course': course_module, 'course': course_module, 'upload_asset_url': upload_asset_url, 'textbook_url': textbook_url, }) @require_POST @login_required @ensure_csrf_cookie def create_textbook(request, org, course, name): """ JSON API endpoint for creating a textbook. Used by the Backbone application. """ location = get_location_and_verify_access(request, org, course, name) store = get_modulestore(location) course_module = store.get_item(location, depth=0) 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_module.pdf_textbooks if "id" in t) textbook["id"] = assign_textbook_id(textbook, tids) existing = course_module.pdf_textbooks existing.append(textbook) course_module.pdf_textbooks = existing if not any(tab['type'] == 'pdf_textbooks' for tab in course_module.tabs): tabs = course_module.tabs tabs.append({"type": "pdf_textbooks"}) course_module.tabs = tabs # Save the data that we've just changed to the underlying # MongoKeyValueStore before we update the mongo datastore. course_module.save() store.update_metadata(course_module.location, own_metadata(course_module)) resp = JsonResponse(textbook, status=201) resp["Location"] = reverse("textbook_by_id", kwargs={ 'org': org, 'course': course, 'name': name, 'tid': textbook["id"], }) return resp @login_required @ensure_csrf_cookie @require_http_methods(("GET", "POST", "PUT", "DELETE")) def textbook_by_id(request, org, course, name, tid): """ JSON API endpoint for manipulating a textbook via its internal ID. Used by the Backbone application. """ location = get_location_and_verify_access(request, org, course, name) store = get_modulestore(location) course_module = store.get_item(location, depth=3) matching_id = [tb for tb in course_module.pdf_textbooks if str(tb.get("id")) == str(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_module.pdf_textbooks.index(textbook) new_textbooks = course_module.pdf_textbooks[0:i] new_textbooks.append(new_textbook) new_textbooks.extend(course_module.pdf_textbooks[i + 1:]) course_module.pdf_textbooks = new_textbooks else: course_module.pdf_textbooks.append(new_textbook) # Save the data that we've just changed to the underlying # MongoKeyValueStore before we update the mongo datastore. course_module.save() store.update_metadata(course_module.location, own_metadata(course_module)) return JsonResponse(new_textbook, status=201) elif request.method == 'DELETE': if not textbook: return JsonResponse(status=404) i = course_module.pdf_textbooks.index(textbook) new_textbooks = course_module.pdf_textbooks[0:i] new_textbooks.extend(course_module.pdf_textbooks[i + 1:]) course_module.pdf_textbooks = new_textbooks course_module.save() store.update_metadata(course_module.location, own_metadata(course_module)) return JsonResponse()