Commit 9c859fd9 by Calen Pennington

Merge pull request #780 from MITx/feature/cdodge/cms-static-content-management

Feature/cdodge/cms static content management
parents 7750bb47 84a06037
from util.json_request import expect_json from util.json_request import expect_json
import json import json
import os
import logging import logging
import sys import sys
import mimetypes
import StringIO
from collections import defaultdict from collections import defaultdict
from django.http import HttpResponse, Http404 # 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.contrib.auth.decorators import login_required
from django.core.context_processors import csrf from django.core.context_processors import csrf
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from django import forms
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.x_module import ModuleSystem from xmodule.x_module import ModuleSystem
...@@ -26,6 +33,13 @@ from functools import partial ...@@ -26,6 +33,13 @@ from functools import partial
from itertools import groupby from itertools import groupby
from operator import attrgetter from operator import attrgetter
from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
#from django.core.cache import cache
from cache_toolbox.core import set_cached_content, get_cached_content, del_cached_content
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -89,9 +103,26 @@ def course_index(request, org, course, name): ...@@ -89,9 +103,26 @@ def course_index(request, org, course, name):
raise Http404 # TODO (vshnayder): better error raise Http404 # TODO (vshnayder): better error
# TODO (cpennington): These need to be read in from the active user # TODO (cpennington): These need to be read in from the active user
course = modulestore().get_item(location) _course = modulestore().get_item(location)
weeks = course.get_children() weeks = _course.get_children()
return render_to_response('course_index.html', {'weeks': weeks})
#upload_asset_callback_url = "/{org}/{course}/course/{name}/upload_asset".format(
# org = org,
# course = course,
# name = name
# )
upload_asset_callback_url = reverse('upload_asset', kwargs = {
'org' : org,
'course' : course,
'coursename' : name
})
logging.debug(upload_asset_callback_url)
return render_to_response('course_index.html', {
'weeks': weeks,
'upload_asset_callback_url': upload_asset_callback_url
})
@login_required @login_required
...@@ -115,12 +146,13 @@ def edit_item(request): ...@@ -115,12 +146,13 @@ def edit_item(request):
lms_link = "{lms_base}/courses/{course_id}/jump_to/{location}".format( lms_link = "{lms_base}/courses/{course_id}/jump_to/{location}".format(
lms_base=settings.LMS_BASE, lms_base=settings.LMS_BASE,
# TODO: These will need to be changed to point to the particular instance of this problem in the particular course # TODO: These will need to be changed to point to the particular instance of this problem in the particular course
course_id=modulestore().get_containing_courses(item.location)[0].id, course_id= modulestore().get_containing_courses(item.location)[0].id,
location=item.location, location=item.location,
) )
else: else:
lms_link = None lms_link = None
return render_to_response('unit.html', { return render_to_response('unit.html', {
'contents': item.get_html(), 'contents': item.get_html(),
'js_module': item.js_module_name, 'js_module': item.js_module_name,
...@@ -333,7 +365,7 @@ def save_item(request): ...@@ -333,7 +365,7 @@ def save_item(request):
if request.POST['data']: if request.POST['data']:
data = request.POST['data'] data = request.POST['data']
modulestore().update_item(item_location, data) modulestore().update_item(item_location, data)
if request.POST['children']: if request.POST['children']:
children = request.POST['children'] children = request.POST['children']
modulestore().update_children(item_location, children) modulestore().update_children(item_location, children)
...@@ -390,3 +422,94 @@ def clone_item(request): ...@@ -390,3 +422,94 @@ def clone_item(request):
modulestore().update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) modulestore().update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
return HttpResponse() return HttpResponse()
'''
cdodge: this method allows for POST uploading of files into the course asset library, which will
be supported by GridFS in MongoDB.
'''
#@login_required
#@ensure_csrf_cookie
def upload_asset(request, org, course, coursename):
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_filename(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(file_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
# <name_without_extention>.thumbnail.jpg
thumbnail_name = os.path.splitext(name)[0] + '.thumbnail.jpg'
# then just store this thumbnail as any other piece of content
thumbnail_file_location = StaticContent.compute_location_filename(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_file_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')
...@@ -118,6 +118,7 @@ TEMPLATE_LOADERS = ( ...@@ -118,6 +118,7 @@ TEMPLATE_LOADERS = (
) )
MIDDLEWARE_CLASSES = ( MIDDLEWARE_CLASSES = (
'contentserver.middleware.StaticContentServer',
'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
...@@ -130,7 +131,7 @@ MIDDLEWARE_CLASSES = ( ...@@ -130,7 +131,7 @@ MIDDLEWARE_CLASSES = (
'track.middleware.TrackMiddleware', 'track.middleware.TrackMiddleware',
'mitxmako.middleware.MakoMiddleware', 'mitxmako.middleware.MakoMiddleware',
'django.middleware.transaction.TransactionMiddleware', 'django.middleware.transaction.TransactionMiddleware'
) )
############################ SIGNAL HANDLERS ################################ ############################ SIGNAL HANDLERS ################################
......
...@@ -28,6 +28,17 @@ MODULESTORE = { ...@@ -28,6 +28,17 @@ MODULESTORE = {
} }
} }
# cdodge: This is the specifier for the MongoDB (using GridFS) backed static content store
# This is for static content for courseware, not system static content (e.g. javascript, css, edX branding, etc)
CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': {
'host': 'localhost',
'db' : 'xcontent',
}
}
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
......
...@@ -10,5 +10,7 @@ ...@@ -10,5 +10,7 @@
<section class="main-content"> <section class="main-content">
</section> </section>
<%include file="widgets/upload_assets.html"/>
</section> </section>
</%block> </%block>
<section>
<div class="assset-upload">
You can upload file assets (such as images) to reference in your courseware
<form action="${upload_asset_callback_url}" method="post" enctype="multipart/form-data">
<input type="file" name="file">
<input type="submit" value="Upload File">
</form>
<div class="progress" style="position:relative; width:400px; border: 1px solid #ddd; padding: 1px; border-radius: 3px;">
<div class="bar" style="background-color: #B4F5B4; width:0%; height:20px; border-radius: 3px;"></div>
<div class="percent">0%</div>
</div>
<div id="status"></div>
</div>
</section>
<script src="http://malsup.github.com/jquery.form.js"></script>
<script>
(function() {
var bar = $('.bar');
var percent = $('.percent');
var status = $('#status');
$('form').ajaxForm({
beforeSend: function() {
status.empty();
var percentVal = '0%';
bar.width(percentVal)
percent.html(percentVal);
},
uploadProgress: function(event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
bar.width(percentVal)
percent.html(percentVal);
},
complete: function(xhr) {
status.html(xhr.responseText);
}
});
})();
</script>
...@@ -17,7 +17,9 @@ urlpatterns = ('', ...@@ -17,7 +17,9 @@ urlpatterns = ('',
'contentstore.views.course_index', name='course_index'), 'contentstore.views.course_index', name='course_index'),
url(r'^github_service_hook$', 'github_sync.views.github_post_receive'), url(r'^github_service_hook$', 'github_sync.views.github_post_receive'),
url(r'^preview/modx/(?P<preview_id>[^/]*)/(?P<location>.*?)/(?P<dispatch>[^/]*)$', url(r'^preview/modx/(?P<preview_id>[^/]*)/(?P<location>.*?)/(?P<dispatch>[^/]*)$',
'contentstore.views.preview_dispatch', name='preview_dispatch') 'contentstore.views.preview_dispatch', name='preview_dispatch'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$',
'contentstore.views.upload_asset', name='upload_asset')
) )
# User creation and updating views # User creation and updating views
......
...@@ -107,3 +107,15 @@ def instance_key(model, instance_or_pk): ...@@ -107,3 +107,15 @@ def instance_key(model, instance_or_pk):
model._meta.module_name, model._meta.module_name,
getattr(instance_or_pk, 'pk', instance_or_pk), getattr(instance_or_pk, 'pk', instance_or_pk),
) )
def content_key(filename):
return 'content:%s' % (filename)
def set_cached_content(content):
cache.set(content_key(content.filename), content)
def get_cached_content(filename):
return cache.get(content_key(filename))
def del_cached_content(filename):
cache.delete(content_key(filename))
import logging
import time
from django.http import HttpResponse, Http404, HttpResponseNotModified
from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent, XASSET_LOCATION_TAG
from cache_toolbox.core import get_cached_content, set_cached_content
from xmodule.exceptions import NotFoundError
class StaticContentServer(object):
def process_request(self, request):
# look to see if the request is prefixed with 'c4x' tag
if request.path.startswith('/' + XASSET_LOCATION_TAG):
# first look in our cache so we don't have to round-trip to the DB
content = get_cached_content(request.path)
if content is None:
# nope, not in cache, let's fetch from DB
try:
content = contentstore().find(request.path)
except NotFoundError:
raise Http404
# since we fetched it from DB, let's cache it going forward
set_cached_content(content)
else:
# @todo: we probably want to have 'cache hit' counters so we can
# measure the efficacy of our caches
pass
# see if the last-modified at hasn't changed, if not return a 302 (Not Modified)
# convert over the DB persistent last modified timestamp to a HTTP compatible
# timestamp, so we can simply compare the strings
last_modified_at_str = content.last_modified_at.strftime("%a, %d-%b-%Y %H:%M:%S GMT")
# see if the client has cached this content, if so then compare the
# timestamps, if they are the same then just return a 304 (Not Modified)
if 'HTTP_IF_MODIFIED_SINCE' in request.META:
if_modified_since = request.META['HTTP_IF_MODIFIED_SINCE']
if if_modified_since == last_modified_at_str:
return HttpResponseNotModified()
response = HttpResponse(content.data, content_type=content.content_type)
response['Last-Modified'] = last_modified_at_str
return response
...@@ -10,6 +10,7 @@ import sys ...@@ -10,6 +10,7 @@ import sys
from datetime import timedelta from datetime import timedelta
from lxml import etree from lxml import etree
from lxml.html import rewrite_links
from pkg_resources import resource_string from pkg_resources import resource_string
from capa.capa_problem import LoncapaProblem from capa.capa_problem import LoncapaProblem
...@@ -332,6 +333,15 @@ class CapaModule(XModule): ...@@ -332,6 +333,15 @@ class CapaModule(XModule):
html = '<div id="problem_{id}" class="problem" data-url="{ajax_url}">'.format( html = '<div id="problem_{id}" class="problem" data-url="{ajax_url}">'.format(
id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "</div>" id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "</div>"
# cdodge: OK, we have to do two rounds of url reference subsitutions
# one which uses the 'asset library' that is served by the contentstore and the
# more global /static/ filesystem based static content.
# NOTE: rewrite_content_links is defined in XModule
# This is a bit unfortunate and I'm sure we'll try to considate this into
# a one step process.
html = rewrite_links(html, self.rewrite_content_links)
# now do the substitutions which are filesystem based, e.g. '/static/' prefixes
return self.system.replace_urls(html, self.metadata['data_dir']) return self.system.replace_urls(html, self.metadata['data_dir'])
def handle_ajax(self, dispatch, get): def handle_ajax(self, dispatch, get):
......
XASSET_LOCATION_TAG = 'c4x'
XASSET_SRCREF_PREFIX = 'xasset:'
class StaticContent(object):
def __init__(self, filename, name, content_type, data, last_modified_at=None):
self.filename = filename
self.name = name
self.content_type = content_type
self.data = data
self.last_modified_at = last_modified_at
@staticmethod
def compute_location_filename(org, course, name):
return '/{0}/{1}/{2}/asset/{3}'.format(XASSET_LOCATION_TAG, org, course, name)
'''
Abstraction for all ContentStore providers (e.g. MongoDB)
'''
class ContentStore(object):
def save(self, content):
raise NotImplementedError
def find(self, filename):
raise NotImplementedError
from __future__ import absolute_import
from importlib import import_module
from os import environ
from django.conf import settings
_CONTENTSTORE = None
def load_function(path):
"""
Load a function by name.
path is a string of the form "path.to.module.function"
returns the imported python object `function` from `path.to.module`
"""
module_path, _, name = path.rpartition('.')
return getattr(import_module(module_path), name)
def contentstore():
global _CONTENTSTORE
if _CONTENTSTORE is None:
class_ = load_function(settings.CONTENTSTORE['ENGINE'])
options = {}
options.update(settings.CONTENTSTORE['OPTIONS'])
_CONTENTSTORE = class_(**options)
return _CONTENTSTORE
from pymongo import Connection
import gridfs
from gridfs.errors import NoFile
import sys
import logging
from .content import StaticContent, ContentStore
from xmodule.exceptions import NotFoundError
class MongoContentStore(ContentStore):
def __init__(self, host, db, port=27017):
logging.debug( 'Using MongoDB for static content serving at host={0} db={1}'.format(host,db))
_db = Connection(host=host, port=port)[db]
self.fs = gridfs.GridFS(_db)
def save(self, content):
with self.fs.new_file(filename=content.filename, content_type=content.content_type, displayname=content.name) as fp:
fp.write(content.data)
return content
def find(self, filename):
try:
with self.fs.get_last_version(filename) as fp:
return StaticContent(fp.filename, fp.displayname, fp.content_type, fp.read(), fp.uploadDate)
except NoFile:
raise NotFoundError()
...@@ -4,6 +4,7 @@ import logging ...@@ -4,6 +4,7 @@ import logging
import os import os
import sys import sys
from lxml import etree from lxml import etree
from lxml.html import rewrite_links
from path import path from path import path
from .x_module import XModule from .x_module import XModule
...@@ -12,6 +13,9 @@ from .xml_module import XmlDescriptor, name_to_pathname ...@@ -12,6 +13,9 @@ from .xml_module import XmlDescriptor, name_to_pathname
from .editing_module import EditingDescriptor from .editing_module import EditingDescriptor
from .stringify import stringify_children from .stringify import stringify_children
from .html_checker import check_html from .html_checker import check_html
from xmodule.modulestore import Location
from xmodule.contentstore.content import XASSET_SRCREF_PREFIX, StaticContent
log = logging.getLogger("mitx.courseware") log = logging.getLogger("mitx.courseware")
...@@ -21,7 +25,8 @@ class HtmlModule(XModule): ...@@ -21,7 +25,8 @@ class HtmlModule(XModule):
js_module_name = "HTMLModule" js_module_name = "HTMLModule"
def get_html(self): def get_html(self):
return self.html # cdodge: perform link substitutions for any references to course static content (e.g. images)
return rewrite_links(self.html, self.rewrite_content_links)
def __init__(self, system, location, definition, descriptor, def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs): instance_state=None, shared_state=None, **kwargs):
...@@ -30,6 +35,7 @@ class HtmlModule(XModule): ...@@ -30,6 +35,7 @@ class HtmlModule(XModule):
self.html = self.definition['data'] self.html = self.definition['data']
class HtmlDescriptor(XmlDescriptor, EditingDescriptor): class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
""" """
Module for putting raw html in a course Module for putting raw html in a course
......
...@@ -12,6 +12,8 @@ from pkg_resources import resource_listdir, resource_string, resource_isdir ...@@ -12,6 +12,8 @@ from pkg_resources import resource_listdir, resource_string, resource_isdir
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.timeparse import parse_time from xmodule.timeparse import parse_time
from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX
log = logging.getLogger('mitx.' + __name__) log = logging.getLogger('mitx.' + __name__)
...@@ -317,6 +319,20 @@ class XModule(HTMLSnippet): ...@@ -317,6 +319,20 @@ class XModule(HTMLSnippet):
get is a dictionary-like object ''' get is a dictionary-like object '''
return "" return ""
# cdodge: added to support dynamic substitutions of
# links for courseware assets (e.g. images). <link> is passed through from lxml.html parser
def rewrite_content_links(self, link):
# see if we start with our format, e.g. 'xasset:<filename>'
if link.startswith(XASSET_SRCREF_PREFIX):
# yes, then parse out the name
name = link[len(XASSET_SRCREF_PREFIX):]
loc = Location(self.location)
# resolve the reference to our internal 'filepath' which
link = StaticContent.compute_location_filename(loc.org, loc.course, name)
return link
def policy_key(location): def policy_key(location):
""" """
......
...@@ -48,3 +48,4 @@ sorl-thumbnail ...@@ -48,3 +48,4 @@ sorl-thumbnail
networkx networkx
pygraphviz pygraphviz
-r repo-requirements.txt -r repo-requirements.txt
pil
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment