diff --git a/.gitmodules b/.gitmodules index 72ec77d0e2..76d1f7d672 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "askbot"] path = askbot url = git@github.com:MITx/askbot-devel.git +[submodule "lms/djangoapps/django-wiki"] + path = lms/djangoapps/django-wiki + url = git@github.com:benjaoming/django-wiki.git diff --git a/common/lib/mitxmako/mitxmako/makoloader.py b/common/lib/mitxmako/mitxmako/makoloader.py new file mode 100644 index 0000000000..53334d1a1b --- /dev/null +++ b/common/lib/mitxmako/mitxmako/makoloader.py @@ -0,0 +1,63 @@ + +from django.template.base import TemplateDoesNotExist +from django.template.loader import make_origin, get_template_from_string +from django.template.loaders.filesystem import Loader as FilesystemLoader +from django.template.loaders.app_directories import Loader as AppDirectoriesLoader + +from mitxmako.template import Template + +class MakoLoader(object): + """ + This is a Django loader object which will load the template as a + Mako template if the first line is "## mako". It is based off BaseLoader + in django.template.loader. + """ + + is_usable = False + + def __init__(self, base_loader): + # base_loader is an instance of a BaseLoader subclass + self.base_loader = base_loader + + def __call__(self, template_name, template_dirs=None): + return self.load_template(template_name, template_dirs) + + def load_template(self, template_name, template_dirs=None): + source, display_name = self.load_template_source(template_name, template_dirs) + + if source.startswith("## mako\n"): + # This is a mako template + template = Template(text=source, uri=template_name) + return template, None + else: + # This is a regular template + origin = make_origin(display_name, self.load_template_source, template_name, template_dirs) + try: + template = get_template_from_string(source, origin, template_name) + return template, None + except TemplateDoesNotExist: + # If compiling the template we found raises TemplateDoesNotExist, back off to + # returning the source and display name for the template we were asked to load. + # This allows for correct identification (later) of the actual template that does + # not exist. + return source, display_name + + def load_template_source(self, template_name, template_dirs=None): + # Just having this makes the template load as an instance, instead of a class. + return self.base_loader.load_template_source(template_name, template_dirs) + + def reset(self): + self.base_loader.reset() + + +class MakoFilesystemLoader(MakoLoader): + is_usable = True + + def __init__(self): + MakoLoader.__init__(self, FilesystemLoader()) + +class MakoAppDirectoriesLoader(MakoLoader): + is_usable = True + + def __init__(self): + MakoLoader.__init__(self, AppDirectoriesLoader()) diff --git a/common/lib/mitxmako/mitxmako/template.py b/common/lib/mitxmako/mitxmako/template.py index 911f5a5b28..65328ae830 100644 --- a/common/lib/mitxmako/mitxmako/template.py +++ b/common/lib/mitxmako/mitxmako/template.py @@ -12,18 +12,48 @@ # See the License for the specific language governing permissions and # limitations under the License. +from django.conf import settings from mako.template import Template as MakoTemplate -from . import middleware +from mitxmako import middleware -django_variables = ['lookup', 'template_dirs', 'output_encoding', +django_variables = ['lookup', 'output_encoding', 'module_directory', 'encoding_errors'] - +# TODO: We should make this a Django Template subclass that simply has the MakoTemplate inside of it? (Intead of inheriting from MakoTemplate) class Template(MakoTemplate): + """ + This bridges the gap between a Mako template and a djano template. It can + be rendered like it is a django template because the arguments are transformed + in a way that MakoTemplate can understand. + """ + def __init__(self, *args, **kwargs): """Overrides base __init__ to provide django variable overrides""" if not kwargs.get('no_django', False): overrides = dict([(k, getattr(middleware, k, None),) for k in django_variables]) + overrides['lookup'] = overrides['lookup']['main'] kwargs.update(overrides) super(Template, self).__init__(*args, **kwargs) + + + def render(self, context_instance): + """ + This takes a render call with a context (from Django) and translates + it to a render call on the mako template. + """ + # collapse context_instance to a single dictionary for mako + context_dictionary = {} + + # In various testing contexts, there might not be a current request context. + if middleware.requestcontext is not None: + for d in middleware.requestcontext: + context_dictionary.update(d) + for d in context_instance: + context_dictionary.update(d) + context_dictionary['settings'] = settings + context_dictionary['MITX_ROOT_URL'] = settings.MITX_ROOT_URL + context_dictionary['django_context'] = context_instance + + return super(Template, self).render(**context_dictionary) + diff --git a/common/lib/mitxmako/mitxmako/templatetag_helpers.py b/common/lib/mitxmako/mitxmako/templatetag_helpers.py new file mode 100644 index 0000000000..e254625d3d --- /dev/null +++ b/common/lib/mitxmako/mitxmako/templatetag_helpers.py @@ -0,0 +1,53 @@ +from django.template import loader +from django.template.base import Template, Context +from django.template.loader import get_template, select_template + +def django_template_include(file_name, mako_context): + """ + This can be used within a mako template to include a django template + in the way that a django-style {% include %} does. Pass it context + which can be the mako context ('context') or a dictionary. + """ + + dictionary = dict( mako_context ) + return loader.render_to_string(file_name, dictionary=dictionary) + + +def render_inclusion(func, file_name, takes_context, django_context, *args, **kwargs): + """ + This allows a mako template to call a template tag function (written + for django templates) that is an "inclusion tag". These functions are + decorated with @register.inclusion_tag. + + -func: This is the function that is registered as an inclusion tag. + You must import it directly using a python import statement. + -file_name: This is the filename of the template, passed into the + @register.inclusion_tag statement. + -takes_context: This is a parameter of the @register.inclusion_tag. + -django_context: This is an instance of the django context. If this + is a mako template rendered through the regular django rendering calls, + a copy of the django context is available as 'django_context'. + -*args and **kwargs are the arguments to func. + """ + + if takes_context: + args = [django_context] + list(args) + + _dict = func(*args, **kwargs) + if isinstance(file_name, Template): + t = file_name + elif not isinstance(file_name, basestring) and is_iterable(file_name): + t = select_template(file_name) + else: + t = get_template(file_name) + + nodelist = t.nodelist + + new_context = Context(_dict) + csrf_token = django_context.get('csrf_token', None) + if csrf_token is not None: + new_context['csrf_token'] = csrf_token + + return nodelist.render(new_context) + + diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 11d4e090f9..40eec1f70f 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -147,7 +147,7 @@ class CourseDescriptor(SequenceDescriptor): return self.location.course @property - def wiki_namespace(self): + def wiki_slug(self): return self.location.course @property diff --git a/common/static/images/search-icon.png b/common/static/images/search-icon.png new file mode 100644 index 0000000000..9ab8b34b75 Binary files /dev/null and b/common/static/images/search-icon.png differ diff --git a/common/static/images/wiki-icons.png b/common/static/images/wiki-icons.png new file mode 100644 index 0000000000..52c6dbc66f Binary files /dev/null and b/common/static/images/wiki-icons.png differ diff --git a/lms/djangoapps/course_wiki/__init__.py b/lms/djangoapps/course_wiki/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/course_wiki/course_nav.py b/lms/djangoapps/course_wiki/course_nav.py new file mode 100644 index 0000000000..1d124972c7 --- /dev/null +++ b/lms/djangoapps/course_wiki/course_nav.py @@ -0,0 +1,146 @@ +import re +from urlparse import urlparse + +from django.http import Http404 +from django.shortcuts import redirect + +from wiki.models import reverse as wiki_reverse +from courseware.courses import get_course_with_access + + +IN_COURSE_WIKI_REGEX = r'/courses/(?P[^/]+/[^/]+/[^/]+)/wiki/(?P.*|)$' + +class Middleware(object): + """ + This middleware is to keep the course nav bar above the wiki while + the student clicks around to other wiki pages. + If it intercepts a request for /wiki/.. that has a referrer in the + form /courses/course_id/... it will redirect the user to the page + /courses/course_id/wiki/... + + It is also possible that someone followed a link leading to a course + that they don't have access to. In this case, we redirect them to the + same page on the regular wiki. + + If we return a redirect, this middleware makes sure that the redirect + keeps the student in the course. + + Finally, if the student is in the course viewing a wiki, we change the + reverse() function to resolve wiki urls as a course wiki url by setting + the _transform_url attribute on wiki.models.reverse. + + Forgive me Father, for I have hacked. + """ + + def __init__(self): + self.redirected = False + + def process_request(self, request): + self.redirected = False + wiki_reverse._transform_url = lambda url: url + + referer = request.META.get('HTTP_REFERER') + destination = request.path + + + if request.method == 'GET': + new_destination = self.get_redirected_url(request.user, referer, destination) + + if new_destination != destination: + # We mark that we generated this redirection, so we don't modify it again + self.redirected = True + return redirect(new_destination) + + course_match = re.match(IN_COURSE_WIKI_REGEX, destination) + if course_match: + course_id = course_match.group('course_id') + prepend_string = '/courses/' + course_match.group('course_id') + wiki_reverse._transform_url = lambda url: prepend_string + url + + return None + + + def process_response(self, request, response): + """ + If this is a redirect response going to /wiki/*, then we might need + to change it to be a redirect going to /courses/*/wiki*. + """ + if not self.redirected and response.status_code == 302: #This is a redirect + referer = request.META.get('HTTP_REFERER') + destination_url = response['LOCATION'] + destination = urlparse(destination_url).path + + new_destination = self.get_redirected_url(request.user, referer, destination) + + if new_destination != destination: + new_url = destination_url.replace(destination, new_destination) + response['LOCATION'] = new_url + + return response + + + def get_redirected_url(self, user, referer, destination): + """ + Returns None if the destination shouldn't be changed. + """ + if not referer: + return destination + referer_path = urlparse(referer).path + + path_match = re.match(r'^/wiki/(?P.*|)$', destination) + if path_match: + # We are going to the wiki. Check if we came from a course + course_match = re.match(r'/courses/(?P[^/]+/[^/]+/[^/]+)/.*', referer_path) + if course_match: + course_id = course_match.group('course_id') + + # See if we are able to view the course. If we are, redirect to it + try: + course = get_course_with_access(user, course_id, 'load') + return "/courses/" + course.id + "/wiki/" + path_match.group('wiki_path') + except Http404: + # Even though we came from the course, we can't see it. So don't worry about it. + pass + + else: + # It is also possible we are going to a course wiki view, but we + # don't have permission to see the course! + course_match = re.match(IN_COURSE_WIKI_REGEX, destination) + if course_match: + course_id = course_match.group('course_id') + # See if we are able to view the course. If we aren't, redirect to regular wiki + try: + course = get_course_with_access(user, course_id, 'load') + # Good, we can see the course. Carry on + return destination + except Http404: + # We can't see the course, so redirect to the regular wiki + return "/wiki/" + course_match.group('wiki_path') + + return destination + + +def context_processor(request): + """ + This is a context processor which looks at the URL while we are + in the wiki. If the url is in the form + /courses/(course_id)/wiki/... + then we add 'course' to the context. This allows the course nav + bar to be shown. + """ + + match = re.match(IN_COURSE_WIKI_REGEX, request.path) + if match: + course_id = match.group('course_id') + + try: + course = get_course_with_access(request.user, course_id, 'load') + return {'course' : course} + except Http404: + # We couldn't access the course for whatever reason. It is too late to change + # the URL here, so we just leave the course context. The middleware shouldn't + # let this happen + pass + + return {} + \ No newline at end of file diff --git a/lms/djangoapps/course_wiki/tests/__init__.py b/lms/djangoapps/course_wiki/tests/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/lms/djangoapps/course_wiki/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py new file mode 100644 index 0000000000..e004265379 --- /dev/null +++ b/lms/djangoapps/course_wiki/tests/tests.py @@ -0,0 +1,119 @@ +from django.core.urlresolvers import reverse +from override_settings import override_settings + +import xmodule.modulestore.django + +from courseware.tests.tests import PageLoader, TEST_DATA_XML_MODULESTORE +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.xml_importer import import_from_xml + + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class WikiRedirectTestCase(PageLoader): + def setUp(self): + xmodule.modulestore.django._MODULESTORES = {} + courses = modulestore().get_courses() + + def find_course(name): + """Assumes the course is present""" + return [c for c in courses if c.location.course==name][0] + + self.full = find_course("full") + self.toy = find_course("toy") + + # Create two accounts + self.student = 'view@test.com' + self.instructor = 'view2@test.com' + self.password = 'foo' + self.create_account('u1', self.student, self.password) + self.create_account('u2', self.instructor, self.password) + self.activate_user(self.student) + self.activate_user(self.instructor) + + + + def test_wiki_redirect(self): + """ + Test that requesting wiki URLs redirect properly to or out of classes. + + An enrolled in student going from /courses/edX/toy/2012_Fall/profile + to /wiki/some/fake/wiki/page/ will redirect to + /courses/edX/toy/2012_Fall/wiki/some/fake/wiki/page/ + + An unenrolled student going to /courses/edX/toy/2012_Fall/wiki/some/fake/wiki/page/ + will be redirected to /wiki/some/fake/wiki/page/ + + """ + self.login(self.student, self.password) + + self.enroll(self.toy) + + referer = reverse("profile", kwargs={ 'course_id' : self.toy.id }) + destination = reverse("wiki:get", kwargs={'path': 'some/fake/wiki/page/'}) + + redirected_to = referer.replace("profile", "wiki/some/fake/wiki/page/") + + resp = self.client.get( destination, HTTP_REFERER=referer) + self.assertEqual(resp.status_code, 302 ) + + self.assertEqual(resp['Location'], 'http://testserver' + redirected_to ) + + + # Now we test that the student will be redirected away from that page if the course doesn't exist + # We do this in the same test because we want to make sure the redirected_to is constructed correctly + + # This is a location like /courses/*/wiki/* , but with an invalid course ID + bad_course_wiki_page = redirected_to.replace( self.toy.location.course, "bad_course" ) + + resp = self.client.get( bad_course_wiki_page, HTTP_REFERER=referer) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp['Location'], 'http://testserver' + destination ) + + + def create_course_page(self, course): + """ + Test that loading the course wiki page creates the wiki page. + The user must be enrolled in the course to see the page. + """ + + course_wiki_home = reverse('course_wiki', kwargs={'course_id' : course.id}) + referer = reverse("profile", kwargs={ 'course_id' : self.toy.id }) + + resp = self.client.get(course_wiki_home, follow=True, HTTP_REFERER=referer) + + course_wiki_page = referer.replace('profile', 'wiki/' + self.toy.wiki_slug + "/") + + ending_location = resp.redirect_chain[-1][0] + ending_status = resp.redirect_chain[-1][1] + + self.assertEquals(ending_location, 'http://testserver' + course_wiki_page ) + self.assertEquals(resp.status_code, 200) + + self.has_course_navigator(resp) + + def has_course_navigator(self, resp): + """ + Ensure that the response has the course navigator. + """ + self.assertTrue( "course info" in resp.content.lower() ) + self.assertTrue( "courseware" in resp.content.lower() ) + + + def test_course_navigator(self): + """" + Test that going from a course page to a wiki page contains the course navigator. + """ + + self.login(self.student, self.password) + self.enroll(self.toy) + self.create_course_page(self.toy) + + + course_wiki_page = reverse('wiki:get', kwargs={'path' : self.toy.wiki_slug + '/'}) + referer = reverse("courseware", kwargs={ 'course_id' : self.toy.id }) + + resp = self.client.get(course_wiki_page, follow=True, HTTP_REFERER=referer) + + self.has_course_navigator(resp) + + diff --git a/lms/djangoapps/course_wiki/views.py b/lms/djangoapps/course_wiki/views.py new file mode 100644 index 0000000000..b1d9f1cf26 --- /dev/null +++ b/lms/djangoapps/course_wiki/views.py @@ -0,0 +1,128 @@ +import logging +import re + +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.exceptions import ImproperlyConfigured +from django.shortcuts import redirect +from wiki.core.exceptions import NoRootURL +from wiki.models import URLPath, Article + +from courseware.courses import get_course_by_id + +log = logging.getLogger(__name__) + +def root_create(request): + """ + In the edX wiki, we don't show the root_create view. Instead, we + just create the root automatically if it doesn't exist. + """ + root = get_or_create_root() + return redirect('wiki:get', path=root.path) + + +def course_wiki_redirect(request, course_id): + """ + This redirects to whatever page on the wiki that the course designates + as it's home page. A course's wiki must be an article on the root (for + example, "/6.002x") to keep things simple. + """ + course = get_course_by_id(course_id) + + course_slug = course.wiki_slug + + valid_slug = True + if not course_slug: + log.exception("This course is improperly configured. The slug cannot be empty.") + valid_slug = False + if re.match('^[-\w\.]+$', course_slug) == None: + log.exception("This course is improperly configured. The slug can only contain letters, numbers, periods or hyphens.") + valid_slug = False + + if not valid_slug: + return redirect("wiki:get", path="") + + + # The wiki needs a Site object created. We make sure it exists here + try: + site = Site.objects.get_current() + except Site.DoesNotExist: + new_site = Site() + new_site.domain = settings.SITE_NAME + new_site.name = "edX" + new_site.save() + if str(new_site.id) != str(settings.SITE_ID): + raise ImproperlyConfigured("No site object was created and the SITE_ID doesn't match the newly created one. " + str(new_site.id) + "!=" + str(settings.SITE_ID)) + + try: + urlpath = URLPath.get_by_path(course_slug, select_related=True) + + results = list( Article.objects.filter( id = urlpath.article.id ) ) + if results: + article = results[0] + else: + article = None + + except (NoRootURL, URLPath.DoesNotExist): + # We will create it in the next block + urlpath = None + article = None + + if not article: + # create it + root = get_or_create_root() + + if urlpath: + # Somehow we got a urlpath without an article. Just delete it and + # recerate it. + urlpath.delete() + + urlpath = URLPath.create_article( + root, + course_slug, + title=course.title, + content="This is the wiki for " + course.title + ".", + user_message="Course page automatically created.", + user=None, + ip_address=None, + article_kwargs={'owner': None, + 'group': None, + 'group_read': True, + 'group_write': True, + 'other_read': True, + 'other_write': True, + }) + + return redirect("wiki:get", path=urlpath.path) + + +def get_or_create_root(): + """ + Returns the root article, or creates it if it doesn't exist. + """ + try: + root = URLPath.root() + if not root.article: + root.delete() + raise NoRootURL + return root + except NoRootURL: + pass + + starting_content = "\n".join(( + "Welcome to the edX Wiki", + "===", + "Visit a course wiki to add an article.")) + + root = URLPath.create_root(title="edX Wiki", + content=starting_content) + article = root.article + article.group = None + article.group_read = True + article.group_write = False + article.other_read = True + article.other_write = False + article.save() + + return root + diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 92ddb2767e..f3b978adac 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -187,7 +187,7 @@ class PageLoader(ActivateLoginTestCase): def unenroll(self, course): """Unenroll the currently logged-in user, and check that it worked.""" resp = self.client.post('/change_enrollment', { - 'enrollment_action': 'enroll', + 'enrollment_action': 'unenroll', 'course_id': course.id, }) data = parse_json(resp) diff --git a/lms/djangoapps/django-wiki b/lms/djangoapps/django-wiki new file mode 160000 index 0000000000..484ff1ce49 --- /dev/null +++ b/lms/djangoapps/django-wiki @@ -0,0 +1 @@ +Subproject commit 484ff1ce497574045e78d4e3ea0ff55ac9b4bd30 diff --git a/lms/envs/common.py b/lms/envs/common.py index 45818c0ff2..be2c7e939d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -90,6 +90,7 @@ sys.path.append(REPO_ROOT) sys.path.append(ASKBOT_ROOT) sys.path.append(ASKBOT_ROOT / "askbot" / "deps") sys.path.append(PROJECT_ROOT / 'djangoapps') +sys.path.append(PROJECT_ROOT / 'djangoapps' / 'django-wiki') sys.path.append(PROJECT_ROOT / 'lib') sys.path.append(COMMON_ROOT / 'djangoapps') sys.path.append(COMMON_ROOT / 'lib') @@ -131,6 +132,13 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'askbot.user_messages.context_processors.user_messages',#must be before auth 'django.contrib.auth.context_processors.auth', #this is required for admin 'django.core.context_processors.csrf', #necessary for csrf protection + + # Added for django-wiki + 'django.core.context_processors.media', + 'django.core.context_processors.tz', + 'django.contrib.messages.context_processors.messages', + 'sekizai.context_processors.sekizai', + 'course_wiki.course_nav.context_processor', ) STUDENT_FILEUPLOAD_MAX_SIZE = 4*1000*1000 # 4 MB @@ -288,6 +296,9 @@ djcelery.setup_loader() SIMPLE_WIKI_REQUIRE_LOGIN_EDIT = True SIMPLE_WIKI_REQUIRE_LOGIN_VIEW = False +################################# WIKI ################################### +WIKI_ACCOUNT_HANDLING = False + ################################# Jasmine ################################### JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' @@ -301,9 +312,13 @@ STATICFILES_FINDERS = ( # List of callables that know how to import templates from various sources. TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - 'askbot.skins.loaders.filesystem_load_template_source', + 'mitxmako.makoloader.MakoFilesystemLoader', + 'mitxmako.makoloader.MakoAppDirectoriesLoader', + + # 'django.template.loaders.filesystem.Loader', + # 'django.template.loaders.app_directories.Loader', + + #'askbot.skins.loaders.filesystem_load_template_source', # 'django.template.loaders.eggs.Loader', ) @@ -319,6 +334,8 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', 'track.middleware.TrackMiddleware', 'mitxmako.middleware.MakoMiddleware', + + 'course_wiki.course_nav.Middleware', 'askbot.middleware.anon_user.ConnectToSessionMessagesMiddleware', 'askbot.middleware.forum_mode.ForumModeMiddleware', @@ -535,6 +552,15 @@ INSTALLED_APPS = ( 'track', 'util', 'certificates', + + #For the wiki + 'wiki', # The new django-wiki from benjaoming + 'course_wiki', # Our customizations + 'django_notify', + 'mptt', + 'sekizai', + 'wiki.plugins.attachments', + 'wiki.plugins.notifications', # For testing 'django_jasmine', diff --git a/lms/envs/devplus.py b/lms/envs/devplus.py index b15322c2c7..bb4524a1ab 100644 --- a/lms/envs/devplus.py +++ b/lms/envs/devplus.py @@ -65,5 +65,7 @@ DEBUG_TOOLBAR_PANELS = ( # Django=1.3.1/1.4 where requests to views get duplicated (your method gets # hit twice). So you can uncomment when you need to diagnose performance # problems, but you shouldn't leave it on. -# 'debug_toolbar.panels.profiling.ProfilingDebugPanel', + 'debug_toolbar.panels.profiling.ProfilingDebugPanel', ) + +#PIPELINE = True diff --git a/lms/static/js/bootstrap-modal.js b/lms/static/js/bootstrap-modal.js new file mode 100644 index 0000000000..38fd0c8468 --- /dev/null +++ b/lms/static/js/bootstrap-modal.js @@ -0,0 +1,218 @@ +/* ========================================================= + * bootstrap-modal.js v2.0.4 + * http://twitter.github.com/bootstrap/javascript.html#modals + * ========================================================= + * Copyright 2012 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================= */ + + +!function ($) { + + "use strict"; // jshint ;_; + + + /* MODAL CLASS DEFINITION + * ====================== */ + + var Modal = function (content, options) { + this.options = options + this.$element = $(content) + .delegate('[data-dismiss="modal"]', 'click.dismiss.modal', $.proxy(this.hide, this)) + } + + Modal.prototype = { + + constructor: Modal + + , toggle: function () { + return this[!this.isShown ? 'show' : 'hide']() + } + + , show: function () { + var that = this + , e = $.Event('show') + + this.$element.trigger(e) + + if (this.isShown || e.isDefaultPrevented()) return + + $('body').addClass('modal-open') + + this.isShown = true + + escape.call(this) + backdrop.call(this, function () { + var transition = $.support.transition && that.$element.hasClass('fade') + + if (!that.$element.parent().length) { + that.$element.appendTo(document.body) //don't move modals dom position + } + + that.$element + .show() + + if (transition) { + that.$element[0].offsetWidth // force reflow + } + + that.$element.addClass('in') + + transition ? + that.$element.one($.support.transition.end, function () { that.$element.trigger('shown') }) : + that.$element.trigger('shown') + + }) + } + + , hide: function (e) { + e && e.preventDefault() + + var that = this + + e = $.Event('hide') + + this.$element.trigger(e) + + if (!this.isShown || e.isDefaultPrevented()) return + + this.isShown = false + + $('body').removeClass('modal-open') + + escape.call(this) + + this.$element.removeClass('in') + + $.support.transition && this.$element.hasClass('fade') ? + hideWithTransition.call(this) : + hideModal.call(this) + } + + } + + + /* MODAL PRIVATE METHODS + * ===================== */ + + function hideWithTransition() { + var that = this + , timeout = setTimeout(function () { + that.$element.off($.support.transition.end) + hideModal.call(that) + }, 500) + + this.$element.one($.support.transition.end, function () { + clearTimeout(timeout) + hideModal.call(that) + }) + } + + function hideModal(that) { + this.$element + .hide() + .trigger('hidden') + + backdrop.call(this) + } + + function backdrop(callback) { + var that = this + , animate = this.$element.hasClass('fade') ? 'fade' : '' + + if (this.isShown && this.options.backdrop) { + var doAnimate = $.support.transition && animate + + this.$backdrop = $(' - \ No newline at end of file + diff --git a/lms/templates/footer.html b/lms/templates/footer.html index c1fc3c3f36..85ed6e1769 100644 --- a/lms/templates/footer.html +++ b/lms/templates/footer.html @@ -1,3 +1,4 @@ +## mako <%! from django.core.urlresolvers import reverse %> <%namespace name='static' file='static_content.html'/> diff --git a/lms/templates/main_django.html b/lms/templates/main_django.html new file mode 100644 index 0000000000..a5c54d259a --- /dev/null +++ b/lms/templates/main_django.html @@ -0,0 +1,43 @@ + +{% load compressed %}{% load sekizai_tags i18n %}{% load url from future %} + + + {% block title %}edX{% endblock %} + + + {% compressed_css 'application' %} + {% compressed_js 'main_vendor' %} + + {% block headextra %}{% endblock %} + {% render_block "css" %} + + + + + + + {% include "navigation.html" %} + +
+ {% block body %}{% endblock %} + + {% block bodyextra %}{% endblock %} +
+ {% include "footer.html" %} + + {% compressed_js 'application' %} + {% compressed_js 'module-js' %} + {% render_block "js" %} + + + +{% comment %} + This is a django template version of our main page from which all + other pages inherit. This file should be rewritten to reflect any + changes in main.html! Files used by {% include %} can be written + as mako templates. + + Inheriting from this file allows us to include apps that use the + django templating system without rewriting all of their views in + mako. +{% endcomment %} \ No newline at end of file diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index 6c4cfc853b..8858e282e4 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -1,3 +1,4 @@ +## mako ## TODO: Split this into two files, one for people who are authenticated, and ## one for people who aren't. Assume a Course object is passed to the former, ## instead of using settings.COURSE_TITLE diff --git a/lms/templates/wiki/article.html b/lms/templates/wiki/article.html new file mode 100644 index 0000000000..b377ad284b --- /dev/null +++ b/lms/templates/wiki/article.html @@ -0,0 +1,39 @@ +{% extends "wiki/base.html" %} +{% load wiki_tags i18n %} +{% load url from future %} + +{% block pagetitle %}{{ article.current_revision.title }}{% endblock %} + +{% block wiki_breadcrumbs %} +{% include "wiki/includes/breadcrumbs.html" %} +{% endblock %} + +{% block wiki_contents %} + +
+ +
+

