diff --git a/cms/envs/common.py b/cms/envs/common.py index 12fa09947a..5ad9068636 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -34,6 +34,7 @@ MITX_FEATURES = { 'ENABLE_DISCUSSION_SERVICE': False, 'AUTH_USE_MIT_CERTIFICATES': False, 'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests + 'STUDIO_NPS_SURVEY': True, } ENABLE_JASMINE = False diff --git a/cms/envs/dev.py b/cms/envs/dev.py index c4465a0e06..ae78b93f06 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -147,3 +147,6 @@ DEBUG_TOOLBAR_CONFIG = { # To see stacktraces for MongoDB queries, set this to True. # Stacktraces slow down page loads drastically (for pages with lots of queries). DEBUG_TOOLBAR_MONGO_STACKTRACES = True + +# disable NPS survey in dev mode +MITX_FEATURES['STUDIO_NPS_SURVEY'] = False diff --git a/cms/templates/base.html b/cms/templates/base.html index fd2b96f03a..15f4c556bb 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -58,6 +58,8 @@ <%include file="widgets/tender.html" /> <%block name="jsextra"> + + <%include file="widgets/qualaroo.html" /> diff --git a/cms/templates/widgets/qualaroo.html b/cms/templates/widgets/qualaroo.html new file mode 100644 index 0000000000..04d10e08d1 --- /dev/null +++ b/cms/templates/widgets/qualaroo.html @@ -0,0 +1,13 @@ +% if settings.MITX_FEATURES.get('STUDIO_NPS_SURVEY'): + + + + + + +% endif diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index db2aa13cb7..3e5108ed3a 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -20,8 +20,7 @@ class AnnotatableModule(AnnotatableFields, XModule): resource_string(__name__, 'js/src/collapsible.coffee'), resource_string(__name__, 'js/src/html/display.coffee'), resource_string(__name__, 'js/src/annotatable/display.coffee')], - 'js': [] - } + 'js': []} js_module_name = "Annotatable" css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]} icon_class = 'annotatable' @@ -49,11 +48,11 @@ class AnnotatableModule(AnnotatableFields, XModule): if color is not None: if color in self.highlight_colors: - cls.append('highlight-'+color) + cls.append('highlight-' + color) attr['_delete'] = highlight_key attr['value'] = ' '.join(cls) - return { 'class' : attr } + return {'class': attr} def _get_annotation_data_attr(self, index, el): """ Returns a dict in which the keys are the HTML data attributes @@ -73,7 +72,7 @@ class AnnotatableModule(AnnotatableFields, XModule): if xml_key in el.attrib: value = el.get(xml_key, '') html_key = attrs_map[xml_key] - data_attrs[html_key] = { 'value': value, '_delete': xml_key } + data_attrs[html_key] = {'value': value, '_delete': xml_key} return data_attrs @@ -91,7 +90,6 @@ class AnnotatableModule(AnnotatableFields, XModule): delete_key = attr[key]['_delete'] del el.attrib[delete_key] - def _render_content(self): """ Renders annotatable content with annotation spans and returns HTML. """ xmltree = etree.fromstring(self.content) @@ -132,4 +130,3 @@ class AnnotatableDescriptor(AnnotatableFields, RawDescriptor): stores_state = True template_dir_name = "annotatable" mako_template = "widgets/raw-edit.html" - diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 308b379ec1..e2c095de2d 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -1,6 +1,16 @@ +/* TODO: move top-level variables to a common _variables.scss. + * NOTE: These variables were only added here because when this was integrated with the CMS, + * SASS compilation errors were triggered because the CMS didn't have the same variables defined + * that the LMS did, so the quick fix was to localize the LMS variables not shared by the CMS. + * -Abarrett and Vshnayder + */ $border-color: #C8C8C8; $body-font-size: em(14); +.annotatable-wrapper { + position: relative; +} + .annotatable-header { margin-bottom: .5em; .annotatable-title { @@ -55,6 +65,7 @@ $body-font-size: em(14); display: inline; cursor: pointer; + $highlight_index: 0; @each $highlight in ( (yellow rgba(255,255,10,0.3) rgba(255,255,10,0.9)), (red rgba(178,19,16,0.3) rgba(178,19,16,0.9)), @@ -62,12 +73,13 @@ $body-font-size: em(14); (green rgba(25,255,132,0.3) rgba(25,255,132,0.9)), (blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)), (purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) { - + + $highlight_index: $highlight_index + 1; $marker: nth($highlight,1); $color: nth($highlight,2); $selected_color: nth($highlight,3); - @if $marker == yellow { + @if $highlight_index == 1 { &.highlight { background-color: $color; &.selected { background-color: $selected_color; } @@ -127,6 +139,7 @@ $body-font-size: em(14); font-weight: 400; padding: 0 10px 10px 10px; background-color: transparent; + border-color: transparent; } p { color: inherit; @@ -143,6 +156,7 @@ $body-font-size: em(14); margin: 0px 0px 10px 0; max-height: 225px; overflow: auto; + line-height: normal; } .annotatable-reply { display: block; @@ -165,5 +179,3 @@ $body-font-size: em(14); border-top-color: rgba(0, 0, 0, .85); } } - - diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 2ad49ae6d7..8a32c8f51e 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -1,7 +1,8 @@ class @Annotatable _debug: false - # selectors for the annotatable xmodule + # selectors for the annotatable xmodule + wrapperSelector: '.annotatable-wrapper' toggleAnnotationsSelector: '.annotatable-toggle-annotations' toggleInstructionsSelector: '.annotatable-toggle-instructions' instructionsSelector: '.annotatable-instructions' @@ -61,7 +62,7 @@ class @Annotatable my: 'bottom center' # of tooltip at: 'top center' # of target target: $(el) # where the tooltip was triggered (i.e. the annotation span) - container: @$el + container: @$(@wrapperSelector) adjust: y: -5 show: @@ -75,6 +76,7 @@ class @Annotatable classes: 'ui-tooltip-annotatable' events: show: @onShowTip + move: @onMoveTip onClickToggleAnnotations: (e) => @toggleAnnotations() @@ -87,6 +89,55 @@ class @Annotatable onShowTip: (event, api) => event.preventDefault() if @annotationsHidden + onMoveTip: (event, api, position) => + ### + This method handles an edge case in which a tooltip is displayed above + a non-overlapping span like this: + + (( TOOLTIP )) + \/ + text text text ... text text text ...... + + + The problem is that the tooltip looks disconnected from both spans, so + we should re-position the tooltip to appear above the span. + ### + + tip = api.elements.tooltip + adjust_y = api.options.position?.adjust?.y || 0 + container = api.options.position?.container || $('body') + target = api.elements.target + + rects = $(target).get(0).getClientRects() + is_non_overlapping = (rects?.length == 2 and rects[0].left > rects[1].right) + + if is_non_overlapping + # we want to choose the largest of the two non-overlapping spans and display + # the tooltip above the center of it (see api.options.position settings) + focus_rect = (if rects[0].width > rects[1].width then rects[0] else rects[1]) + rect_center = focus_rect.left + (focus_rect.width / 2) + rect_top = focus_rect.top + tip_width = $(tip).width() + tip_height = $(tip).height() + + # tooltip is positioned relative to its container, so we need to factor in offsets + container_offset = $(container).offset() + offset_left = -container_offset.left + offset_top = $(document).scrollTop() - container_offset.top + + tip_left = offset_left + rect_center - (tip_width / 2) + tip_top = offset_top + rect_top - tip_height + adjust_y + + # make sure the new tip position doesn't clip the edges of the screen + win_width = $(window).width() + if tip_left < offset_left + tip_left = offset_left + else if tip_left + tip_width > win_width + offset_left + tip_left = win_width + offset_left - tip_width + + # final step: update the position object (used by qtip2 to show the tip after the move event) + $.extend position, 'left': tip_left, 'top': tip_top + getSpanForProblemReturn: (el) -> problem_id = $(@problemReturnSelector).index(el) @$(@spanSelector).filter("[data-problem-id='#{problem_id}']") diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py index 5ae8d890e6..a88c906b9c 100644 --- a/common/lib/xmodule/xmodule/videoalpha_module.py +++ b/common/lib/xmodule/xmodule/videoalpha_module.py @@ -131,7 +131,7 @@ class VideoAlphaModule(VideoAlphaFields, XModule): else: # VS[compat] # cdodge: filesystem static content support. - caption_asset_path = "/static/{0}/subs/".format(getattr(self, 'data_dir', None)) + caption_asset_path = "/static/subs/" return self.system.render_template('videoalpha.html', { 'youtube_streams': self.youtube_streams, diff --git a/lms/djangoapps/courseware/management/commands/remove_input_state.py b/lms/djangoapps/courseware/management/commands/remove_input_state.py new file mode 100644 index 0000000000..9adabeafc9 --- /dev/null +++ b/lms/djangoapps/courseware/management/commands/remove_input_state.py @@ -0,0 +1,143 @@ +''' +This is a one-off command aimed at fixing a temporary problem encountered where input_state was added to +the same dict object in capa problems, so was accumulating. The fix is simply to remove input_state entry +from state for all problems in the affected date range. +''' + +import json +import logging +from optparse import make_option + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from courseware.models import StudentModule, StudentModuleHistory + +LOG = logging.getLogger(__name__) + + +class Command(BaseCommand): + ''' + The fix here is to remove the "input_state" entry in the StudentModule objects of any problems that + contain them. No problem is yet making use of this, and the code should do the right thing if it's + missing (by recreating an empty dict for its value). + + To narrow down the set of problems that might need fixing, the StudentModule + objects to be checked is filtered down to those: + + created < '2013-03-29 16:30:00' (the problem must have been answered before the buggy code was reverted, + on Prod and Edge) + modified > '2013-03-28 22:00:00' (the problem must have been visited after the bug was introduced + on Prod and Edge) + state like '%input_state%' (the problem must have "input_state" set). + + This filtering is done on the production database replica, so that the larger select queries don't lock + the real production database. The list of id values for Student Modules is written to a file, and the + file is passed into this command. The sql file passed to mysql contains: + + select sm.id from courseware_studentmodule sm + where sm.modified > "2013-03-28 22:00:00" + and sm.created < "2013-03-29 16:30:00" + and sm.state like "%input_state%" + and sm.module_type = 'problem'; + + ''' + + num_visited = 0 + num_changed = 0 + num_hist_visited = 0 + num_hist_changed = 0 + + option_list = BaseCommand.option_list + ( + make_option('--save', + action='store_true', + dest='save_changes', + default=False, + help='Persist the changes that were encountered. If not set, no changes are saved.'), + ) + + def fix_studentmodules_in_list(self, save_changes, idlist_path): + '''Read in the list of StudentModule objects that might need fixing, and then fix each one''' + + # open file and read id values from it: + for line in open(idlist_path, 'r'): + student_module_id = line.strip() + # skip the header, if present: + if student_module_id == 'id': + continue + try: + module = StudentModule.objects.get(id=student_module_id) + except StudentModule.DoesNotExist: + LOG.error("Unable to find student module with id = {0}: skipping... ".format(student_module_id)) + continue + self.remove_studentmodule_input_state(module, save_changes) + + hist_modules = StudentModuleHistory.objects.filter(student_module_id=student_module_id) + for hist_module in hist_modules: + self.remove_studentmodulehistory_input_state(hist_module, save_changes) + + @transaction.autocommit + def remove_studentmodule_input_state(self, module, save_changes): + ''' Fix the grade assigned to a StudentModule''' + module_state = module.state + if module_state is None: + # not likely, since we filter on it. But in general... + LOG.info("No state found for {type} module {id} for student {student} in course {course_id}" + .format(type=module.module_type, id=module.module_state_key, + student=module.student.username, course_id=module.course_id)) + return + + state_dict = json.loads(module_state) + self.num_visited += 1 + + if 'input_state' not in state_dict: + pass + elif save_changes: + # make the change and persist + del state_dict['input_state'] + module.state = json.dumps(state_dict) + module.save() + self.num_changed += 1 + else: + # don't make the change, but increment the count indicating the change would be made + self.num_changed += 1 + + @transaction.autocommit + def remove_studentmodulehistory_input_state(self, module, save_changes): + ''' Fix the grade assigned to a StudentModule''' + module_state = module.state + if module_state is None: + # not likely, since we filter on it. But in general... + LOG.info("No state found for {type} module {id} for student {student} in course {course_id}" + .format(type=module.module_type, id=module.module_state_key, + student=module.student.username, course_id=module.course_id)) + return + + state_dict = json.loads(module_state) + self.num_hist_visited += 1 + + if 'input_state' not in state_dict: + pass + elif save_changes: + # make the change and persist + del state_dict['input_state'] + module.state = json.dumps(state_dict) + module.save() + self.num_hist_changed += 1 + else: + # don't make the change, but increment the count indicating the change would be made + self.num_hist_changed += 1 + + def handle(self, *args, **options): + '''Handle management command request''' + if len(args) != 1: + raise CommandError("missing idlist file") + idlist_path = args[0] + save_changes = options['save_changes'] + LOG.info("Starting run: reading from idlist file {0}; save_changes = {1}".format(idlist_path, save_changes)) + + self.fix_studentmodules_in_list(save_changes, idlist_path) + + LOG.info("Finished run: updating {0} of {1} student modules".format(self.num_changed, self.num_visited)) + LOG.info("Finished run: updating {0} of {1} student history modules".format(self.num_hist_changed, + self.num_hist_visited))