Files
edx-platform/cms/djangoapps/contentstore/views.py
2012-10-10 12:49:52 -04:00

756 lines
27 KiB
Python

from util.json_request import expect_json
import exceptions
import json
import logging
import mimetypes
import os
import StringIO
import sys
import time
from collections import defaultdict
from uuid import uuid4
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
from PIL import Image
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden
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 django import forms
from xmodule.modulestore import Location
from xmodule.x_module import ModuleSystem
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from github_sync import export_to_github
from static_replace import replace_urls
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
from functools import partial
from itertools import groupby
from operator import attrgetter
from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
from cache_toolbox.core import set_cached_content, get_cached_content, del_cached_content
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
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state
from xmodule.templates import all_templates
log = logging.getLogger(__name__)
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
# ==== 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})
@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})
# ==== 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().get_items(['i4x', None, None, 'course', None])
# filter out courses that we don't have access to
courses = filter(lambda course: has_access(request.user, course.location), courses)
return render_to_response('index.html', {
'courses': [(course.metadata.get('display_name'),
reverse('course_index', args=[
course.location.org,
course.location.course,
course.location.name]))
for course in courses]
})
# ==== 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 = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
upload_asset_callback_url = reverse('upload_asset', kwargs = {
'org' : org,
'course' : course,
'coursename' : name
})
course = modulestore().get_item(location)
sections = course.get_children()
return render_to_response('overview.html', {
'sections': sections,
'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
if not has_access(request.user, location):
raise PermissionDenied()
item = modulestore().get_item(location)
lms_link = get_lms_link_for_item(location)
# make sure that location references a 'sequential', otherwise return BadRequest
if item.location.category != 'sequential':
return HttpResponseBadRequest
logging.debug('Start = {0}'.format(item.start))
return render_to_response('edit_subsection.html',
{'subsection': item,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'lms_link': lms_link
})
@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
"""
# check that we have permissions to edit this item
if not has_access(request.user, location):
raise PermissionDenied()
item = modulestore().get_item(location)
# The non-draft location
lms_link = get_lms_link_for_item(item.location._replace(revision=None))
component_templates = defaultdict(list)
templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
for template in templates:
if template.location.category in COMPONENT_TYPES:
component_templates[template.location.category].append((
template.display_name,
template.location.url(),
))
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)
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
containing_section_locs = modulestore().get_parent_locations(containing_subsection.location)
containing_section = modulestore().get_item(containing_section_locs[0])
unit_state = compute_unit_state(item)
try:
published_date = time.strftime('%B %d, %Y', item.metadata.get('published_date'))
except TypeError:
published_date = None
return render_to_response('unit.html', {
'unit': item,
'unit_location': published_location,
'components': components,
'component_templates': component_templates,
'draft_preview_link': lms_link,
'published_preview_link': lms_link,
'subsection': containing_subsection,
'section': containing_section,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'unit_state': unit_state,
'published_date': published_date,
})
@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 Http404 # TODO (vshnayder): better error
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 user_author_string(user):
'''Get an author string for commits by this user. Format:
first last <email@email.com>.
If the first and last names are blank, uses the username instead.
Assumes that the email is not blank.
'''
f = user.first_name
l = user.last_name
if f == '' and l == '':
f = user.username
return '{first} {last} <{email}>'.format(first=f,
last=l,
email=user.email)
@login_required
def preview_dispatch(request, preview_id, location, dispatch=None):
"""
Dispatch an AJAX action to a preview XModule
Expects a POST request, and passes the arguments to the module
preview_id (str): An identifier specifying which preview this module is used for
location: The Location of the module to dispatch to
dispatch: The action to execute
"""
instance_state, shared_state = load_preview_state(request, preview_id, location)
descriptor = modulestore().get_item(location)
instance = load_preview_module(request, preview_id, descriptor, instance_state, shared_state)
# 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:
log.exception("error processing ajax call")
raise
save_preview_state(request, preview_id, location, instance.get_instance_state(), instance.get_shared_state())
return HttpResponse(ajax_return)
def load_preview_state(request, preview_id, location):
"""
Load the state of a preview module from the request
preview_id (str): An identifier specifying which preview this module is used for
location: The Location of the module to dispatch to
"""
if 'preview_states' not in request.session:
request.session['preview_states'] = defaultdict(dict)
instance_state = request.session['preview_states'][preview_id, location].get('instance')
shared_state = request.session['preview_states'][preview_id, location].get('shared')
return instance_state, shared_state
def save_preview_state(request, preview_id, location, instance_state, shared_state):
"""
Save the state of a preview module to the request
preview_id (str): An identifier specifying which preview this module is used for
location: The Location of the module to dispatch to
instance_state: The instance state to save
shared_state: The shared state to save
"""
if 'preview_states' not in request.session:
request.session['preview_states'] = defaultdict(dict)
request.session['preview_states'][preview_id, location]['instance'] = instance_state
request.session['preview_states'][preview_id, location]['shared'] = shared_state
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 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
"""
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=replace_urls,
user=request.user,
)
def get_preview_module(request, preview_id, location):
"""
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
"""
descriptor = modulestore().get_item(location)
instance_state, shared_state = descriptor.get_sample_state()[0]
return load_preview_module(request, preview_id, descriptor, instance_state, shared_state)
def load_preview_module(request, preview_id, descriptor, instance_state, shared_state):
"""
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_constructor(system)(instance_state, shared_state)
except:
module = ErrorDescriptor.from_descriptor(
descriptor,
error_msg=exc_info_to_str(sys.exc_info())
).xmodule_constructor(system)(None, None)
module.get_html = wrap_xmodule(
module.get_html,
module,
"xmodule_display.html",
)
module.get_html = replace_static_urls(
module.get_html,
module.metadata.get('data_dir', module.location.course)
)
save_preview_state(request, preview_id, descriptor.location.url(),
module.get_instance_state(), module.get_shared_state())
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, instance_state, shared_state)
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)
def _delete_item(item, recurse=False):
if recurse:
children = item.get_children()
for child in children:
_delete_item(child, recurse)
modulestore().delete_item(item.location);
@login_required
@expect_json
def delete_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()
# optional parameter to delete all children (default False)
delete_children = request.POST.get('delete_children', False)
item = modulestore().get_item(item_location)
if delete_children:
_xmodule_recurse(item, lambda i: modulestore().delete_item(i.location))
else:
modulestore().delete_item(item.location)
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()
if request.POST['data']:
data = request.POST['data']
modulestore().update_item(item_location, data)
if request.POST['children']:
children = request.POST['children']
modulestore().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['metadata']:
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 in posted_metadata.keys():
# NOTE: We don't want clients to be able to delete 'system metadata' which are not intended to be user
# editable
if posted_metadata[metadata_key] is None and metadata_key not in existing_item.system_metadata_fields:
# remove both from passed in collection as well as the collection read in from the modulestore
if metadata_key in existing_item.metadata:
del existing_item.metadata[metadata_key]
del posted_metadata[metadata_key]
# overlay the new metadata over the modulestore sourced collection to support partial updates
existing_item.metadata.update(posted_metadata)
# commit to datastore
modulestore().update_metadata(item_location, existing_item.metadata)
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))
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 = modulestore().get_item(parent_location)
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
new_item = modulestore().clone_item(template, dest_location)
# TODO: This needs to be deleted when we have proper storage for static content
new_item.metadata['data_dir'] = parent.metadata['data_dir']
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
new_item.metadata['display_name'] = display_name
modulestore().update_metadata(new_item.location.url(), new_item.own_metadata)
modulestore().update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
return HttpResponse(json.dumps({'id': dest_location.url()}))
#@login_required
#@ensure_csrf_cookie
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 = ['i4x', org, course, 'course', coursename]
if not has_access(request.user, location):
return HttpResponseForbidden()
# Does the course actually exist?!?
try:
item = 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
name = request.FILES['file'].name
mime_type = request.FILES['file'].content_type
filedata = request.FILES['file'].read()
file_location = StaticContent.compute_location(org, course, name)
content = StaticContent(file_location, name, mime_type, filedata)
# first commit to the DB
contentstore().save(content)
# then remove the cache so we're not serving up stale content
# NOTE: we're not re-populating the cache here as the DB owns the last-modified timestamp
# which is used when serving up static content. This integrity is needed for
# browser-side caching support. We *could* re-fetch the saved content so that we have the
# timestamp populated, but we might as well wait for the first real request to come in
# to re-populate the cache.
del_cached_content(content.location)
# if we're uploading an image, then let's generate a thumbnail so that we can
# serve it up when needed without having to rescale on the fly
if mime_type.split('/')[0] == 'image':
try:
# not sure if this is necessary, but let's rewind the stream just in case
request.FILES['file'].seek(0)
# use PIL to do the thumbnail generation (http://www.pythonware.com/products/pil/)
# My understanding is that PIL will maintain aspect ratios while restricting
# the max-height/width to be whatever you pass in as 'size'
# @todo: move the thumbnail size to a configuration setting?!?
im = Image.open(request.FILES['file'])
# I've seen some exceptions from the PIL library when trying to save palletted
# PNG files to JPEG. Per the google-universe, they suggest converting to RGB first.
im = im.convert('RGB')
size = 128, 128
im.thumbnail(size, Image.ANTIALIAS)
thumbnail_file = StringIO.StringIO()
im.save(thumbnail_file, 'JPEG')
thumbnail_file.seek(0)
# use a naming convention to associate originals with the thumbnail
thumbnail_name = content.generate_thumbnail_name()
# then just store this thumbnail as any other piece of content
thumbnail_file_location = StaticContent.compute_location(org, course,
thumbnail_name)
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
'image/jpeg', thumbnail_file)
contentstore().save(thumbnail_content)
# remove any cached content at this location, as thumbnails are treated just like any
# other bit of static content
del_cached_content(thumbnail_content.location)
except:
# catch, log, and continue as thumbnails are not a hard requirement
logging.error('Failed to generate thumbnail for {0}. Continuing...'.format(name))
return HttpResponse('Upload completed')
'''
This view will return all CMS users who are editors for the specified course
'''
@login_required
@ensure_csrf_cookie
def manage_users(request, location):
# check that logged in user has permissions to this item
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
raise PermissionDenied()
return render_to_response('manage_users.html', {
'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('/')
})
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
'''
This POST-back view will add a user - specified by email - to the list of editors for
the specified course
'''
@expect_json
@login_required
@ensure_csrf_cookie
def add_user(request, location):
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()
'''
This POST-back view will remove a user - specified by email - from the list of editors for
the specified course
'''
@expect_json
@login_required
@ensure_csrf_cookie
def remove_user(request, location):
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))
remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME)
return create_json_response()
@login_required
@ensure_csrf_cookie
def asset_index(request, location):
return render_to_response('asset_index.html',{})
# 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', {})