diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py deleted file mode 100644 index 5938bfe986..0000000000 --- a/cms/djangoapps/contentstore/views.py +++ /dev/null @@ -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 . - - 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 diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py new file mode 100644 index 0000000000..0da8a15707 --- /dev/null +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -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 * diff --git a/cms/djangoapps/contentstore/views/access.py b/cms/djangoapps/contentstore/views/access.py new file mode 100644 index 0000000000..803313e274 --- /dev/null +++ b/cms/djangoapps/contentstore/views/access.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py new file mode 100644 index 0000000000..25f7bb066d --- /dev/null +++ b/cms/djangoapps/contentstore/views/assets.py @@ -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': '' + }) diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py new file mode 100644 index 0000000000..e9f4e1c7b4 --- /dev/null +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py new file mode 100644 index 0000000000..18253925e1 --- /dev/null +++ b/cms/djangoapps/contentstore/views/component.py @@ -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() diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py new file mode 100644 index 0000000000..ecc271926a --- /dev/null +++ b/cms/djangoapps/contentstore/views/course.py @@ -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") diff --git a/cms/djangoapps/contentstore/views/error.py b/cms/djangoapps/contentstore/views/error.py new file mode 100644 index 0000000000..0292b9d389 --- /dev/null +++ b/cms/djangoapps/contentstore/views/error.py @@ -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', {})) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py new file mode 100644 index 0000000000..67a4ad4e0c --- /dev/null +++ b/cms/djangoapps/contentstore/views/item.py @@ -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() diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py new file mode 100644 index 0000000000..515ea3e837 --- /dev/null +++ b/cms/djangoapps/contentstore/views/preview.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/public.py b/cms/djangoapps/contentstore/views/public.py new file mode 100644 index 0000000000..0433aa9e9d --- /dev/null +++ b/cms/djangoapps/contentstore/views/public.py @@ -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', {}) diff --git a/cms/djangoapps/contentstore/views/requests.py b/cms/djangoapps/contentstore/views/requests.py new file mode 100644 index 0000000000..b02a13fe3f --- /dev/null +++ b/cms/djangoapps/contentstore/views/requests.py @@ -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) diff --git a/cms/djangoapps/contentstore/views/session_kv_store.py b/cms/djangoapps/contentstore/views/session_kv_store.py new file mode 100644 index 0000000000..309518c27d --- /dev/null +++ b/cms/djangoapps/contentstore/views/session_kv_store.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py new file mode 100644 index 0000000000..f9349a5ca0 --- /dev/null +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -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', {}) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py new file mode 100644 index 0000000000..462b184718 --- /dev/null +++ b/cms/djangoapps/contentstore/views/user.py @@ -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 . + + 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()