Commit ac7d31ae by Ali Reza Sharafat Committed by stv

utility section and the caption checking utility

parent 94c53718
""" Unit tests for utility methods in views.py. """
from django.conf import settings
from contentstore.utils import get_modulestore
from contentstore.views.utility import expand_utility_action_url
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import loc_mapper
import json
import mock
from .utils import CourseTestCase
class UtilitiesTestCase(CourseTestCase):
""" Test for utility get and put methods. """
def setUp(self):
""" Creates the test course. """
super(UtilitiesTestCase, self).setUp()
self.course = CourseFactory.create(org='mitX', number='333', display_name='Utilities Course')
self.location = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True)
self.utilities_url = self.location.url_reverse('utilities/', '')
def get_persisted_utilities(self):
""" Returns the utilities. """
return settings.COURSE_UTILITIES
def compare_utilities(self, persisted, request):
"""
Handles url expansion as possible difference and descends into guts
"""
self.assertEqual(persisted['short_description'], request['short_description'])
expanded_utility = expand_utility_action_url(self.course, persisted)
for pers, req in zip(expanded_utility['items'], request['items']):
self.assertEqual(pers['short_description'], req['short_description'])
self.assertEqual(pers['long_description'], req['long_description'])
self.assertEqual(pers['action_url'], req['action_url'])
self.assertEqual(pers['action_text'], req['action_text'])
self.assertEqual(pers['action_external'], req['action_external'])
def test_get_utilities(self):
""" Tests the get utilities method and URL expansion. """
response = self.client.get(self.utilities_url)
self.assertContains(response, "Bulk Operations")
# Verify expansion of action URL happened.
self.assertContains(response, 'captions/mitX.333.Utilities_Course')
# Verify persisted utility does NOT have expanded URL.
utility_0 = self.get_persisted_utilities()[0]
self.assertEqual('utility/captions', get_action_url(utility_0, 0))
payload = response.content
# Now delete the utilities from the course and verify they get repopulated (for courses
# created before utilities were introduced).
with mock.patch('django.conf.settings.COURSE_UTILITIES', []):
print settings.COURSE_UTILITIES
modulestore = get_modulestore(self.course.location)
modulestore.update_item(self.course, self.user.id)
self.assertEqual(self.get_persisted_utilities(), [])
def test_get_utilities_html(self):
""" Tests getting the HTML template for the utilities page). """
response = self.client.get(self.utilities_url, HTTP_ACCEPT='text/html')
self.assertContains(response, "What are course utilities?")
# The HTML generated will define the handler URL (for use by the Backbone model).
self.assertContains(response, self.utilities_url)
def test_update_utilities_no_index(self):
""" No utility index, should return all of them. """
returned_utilities = json.loads(self.client.get(self.utilities_url).content)
# Verify that persisted utilities do not have expanded action URLs.
# compare_utilities will verify that returned_utilities DO have expanded action URLs.
pers = self.get_persisted_utilities()
self.assertEqual('utility/captions', get_first_item(pers[0]).get('action_url'))
for pay, resp in zip(pers, returned_utilities):
self.compare_utilities(pay, resp)
def test_utilities_post_unsupported(self):
""" Post operation is not supported. """
update_url = self.location.url_reverse('utilities/', '100')
response = self.client.post(update_url)
self.assertEqual(response.status_code, 404)
def test_utilities_put_unsupported(self):
""" Put operation is not supported. """
update_url = self.location.url_reverse('utilities/', '100')
response = self.client.put(update_url)
self.assertEqual(response.status_code, 404)
def test_utilities_delete_unsupported(self):
""" Delete operation is not supported. """
update_url = self.location.url_reverse('utilities/', '100')
response = self.client.delete(update_url)
self.assertEqual(response.status_code, 404)
def test_expand_utility_action_url(self):
"""
Tests the method to expand utility action url.
"""
def test_expansion(utility, index, stored, expanded):
"""
Tests that the expected expanded value is returned for the item at the given index.
Also verifies that the original utility is not modified.
"""
self.assertEqual(get_action_url(utility, index), stored)
expanded_utility = expand_utility_action_url(self.course, utility)
self.assertEqual(get_action_url(expanded_utility, index), expanded)
# Verify no side effect in the original list.
self.assertEqual(get_action_url(utility, index), stored)
test_expansion(settings.COURSE_UTILITIES[0], 0, 'utility/captions', '/utility/captions/mitX.333.Utilities_Course/branch/draft/block/Utilities_Course')
def get_first_item(utility):
""" Returns the first item from the utility. """
return utility['items'][0]
def get_action_url(utility, index):
"""
Returns the action_url for the item at the specified index in the given utility.
"""
return utility['items'][index]['action_url']
...@@ -6,6 +6,8 @@ ...@@ -6,6 +6,8 @@
# All files below declare exports with __all__ # All files below declare exports with __all__
from .assets import * from .assets import *
from .checklist import * from .checklist import *
from .utility import *
from .utilities.captions import *
from .component import * from .component import *
from .course import * from .course import *
from .error import * from .error import *
......
...@@ -197,7 +197,33 @@ def check_transcripts(request): ...@@ -197,7 +197,33 @@ def check_transcripts(request):
`video` is html5 or youtube video_id `video` is html5 or youtube video_id
`mode` is youtube, ,p4 or webm `mode` is youtube, ,p4 or webm
Returns transcripts_presence dict:: Returns transcripts_presence: dictionary containing the status of the video
"""
response = {
'html5_local': [],
'html5_equal': False,
'is_youtube_mode': False,
'youtube_local': False,
'youtube_server': False,
'youtube_diff': True,
'current_item_subs': None,
'status': 'Success',
}
try:
__, videos, item = _validate_transcripts_data(request)
except TranscriptsRequestValidationException as e:
return error_response(response, e.message)
transcripts_presence = get_transcripts_presence(videos, item)
return JsonResponse(transcripts_presence)
def get_transcripts_presence(videos, item):
""" fills in the transcripts_presence dictionary after for a given component
with its list of videos.
Returns transcripts_presence dict:
html5_local: list of html5 ids, if subtitles exist locally for them; html5_local: list of html5 ids, if subtitles exist locally for them;
is_youtube_mode: bool, if we have youtube_id, and as youtube mode is of higher priority, reflect this with flag; is_youtube_mode: bool, if we have youtube_id, and as youtube mode is of higher priority, reflect this with flag;
...@@ -217,14 +243,8 @@ def check_transcripts(request): ...@@ -217,14 +243,8 @@ def check_transcripts(request):
'youtube_server': False, 'youtube_server': False,
'youtube_diff': True, 'youtube_diff': True,
'current_item_subs': None, 'current_item_subs': None,
'status': 'Error', 'status': 'Success',
} }
try:
__, videos, item = _validate_transcripts_data(request)
except TranscriptsRequestValidationException as e:
return error_response(transcripts_presence, e.message)
transcripts_presence['status'] = 'Success'
filename = 'subs_{0}.srt.sjson'.format(item.sub) filename = 'subs_{0}.srt.sjson'.format(item.sub)
content_location = StaticContent.compute_location( content_location = StaticContent.compute_location(
...@@ -292,8 +312,7 @@ def check_transcripts(request): ...@@ -292,8 +312,7 @@ def check_transcripts(request):
'command': command, 'command': command,
'subs': subs_to_use, 'subs': subs_to_use,
}) })
return JsonResponse(transcripts_presence) return transcripts_presence
def _transcripts_logic(transcripts_presence, videos): def _transcripts_logic(transcripts_presence, videos):
""" """
...@@ -384,7 +403,7 @@ def choose_transcripts(request): ...@@ -384,7 +403,7 @@ def choose_transcripts(request):
if item.sub != html5_id: # update sub value if item.sub != html5_id: # update sub value
item.sub = html5_id item.sub = html5_id
item.save_with_metadata(request.user) item.save_with_metadata(request.user)
response = {'status': 'Success', 'subs': item.sub} response = {'status': 'Success', 'subs': item.sub}
return JsonResponse(response) return JsonResponse(response)
...@@ -415,7 +434,7 @@ def replace_transcripts(request): ...@@ -415,7 +434,7 @@ def replace_transcripts(request):
item.sub = youtube_id item.sub = youtube_id
item.save_with_metadata(request.user) item.save_with_metadata(request.user)
response = {'status': 'Success', 'subs': item.sub} response = {'status': 'Success', 'subs': item.sub}
return JsonResponse(response) return JsonResponse(response)
......
"""
Views related to operations on course objects
"""
import json
import logging
import os
from django_future.csrf import ensure_csrf_cookie
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.utils.translation import ugettext as _
from edxmako.shortcuts import render_to_response
from models.settings.course_grading import CourseGradingModel
from util.json_request import JsonResponse
from xmodule.modulestore.django import modulestore, loc_mapper
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError
from xmodule.modulestore.locator import BlockUsageLocator
from xmodule.video_module.transcripts_utils import (
GetTranscriptsFromYouTubeException,
TranscriptsRequestValidationException,
download_youtube_subs)
from ..access import has_course_access
from ..transcripts_ajax import get_transcripts_presence
from ..course import _get_locator_and_course
log = logging.getLogger(__name__)
__all__ = ['utility_captions_handler']
# pylint: disable=unused-argument
@login_required
def utility_captions_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None):
"""
The restful handler for captions requests in the utilities area.
It provides the list of course videos as well as their status. It also lets
the user update the captions by pulling the latest version from YouTube.
GET
json: get the status of the captions of a given video
html: return page containing a list of videos in the course
POST
json: update the captions of a given video by copying the version of the captions hosted in youtube.
"""
response_format = request.REQUEST.get('format', 'html')
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
if request.method == 'POST': # update
try:
locations = _validate_captions_data_update(request)
except TranscriptsRequestValidationException as e:
return error_response(e.message)
return json_update_videos(request, locations)
elif request.method == 'GET': # get status
try:
data, item = _validate_captions_data_get(request)
except TranscriptsRequestValidationException as e:
return error_response(e.message)
return json_get_video_status(data, item)
else:
return HttpResponseBadRequest()
elif request.method == 'GET': # assume html
return captions_index(request, package_id, branch, version_guid, block)
else:
return HttpResponseNotFound()
@login_required
@ensure_csrf_cookie
def json_update_videos(request, locations):
"""
Updates the captions of a given list of videos and returns the status of the
videos in json format
request: the incoming request to update the videos
locations: list of locations of videos to be updated
"""
results = []
for key in locations:
try:
#update transcripts
item = modulestore().get_item(key)
download_youtube_subs({1.0: item.youtube_id_1_0}, item, settings)
item.sub = item.youtube_id_1_0
item.save_with_metadata(request.user)
#get new status
videos = {'youtube': item.youtube_id_1_0}
html5 = {}
for url in item.html5_sources:
name = os.path.splitext(url.split('/')[-1])[0]
html5[name] = 'html5'
videos['html5'] = html5
captions_dict = get_transcripts_presence(videos, item)
captions_dict.update({'location': key})
results.append(captions_dict)
except GetTranscriptsFromYouTubeException as e:
log.debug(e)
results.append({'location': key, 'command': e})
return JsonResponse(results)
@login_required
@ensure_csrf_cookie
def captions_index(request, package_id, branch, version_guid, block):
"""
Display a list of course videos as well as their status (up to date, or out of date)
org, course, name: Attributes of the Location for the item to edit
"""
locator, course = _get_locator_and_course(
package_id, branch, version_guid, block, request.user, depth=3
)
return render_to_response('captions.html',
{
'videos': get_videos(course),
'context_course': course,
}
)
def error_response(message, response=None, status_code=400):
"""
Simplify similar actions: log message and return JsonResponse with message included in response.
By default return 400 (Bad Request) Response.
"""
if response is None:
response = {}
log.debug(message)
response['message'] = message
return JsonResponse(response, status_code)
def _validate_captions_data_get(request):
"""
Happens on 'GET'. Validates, that request contains all proper data for transcripts processing.
Returns touple of two elements:
data: dict, loaded json from request,
item: video item from storage
Raises `TranscriptsRequestValidationException` if validation is unsuccessful
or `PermissionDenied` if user has no access.
"""
try:
data = json.loads(request.GET.get('video', '{}'))
except ValueError:
raise TranscriptsRequestValidationException(_("Invalid location."))
if not data:
raise TranscriptsRequestValidationException(_('Incoming video data is empty.'))
location = data.get('location')
item = _validate_location(location)
return data, item
def _validate_captions_data_update(request):
"""
Happens on 'POST'. Validates, that request contains all proper data for transcripts processing.
Returns data: dict, loaded json from request
Raises `TranscriptsRequestValidationException` if validation is unsuccessful
or `PermissionDenied` if user has no access.
"""
try:
data = json.loads(request.POST.get('update_array', '[]'))
except ValueError:
raise TranscriptsRequestValidationException(_("Invalid locations."))
if not data:
raise TranscriptsRequestValidationException(_('Incoming update_array data is empty.'))
for location in data:
_validate_location(location)
return data
def _validate_location(location):
try:
item = modulestore().get_item(location)
except (ItemNotFoundError, InvalidLocationError, InsufficientSpecificationError):
raise TranscriptsRequestValidationException(_("Can't find item by locator."))
if item.category != 'video':
raise TranscriptsRequestValidationException(_('Transcripts are supported only for "video" modules.'))
return item
def json_get_video_status(video_meta, item):
"""
Fetches the status of a given video
Returns: json response which includes a detailed status of the video captions
"""
videos = {'youtube': item.youtube_id_1_0}
html5 = {}
for url in item.html5_sources:
name = os.path.splitext(url.split('/')[-1])[0]
html5[name] = 'html5'
videos['html5'] = html5
transcripts_presence = get_transcripts_presence(videos, item)
video_meta.update(transcripts_presence)
return JsonResponse(video_meta)
def get_videos(course):
"""
Fetches the list of course videos
Returns: A list of tuples representing (name, location) of each video
"""
video_list = []
for section in course.get_children():
for subsection in section.get_children():
for unit in subsection.get_children():
for component in unit.get_children():
if component.location.category == 'video':
video_list.append({'name': component.display_name_with_default, 'location': str(component.location)})
return video_list
import copy
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseNotFound
from django.views.decorators.http import require_http_methods
from django_future.csrf import ensure_csrf_cookie
from edxmako.shortcuts import render_to_response
from util.json_request import JsonResponse
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.locator import BlockUsageLocator
from ..utils import get_modulestore
from .access import has_course_access
__all__ = ['utility_handler']
# pylint: disable=unused-argument
@require_http_methods(("GET"))
@login_required
@ensure_csrf_cookie
def utility_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None):
"""
The restful handler for utilities.
GET
html: return html page for all utilities
json: return json representing all utilities.
"""
location = BlockUsageLocator(package_id=package_id, branch=branch, version_guid=version_guid, block_id=block)
if not has_course_access(request.user, location):
raise PermissionDenied()
old_location = loc_mapper().translate_locator_to_location(location)
modulestore = get_modulestore(old_location)
course_module = modulestore.get_item(old_location)
json_request = 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json')
if request.method == 'GET':
expanded_utilities = expand_all_action_urls(course_module)
if json_request:
return JsonResponse(expanded_utilities)
else:
handler_url = location.url_reverse('utilities/', '')
return render_to_response('utilities.html',
{
'handler_url': handler_url,
'context_course': course_module,
'utilities': expanded_utilities
})
else:
# return HttpResponseNotFound()
raise NotImplementedError()
def expand_all_action_urls(course_module):
"""
Gets the utilities out of the course module and expands their action urls.
Returns a copy of the utilities with modified urls, without modifying the persisted version
of the utilities.
"""
expanded_utilities = []
for utility in settings.COURSE_UTILITIES:
expanded_utilities.append(expand_utility_action_url(course_module, utility))
return expanded_utilities
def expand_utility_action_url(course_module, utility):
"""
Expands the action URLs for a given utility and returns the modified version.
The method does a copy of the input utility and does not modify the input argument.
"""
expanded_utility = copy.deepcopy(utility)
for item in expanded_utility.get('items'):
url_prefix = item.get('action_url')
ctx_loc = course_module.location
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
item['action_url'] = location.url_reverse(url_prefix, '')
return expanded_utility
...@@ -467,6 +467,18 @@ YOUTUBE = { ...@@ -467,6 +467,18 @@ YOUTUBE = {
}, },
} }
############################## Utilities ##########################################
COURSE_UTILITIES = [
{"short_description": "Bulk Operations",
"items": [
{"short_description": "Get all captions from YouTube",
"long_description": "This utility will attempt to get or update captions for all videos in the course from YouTube. Please allow it a couple of minutes to run.",
"action_url": "utility/captions",
"action_text": "Check Captions",
"action_external": False}]}
]
############################ APPS ##################################### ############################ APPS #####################################
INSTALLED_APPS = ( INSTALLED_APPS = (
......
define(["backbone", "underscore", "js/models/utility"],
function(Backbone, _, UtilityModel) {
var UtilityCollection = Backbone.Collection.extend({
model : UtilityModel,
parse: function(response) {
_.each(response,
function( element, idx ) {
element.id = idx;
});
return response;
},
// Disable caching so the browser back button will work (utilities have links to other
// places within Studio).
fetch: function (options) {
options.cache = false;
return Backbone.Collection.prototype.fetch.call(this, options);
}
});
return UtilityCollection;
});
define(["backbone"], function(Backbone) {
var Utility = Backbone.Model.extend({
});
return Utility;
});
define(["js/views/baseview", "underscore", "jquery"], function(BaseView, _, $) {
var UtilityView = BaseView.extend({
// takes CMS.Models.Utilities as model
events : {
'click .course-utility .utility-title' : "toggleUtility",
'click .course-checkutilitylist .task input' : "toggleTask",
'click a[rel="external"]' : "popup"
},
initialize : function() {
var self = this;
this.template = _.template($("#utility-tpl").text());
this.collection.fetch({
reset: true,
complete: function() {
self.render();
}
});
},
render: function() {
// catch potential outside call before template loaded
if (!this.template) return this;
this.$el.empty();
var self = this;
_.each(this.collection.models,
function(utility, index) {
self.$el.append(self.renderTemplate(utility, index));
});
return this;
},
renderTemplate: function (utility, index) {
var utilityItems = utility.attributes['items'];
var itemsChecked = 0;
_.each(utilityItems,
function(utility) {
if (utility['is_checked']) {
itemsChecked +=1;
}
});
var percentChecked = Math.round((itemsChecked/utilityItems.length)*100);
return this.template({
utilityIndex : index,
utilityShortDescription : utility.attributes['short_description'],
items: utilityItems,
itemsChecked: itemsChecked,
percentChecked: percentChecked});
},
toggleUtility : function(e) {
e.preventDefault();
$(e.target).closest('.course-utility').toggleClass('is-collapsed');
},
toggleTask : function (e) {
var self = this;
var completed = 'is-completed';
var $checkbox = $(e.target);
var $task = $checkbox.closest('.task');
$task.toggleClass(completed);
var utility_index = $checkbox.data('utility');
var task_index = $checkbox.data('task');
var model = this.collection.at(utility_index);
model.attributes.items[task_index].is_checked = $task.hasClass(completed);
model.save({},
{
success : function() {
var updatedTemplate = self.renderTemplate(model, utility_index);
self.$el.find('#course-utility'+utility_index).first().replaceWith(updatedTemplate);
analytics.track('Toggled a Utility Task', {
'course': course_location_analytics,
'task': model.attributes.items[task_index].short_description,
'state': model.attributes.items[task_index].is_checked
});
}
});
},
popup: function(e) {
e.preventDefault();
window.open($(e.target).attr('href'));
}
});
return UtilityView;
});
...@@ -363,10 +363,12 @@ body.course.advanced .nav-course-settings-advanced, ...@@ -363,10 +363,12 @@ body.course.advanced .nav-course-settings-advanced,
body.course.import .nav-course-tools .title, body.course.import .nav-course-tools .title,
body.course.export .nav-course-tools .title, body.course.export .nav-course-tools .title,
body.course.checklists .nav-course-tools .title, body.course.checklists .nav-course-tools .title,
body.course.utilities .nav-course-tools .title,
body.course.import .nav-course-tools-import, body.course.import .nav-course-tools-import,
body.course.export .nav-course-tools-export, body.course.export .nav-course-tools-export,
body.course.checklists .nav-course-tools-checklists, body.course.checklists .nav-course-tools-checklists,
body.course.utilities .nav-course-tools-utilities,
{ {
color: $blue; color: $blue;
......
...@@ -45,6 +45,8 @@ ...@@ -45,6 +45,8 @@
@import 'views/container'; @import 'views/container';
@import 'views/users'; @import 'views/users';
@import 'views/checklists'; @import 'views/checklists';
@import 'views/utilities';
@import 'views/captions';
@import 'views/textbooks'; @import 'views/textbooks';
@import 'views/export-git'; @import 'views/export-git';
......
// studio - views - course subsection
// ====================
.view-captions {
.main-wrapper {
margin-top: ($baseline*2);
.list-actions {
padding: ($baseline/2);
background: $gray-l5;
.action-primary {
@include blue-button();
@extend %t-action3;
font-weight: 600;
[class^="icon-"] {
@extend %t-icon5;
display: inline-block;
vertical-align: middle;
margin-top: -3px;
}
}
}
}
.legend-table td {
padding-right: 5px;
.green {
color: $green;
}
.red {
color: $red;
}
.yellow {
background: $yellow;
}
}
.subsection-body {
padding: 32px 40px;
@include clearfix;
> div {
margin-bottom: 40px;
}
.selectall-label {
font-weight: normal;
}
input {
font-size: 14px;
margin-right: 10px;
}
}
.sortable-unit-list {
.green {
background: $green-l5;
}
.red {
background: $red-l5;
}
.yellow {
background: $yellow-l5;
}
.courseware-unit {
@include font-size(13);
@include clearfix();
margin: -1px 0 0 0;
.section-item {
@include transition(background $tmg-avg ease-in-out 0);
@include font-size(13);
position: relative;
display: block;
padding: 6px 8px 8px;
margin: 0;
font-weight: normal;
.green {
color: $green;
}
.red {
color: $red;
}
.yellow {
background: $yellow;
}
&:hover {
background: $blue-l5;
.popup {
display: block;
}
}
.popup {
position: absolute;
height: 25px;
top: 3px;
width: 105px;
background: rgba(255,255,255, .75);
border: 1px solid #aaa;
border-radius: 3px;
padding: 2px 5px;
right: 25px;
display: none;
#platform, #local, #remote {
padding-right: 8px;
}
}
}
}
.actions-list {
display: inline-block;
margin-bottom: 0;
}
.actions-item {
@include font-size(13);
display: inline-block;
padding: 0 4px;
vertical-align: middle;
.action {
min-width: ($baseline*.75);
color: $gray-l2;
&:hover,
&.is-set {
color: $blue;
visibility: visible;
}
}
}
}
// UI: DnD - specific elems/cases - units
.courseware-unit {
.draggable-drop-indicator-before {
top: 0;
}
.draggable-drop-indicator-after {
bottom: 0;
}
}
// UI: DnD - specific elems/cases - empty parents initial drop indicator
.draggable-drop-indicator-initial {
display: none;
}
}
// Studio - Course Utilities
// ====================
.view-utilities {
.content-primary, .content-supplementary {
@include box-sizing(border-box);
float: left;
}
.content-primary {
width: flex-grid(9, 12);
margin-right: flex-gutter();
}
// utilities - general
.course-utility {
@extend %ui-window;
margin: 0 0 ($baseline*2) 0;
&:last-child {
margin-bottom: 0;
}
// visual status
.viz-utility-status {
@extend %cont-text-hide;
@include size(100%,($baseline/4));
position: relative;
display: block;
margin: 0;
background: $gray-l4;
.viz-utility-status-value {
@include transition(width $tmg-s2 ease-in-out .25s);
position: absolute;
top: 0;
left: 0;
width: 0%;
height: ($baseline/4);
background: $green;
.int {
@extend %cont-text-sr;
}
}
}
// <span class="viz viz-utility-status"><span class="viz value viz-utility-status-value"><span class="int">0</span>% of utility completed</span></span>
// header/title
header {
@include clearfix();
box-shadow: inset 0 -1px 1px $shadow-l1;
margin-bottom: 0;
border-bottom: 1px solid $gray-l3;
padding: $baseline ($baseline*1.5);
.utility-title {
@include transition(color $tmg-f2 ease-in-out 0s);
width: flex-grid(6, 9);
margin: 0 flex-gutter() 0 0;
float: left;
.ui-toggle-expansion {
@include transition(all $tmg-f2 ease-in-out 0s);
@include font-size(21);
display: inline-block;
vertical-align: middle;
margin-right: ($baseline/2);
color: $gray-l4;
}
&.is-selectable {
cursor: pointer;
&:hover {
color: $blue;
.ui-toggle-expansion {
color: $blue;
}
}
}
}
}
// utility actions
.course-utility-actions {
@include clearfix();
@include transition(border $tmg-f2 ease-in-out .25s);
box-shadow: inset 0 1px 1px $shadow-l1;
border-top: 1px solid $gray-l2;
padding: $baseline ($baseline*1.5);
background: $gray-l4;
.action-primary {
@include green-button();
float: left;
}
.action-secondary {
@include grey-button();
@extend %t-action3;
font-weight: 400;
float: right;
}
}
// state - collapsed
&.is-collapsed {
header {
box-shadow: none;
.utility-title {
.ui-toggle-expansion {
@include transform(rotate(-90deg));
@include transform-origin(50% 50%);
}
}
}
.list-tasks {
height: 0;
}
}
// state - completed
&.is-completed {
.viz-utility-status {
.viz-utility-status-value {
width: 100%;
}
}
header {
.utility-title, {
color: $green;
}
.utility-status {
.status-count, .status-amount, {
color: $green;
}
}
}
}
// state - not available
.is-not-available {
}
}
// list of tasks
.list-tasks {
height: auto;
overflow: hidden;
.task {
@include transition(background $tmg-f2 ease-in-out 0s, border $tmg-f3 ease-in-out 0s);
@include clearfix();
position: relative;
border-top: 1px solid $white;
border-bottom: 1px solid $gray-l5;
padding: $baseline ($baseline*1.5);
background: $white;
opacity: 1.0;
&:last-child {
margin-bottom: 0;
border-bottom: none;
}
.task-input {
display: inline-block;
vertical-align: text-top;
float: left;
margin: ($baseline/2) flex-gutter() 0 0;
}
.task-details {
display: inline-block;
vertical-align: text-top;
float: left;
width: flex-grid(6,9);
font-weight: 500;
.task-name {
@include transition(color $tmg-f2 ease-in-out 0s);
vertical-align: baseline;
cursor: pointer;
margin-bottom: 0;
}
.task-description {
@extend %t-copy-sub1;
@include transition(color $tmg-f2 ease-in-out 0s);
color: $gray-l2;
}
.task-support {
@extend %t-copy-sub2;
@include transition(opacity $tmg-f2 ease-in-out 0s);
opacity: 0.0;
pointer-events: none;
}
}
.task-actions {
@include transition(opacity $tmg-f2 ease-in-out 0.25s);
@include clearfix();
display: inline-block;
vertical-align: middle;
float: right;
width: flex-grid(2,9);
margin: ($baseline/2) 0 0 flex-gutter();
opacity: 0.0;
pointer-events: none;
text-align: right;
.action-primary {
@include blue-button;
@extend %t-action4;
font-weight: 600;
text-align: center;
}
.action-secondary {
@extend %t-action4;
margin-top: ($baseline/2);
}
}
// state - hover
&:hover {
background: $blue-l5;
border-bottom-color: $blue-l4;
border-top-color: $blue-l4;
opacity: 1.0;
.task-details {
.task-support {
opacity: 1.0;
pointer-events: auto;
}
}
.task-actions {
opacity: 1.0;
pointer-events: auto;
}
}
// state - completed
&.is-completed {
background: $gray-l6;
border-top-color: $gray-l5;
border-bottom-color: $gray-l5;
.task-name {
color: $gray-l2;
}
.task-actions {
.action-primary {
@include grey-button;
@extend %t-action4;
font-weight: 600;
text-align: center;
}
}
&:hover {
background: $gray-l5;
border-bottom-color: $gray-l4;
border-top-color: $gray-l4;
.task-details {
opacity:1.0;
}
}
}
}
}
.content-supplementary {
width: flex-grid(3, 12);
}
}
<section
class="course-utility"
id="<%= 'course-uility' + utilityIndex %>">
<header>
<h3 class="utility-title title-2 is-selectable" title="Collapse/Expand this utility">
<i class="icon-caret-down ui-toggle-expansion"></i>
<%= utilityShortDescription %></h3>
</header>
<ul class="list list-tasks">
<% var taskIndex = 0; %>
<% _.each(items, function(item) { %>
<li class="task">
<% var taskId = 'course-utility' + utilityIndex + '-task' + taskIndex; %>
<label class="task-details" for="<%= taskId %>">
<h4 class="task-name title title-3"><%= item['short_description'] %></h4>
<p class="task-description"><%= item['long_description'] %></p>
</label>
<% if (item['action_text'] !== '' && item['action_url'] !== '') { %>
<ul class="list-actions task-actions">
<li class="action-item">
<a href="<%= item['action_url'] %>" class="action action-primary"
<% if (item['action_external']) { %>
rel="external" title="<%= gettext("This link will open in a new browser window/tab") %>"
<% } %>
><%= item['action_text'] %></a>
</li>
</ul>
<% } %>
</li>
<% taskIndex+=1; }) %>
</ul>
</section>
<%inherit file="base.html" />
<%!
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
%>
<%block name="title">Course Utilities</%block>
<%block name="bodyclass">is-signedin course view-utilities</%block>
<%namespace name='static' file='static_content.html'/>
<%block name="header_extras">
% for template_name in ["utility"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["domReady!", "jquery", "js/collections/utility", "js/views/utility"],
function(doc, $, UtilityCollection, UtilityView) {
var utilityCollection = new UtilityCollection();
utilityCollection.url = "${handler_url}";
var editor = new UtilityView({
el: $('.course-utilities'),
collection: utilityCollection
});
utilityCollection.fetch({reset: true});
});
</script>
</%block>
<%block name="content">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Tools")}</small>
<span class="sr">&gt; </span>${_("Course Utilities")}
</h1>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
<form id="course-utilities" class="course-utilities" method="post" action="">
<h2 class="title title-3 sr">${_("Current Utilities")}</h2>
</form>
</article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
<h3 class="title title-3">${_("What are course utilities?")}</h3>
<p>
${_("The utilities section provides a gateway to self-service tasks or bulk operations to course instructors. The utilities are meant to provide services that makes it easier for the instructors to manage their courses.")}
</p>
</div>
<div class="bit">
<h3 class="title title-3">Studio utilities</h3>
<nav class="nav-page utilities-current">
<ol>
% for utility in utilities:
<li class="nav-item">
<a rel="view" href="${'#course-utilities' + str(loop.index)}">${utility['short_description']}</a>
</li>
% endfor
</ol>
</nav>
</div>
</aside>
</section>
</div>
</%block>
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
index_url = location.url_reverse('course') index_url = location.url_reverse('course')
checklists_url = location.url_reverse('checklists') checklists_url = location.url_reverse('checklists')
utilities_url = location.url_reverse('utilities')
course_team_url = location.url_reverse('course_team') course_team_url = location.url_reverse('course_team')
assets_url = location.url_reverse('assets') assets_url = location.url_reverse('assets')
textbooks_url = location.url_reverse('textbooks') textbooks_url = location.url_reverse('textbooks')
...@@ -104,6 +105,9 @@ ...@@ -104,6 +105,9 @@
<li class="nav-item nav-course-tools-export"> <li class="nav-item nav-course-tools-export">
<a href="${export_url}">${_("Export")}</a> <a href="${export_url}">${_("Export")}</a>
</li> </li>
<li class="nav-item nav-course-tools-utilities">
<a href="${utilities_url}">${_("Utilities")}</a>
</li>
% if settings.FEATURES.get('ENABLE_EXPORT_GIT') and context_course.giturl: % if settings.FEATURES.get('ENABLE_EXPORT_GIT') and context_course.giturl:
<li class="nav-item nav-course-tools-export-git"> <li class="nav-item nav-course-tools-export-git">
<a href="${reverse('export_git', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Export to Git")}</a> <a href="${reverse('export_git', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Export to Git")}</a>
......
<!--
This def will enumerate through a passed in video components and list them all.
-->
<%def name="enum_components(unit_components, actions=True, selected=None, sortable=True)">
% for component in unit_components:
<li class="courseware-unit unit is-draggable" data-locator=${component['location']}>
<%include file="_ui-dnd-indicator-before.html" />
<label class="section-item">
<input type="checkbox" class="selectedId" name=${component['location']} value="" onclick="captionUtils.resetSelectAll();" />
<span class="component-name">${component['name']}</span>
<div class="popup">
<i class="icon-youtube icon-large platform"></i>
<span class="icon-stack local">
<i class="icon-folder-close icon-stack-base"></i>
<i class="icon-ok icon-light local"></i>
</span>
<span class="icon-stack remote">
<i class="icon-cloud icon-stack-base"></i>
<i class="icon-ok icon-light remote"></i>
</span>
<span class="icon-stack sync">
<i class="icon-sign-blank icon-stack-base"></i>
<i class="icon-link icon-light icon-large sync"></i>
</span>
</div>
<div class="item-actions">
<i class="icon-spinner icon-spin icon-large"></i>
</div>
</label>
<%include file="_ui-dnd-indicator-after.html" />
</li>
% endfor
</%def>
...@@ -79,6 +79,9 @@ urlpatterns += patterns( ...@@ -79,6 +79,9 @@ urlpatterns += patterns(
url(r'(?ix)^unit($|/){}$'.format(parsers.URL_RE_SOURCE), 'unit_handler'), url(r'(?ix)^unit($|/){}$'.format(parsers.URL_RE_SOURCE), 'unit_handler'),
url(r'(?ix)^container($|/){}$'.format(parsers.URL_RE_SOURCE), 'container_handler'), url(r'(?ix)^container($|/){}$'.format(parsers.URL_RE_SOURCE), 'container_handler'),
url(r'(?ix)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'), url(r'(?ix)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'),
url(r'(?ix)^utilities($|/){}$'.format(parsers.URL_RE_SOURCE), 'utility_handler'),
url(r'(?ix)^utility/captions($|/){}$'.format(parsers.URL_RE_SOURCE), 'utility_captions_handler'),
url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'), url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'),
url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'), url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'),
url(r'(?ix)^import/{}$'.format(parsers.URL_RE_SOURCE), 'import_handler'), url(r'(?ix)^import/{}$'.format(parsers.URL_RE_SOURCE), 'import_handler'),
......
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