Commit 68ab1973 by Bridger Maxwell

Merge remote-tracking branch 'origin/master' into feature/bridger/course_grading

Conflicts:
	lms/djangoapps/courseware/views.py
parents 188eeed3 26a63850
......@@ -207,7 +207,7 @@ def preview_module_system(request, preview_id, descriptor):
descriptor: An XModuleDescriptor
"""
return ModuleSystem(
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']),
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,
......@@ -247,7 +247,7 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
module = descriptor.xmodule_constructor(system)(instance_state, shared_state)
module.get_html = replace_static_urls(
wrap_xmodule(module.get_html, module, "xmodule_display.html"),
module.metadata['data_dir'], module
module.metadata['data_dir']
)
save_preview_state(request, preview_id, descriptor.location.url(),
module.get_instance_state(), module.get_shared_state())
......@@ -276,8 +276,13 @@ def save_item(request):
if not has_access(request.user, item_location):
raise Http404 # TODO (vshnayder): better error
data = json.loads(request.POST['data'])
modulestore().update_item(item_location, data)
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)
# Export the course back to github
# This uses wildcarding to find the course, which requires handling
......
......@@ -86,7 +86,12 @@ def export_to_github(course, commit_message, author_str=None):
If author_str is specified, uses it in the commit.
'''
course_dir = course.metadata.get('data_dir', course.location.course)
repo_settings = load_repo_settings(course_dir)
try:
repo_settings = load_repo_settings(course_dir)
except InvalidRepo:
log.warning("Invalid repo {0}, not exporting data to xml".format(course_dir))
return
git_repo = setup_repo(repo_settings)
fs = OSFS(git_repo.working_dir)
......
......@@ -2,6 +2,7 @@ class CMS.Models.Module extends Backbone.Model
url: '/save_item'
defaults:
data: ''
children: ''
loadModule: (element) ->
elt = $(element).find('.xmodule_edit').first()
......@@ -11,5 +12,5 @@ class CMS.Models.Module extends Backbone.Model
"/edit_item?#{$.param(id: @get('id'))}"
save: (args...) ->
@set(data: JSON.stringify(@module.save())) if @module
@set(data: @module.save()) if @module
super(args...)
......@@ -13,18 +13,22 @@ class CMS.Views.ModuleEdit extends Backbone.View
# Load preview modules
XModule.loadModules('display')
@$children = @$el.find('#sortable')
@enableDrag()
enableDrag: ->
enableDrag: =>
# Enable dragging things in the #sortable div (if there is one)
if $("#sortable").length > 0
$("#sortable").sortable({
if @$children.length > 0
@$children.sortable(
placeholder: "ui-state-highlight"
})
$("#sortable").disableSelection();
save: (event) ->
update: (event, ui) =>
@model.set(children: @$children.find('.module-edit').map(
(idx, el) -> $(el).data('id')
).toArray())
)
@$children.disableSelection()
save: (event) =>
event.preventDefault()
@model.save().done((previews) =>
alert("Your changes have been saved.")
......
......@@ -141,11 +141,15 @@ textarea {
}
}
// .wip {
// outline: 1px solid #f00 !important;
// position: relative;
// }
.hidden {
display: none;
.wip {
outline: 1px solid #f00 !important;
position: relative;
&:after {
content: "WIP";
font-size: 8px;
padding: 2px;
background: #f00;
color: #fff;
@include position(absolute, 0px 0px 0 0);
}
}
......@@ -49,7 +49,7 @@
</section>
% endfor
</section>
<div class="actions wip">
<div class="actions">
<a href="" class="save-update">Save &amp; Update</a>
<a href="#" class="cancel">Cancel</a>
</div>
......
......@@ -60,7 +60,7 @@
data-type="${module.js_module_name}"
data-preview-type="${module.module_class.js_module_name}">
<a href="#" class="module-edit">${module.url_name}</a>
<a href="#" class="module-edit">${module.display_name}</a>
<a href="#" class="draggable">handle</a>
</li>
% endfor
......@@ -68,31 +68,6 @@
</ul>
</li>
%endfor
<li class="wip">
<header>
<h1>Course Scratch Pad</h1>
</header>
<ul>
<li>
<a href="#" class="problem-edit">Problem title 11</a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="#" class="problem-edit">Problem title 13 </a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="#" class="problem-edit"> Problem title 14</a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="" class="video-edit">Video 3</a>
<a href="#" class="draggable">handle</a>
</li>
<%include file="module-dropdown.html"/>
</ul>
</li>
</ol>
<section class="new-section">
......
......@@ -39,7 +39,7 @@
<a href="#" class="module-edit"
data-id="${child.location.url()}"
data-type="${child.js_module_name}"
data-preview-type="${child.module_class.js_module_name}">${child.url_name}</a>
data-preview-type="${child.module_class.js_module_name}">${child.display_name}</a>
<a href="#" class="draggable">handle</a>
</li>
%endfor
......@@ -48,61 +48,6 @@
</ol>
</section>
<section class="scratch-pad wip">
<ol>
<li class="new-module">
<%include file="new-module.html"/>
</li>
<li>
<header>
<h2>Section Scratch</h2>
</header>
<ul>
<li>
<a href="#" class="problem-edit">Problem title 11</a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="#" class="problem-edit">Problem title 13 </a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="#" class="problem-edit"> Problem title 14</a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="" class="video-edit">Video 3</a>
<a href="#" class="draggable">handle</a>
</li>
</ul>
</li>
<li>
<header>
<h2>Course Scratch</h2>
</header>
<ul>
<li>
<a href="#" class="problem-edit">Problem title 11</a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="#" class="problem-edit">Problem title 13 </a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="#" class="problem-edit"> Problem title 14</a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="" class="video-edit">Video 3</a>
<a href="#" class="draggable">handle</a>
</li>
</ul>
</li>
</ol>
</section>
</div>
</section>
<section class="week-new">
<header>
<div>
<h1 class="editable">Week 3</h1>
<p><a href="#">+ new goal</a></p>
</div>
<section class="goals">
<ul>
<li>
<p><strong>Please add a goal for this section</strong> </p>
</li>
</ul>
</section>
</header>
<section class="content">
<section class="filters">
<ul>
<li>
<label for="">Sort by</label>
<select>
<option value="">Recently Modified</option>
</select>
</li>
<li>
<label for="">Display</label>
<select>
<option value="">All content</option>
</select>
</li>
<li>
<select>
<option value="">Internal Only</option>
</select>
</li>
<li class="advanced">
<a href="#">Advanced filters</a>
</li>
<li>
<input type="search" name="" id="" value="" />
</li>
</ul>
</section>
<div>
<section class="modules empty">
<p>This are no groups or units in this section yet</p>
<a href="#">Add a Group</a>
<a href="#">Add a Unit</a>
</section>
<section class="scratch-pad">
<ol>
<li>
<header>
<h2>Section Scratch</h2>
</header>
<ul>
<li class="empty">
<p><a href="#">There are no units in this scratch yet. Add one</a></p>
</li>
</ul>
</li>
<li>
<header>
<h2>Course Scratch</h2>
</header>
<ul>
<li>
<a href="" class="problem-edit">Problem title 11</a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="#" class="sequence-edit">Problem Group</a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="#" class="problem-edit">Problem title 14</a>
<a href="#" class="draggable">handle</a>
</li>
<li>
<a href="#" class="video-edit">Video 3</a>
<a href="#" class="draggable">handle</a>
</li>
</ul>
</li>
<li class="new-module">
<%include file="new-module.html"/>
</li>
</ol>
</section>
</div>
</section>
</section>
......@@ -82,6 +82,8 @@ def index(request, extra_context={}, user=None):
domain=domain)
context = {'universities': universities, 'entries': entries}
context.update(extra_context)
if request.REQUEST.get('next', False):
context['show_login_immediately'] = True
return render_to_response('index.html', context)
def course_from_id(course_id):
......
......@@ -35,7 +35,7 @@ def wrap_xmodule(get_html, module, template):
return _get_html
def replace_course_urls(get_html, course_id, module):
def replace_course_urls(get_html, course_id):
"""
Updates the supplied module with a new get_html function that wraps
the old get_html function and substitutes urls of the form /course/...
......@@ -46,7 +46,7 @@ def replace_course_urls(get_html, course_id, module):
return replace_urls(get_html(), staticfiles_prefix='/courses/'+course_id, replace_prefix='/course/')
return _get_html
def replace_static_urls(get_html, prefix, module):
def replace_static_urls(get_html, prefix):
"""
Updates the supplied module with a new get_html function that wraps
the old get_html function and substitutes urls of the form /static/...
......
......@@ -333,6 +333,10 @@ def textline_dynamath(element, value, status, render_template, msg=''):
if '' in preprocessor.values():
preprocessor = None
# Escape characters in student input for safe XML parsing
escapedict = {'"': '&quot;'}
value = saxutils.escape(value, escapedict)
context = {'id': eid, 'value': value, 'state': status, 'count': count, 'size': size,
'msg': msg, 'hidden': hidden,
'preprocessor': preprocessor,
......
......@@ -1627,6 +1627,10 @@ class ImageResponse(LoncapaResponse):
for aid in self.answer_ids: # loop through IDs of <imageinput> fields in our stanza
given = student_answers[aid] # this should be a string of the form '[x,y]'
if not given: # No answer to parse. Mark as incorrect and move on
correct_map.set(aid, 'incorrect')
continue
# parse expected answer
# TODO: Compile regexp on file load
m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]', expectedset[aid].strip().replace(' ', ''))
......
<form class="choicegroup capa_inputtype" id="inputtype_${id}">
% for choice_id, choice_description in choices:
<label for="input_${id}_${choice_id}"> <input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}"
% if choice_id in value:
checked="true"
% endif
/> ${choice_description} </label>
% endfor
<span id="answer_${id}"></span>
% if state == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif state == 'correct':
......@@ -18,4 +8,16 @@
% elif state == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
% endif
<fieldset>
% for choice_id, choice_description in choices:
<label for="input_${id}_${choice_id}"> <input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}"
% if choice_id in value:
checked="true"
% endif
/> ${choice_description} </label>
% endfor
<span id="answer_${id}"></span>
</fieldset>
</form>
import cgi
import datetime
import dateutil
import dateutil.parser
......@@ -125,17 +126,17 @@ class CapaModule(XModule):
self.name = only_one(dom2.xpath('/problem/@name'))
if self.rerandomize == 'never':
seed = 1
self.seed = 1
elif self.rerandomize == "per_student" and hasattr(self.system, 'id'):
seed = system.id
self.seed = system.id
else:
seed = None
self.seed = None
try:
# TODO (vshnayder): move as much as possible of this work and error
# checking to descriptor load time
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
instance_state, seed=seed, system=self.system)
instance_state, seed=self.seed, system=self.system)
except Exception as err:
msg = 'cannot create LoncapaProblem {loc}: {err}'.format(
loc=self.location.url(), err=err)
......@@ -154,7 +155,7 @@ class CapaModule(XModule):
(self.location.url(), msg))
self.lcp = LoncapaProblem(
problem_text, self.location.html_id(),
instance_state, seed=seed, system=self.system)
instance_state, seed=self.seed, system=self.system)
else:
# add extra info and raise
raise Exception(msg), None, sys.exc_info()[2]
......@@ -172,6 +173,8 @@ class CapaModule(XModule):
return "per_student"
elif rerandomize == "never":
return "never"
elif rerandomize == "onreset":
return "onreset"
else:
raise Exception("Invalid rerandomize attribute " + rerandomize)
......@@ -214,9 +217,10 @@ class CapaModule(XModule):
try:
html = self.lcp.get_html()
except Exception, err:
log.exception(err)
# TODO (vshnayder): another switch on DEBUG.
if self.system.DEBUG:
log.exception(err)
msg = (
'[courseware.capa.capa_module] <font size="+1" color="red">'
'Failed to generate HTML for problem %s</font>' %
......@@ -225,7 +229,47 @@ class CapaModule(XModule):
msg += '<p><pre>%s</pre></p>' % traceback.format_exc().replace('<', '&lt;')
html = msg
else:
raise
# We're in non-debug mode, and possibly even in production. We want
# to avoid bricking of problem as much as possible
# Presumably, student submission has corrupted LoncapaProblem HTML.
# First, pull down all student answers
student_answers = self.lcp.student_answers
answer_ids = student_answers.keys()
# Some inputtypes, such as dynamath, have additional "hidden" state that
# is not exposed to the student. Keep those hidden
# TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id
hidden_state_keywords = ['dynamath']
for answer_id in answer_ids:
for hidden_state_keyword in hidden_state_keywords:
if answer_id.find(hidden_state_keyword) >= 0:
student_answers.pop(answer_id)
# Next, generate a fresh LoncapaProblem
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
state=None, # Tabula rasa
seed=self.seed, system=self.system)
# Prepend a scary warning to the student
warning = '<div class="capa_reset">'\
'<h2>Warning: The problem has been reset to its initial state!</h2>'\
'The problem\'s state was corrupted by an invalid submission. ' \
'The submission consisted of:'\
'<ul>'
for student_answer in student_answers.values():
if student_answer != '':
warning += '<li>' + cgi.escape(student_answer) + '</li>'
warning += '</ul>'\
'If this error persists, please contact the course staff.'\
'</div>'
html = warning
try:
html += self.lcp.get_html()
except Exception, err: # Couldn't do it. Give up
log.exception(err)
raise
content = {'name': self.display_name,
'html': html,
......@@ -259,7 +303,7 @@ class CapaModule(XModule):
save_button = False
# Only show the reset button if pressing it will show different values
if self.rerandomize != 'always':
if self.rerandomize not in ["always", "onreset"]:
reset_button = False
# User hasn't submitted an answer yet -- we don't want resets
......@@ -569,7 +613,7 @@ class CapaModule(XModule):
return "Refresh the page and make an attempt before resetting."
self.lcp.do_reset()
if self.rerandomize == "always":
if self.rerandomize in ["always", "onreset"]:
# reset random number generator seed (note the self.lcp.get_state()
# in next line)
self.lcp.seed = None
......
from fs.errors import ResourceNotFoundError
import time
import logging
import requests
from lxml import etree
from path import path # NOTE (THK): Only used for detecting presence of syllabus
import requests
import time
from xmodule.util.decorators import lazyproperty
from xmodule.graders import load_grading_policy
......@@ -21,10 +21,15 @@ class CourseDescriptor(SequenceDescriptor):
self.title = title
self.book_url = book_url
self.table_of_contents = self._get_toc_from_s3()
@classmethod
def from_xml_object(cls, xml_object):
return cls(xml_object.get('title'), xml_object.get('book_url'))
self.start_page = int(self.table_of_contents[0].attrib['page'])
# The last page should be the last element in the table of contents,
# but it may be nested. So recurse all the way down the last element
last_el = self.table_of_contents[-1]
while last_el.getchildren():
last_el = last_el[-1]
self.end_page = int(last_el.attrib['page'])
@property
def table_of_contents(self):
......@@ -57,10 +62,18 @@ class CourseDescriptor(SequenceDescriptor):
return table_of_contents
def __init__(self, system, definition=None, **kwargs):
super(CourseDescriptor, self).__init__(system, definition, **kwargs)
self.textbooks = self.definition['data']['textbooks']
self.textbooks = []
for title, book_url in self.definition['data']['textbooks']:
try:
self.textbooks.append(self.Textbook(title, book_url))
except:
# If we can't get to S3 (e.g. on a train with no internet), don't break
# the rest of the courseware.
log.exception("Couldn't load textbook ({0}, {1})".format(title, book_url))
continue
self.wiki_slug = self.definition['data']['wiki_slug'] or self.location.course
......@@ -82,7 +95,6 @@ class CourseDescriptor(SequenceDescriptor):
# disable the syllabus content for courses that do not provide a syllabus
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
def set_grading_policy(self, policy_str):
"""Parse the policy specified in policy_str, and save it"""
try:
......@@ -94,19 +106,11 @@ class CourseDescriptor(SequenceDescriptor):
# the error log.
self._grading_policy = {}
@classmethod
def definition_from_xml(cls, xml_object, system):
textbooks = []
for textbook in xml_object.findall("textbook"):
try:
txt = cls.Textbook.from_xml_object(textbook)
except:
# If we can't get to S3 (e.g. on a train with no internet), don't break
# the rest of the courseware.
log.exception("Couldn't load textbook")
continue
textbooks.append(txt)
textbooks.append((textbook.get('title'), textbook.get('book_url')))
xml_object.remove(textbook)
#Load the wiki tag if it exists
......@@ -116,7 +120,7 @@ class CourseDescriptor(SequenceDescriptor):
wiki_slug = wiki_tag.attrib.get("slug", default=None)
xml_object.remove(wiki_tag)
definition = super(CourseDescriptor, cls).definition_from_xml(xml_object, system)
definition = super(CourseDescriptor, cls).definition_from_xml(xml_object, system)
definition.setdefault('data', {})['textbooks'] = textbooks
definition['data']['wiki_slug'] = wiki_slug
......@@ -135,6 +139,13 @@ class CourseDescriptor(SequenceDescriptor):
return self._grading_policy['GRADE_CUTOFFS']
@property
def tabs(self):
"""
Return the tabs config, as a python object, or None if not specified.
"""
return self.metadata.get('tabs')
@property
def show_calculator(self):
return self.metadata.get("show_calculator", None) == "Yes"
......
......@@ -36,9 +36,36 @@ section.problem {
}
.choicegroup {
@include clearfix;
label.choicegroup_correct:after {
content: url('../images/correct-icon.png');
}
> span {
padding-right: 20px;
float: left;
background-position: 0 0 !important;
}
fieldset {
@include box-sizing(border-box);
float: left;
border-left: 1px solid #ddd;
padding-left: 20px;
margin: 20px 0;
}
input[type="radio"],
input[type="checkbox"] {
float: left;
margin: 4px 8px 0 0;
}
text {
display: block;
margin-left: 25px;
}
}
ol.enumerate {
......@@ -52,6 +79,23 @@ section.problem {
}
}
.solution-span {
> span {
margin: 20px 0;
display: block;
border: 1px solid #ddd;
padding: 9px 15px 20px;
background: #FFF;
position: relative;
@include box-shadow(inset 0 0 0 1px #eee);
@include border-radius(3px);
&:empty {
display: none;
}
}
}
div {
p {
&.answer {
......@@ -432,18 +476,23 @@ section.problem {
input.save {
@extend .blue-button;
}
.submission_feedback {
// background: #F3F3F3;
// border: 1px solid #ddd;
// @include border-radius(3px);
// padding: 8px 12px;
// margin-top: 10px;
@include inline-block;
font-style: italic;
margin: 8px 0 0 10px;
color: #777;
-webkit-font-smoothing: antialiased;
}
}
.detailed-solution {
border: 1px solid #ddd;
padding: 9px 15px 20px;
margin-bottom: 10px;
background: #FFF;
position: relative;
@include box-shadow(inset 0 0 0 1px #eee);
@include border-radius(3px);
p:first-child {
> p:first-child {
font-size: 0.9em;
font-weight: bold;
font-style: normal;
......@@ -465,6 +514,22 @@ section.problem {
margin-top: 10px;
}
div.capa_reset {
padding: 25px;
border: 1px solid $error-red;
background-color: lighten($error-red, 25%);
border-radius: 3px;
font-size: 1em;
margin-top: 10px;
margin-bottom: 10px;
}
.capa_reset>h2 {
color: #AA0000;
}
.capa_reset li {
font-size: 0.9em;
}
.hints {
border: 1px solid #ccc;
......
......@@ -11,6 +11,8 @@ class @Video
@parseSpeed()
$("#video_#{@id}").data('video', this).addClass('video-load-complete')
@hide_captions = $.cookie('hide_captions') == 'true'
if YT.Player
@embed()
else
......
class @VideoCaption extends Subview
initialize: ->
@loaded = false
@loaded = false
bind: ->
$(window).bind('resize', @resize)
......@@ -49,7 +49,7 @@ class @VideoCaption extends Subview
@$('.subtitles').html(container.html())
@$('.subtitles li[data-index]').click @seekPlayer
# prepend and append an empty <li> for cosmatic reason
# prepend and append an empty <li> for cosmetic reason
@$('.subtitles').prepend($('<li class="spacing">').height(@topSpacingHeight()))
.append($('<li class="spacing">').height(@bottomSpacingHeight()))
......@@ -130,14 +130,21 @@ class @VideoCaption extends Subview
toggle: (event) =>
event.preventDefault()
if @el.hasClass('closed')
if @el.hasClass('closed') # Captions are "closed" e.g. turned off
@hideCaptions(false)
else # Captions are on
@hideCaptions(true)
hideCaptions: (hide_captions) =>
if hide_captions
@$('.hide-subtitles').attr('title', 'Turn on captions')
@el.addClass('closed')
else
@$('.hide-subtitles').attr('title', 'Turn off captions')
@el.removeClass('closed')
@scrollCaption()
else
@$('.hide-subtitles').attr('title', 'Turn on captions')
@el.addClass('closed')
$.cookie('hide_captions', hide_captions, expires: 3650, path: '/')
captionHeight: ->
if @el.hasClass('fullscreen')
$(window).height() - @$('.video-controls').height()
......
......@@ -45,6 +45,7 @@ class @VideoPlayer extends Subview
events:
onReady: @onReady
onStateChange: @onStateChange
@caption.hideCaptions(@['video'].hide_captions)
addToolTip: ->
@$('.add-fullscreen, .hide-subtitles').qtip
......
......@@ -316,3 +316,9 @@ class MongoModuleStore(ModuleStoreBase):
{'_id': True})
return [i['_id'] for i in items]
def get_errored_courses(self):
"""
This function doesn't make sense for the mongo modulestore, as courses
are loaded on demand, rather than up front
"""
return {}
......@@ -414,7 +414,6 @@ class XMLModuleStore(ModuleStoreBase):
policy_str = self.read_grading_policy(paths, tracker)
course_descriptor.set_grading_policy(policy_str)
log.debug('========> Done with course import from {0}'.format(course_dir))
return course_descriptor
......
......@@ -21,7 +21,8 @@ class SequenceModule(XModule):
''' Layout module which lays out content in a temporal sequence
'''
js = {'coffee': [resource_string(__name__,
'js/src/sequence/display.coffee')]}
'js/src/sequence/display.coffee')],
'js': [resource_string(__name__, 'js/src/sequence/display/jquery.sequence.js')]}
css = {'scss': [resource_string(__name__, 'css/sequence/display.scss')]}
js_module_name = "Sequence"
......
......@@ -105,7 +105,7 @@ NUMPY_VER="1.6.2"
SCIPY_VER="0.10.1"
BREW_FILE="$BASE/mitx/brew-formulas.txt"
LOG="/var/tmp/install-$(date +%Y%m%d-%H%M%S).log"
APT_PKGS="curl git python-virtualenv build-essential python-dev gfortran liblapack-dev libfreetype6-dev libpng12-dev libxml2-dev libxslt-dev yui-compressor coffeescript graphviz libgraphviz-dev"
APT_PKGS="pkg-config curl git python-virtualenv build-essential python-dev gfortran liblapack-dev libfreetype6-dev libpng12-dev libxml2-dev libxslt-dev yui-compressor coffeescript graphviz graphviz-dev"
if [[ $EUID -eq 0 ]]; then
error "This script should not be run using sudo or as the root user"
......@@ -223,7 +223,7 @@ EO
command -v brew &>/dev/null || {
output "Installing brew"
/usr/bin/ruby -e "$(curl -fsSL https://raw.github.com/mxcl/homebrew/master/Library/Contributions/install_homebrew.rb)"
/usr/bin/ruby <(curl -fsSkL raw.github.com/mxcl/homebrew/go)
}
command -v git &>/dev/null || {
output "Installing git"
......
......@@ -219,6 +219,13 @@ Values are dictionaries of the form {"metadata-key" : "metadata-value"}.
* The order in which things appear does not matter, though it may be helpful to organize the file in the same order as things appear in the content.
* NOTE: json is picky about commas. If you have trailing commas before closing braces, it will complain and refuse to parse the file. This can be irritating at first.
Supported fields at the course level:
* "start" -- specify the start date for the course. Format-by-example: "2012-09-05T12:00".
* "enrollment_start", "enrollment_end" -- when can students enroll? (if not specified, can enroll anytime). Same format as "start".
* "tabs" -- have custom tabs in the courseware. See below for details on config.
* TODO: there are others
### Grading policy file contents
TODO: This needs to be improved, but for now here's a sketch of how grading works:
......@@ -274,6 +281,7 @@ __Inherited:__
* `showanswer` - When to show answer. For 'attempted', will show answer after first attempt. Values: never, attempted, answered, closed. Default: closed. Optional.
* `graded` - Whether this section will count towards the students grade. "true" or "false". Defaults to "false".
* `rerandomise` - Randomize question on each attempt. Values: 'always' (students see a different version of the problem after each attempt to solve it)
'onreset' (randomize question when reset button is pressed by the student)
'never' (all students see the same version of the problem)
'per_student' (individual students see the same version of the problem each time the look at it, but that version is different from what other students see)
Default: 'always'. Optional.
......@@ -340,7 +348,43 @@ If you look at some older xml, you may see some tags or metadata attributes that
# Static links
if your content links (e.g. in an html file) to `"static/blah/ponies.jpg"`, we will look for this in `YOUR_COURSE_DIR/blah/ponies.jpg`. Note that this is not looking in a `static/` subfolder in your course dir. This may (should?) change at some point. Links that include `/course` will be rewritten to the root of your course in the courseware (e.g. `courses/{org}/{course}/{url_name}/` in the current url structure). This is useful for linking to the course wiki, for example.
If your content links (e.g. in an html file) to `"static/blah/ponies.jpg"`, we will look for this...
* If your course dir has a `static/` subdirectory, we will look in `YOUR_COURSE_DIR/static/blah/ponies.jpg`. This is the prefered organization, as it does not expose anything except what's in `static/` to the world.
* If your course dir does not have a `static/` subdirectory, we will look in `YOUR_COURSE_DIR/blah/ponies.jpg`. This is the old organization, and requires that the web server allow access to everything in the couse dir. To switch to the new organization, move all your static content into a new `static/` dir (e.g. if you currently have things in `images/`, `css/`, and `special/`, create a dir called `static/`, and move `images/, css/, and special/` there).
Links that include `/course` will be rewritten to the root of your course in the courseware (e.g. `courses/{org}/{course}/{url_name}/` in the current url structure). This is useful for linking to the course wiki, for example.
# Tabs
If you want to customize the courseware tabs displayed for your course, specify a "tabs" list in the course-level policy. e.g.:
"tabs" : [
{"type": "courseware"}, # no name--always "Courseware" for consistency between courses
{"type": "course_info", "name": "Course Info"},
{"type": "external_link", "name": "My Discussion", "link": "http://www.mydiscussion.org/blah"},
{"type": "progress", "name": "Progress"},
{"type": "wiki", "name": "Wonderwiki"},
{"type": "static_tab", "url_slug": "news", "name": "Exciting news"},
{"type": "textbooks"} # generates one tab per textbook, taking names from the textbook titles
]
* If you specify any tabs, you must specify all tabs. They will appear in the order given.
* The first two tabs must have types `"courseware"` and `"course_info"`, in that order. Otherwise, we'll refuse to load the course.
* for static tabs, the url_slug will be the url that points to the tab. It can not be one of the existing courseware url types (even if those aren't used in your course). The static content will come from `tabs/{course_url_name}/{url_slug}.html`, or `tabs/{url_slug}.html` if that doesn't exist.
* An Instructor tab will be automatically added at the end for course staff users.
## Supported tab types:
* "courseware". No other parameters.
* "course_info". Parameter "name".
* "wiki". Parameter "name".
* "discussion". Parameter "name".
* "external_link". Parameters "name", "link".
* "textbooks". No parameters--generates tab names from book titles.
* "progress". Parameter "name".
# Tips for content developers
......
......@@ -254,12 +254,11 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
module.get_html = replace_static_urls(
wrap_xmodule(module.get_html, module, 'xmodule_display.html'),
module.metadata['data_dir'], module
)
module.metadata['data_dir'])
# Allow URLs of the form '/course/' refer to the root of multicourse directory
# hierarchy of this course
module.get_html = replace_course_urls(module.get_html, course_id, module)
module.get_html = replace_course_urls(module.get_html, course_id)
if settings.MITX_FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF'):
if has_access(user, module, 'staff'):
......
......@@ -22,6 +22,7 @@ from django.views.decorators.cache import cache_control
from courseware import grades
from courseware.access import has_access
from courseware.courses import (get_course_with_access, get_courses_by_university)
import courseware.tabs as tabs
from courseware.models import StudentModuleCache
from module_render import toc_for_course, get_module, get_instance_module
from student.models import UserProfile
......@@ -343,6 +344,30 @@ def course_info(request, course_id):
return render_to_response('courseware/info.html', {'course': course,
'staff_access': staff_access,})
@ensure_csrf_cookie
def static_tab(request, course_id, tab_slug):
"""
Display the courses tab with the given name.
Assumes the course_id is in a valid format.
"""
course = get_course_with_access(request.user, course_id, 'load')
tab = tabs.get_static_tab_by_slug(course, tab_slug)
if tab is None:
raise Http404
contents = tabs.get_static_tab_contents(course, tab)
if contents is None:
raise Http404
staff_access = has_access(request.user, course, 'staff')
return render_to_response('courseware/static_tab.html',
{'course': course,
'tab': tab,
'tab_contents': contents,
'staff_access': staff_access,})
# TODO arjun: remove when custom tabs in place, see courseware/syllabus.py
@ensure_csrf_cookie
def syllabus(request, course_id):
......@@ -357,6 +382,7 @@ def syllabus(request, course_id):
return render_to_response('courseware/syllabus.html', {'course': course,
'staff_access': staff_access,})
def registered_for_course(course, user):
'''Return CourseEnrollment if user is registered for course, else False'''
if user is None:
......@@ -404,6 +430,9 @@ def university_profile(request, org_id):
context = dict(courses=courses, org_id=org_id)
template_file = "university_profile/{0}.html".format(org_id).lower()
if request.REQUEST.get('next', False):
context['show_login_immediately'] = True
return render_to_response(template_file, context)
def render_notifications(request, course, notifications):
......
......@@ -28,26 +28,6 @@ PAGES_NEARBY_DELTA = 2
escapedict = {'"': '&quot;'}
log = logging.getLogger("edx.discussions")
def _general_discussion_id(course_id):
return course_id.replace('/', '_').replace('.', '_')
def _should_perform_search(request):
return bool(request.GET.get('text', False) or \
request.GET.get('tags', False))
def render_accordion(request, course, discussion_id):
# TODO: Delete if obsolete
discussion_info = utils.get_categorized_discussion_info(request, course)
context = {
'course': course,
'discussion_info': discussion_info,
'active': discussion_id,
'csrf': csrf(request)['csrf_token'],
}
return render_to_string('discussion/_accordion.html', context)
def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAGE):
"""
This may raise cc.utils.CommentClientError or
......@@ -63,6 +43,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
'tags': '',
'commentable_id': discussion_id,
'course_id': course_id,
'user_id': request.user.id,
}
if not request.GET.get('sort_key'):
......@@ -101,6 +82,7 @@ def inline_discussion(request, course_id, discussion_id):
# TODO (vshnayder): since none of this code seems to be aware of the fact that
# sometimes things go wrong, I suspect that the js client is also not
# checking for errors on request. Check and fix as needed.
log.error("Error loading inline discussion threads.")
raise Http404
def infogetter(thread):
......@@ -134,6 +116,7 @@ def forum_form_discussion(request, course_id):
unsafethreads, query_params = get_threads(request, course_id) # This might process a search query
threads = [utils.safe_content(thread) for thread in unsafethreads]
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
log.error("Error loading forum discussion threads: %s" % str(err))
raise Http404
user_info = cc.User.from_django_user(request.user).to_dict()
......@@ -184,14 +167,18 @@ def forum_form_discussion(request, course_id):
@login_required
def single_thread(request, course_id, discussion_id, thread_id):
course = get_course_with_access(request.user, course_id, 'load')
cc_user = cc.User.from_django_user(request.user)
user_info = cc_user.to_dict()
try:
thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
log.error("Error loading single thread.")
raise Http404
if request.is_ajax():
course = get_course_with_access(request.user, course_id, 'load')
user_info = cc.User.from_django_user(request.user).to_dict()
try:
thread = cc.Thread.find(thread_id).retrieve(recursive=True)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
raise Http404
courseware_context = get_courseware_context(thread, course)
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
......@@ -208,13 +195,13 @@ def single_thread(request, course_id, discussion_id, thread_id):
})
else:
course = get_course_with_access(request.user, course_id, 'load')
category_map = utils.get_discussion_category_map(course)
try:
threads, query_params = get_threads(request, course_id)
thread = cc.Thread.find(thread_id).retrieve(recursive=True)
threads.append(thread.to_dict())
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
log.error("Error loading single thread.")
raise Http404
course = get_course_with_access(request.user, course_id, 'load')
......@@ -236,8 +223,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
# course_id,
#)
user_info = cc.User.from_django_user(request.user).to_dict()
def infogetter(thread):
return utils.get_annotated_content_infos(course_id, thread, request.user, user_info)
......
......@@ -189,7 +189,7 @@ def initialize_discussion_info(course):
"sort_key": entry["sort_key"],
"start_date": entry["start_date"]}
default_topics = {'General': course.location.html_id()}
default_topics = {'General': {'id' :course.location.html_id()}}
discussion_topics = course.metadata.get('discussion_topics', default_topics)
for topic, entry in discussion_topics.items():
category_map['entries'][topic] = {"id": entry["id"],
......@@ -336,7 +336,8 @@ def safe_content(content):
'endorsed', 'parent_id', 'thread_id', 'votes', 'closed', 'created_at',
'updated_at', 'depth', 'type', 'commentable_id', 'comments_count',
'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
'courseware_title', 'courseware_url', 'tags'
'courseware_title', 'courseware_url', 'tags', 'unread_comments_count',
'read',
]
if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False):
......
......@@ -85,7 +85,8 @@ def instructor_dashboard(request, course_id):
writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
writer.writerow(datatable['header'])
for datarow in datatable['data']:
writer.writerow(datarow)
encoded_row = [unicode(s).encode('utf-8') for s in datarow]
writer.writerow(encoded_row)
return response
def get_staff_group(course):
......@@ -250,7 +251,7 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True,
If get_raw_scores=True, then instead of grade summaries, the raw grades for all graded modules are returned.
'''
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username')
header = ['ID', 'Username', 'Full Name', 'edX email', 'External email']
if get_grades:
......
......@@ -7,16 +7,23 @@ from courseware.courses import get_course_with_access
from lxml import etree
@login_required
def index(request, course_id, book_index, page=0):
def index(request, course_id, book_index, page=None):
course = get_course_with_access(request.user, course_id, 'load')
staff_access = has_access(request.user, course, 'staff')
textbook = course.textbooks[int(book_index)]
book_index = int(book_index)
textbook = course.textbooks[book_index]
table_of_contents = textbook.table_of_contents
if page is None:
page = textbook.start_page
return render_to_response('staticbook.html',
{'page': int(page), 'course': course, 'book_url': textbook.book_url,
{'book_index': book_index, 'page': int(page),
'course': course, 'book_url': textbook.book_url,
'table_of_contents': table_of_contents,
'start_page' : textbook.start_page,
'end_page' : textbook.end_page,
'staff_access': staff_access})
def index_shifted(request, course_id, page):
......
"""
Settings for the LMS that runs alongside the CMS on AWS
"""
from ..dev import *
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'xmodule',
'collection': 'modulestore',
'fs_root': DATA_DIR,
'render_template': 'mitxmako.shortcuts.render_to_string',
}
}
}
......@@ -406,7 +406,6 @@ MIDDLEWARE_CLASSES = (
# 'debug_toolbar.middleware.DebugToolbarMiddleware',
'django_comment_client.utils.ViewNameMiddleware',
'django_comment_client.utils.QueryCountDebugMiddleware',
)
############################### Pipeline #######################################
......@@ -551,6 +550,8 @@ PIPELINE_JS = {
}
}
PIPELINE_DISABLE_WRAPPER = True
# Compile all coffee files in course data directories if they are out of date.
# TODO: Remove this once we move data into Mongo. This is only temporary while
# course data directories are still in use.
......
......@@ -135,7 +135,8 @@ MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
################################ DEBUG TOOLBAR #################################
INSTALLED_APPS += ('debug_toolbar',)
MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',)
MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware',
'debug_toolbar.middleware.DebugToolbarMiddleware',)
INTERNAL_IPS = ('127.0.0.1',)
DEBUG_TOOLBAR_PANELS = (
......
......@@ -8,8 +8,9 @@ class Thread(models.Model):
accessible_fields = [
'id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id',
'created_at', 'updated_at', 'comments_count', 'at_position_list',
'children', 'type', 'highlighted_title', 'highlighted_body', 'endorsed'
'created_at', 'updated_at', 'comments_count', 'unread_comments_count',
'at_position_list', 'children', 'type', 'highlighted_title',
'highlighted_body', 'endorsed', 'read'
]
updatable_fields = [
......@@ -59,7 +60,21 @@ class Thread(models.Model):
else:
return super(Thread, cls).url(action, params)
# TODO: This is currently overriding Model._retrieve only to add parameters
# for the request. Model._retrieve should be modified to handle this such
# that subclasses don't need to override for this.
def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes)
response = perform_request('get', url, {'recursive': kwargs.get('recursive')})
request_params = {
'recursive': kwargs.get('recursive'),
'user_id': kwargs.get('user_id'),
'mark_as_read': kwargs.get('mark_as_read', True),
}
# user_id may be none, in which case it shouldn't be part of the
# request.
request_params = strip_none(request_params)
response = perform_request('get', url, request_params)
self.update_attributes(**response)
class @DiscussionFilter
@filterDrop: (e) ->
$drop = $(e.target).parents('.topic_menu_wrapper, .browse-topic-drop-menu-wrapper')
query = $(e.target).val()
$items = $drop.find('a')
if(query.length == 0)
$items.removeClass('hidden')
return;
$items.addClass('hidden')
$items.each (i) ->
thisText = $(this).not('.unread').text()
$(this).parents('ul').siblings('a').not('.unread').each (i) ->
thisText = thisText + ' ' + $(this).text();
test = true
terms = thisText.split(' ')
if(thisText.toLowerCase().search(query.toLowerCase()) == -1)
test = false
if(test)
$(this).removeClass('hidden')
# show children
$(this).parent().find('a').removeClass('hidden');
# show parents
$(this).parents('ul').siblings('a').removeClass('hidden');
......@@ -14,6 +14,9 @@ if Backbone?
@newPostView = new NewPostView(el: $(".new-post-article"), collection: @discussion)
@nav.on "thread:created", @navigateToThread
@newPost = $('.new-post-article')
$('.new-post-btn').bind "click", @showNewPost
$('.new-post-cancel').bind "click", @hideNewPost
allThreads: ->
@nav.updateSidebar()
......@@ -24,6 +27,8 @@ if Backbone?
showThread: (forum_name, thread_id) ->
@thread = @discussion.get(thread_id)
@thread.set("unread_comments_count", 0)
@thread.set("read", true)
@setActiveThread()
if(@main)
@main.cleanup()
......@@ -43,3 +48,10 @@ if Backbone?
navigateToAllThreads: =>
@navigate("", trigger: true)
showNewPost: (event) =>
@newPost.slideDown(300)
$('.new-post-title').focus()
hideNewPost: (event) =>
@newPost.slideUp(300)
$ ->
new TooltipManager
class @TooltipManager
constructor: () ->
@$body = $('body')
@$tooltip = $('<div class="tooltip"></div>')
@$body.delegate '[data-tooltip]',
'mouseover': @showTooltip,
'mousemove': @moveTooltip,
'mouseout': @hideTooltip,
'click': @hideTooltip
showTooltip: (e) =>
tooltipText = $(e.target).attr('data-tooltip')
@$tooltip.html(tooltipText)
@$body.append(@$tooltip)
$(e.target).children().css('pointer-events', 'none')
tooltipCoords =
x: e.pageX - (@$tooltip.outerWidth() / 2)
y: e.pageY - (@$tooltip.outerHeight() + 15)
@$tooltip.css
'left': tooltipCoords.x,
'top': tooltipCoords.y
@tooltipTimer = setTimeout ()=>
@$tooltip.show().css('opacity', 1)
@tooltipTimer = setTimeout ()=>
@hideTooltip()
, 3000
, 500
moveTooltip: (e) =>
tooltipCoords =
x: e.pageX - (@$tooltip.outerWidth() / 2)
y: e.pageY - (@$tooltip.outerHeight() + 15)
@$tooltip.css
'left': tooltipCoords.x
'top': tooltipCoords.y
hideTooltip: (e) =>
@$tooltip.hide().css('opacity', 0)
clearTimeout(@tooltipTimer)
......@@ -9,6 +9,7 @@ if Backbone?
"click .browse-topic-drop-search-input": "ignoreClick"
"click .post-list .list-item a": "threadSelected"
"click .post-list .more-pages a": "loadMorePages"
'keyup .browse-topic-drop-search-input': DiscussionFilter.filterDrop
initialize: ->
@displayedCollection = new Discussion(@collection.models, pages: @collection.pages)
......@@ -129,6 +130,8 @@ if Backbone?
content.addClass("followed")
if thread.get('endorsed')
content.addClass("resolved")
if thread.get('read')
content.addClass("read")
@highlight(content)
......@@ -244,7 +247,8 @@ if Backbone?
item = $(event.target).closest('li')
if item.find("span.board-name").data("discussion_id") == "#all"
@discussionIds = ""
@clearSearch()
@$(".post-search-field").val("")
@retrieveAllThreads()
else
discussionIds = _.map item.find(".board-name[data-discussion_id]"), (board) -> $(board).data("discussion_id").id
@retrieveDiscussions(discussionIds)
......@@ -277,6 +281,18 @@ if Backbone?
Content.loadContentInfos(response.content_info)
@displayedCollection.reset(@collection.models)
retrieveAllThreads: () ->
url = DiscussionUtil.urlFor("threads")
DiscussionUtil.safeAjax
url: url
type: "GET"
success: (response, textStatus) =>
@collection.current_page = response.page
@collection.pages = response.num_pages
@collection.reset(response.discussion_data)
Content.loadContentInfos(response.content_info)
@displayedCollection.reset(@collection.models)
sortThreads: (event) ->
@$(".sort-bar a").removeClass("active")
$(event.target).addClass("active")
......
......@@ -21,6 +21,7 @@ if Backbone?
"click .topic_dropdown_button": "toggleTopicDropdown"
"click .topic_menu_wrapper": "setTopic"
"click .topic_menu_search": "ignoreClick"
"keyup .form-topic-drop-search-input": DiscussionFilter.filterDrop
# Because we want the behavior that when the body is clicked the menu is
# closed, we need to ignore clicks in the search field and stop propagation.
......@@ -120,9 +121,6 @@ if Backbone?
anonymous_to_peers = false || @$("input.discussion-anonymous-to-peers").is(":checked")
follow = false || @$("input.discussion-follow").is(":checked")
$formTopicDropBtn.bind('click', showFormTopicDrop)
$formTopicDropMenu.bind('click', setFormTopic)
url = DiscussionUtil.urlFor('create_thread', @topicId)
DiscussionUtil.safeAjax
......
var $body;
var $browse;
var $search;
var $searchField;
var $topicDrop;
var $currentBoard;
var $tooltip;
var $newPost;
var $thread;
var $sidebar;
var $sidebarWidthStyles;
var $formTopicDropBtn;
var $formTopicDropMenu;
var $postListWrapper;
var $dropFilter;
var $topicFilter;
var $discussionBody;
var sidebarWidth;
var sidebarHeight;
var sidebarHeaderHeight;
var sidebarXOffset;
var scrollTop;
var discussionsBodyTop;
var discussionsBodyBottom;
var tooltipTimer;
var tooltipCoords;
var SIDEBAR_PADDING = 10;
var SIDEBAR_HEADER_HEIGHT = 87;
$(document).ready(function() {
$body = $('body');
//$browse = $('.browse-search .browse');
//$search = $('.browse-search .search');
$searchField = $('.post-search-field');
//$topicDrop = $('.browse-topic-drop-menu-wrapper');
$currentBoard = $('.current-board');
$tooltip = $('<div class="tooltip"></div>');
$newPost = $('.new-post-article');
$sidebar = $('.sidebar');
$discussionBody = $('.discussion-body');
$postListWrapper = $('.post-list-wrapper');
$formTopicDropBtn = $('.new-post-article .form-topic-drop-btn');
$formTopicDropMenu = $('.new-post-article .form-topic-drop-menu-wrapper');
// $dropFilter = $('.browse-topic-drop-search-input');
// $topicFilter = $('.topic-drop-search-input');
$sidebarWidthStyles = $('<style></style>');
$body.append($sidebarWidthStyles);
sidebarWidth = $('.sidebar').width();
sidebarXOffset = $sidebar.offset().top;
//$browse.bind('click', showTopicDrop);
//$search.bind('click', showSearch);
// $topicDrop.bind('click', setTopic);
$formTopicDropBtn.bind('click', showFormTopicDrop);
$formTopicDropMenu.bind('click', setFormTopic);
$('.new-post-btn').bind('click', newPost);
$('.new-post-cancel').bind('click', closeNewPost);
$body.delegate('[data-tooltip]', {
'mouseover': showTooltip,
'mousemove': moveTooltip,
'mouseout': hideTooltip,
'click': hideTooltip
});
$body.delegate('.browse-topic-drop-search-input, .form-topic-drop-search-input', 'keyup', filterDrop);
});
function filterDrop(e) {
/*
* multiple queries
*/
// var $drop = $(e.target).parents('.form-topic-drop-menu-wrapper, .browse-topic-drop-menu-wrapper');
// var queries = $(this).val().split(' ');
// var $items = $drop.find('a');
// if(queries.length == 0) {
// $items.show();
// return;
// }
// $items.hide();
// $items.each(function(i) {
// var thisText = $(this).children().not('.unread').text();
// $(this).parents('ul').siblings('a').not('.unread').each(function(i) {
// thisText = thisText + ' ' + $(this).text();
// });
// var test = true;
// var terms = thisText.split(' ');
// for(var i = 0; i < queries.length; i++) {
// if(thisText.toLowerCase().search(queries[i].toLowerCase()) == -1) {
// test = false;
// }
// }
// if(test) {
// $(this).show();
// // show children
// $(this).parent().find('a').show();
// // show parents
// $(this).parents('ul').siblings('a').show();
// }
// });
/*
* single query
*/
var $drop = $(e.target).parents('.topic_menu_wrapper, .browse-topic-drop-menu-wrapper');
var query = $(this).val();
var $items = $drop.find('a');
if(query.length == 0) {
$items.removeClass('hidden');
return;
}
$items.addClass('hidden');
$items.each(function(i) {
var thisText = $(this).not('.unread').text();
$(this).parents('ul').siblings('a').not('.unread').each(function(i) {
thisText = thisText + ' ' + $(this).text();
});
var test = true;
var terms = thisText.split(' ');
if(thisText.toLowerCase().search(query.toLowerCase()) == -1) {
test = false;
}
if(test) {
$(this).removeClass('hidden');
// show children
$(this).parent().find('a').removeClass('hidden');
// show parents
$(this).parents('ul').siblings('a').removeClass('hidden');
}
});
}
function showTooltip(e) {
var tooltipText = $(this).attr('data-tooltip');
$tooltip.html(tooltipText);
$body.append($tooltip);
$(this).children().css('pointer-events', 'none');
tooltipCoords = {
x: e.pageX - ($tooltip.outerWidth() / 2),
y: e.pageY - ($tooltip.outerHeight() + 15)
};
$tooltip.css({
'left': tooltipCoords.x,
'top': tooltipCoords.y
});
tooltipTimer = setTimeout(function() {
$tooltip.show().css('opacity', 1);
tooltipTimer = setTimeout(function() {
hideTooltip();
}, 3000);
}, 500);
}
function moveTooltip(e) {
tooltipCoords = {
x: e.pageX - ($tooltip.outerWidth() / 2),
y: e.pageY - ($tooltip.outerHeight() + 15)
};
$tooltip.css({
'left': tooltipCoords.x,
'top': tooltipCoords.y
});
}
function hideTooltip(e) {
$tooltip.hide().css('opacity', 0);
clearTimeout(tooltipTimer);
}
function showBrowse(e) {
$browse.addClass('is-open');
$search.removeClass('is-open');
$searchField.val('');
}
function showSearch(e) {
$search.addClass('is-open');
$browse.removeClass('is-open');
setTimeout(function() {
$searchField.focus();
}, 200);
}
function showTopicDrop(e) {
e.preventDefault();
$browse.addClass('is-dropped');
if(!$topicDrop[0]) {
$topicDrop = $('.browse-topic-drop-menu-wrapper');
}
$topicDrop.show();
$browse.unbind('click', showTopicDrop);
$body.bind('keyup', setActiveDropItem);
$browse.bind('click', hideTopicDrop);
setTimeout(function() {
$body.bind('click', hideTopicDrop);
}, 0);
}
function hideTopicDrop(e) {
if(e.target == $('.browse-topic-drop-search-input')[0]) {
return;
}
$browse.removeClass('is-dropped');
$topicDrop.hide();
$body.unbind('click', hideTopicDrop);
$browse.bind('click', showTopicDrop);
}
function setTopic(e) {
if(e.target == $('.browse-topic-drop-search-input')[0]) {
return;
}
var $item = $(e.target).closest('a');
var boardName = $item.find('.board-name').html();
$item.parents('ul').not('.browse-topic-drop-menu').each(function(i) {
boardName = $(this).siblings('a').find('.board-name').html() + ' / ' + boardName;
});
if(!$currentBoard[0]) {
$currentBoard = $('.current-board');
}
$currentBoard.html(boardName);
var fontSize = 16;
$currentBoard.css('font-size', '16px');
while($currentBoard.width() > (sidebarWidth * .8) - 40) {
fontSize--;
if(fontSize < 11) {
break;
}
$currentBoard.css('font-size', fontSize + 'px');
}
showBrowse();
}
function newPost(e) {
$newPost.slideDown(300);
$('.new-post-title').focus();
}
function closeNewPost(e) {
$newPost.slideUp(300);
}
function showFormTopicDrop(e) {
$formTopicDropBtn.addClass('is-dropped');
$formTopicDropMenu.show();
$formTopicDropBtn.unbind('click', showFormTopicDrop);
$formTopicDropBtn.bind('click', hideFormTopicDrop);
setTimeout(function() {
$body.bind('click', hideFormTopicDrop);
}, 0);
}
function hideFormTopicDrop(e) {
if(e.target == $('.topic-drop-search-input')[0]) {
return;
}
$formTopicDropBtn.removeClass('is-dropped');
$formTopicDropMenu.hide();
$body.unbind('click', hideFormTopicDrop);
$formTopicDropBtn.unbind('click', hideFormTopicDrop);
$formTopicDropBtn.bind('click', showFormTopicDrop);
}
function setFormTopic(e) {
if(e.target == $('.topic-drop-search-input')[0]) {
return;
}
$formTopicDropBtn.removeClass('is-dropped');
hideFormTopicDrop(e);
var $item = $(e.target);
var boardName = $item.html();
$item.parents('ul').not('.form-topic-drop-menu').each(function(i) {
boardName = $(this).siblings('a').html() + ' / ' + boardName;
});
$formTopicDropBtn.html(boardName + ' <span class="drop-arrow">▾</span>');
}
function updateSidebar(e) {
// determine page scroll attributes
scrollTop = $(window).scrollTop();
discussionsBodyTop = $discussionBody.offset().top;
discussionsBodyBottom = discussionsBodyTop + $discussionBody.height();
var windowHeight = $(window).height();
// toggle fixed positioning
if(scrollTop > discussionsBodyTop - SIDEBAR_PADDING) {
$sidebar.addClass('fixed');
$sidebar.css('top', SIDEBAR_PADDING + 'px');
} else {
$sidebar.removeClass('fixed');
$sidebar.css('top', '0');
}
// set sidebar width
var sidebarWidth = .32 * $discussionBody.width() - 10;
$sidebar.css('width', sidebarWidth + 'px');
// show the entire sidebar at all times
var sidebarHeight = windowHeight - (scrollTop < discussionsBodyTop - SIDEBAR_PADDING ? discussionsBodyTop - scrollTop : SIDEBAR_PADDING) - SIDEBAR_PADDING - (scrollTop + windowHeight > discussionsBodyBottom + SIDEBAR_PADDING ? scrollTop + windowHeight - discussionsBodyBottom - SIDEBAR_PADDING : 0);
$sidebar.css('height', sidebarHeight > 400 ? sidebarHeight : 400 + 'px');
// update the list height
if(!$postListWrapper[0]) {
$postListWrapper = $('.post-list-wrapper');
}
$postListWrapper.css('height', (sidebarHeight - SIDEBAR_HEADER_HEIGHT - 4) + 'px');
// update title wrappers
var titleWidth = sidebarWidth - 115;
$sidebarWidthStyles.html('.discussion-body .post-list a .title { width: ' + titleWidth + 'px !important; }');
}
......@@ -79,7 +79,7 @@ jQuery.extend({
try {
if(io.contentWindow){
xml.responseText = io.contentWindow.document.body ?
io.contentWindow.document.body.innerText : null;
io.contentWindow.document.body.textContent || io.contentWindow.document.body.innerText : null;
xml.responseXML = io.contentWindow.document.XMLDocument ?
io.contentWindow.document.XMLDocument : io.contentWindow.document;
......
......@@ -940,7 +940,7 @@ body.discussion {
display: block;
width: 100%;
height: 30px;
padding: 0;
padding: 0 0 0 30px;
margin: 14px auto;
@include box-sizing(border-box);
border: 1px solid #acacac;
......@@ -951,7 +951,6 @@ body.discussion {
font-weight: 400;
font-size: 13px;
line-height: 20px;
text-indent: 30px;
color: #333;
outline: 0;
cursor: pointer;
......@@ -1019,6 +1018,7 @@ body.discussion {
text-shadow: 0 -1px 0 rgba(0, 0, 0, .3);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 1px rgba(0, 0, 0, .2) inset;
}
}
}
......@@ -1053,14 +1053,17 @@ body.discussion {
}
a {
position: relative;
display: block;
height: 36px;
position: relative;
float: left;
clear: both;
width: 100%;
padding: 0 10px 0 18px;
margin-bottom: 1px;
margin-right: -1px;
@include linear-gradient(top, rgba(255, 255, 255, .7), rgba(255, 255, 255, 0));
background-color: #fff;
@include clearfix;
&:hover {
@include linear-gradient(top, rgba(255, 255, 255, .7), rgba(255, 255, 255, 0));
......@@ -1097,15 +1100,23 @@ body.discussion {
}
.title {
display: block;
float: left;
width: 70%;
margin: 8px 0 10px;
font-size: 13px;
font-weight: 700;
line-height: 34px;
line-height: 1.4;
color: #333;
}
&.read .title {
font-weight: 400;
color: #737373;
&.read {
background: #f2f2f2;
.title {
font-weight: 400;
color: #737373;
}
}
&.resolved:before {
......@@ -1122,7 +1133,7 @@ body.discussion {
content: '';
position: absolute;
top: 0;
right: 1px;
right: 0;
width: 10px;
height: 12px;
background: url(../images/following-flag.png) no-repeat;
......@@ -1165,22 +1176,13 @@ body.discussion {
}
}
.title {
display: block;
float: left;
width: 70%;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.votes-count,
.comments-count {
display: block;
float: right;
width: 32px;
height: 16px;
margin-top: 9px;
margin-top: 8px;
border-radius: 2px;
@include linear-gradient(top, #d4d4d4, #dfdfdf);
font-size: 11px;
......@@ -1207,12 +1209,13 @@ body.discussion {
background-position: 0 -5px;
}
&.new {
@include linear-gradient(top, #84d7fe, #99e0fe);
&.unread {
@include linear-gradient(top, #84d7fe, #60a8d6);
color: #333;
&:after {
color: #99e0fe;
background-position: 0 0px;
}
}
}
......@@ -1260,6 +1263,10 @@ body.discussion {
padding: 40px;
min-height: 468px;
a {
word-wrap: break-word;
}
h1 {
margin-bottom: 10px;
font-size: 28px;
......@@ -1317,6 +1324,7 @@ body.discussion {
position: relative;
z-index: 100;
margin-top: 5px;
margin-left: 40px;
}
.post-tools {
......@@ -1472,17 +1480,31 @@ body.discussion {
}
}
blockquote {
background: #f6f6f6;
border-radius: 3px;
padding: 5px 10px;
font-size: 14px;
}
.comments {
list-style: none;
margin-top: 20px;
padding: 0;
border-top: 1px solid #ddd;
li {
> li {
background: #f6f6f6;
border-bottom: 1px solid #ddd;
}
blockquote {
background: #e6e6e6;
border-radius: 3px;
padding: 5px 10px;
font-size: 14px;
}
.comment-form {
background: #eee;
@include clearfix;
......@@ -1506,7 +1528,6 @@ body.discussion {
.discussion-errors {
margin: 0px;
}
}
.response-body {
......
......@@ -6,54 +6,21 @@ if active_page == None and active_page_context is not UNDEFINED:
# If active_page is not passed in as an argument, it may be in the context as active_page_context
active_page = active_page_context
def url_class(url):
if url == active_page:
def url_class(is_active):
if is_active:
return "active"
return ""
%>
<%! from django.core.urlresolvers import reverse %>
<%! from courseware.access import has_access %>
<%! from courseware.tabs import get_course_tabs %>
<nav class="${active_page} course-material">
<div class="inner-wrapper">
<ol class="course-tabs">
<li class="courseware"><a href="${reverse('courseware', args=[course.id])}" class="${url_class('courseware')}">Courseware</a></li>
<li class="info"><a href="${reverse('info', args=[course.id])}" class="${url_class('info')}">Course Info</a></li>
% if hasattr(course,'syllabus_present') and course.syllabus_present:
<li class="syllabus"><a href="${reverse('syllabus', args=[course.id])}" class="${url_class('syllabus')}">Syllabus</a></li>
% endif
% if user.is_authenticated():
% if settings.MITX_FEATURES.get('ENABLE_TEXTBOOK'):
% for index, textbook in enumerate(course.textbooks):
<li class="book"><a href="${reverse('book', args=[course.id, index])}" class="${url_class('book')}">${textbook.title}</a></li>
% endfor
% endif
## If they have a discussion link specified, use that even if we feature
## flag discussions off. Disabling that is mostly a server safety feature
## at this point, and we don't need to worry about external sites.
% if course.discussion_link:
<li class="discussion"><a href="${course.discussion_link}">Discussion</a></li>
% elif settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
<li class="discussion"><a href="${reverse('django_comment_client.forum.views.forum_form_discussion', args=[course.id])}" class="${url_class('discussion')}">Discussion</a></li>
## <li class="news"><a href="${reverse('news', args=[course.id])}" class="${url_class('news')}">News</a></li>
% endif
## This is Askbot, which we should be retiring soon...
% if settings.MITX_FEATURES.get('ENABLE_DISCUSSION'):
<li class="discussion"><a href="${reverse('questions')}">Discussion</a></li>
% endif
% endif
% if settings.WIKI_ENABLED:
<li class="wiki"><a href="${reverse('course_wiki', args=[course.id])}" class="${url_class('wiki')}">Wiki</a></li>
% endif
% if user.is_authenticated() and not course.hide_progress_tab:
<li class="profile"><a href="${reverse('progress', args=[course.id])}" class="${url_class('progress')}">Progress</a></li>
% endif
% if staff_access:
<li class="instructor"><a href="${reverse('instructor_dashboard', args=[course.id])}" class="${url_class('instructor')}">Instructor</a></li>
% endif
% for tab in get_course_tabs(user, course, active_page):
<li>
<a href="${tab.link | h}" class="${url_class(tab.is_active)}">${tab.name | h}</a>
</li>
% endfor
<%block name="extratabs" />
</ol>
</div>
......
<%inherit file="/main.html" />
<%namespace name='static' file='/static_content.html'/>
<%block name="headextra">
<%static:css group='course'/>
</%block>
<%block name="title"><title>${course.number} ${tab['name']}</title></%block>
<%include file="/courseware/course_navigation.html" args="active_page='static_tab_{0}'.format(tab['url_slug'])" />
<section class="container">
<div class="static_tab_wrapper">
${tab_contents}
</div>
</section>
......@@ -119,7 +119,7 @@
<script type="text/template" id="response-comment-show-template">
<div id="comment_${'<%- id %>'}">
<div class="response-body">${'<%- body %>'}</div>
<p class="posted-details">&ndash;posted <span class="timeago" title="${'<%- created_at %>'}">${'<%- created_at %>'}</span> by
<p class="posted-details">&ndash;posted <span class="timeago" title="${'<%- created_at %>'}">${'<%- created_at %>'}</span> by
${"<% if (obj.username) { %>"}
<a href="${'<%- user_url %>'}" class="profile-link">${'<%- username %>'}</a>
${"<% } else {print('anonymous');} %>"}
......@@ -128,5 +128,13 @@
</script>
<script type="text/template" id="thread-list-item-template">
<a href="${'<%- id %>'}" data-id="${'<%- id %>'}"><span class="title">${"<%- title %>"}</span> <span class="comments-count">${"<%- comments_count %>"}</span><span class="votes-count">+${"<%- votes['up_count'] %>"}</span></a>
<a href="${'<%- id %>'}" data-id="${'<%- id %>'}">
<span class="title">${"<%- title %>"}</span>
${"<% if (unread_comments_count > 0) { %>"}
<span class="comments-count unread" data-tooltip="${"<%- unread_comments_count %>"} new comment${"<%- unread_comments_count > 1 ? 's' : '' %>"}">${"<%- comments_count %>"}</span>
${"<% } else { %>"}
<span class="comments-count">${"<%- comments_count %>"}</span>
${"<% } %>"}
<span class="votes-count">+${"<%- votes['up_count'] %>"}</span>
</a>
</script>
......@@ -21,8 +21,6 @@
<%include file="_new_post.html" />
<script type="text/javascript" src="${static.url('js/discussions-temp.js')}"></script>
<section class="discussion container" id="discussion-container" data-roles="${roles}" data-course-id="${course_id}" data-user-info="${user_info}" data-threads="${threads}" data-thread-pages="${thread_pages}">
<div class="discussion-body">
<div class="sidebar"></div>
......
......@@ -16,7 +16,6 @@
<%block name="js_extra">
<%include file="_js_body_dependencies.html" />
<%static:js group='discussion'/>
<script type="text/javascript" src="${static.url('js/discussions-temp.js')}"></script>
</%block>
......
......@@ -147,28 +147,10 @@
% if show_signup_immediately is not UNDEFINED:
<script type="text/javascript">
function dosignup(){
comp = document.getElementById('signup_action');
try { //in firefox
comp.click();
return;
} catch(ex) {}
try { // in old chrome
if(document.createEvent) {
var e = document.createEvent('MouseEvents');
e.initEvent( 'click', true, true );
comp.dispatchEvent(e);
return;
}
} catch(ex) {}
try { // in IE, safari
if(document.createEventObject) {
var evObj = document.createEventObject();
comp.fireEvent("onclick", evObj);
return;
}
} catch(ex) {}
}
$(window).load(dosignup);
$(window).load(function() {$('#signup_action').trigger("click");});
</script>
% endif
% elif show_login_immediately is not UNDEFINED:
<script type="text/javascript">
$(window).load(function() {$('#login').trigger("click");});
</script>
% endif
\ No newline at end of file
......@@ -46,7 +46,11 @@
(function() {
$(document).delegate('#login_form', 'ajax:success', function(data, json, xhr) {
if(json.success) {
location.href="${reverse('dashboard')}";
% if request.REQUEST.get('next', False):
location.href="${request.REQUEST.get('next')}";
% else:
location.href="${reverse('dashboard')}";
% endif
} else {
if($('#login_error').length == 0) {
$('#login_form').prepend('<div id="login_error" class="modal-form-error"></div>');
......
......@@ -13,7 +13,7 @@
<input type="hidden" name="problem_id" value="${ problem['name'] }">
% if check_button:
<input class="check" type="button" value="${ check_button }">
<input class="check ${ check_button }" type="button" value="${ check_button }">
% endif
% if reset_button:
<input class="reset" type="button" value="Reset">
......@@ -26,7 +26,7 @@
% endif
% if attempts_allowed :
<section class="submission_feedback">
You have used ${ attempts_used } of ${ attempts_allowed } submissions
You have used ${ attempts_used } of ${ attempts_allowed } submissions
</section>
% endif
</section>
......
<%inherit file="main.html" />
<%namespace name='static' file='static_content.html'/>
<%block name="title"><title>Textbook – MITx 6.002x</title></%block>
<%block name="title"><title>${course.number} Textbook</title></%block>
<%block name="headextra">
<%static:css group='course'/>
......@@ -38,14 +38,14 @@ function goto_page(n) {
function prev_page() {
var newpage=page-1;
if(newpage<0) newpage=0;
if(newpage< ${start_page}) newpage=${start_page};
goto_page(newpage);
log_event("book", {"type":"prevpage","new":page});
}
function next_page() {
var newpage=page+1;
if(newpage>1008) newpage=1008;
if(newpage> ${end_page}) newpage=${end_page};
goto_page(newpage);
log_event("book", {"type":"nextpage","new":page});
}
......@@ -61,7 +61,7 @@ $("#open_close_accordion a").click(function(){
</script>
</%block>
<%include file="/courseware/course_navigation.html" args="active_page='book'" />
<%include file="/courseware/course_navigation.html" args="active_page='textbook/{0}'.format(book_index)" />
<section class="container">
<div class="book-wrapper">
......@@ -97,6 +97,10 @@ $("#open_close_accordion a").click(function(){
% for entry in table_of_contents:
${print_entry(entry)}
% endfor
## Don't delete this empty list item. Without it, Jquery.TreeView won't
## render the last list item as expandable.
<li></li>
</ul>
</section>
......
......@@ -97,6 +97,33 @@ urlpatterns = ('',
if settings.PERFSTATS:
urlpatterns += (url(r'^reprofile$','perfstats.views.end_profile'),)
# Multicourse wiki (Note: wiki urls must be above the courseware ones because of
# the custom tab catch-all)
if settings.WIKI_ENABLED:
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_id>[^/]+/[^/]+/[^/]+)/course_wiki/?$',
'course_wiki.views.course_wiki_redirect', name="course_wiki"),
url(r'^courses/(?:[^/]+/[^/]+/[^/]+)/wiki/', include(wiki_pattern())),
)
if settings.COURSEWARE_ENABLED:
urlpatterns += (
# Hook django-masquerade, allowing staff to view site as other users
......@@ -164,6 +191,10 @@ if settings.COURSEWARE_ENABLED:
'instructor.views.grade_summary', name='grade_summary'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/enroll_students$',
'instructor.views.enroll_students', name='enroll_students'),
# This MUST be the last view in the courseware--it's a catch-all for custom tabs.
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/(?P<tab_slug>.*)$',
'courseware.views.static_tab', name="static_tab"),
)
# discussion forums live within courseware, so courseware must be enabled first
......@@ -176,29 +207,6 @@ if settings.COURSEWARE_ENABLED:
include('django_comment_client.urls'))
)
# Multicourse wiki
if settings.WIKI_ENABLED:
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_id>[^/]+/[^/]+/[^/]+)/course_wiki/?$',
'course_wiki.views.course_wiki_redirect', name="course_wiki"),
url(r'^courses/(?:[^/]+/[^/]+/[^/]+)/wiki/', include(wiki_pattern())),
)
if settings.QUICKEDIT:
urlpatterns += (url(r'^quickedit/(?P<id>[^/]*)$', 'dogfood.views.quickedit'),)
urlpatterns += (url(r'^dogfood/(?P<id>[^/]*)$', 'dogfood.views.df_capa_problem'),)
......
......@@ -125,8 +125,8 @@ TEST_TASKS = []
end
# Per environment tasks
Dir["#{system}/envs/*.py"].each do |env_file|
env = File.basename(env_file).gsub(/\.py/, '')
Dir["#{system}/envs/**/*.py"].each do |env_file|
env = env_file.gsub("#{system}/envs/", '').gsub(/\.py/, '').gsub('/', '.')
desc "Attempt to import the settings file #{system}.envs.#{env} and report any errors"
task "#{system}:check_settings:#{env}" => :predjango do
sh("echo 'import #{system}.envs.#{env}' | #{django_admin(system, env, 'shell')}")
......
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