{{ article.current_revision.title }}

+ {% block wiki_contents_tab %} + {% wiki_render article %} + {% endblock %} +
+ +
+
+ {% trans "Last modified:" %}
+ {{ article.current_revision.modified }} +
+ +
+
+ + + +{% endblock %} + +{% block footer_prepend %} +

{% trans "This article was last modified:" %} {{ article.current_revision.modified }}

+{% endblock %} diff --git a/lms/templates/wiki/base.html b/lms/templates/wiki/base.html new file mode 100644 index 0000000000..d05512494e --- /dev/null +++ b/lms/templates/wiki/base.html @@ -0,0 +1,38 @@ +{% extends "main_django.html" %} +{% load compressed %}{% load sekizai_tags i18n %}{% load url from future %} + +{% block title %}{% block pagetitle %}{% endblock %} | edX Wiki{% endblock %} + +{% block headextra %} + {% compressed_css 'course' %} + +{% endblock %} + + +{% block body %} + {% if course %} + {% include "course_navigation.html" with active_page_context="wiki" %} + {% endif %} + +
+ + {% block wiki_body %} + + {% block wiki_breadcrumbs %}{% endblock %} + + {% if messages %} + {% for message in messages %} +
+ × + {{ message }} +
+ {% endfor %} + {% endif %} + + {% block wiki_contents %}{% endblock %} + + {% endblock %} + +
+ +{% endblock %} diff --git a/lms/templates/wiki/includes/article_menu.html b/lms/templates/wiki/includes/article_menu.html new file mode 100644 index 0000000000..33d1ba0cf9 --- /dev/null +++ b/lms/templates/wiki/includes/article_menu.html @@ -0,0 +1,47 @@ +{% load i18n wiki_tags %}{% load url from future %} + +{% with selected_tab as selected %} + + + + + + + +
  • + + + {% trans "View" %} + +
  • +
  • + + + {% trans "Edit" %} + +
  • +
  • + + + {% trans "Changes" %} + +
  • +{% for plugin in article_tabs %} +
  • + + + {{ plugin.article_tab.0 }} + +
  • +{% endfor %} + +
  • + {% if not user.is_anonymous %} + + + {% trans "Settings" %} + + {% endif %} +
  • + +{% endwith %} diff --git a/lms/templates/wiki/includes/breadcrumbs.html b/lms/templates/wiki/includes/breadcrumbs.html new file mode 100644 index 0000000000..6afe248a29 --- /dev/null +++ b/lms/templates/wiki/includes/breadcrumbs.html @@ -0,0 +1,52 @@ +{% load i18n %}{% load url from future %} +{% if urlpath %} + +
    + + + +
    +
    + + + + + +
    +
    + + + +
    + +{% endif %} diff --git a/lms/templates/wiki/preview_inline.html b/lms/templates/wiki/preview_inline.html new file mode 100644 index 0000000000..42971e5c2d --- /dev/null +++ b/lms/templates/wiki/preview_inline.html @@ -0,0 +1,33 @@ + +{% load wiki_tags i18n %}{% load compressed %} + + + {% compressed_css 'course' %} + + +
    + {% if revision %} +
    + {% trans "Previewing revision" %}: {{ revision.created }} (#{{ revision.revision_number }}) by {% if revision.user %}{{ revision.user }}{% else %}{% if user|is_moderator %}{{ revision.ip_address|default:"anonymous (IP not logged)" }}{% else %}{% trans "anonymous (IP logged)" %}{% endif %}{% endif %} +
    + {% endif %} + + {% if merge %} +
    + {% trans "Previewing merge between" %}: + {{ merge1.created }} (#{{ merge1.revision_number }}) by {% if merge1.user %}{{ merge1.user }}{% else %}{% if user|is_moderator %}{{ merge1.ip_address|default:"anonymous (IP not logged)" }}{% else %}{% trans "anonymous (IP logged)" %}{% endif %}{% endif %} + {% trans "and" %} + {{ merge1.created }} (#{{ merge1.revision_number }}) by {% if merge1.user %}{{ merge1.user }}{% else %}{% if user|is_moderator %}{{ merge1.ip_address|default:"anonymous (IP not logged)" }}{% else %}{% trans "anonymous (IP logged)" %}{% endif %}{% endif %} +
    + {% endif %} + +

    {{ title }}

    + + {% wiki_render article content %} +
    + + + + + + diff --git a/lms/urls.py b/lms/urls.py index aaeba1b51e..14e0fa0658 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -156,9 +156,25 @@ if settings.COURSEWARE_ENABLED: # Multicourse wiki if settings.WIKI_ENABLED: - urlpatterns += ( - url(r'^wiki/', include('simplewiki.urls')), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/wiki/', include('simplewiki.urls')), + from wiki.urls import get_pattern as wiki_pattern + from django_notify.urls import get_pattern as notify_pattern + + # Note that some of these urls are repeated in course_wiki.course_nav. Make sure to update + # them together. + urlpatterns += ( + # First we include views from course_wiki that we use to override the default views. + # They come first in the urlpatterns so they get resolved first + url('^wiki/create-root/$', 'course_wiki.views.root_create', name='root_create'), + + + url(r'^wiki/', include(wiki_pattern())), + url(r'^notify/', include(notify_pattern())), + + # These urls are for viewing the wiki in the context of a course. They should + # never be returned by a reverse() so they come after the other url patterns + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/course_wiki/?$', + 'course_wiki.views.course_wiki_redirect', name="course_wiki"), + url(r'^courses/(?:[^/]+/[^/]+/[^/]+)/wiki/', include(wiki_pattern())), ) if settings.QUICKEDIT: diff --git a/requirements.txt b/requirements.txt index ef16d2c577..983b4c5547 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,4 +43,5 @@ django-robots django-ses django-storages django-threaded-multihost +django-sekizai<0.7 -r repo-requirements.txt