Commit 3d44c83f by Miles Steele

Merge pull request #322 from edx/feature/msteele/instrdash

Instructor Dashboard v2 (disabled)
parents eb9f0347 4b671d11
...@@ -83,4 +83,4 @@ Ian Hoover <ihoover@edx.org> ...@@ -83,4 +83,4 @@ Ian Hoover <ihoover@edx.org>
Mukul Goyal <miki@edx.org> Mukul Goyal <miki@edx.org>
Robert Marks <rmarks@edx.org> Robert Marks <rmarks@edx.org>
Yarko Tymciurak <yarkot1@gmail.com> Yarko Tymciurak <yarkot1@gmail.com>
Miles Steele <miles@milessteele.com>
/*
IMPORTANT:
In order to preserve the uniform grid appearance, all cell styles need to have padding, margin and border sizes.
No built-in (selected, editable, highlight, flashing, invalid, loading, :focus) or user-specified CSS
classes should alter those!
*/
.slick-header.ui-state-default, .slick-headerrow.ui-state-default {
width: 100%;
overflow: hidden;
border-left: 0px;
}
.slick-header-columns, .slick-headerrow-columns {
position: relative;
white-space: nowrap;
cursor: default;
overflow: hidden;
}
.slick-header-column.ui-state-default {
position: relative;
display: inline-block;
overflow: hidden;
-o-text-overflow: ellipsis;
text-overflow: ellipsis;
height: 16px;
line-height: 16px;
margin: 0;
padding: 4px;
border-right: 1px solid silver;
border-left: 0px;
border-top: 0px;
border-bottom: 0px;
float: left;
}
.slick-headerrow-column.ui-state-default {
padding: 4px;
}
.slick-header-column-sorted {
font-style: italic;
}
.slick-sort-indicator {
display: inline-block;
width: 8px;
height: 5px;
margin-left: 4px;
margin-top: 6px;
float: left;
}
.slick-sort-indicator-desc {
background: url(images/sort-desc.gif);
}
.slick-sort-indicator-asc {
background: url(images/sort-asc.gif);
}
.slick-resizable-handle {
position: absolute;
font-size: 0.1px;
display: block;
cursor: col-resize;
width: 4px;
right: 0px;
top: 0;
height: 100%;
}
.slick-sortable-placeholder {
background: silver;
}
.grid-canvas {
position: relative;
outline: 0;
}
.slick-row.ui-widget-content, .slick-row.ui-state-active {
position: absolute;
border: 0px;
width: 100%;
}
.slick-cell, .slick-headerrow-column {
position: absolute;
border: 1px solid transparent;
border-right: 1px dotted silver;
border-bottom-color: silver;
overflow: hidden;
-o-text-overflow: ellipsis;
text-overflow: ellipsis;
vertical-align: middle;
z-index: 1;
padding: 1px 2px 2px 1px;
margin: 0;
white-space: nowrap;
cursor: default;
}
.slick-group {
}
.slick-group-toggle {
display: inline-block;
}
.slick-cell.highlighted {
background: lightskyblue;
background: rgba(0, 0, 255, 0.2);
-webkit-transition: all 0.5s;
-moz-transition: all 0.5s;
-o-transition: all 0.5s;
transition: all 0.5s;
}
.slick-cell.flashing {
border: 1px solid red !important;
}
.slick-cell.editable {
z-index: 11;
overflow: visible;
background: white;
border-color: black;
border-style: solid;
}
.slick-cell:focus {
outline: none;
}
.slick-reorder-proxy {
display: inline-block;
background: blue;
opacity: 0.15;
filter: alpha(opacity = 15);
cursor: move;
}
.slick-reorder-guide {
display: inline-block;
height: 2px;
background: blue;
opacity: 0.7;
filter: alpha(opacity = 70);
}
.slick-selection {
z-index: 10;
position: absolute;
border: 2px dashed black;
}
/*
* jQuery UI CSS Framework 1.8.16
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Theming/API
*/
/* Layout helpers
----------------------------------*/
.ui-helper-hidden { display: none; }
.ui-helper-hidden-accessible { position: absolute !important; clip: rect(1px 1px 1px 1px); clip: rect(1px,1px,1px,1px); }
.ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; }
.ui-helper-clearfix:after { content: "."; display: block; height: 0; clear: both; visibility: hidden; }
.ui-helper-clearfix { display: inline-block; }
/* required comment for clearfix to work in Opera \*/
* html .ui-helper-clearfix { height:1%; }
.ui-helper-clearfix { display:block; }
/* end clearfix */
.ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); }
/* Interaction Cues
----------------------------------*/
.ui-state-disabled { cursor: default !important; }
/* Icons
----------------------------------*/
/* states and images */
.ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; }
/* Misc visuals
----------------------------------*/
/* Overlays */
.ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
/*
* jQuery UI CSS Framework 1.8.16
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Theming/API
*
* To view and modify this theme, visit http://jqueryui.com/themeroller/?ffDefault=Verdana,Arial,sans-serif&fwDefault=normal&fsDefault=1.1em&cornerRadius=4px&bgColorHeader=cccccc&bgTextureHeader=03_highlight_soft.png&bgImgOpacityHeader=75&borderColorHeader=aaaaaa&fcHeader=222222&iconColorHeader=222222&bgColorContent=ffffff&bgTextureContent=01_flat.png&bgImgOpacityContent=75&borderColorContent=aaaaaa&fcContent=222222&iconColorContent=222222&bgColorDefault=e6e6e6&bgTextureDefault=02_glass.png&bgImgOpacityDefault=75&borderColorDefault=d3d3d3&fcDefault=555555&iconColorDefault=888888&bgColorHover=dadada&bgTextureHover=02_glass.png&bgImgOpacityHover=75&borderColorHover=999999&fcHover=212121&iconColorHover=454545&bgColorActive=ffffff&bgTextureActive=02_glass.png&bgImgOpacityActive=65&borderColorActive=aaaaaa&fcActive=212121&iconColorActive=454545&bgColorHighlight=fbf9ee&bgTextureHighlight=02_glass.png&bgImgOpacityHighlight=55&borderColorHighlight=fcefa1&fcHighlight=363636&iconColorHighlight=2e83ff&bgColorError=fef1ec&bgTextureError=02_glass.png&bgImgOpacityError=95&borderColorError=cd0a0a&fcError=cd0a0a&iconColorError=cd0a0a&bgColorOverlay=aaaaaa&bgTextureOverlay=01_flat.png&bgImgOpacityOverlay=0&opacityOverlay=30&bgColorShadow=aaaaaa&bgTextureShadow=01_flat.png&bgImgOpacityShadow=0&opacityShadow=30&thicknessShadow=8px&offsetTopShadow=-8px&offsetLeftShadow=-8px&cornerRadiusShadow=8px
*/
/* Component containers
----------------------------------*/
.ui-widget { font-family: Verdana,Arial,sans-serif; font-size: 1.1em; }
.ui-widget .ui-widget { font-size: 1em; }
.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Verdana,Arial,sans-serif; font-size: 1em; }
.ui-widget-content { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_flat_75_ffffff_40x100.png) 50% 50% repeat-x; color: #222222; }
.ui-widget-content a { color: #222222; }
.ui-widget-header { border: 1px solid #aaaaaa; background: #cccccc url(images/ui-bg_highlight-soft_75_cccccc_1x100.png) 50% 50% repeat-x; color: #222222; font-weight: bold; }
.ui-widget-header a { color: #222222; }
/* Interaction states
----------------------------------*/
.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3; background: #e6e6e6 url(images/ui-bg_glass_75_e6e6e6_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #555555; }
.ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #555555; text-decoration: none; }
.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #999999; background: #dadada url(images/ui-bg_glass_75_dadada_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #212121; }
.ui-state-hover a, .ui-state-hover a:hover { color: #212121; text-decoration: none; }
.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_glass_65_ffffff_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #212121; }
.ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #212121; text-decoration: none; }
.ui-widget :active { outline: none; }
/* Interaction Cues
----------------------------------*/
.ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight {border: 1px solid #fcefa1; background: #fbf9ee url(images/ui-bg_glass_55_fbf9ee_1x400.png) 50% 50% repeat-x; color: #363636; }
.ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #363636; }
.ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #cd0a0a; background: #fef1ec url(images/ui-bg_glass_95_fef1ec_1x400.png) 50% 50% repeat-x; color: #cd0a0a; }
.ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #cd0a0a; }
.ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #cd0a0a; }
.ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; }
.ui-priority-secondary, .ui-widget-content .ui-priority-secondary, .ui-widget-header .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; }
.ui-state-disabled, .ui-widget-content .ui-state-disabled, .ui-widget-header .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; }
/* Icons
----------------------------------*/
/* states and images */
.ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_222222_256x240.png); }
.ui-widget-content .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); }
.ui-widget-header .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); }
.ui-state-default .ui-icon { background-image: url(images/ui-icons_888888_256x240.png); }
.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); }
.ui-state-active .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); }
.ui-state-highlight .ui-icon {background-image: url(images/ui-icons_2e83ff_256x240.png); }
.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_cd0a0a_256x240.png); }
/* positioning */
.ui-icon-carat-1-n { background-position: 0 0; }
.ui-icon-carat-1-ne { background-position: -16px 0; }
.ui-icon-carat-1-e { background-position: -32px 0; }
.ui-icon-carat-1-se { background-position: -48px 0; }
.ui-icon-carat-1-s { background-position: -64px 0; }
.ui-icon-carat-1-sw { background-position: -80px 0; }
.ui-icon-carat-1-w { background-position: -96px 0; }
.ui-icon-carat-1-nw { background-position: -112px 0; }
.ui-icon-carat-2-n-s { background-position: -128px 0; }
.ui-icon-carat-2-e-w { background-position: -144px 0; }
.ui-icon-triangle-1-n { background-position: 0 -16px; }
.ui-icon-triangle-1-ne { background-position: -16px -16px; }
.ui-icon-triangle-1-e { background-position: -32px -16px; }
.ui-icon-triangle-1-se { background-position: -48px -16px; }
.ui-icon-triangle-1-s { background-position: -64px -16px; }
.ui-icon-triangle-1-sw { background-position: -80px -16px; }
.ui-icon-triangle-1-w { background-position: -96px -16px; }
.ui-icon-triangle-1-nw { background-position: -112px -16px; }
.ui-icon-triangle-2-n-s { background-position: -128px -16px; }
.ui-icon-triangle-2-e-w { background-position: -144px -16px; }
.ui-icon-arrow-1-n { background-position: 0 -32px; }
.ui-icon-arrow-1-ne { background-position: -16px -32px; }
.ui-icon-arrow-1-e { background-position: -32px -32px; }
.ui-icon-arrow-1-se { background-position: -48px -32px; }
.ui-icon-arrow-1-s { background-position: -64px -32px; }
.ui-icon-arrow-1-sw { background-position: -80px -32px; }
.ui-icon-arrow-1-w { background-position: -96px -32px; }
.ui-icon-arrow-1-nw { background-position: -112px -32px; }
.ui-icon-arrow-2-n-s { background-position: -128px -32px; }
.ui-icon-arrow-2-ne-sw { background-position: -144px -32px; }
.ui-icon-arrow-2-e-w { background-position: -160px -32px; }
.ui-icon-arrow-2-se-nw { background-position: -176px -32px; }
.ui-icon-arrowstop-1-n { background-position: -192px -32px; }
.ui-icon-arrowstop-1-e { background-position: -208px -32px; }
.ui-icon-arrowstop-1-s { background-position: -224px -32px; }
.ui-icon-arrowstop-1-w { background-position: -240px -32px; }
.ui-icon-arrowthick-1-n { background-position: 0 -48px; }
.ui-icon-arrowthick-1-ne { background-position: -16px -48px; }
.ui-icon-arrowthick-1-e { background-position: -32px -48px; }
.ui-icon-arrowthick-1-se { background-position: -48px -48px; }
.ui-icon-arrowthick-1-s { background-position: -64px -48px; }
.ui-icon-arrowthick-1-sw { background-position: -80px -48px; }
.ui-icon-arrowthick-1-w { background-position: -96px -48px; }
.ui-icon-arrowthick-1-nw { background-position: -112px -48px; }
.ui-icon-arrowthick-2-n-s { background-position: -128px -48px; }
.ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; }
.ui-icon-arrowthick-2-e-w { background-position: -160px -48px; }
.ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; }
.ui-icon-arrowthickstop-1-n { background-position: -192px -48px; }
.ui-icon-arrowthickstop-1-e { background-position: -208px -48px; }
.ui-icon-arrowthickstop-1-s { background-position: -224px -48px; }
.ui-icon-arrowthickstop-1-w { background-position: -240px -48px; }
.ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; }
.ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; }
.ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; }
.ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; }
.ui-icon-arrowreturn-1-w { background-position: -64px -64px; }
.ui-icon-arrowreturn-1-n { background-position: -80px -64px; }
.ui-icon-arrowreturn-1-e { background-position: -96px -64px; }
.ui-icon-arrowreturn-1-s { background-position: -112px -64px; }
.ui-icon-arrowrefresh-1-w { background-position: -128px -64px; }
.ui-icon-arrowrefresh-1-n { background-position: -144px -64px; }
.ui-icon-arrowrefresh-1-e { background-position: -160px -64px; }
.ui-icon-arrowrefresh-1-s { background-position: -176px -64px; }
.ui-icon-arrow-4 { background-position: 0 -80px; }
.ui-icon-arrow-4-diag { background-position: -16px -80px; }
.ui-icon-extlink { background-position: -32px -80px; }
.ui-icon-newwin { background-position: -48px -80px; }
.ui-icon-refresh { background-position: -64px -80px; }
.ui-icon-shuffle { background-position: -80px -80px; }
.ui-icon-transfer-e-w { background-position: -96px -80px; }
.ui-icon-transferthick-e-w { background-position: -112px -80px; }
.ui-icon-folder-collapsed { background-position: 0 -96px; }
.ui-icon-folder-open { background-position: -16px -96px; }
.ui-icon-document { background-position: -32px -96px; }
.ui-icon-document-b { background-position: -48px -96px; }
.ui-icon-note { background-position: -64px -96px; }
.ui-icon-mail-closed { background-position: -80px -96px; }
.ui-icon-mail-open { background-position: -96px -96px; }
.ui-icon-suitcase { background-position: -112px -96px; }
.ui-icon-comment { background-position: -128px -96px; }
.ui-icon-person { background-position: -144px -96px; }
.ui-icon-print { background-position: -160px -96px; }
.ui-icon-trash { background-position: -176px -96px; }
.ui-icon-locked { background-position: -192px -96px; }
.ui-icon-unlocked { background-position: -208px -96px; }
.ui-icon-bookmark { background-position: -224px -96px; }
.ui-icon-tag { background-position: -240px -96px; }
.ui-icon-home { background-position: 0 -112px; }
.ui-icon-flag { background-position: -16px -112px; }
.ui-icon-calendar { background-position: -32px -112px; }
.ui-icon-cart { background-position: -48px -112px; }
.ui-icon-pencil { background-position: -64px -112px; }
.ui-icon-clock { background-position: -80px -112px; }
.ui-icon-disk { background-position: -96px -112px; }
.ui-icon-calculator { background-position: -112px -112px; }
.ui-icon-zoomin { background-position: -128px -112px; }
.ui-icon-zoomout { background-position: -144px -112px; }
.ui-icon-search { background-position: -160px -112px; }
.ui-icon-wrench { background-position: -176px -112px; }
.ui-icon-gear { background-position: -192px -112px; }
.ui-icon-heart { background-position: -208px -112px; }
.ui-icon-star { background-position: -224px -112px; }
.ui-icon-link { background-position: -240px -112px; }
.ui-icon-cancel { background-position: 0 -128px; }
.ui-icon-plus { background-position: -16px -128px; }
.ui-icon-plusthick { background-position: -32px -128px; }
.ui-icon-minus { background-position: -48px -128px; }
.ui-icon-minusthick { background-position: -64px -128px; }
.ui-icon-close { background-position: -80px -128px; }
.ui-icon-closethick { background-position: -96px -128px; }
.ui-icon-key { background-position: -112px -128px; }
.ui-icon-lightbulb { background-position: -128px -128px; }
.ui-icon-scissors { background-position: -144px -128px; }
.ui-icon-clipboard { background-position: -160px -128px; }
.ui-icon-copy { background-position: -176px -128px; }
.ui-icon-contact { background-position: -192px -128px; }
.ui-icon-image { background-position: -208px -128px; }
.ui-icon-video { background-position: -224px -128px; }
.ui-icon-script { background-position: -240px -128px; }
.ui-icon-alert { background-position: 0 -144px; }
.ui-icon-info { background-position: -16px -144px; }
.ui-icon-notice { background-position: -32px -144px; }
.ui-icon-help { background-position: -48px -144px; }
.ui-icon-check { background-position: -64px -144px; }
.ui-icon-bullet { background-position: -80px -144px; }
.ui-icon-radio-off { background-position: -96px -144px; }
.ui-icon-radio-on { background-position: -112px -144px; }
.ui-icon-pin-w { background-position: -128px -144px; }
.ui-icon-pin-s { background-position: -144px -144px; }
.ui-icon-play { background-position: 0 -160px; }
.ui-icon-pause { background-position: -16px -160px; }
.ui-icon-seek-next { background-position: -32px -160px; }
.ui-icon-seek-prev { background-position: -48px -160px; }
.ui-icon-seek-end { background-position: -64px -160px; }
.ui-icon-seek-start { background-position: -80px -160px; }
/* ui-icon-seek-first is deprecated, use ui-icon-seek-start instead */
.ui-icon-seek-first { background-position: -80px -160px; }
.ui-icon-stop { background-position: -96px -160px; }
.ui-icon-eject { background-position: -112px -160px; }
.ui-icon-volume-off { background-position: -128px -160px; }
.ui-icon-volume-on { background-position: -144px -160px; }
.ui-icon-power { background-position: 0 -176px; }
.ui-icon-signal-diag { background-position: -16px -176px; }
.ui-icon-signal { background-position: -32px -176px; }
.ui-icon-battery-0 { background-position: -48px -176px; }
.ui-icon-battery-1 { background-position: -64px -176px; }
.ui-icon-battery-2 { background-position: -80px -176px; }
.ui-icon-battery-3 { background-position: -96px -176px; }
.ui-icon-circle-plus { background-position: 0 -192px; }
.ui-icon-circle-minus { background-position: -16px -192px; }
.ui-icon-circle-close { background-position: -32px -192px; }
.ui-icon-circle-triangle-e { background-position: -48px -192px; }
.ui-icon-circle-triangle-s { background-position: -64px -192px; }
.ui-icon-circle-triangle-w { background-position: -80px -192px; }
.ui-icon-circle-triangle-n { background-position: -96px -192px; }
.ui-icon-circle-arrow-e { background-position: -112px -192px; }
.ui-icon-circle-arrow-s { background-position: -128px -192px; }
.ui-icon-circle-arrow-w { background-position: -144px -192px; }
.ui-icon-circle-arrow-n { background-position: -160px -192px; }
.ui-icon-circle-zoomin { background-position: -176px -192px; }
.ui-icon-circle-zoomout { background-position: -192px -192px; }
.ui-icon-circle-check { background-position: -208px -192px; }
.ui-icon-circlesmall-plus { background-position: 0 -208px; }
.ui-icon-circlesmall-minus { background-position: -16px -208px; }
.ui-icon-circlesmall-close { background-position: -32px -208px; }
.ui-icon-squaresmall-plus { background-position: -48px -208px; }
.ui-icon-squaresmall-minus { background-position: -64px -208px; }
.ui-icon-squaresmall-close { background-position: -80px -208px; }
.ui-icon-grip-dotted-vertical { background-position: 0 -224px; }
.ui-icon-grip-dotted-horizontal { background-position: -16px -224px; }
.ui-icon-grip-solid-vertical { background-position: -32px -224px; }
.ui-icon-grip-solid-horizontal { background-position: -48px -224px; }
.ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; }
.ui-icon-grip-diagonal-se { background-position: -80px -224px; }
/* Misc visuals
----------------------------------*/
/* Corner radius */
.ui-corner-all, .ui-corner-top, .ui-corner-left, .ui-corner-tl { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; -khtml-border-top-left-radius: 4px; border-top-left-radius: 4px; }
.ui-corner-all, .ui-corner-top, .ui-corner-right, .ui-corner-tr { -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; -khtml-border-top-right-radius: 4px; border-top-right-radius: 4px; }
.ui-corner-all, .ui-corner-bottom, .ui-corner-left, .ui-corner-bl { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; -khtml-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; }
.ui-corner-all, .ui-corner-bottom, .ui-corner-right, .ui-corner-br { -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; -khtml-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; }
/* Overlays */
.ui-widget-overlay { background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); }
.ui-widget-shadow { margin: -8px 0 0 -8px; padding: 8px; background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); -moz-border-radius: 8px; -khtml-border-radius: 8px; -webkit-border-radius: 8px; border-radius: 8px; }/*
* jQuery UI Resizable 1.8.16
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Resizable#theming
*/
.ui-resizable { position: relative;}
.ui-resizable-handle { position: absolute;font-size: 0.1px;z-index: 99999; display: block; }
.ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; }
.ui-resizable-n { cursor: n-resize; height: 7px; width: 100%; top: -5px; left: 0; }
.ui-resizable-s { cursor: s-resize; height: 7px; width: 100%; bottom: -5px; left: 0; }
.ui-resizable-e { cursor: e-resize; width: 7px; right: -5px; top: 0; height: 100%; }
.ui-resizable-w { cursor: w-resize; width: 7px; left: -5px; top: 0; height: 100%; }
.ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; }
.ui-resizable-sw { cursor: sw-resize; width: 9px; height: 9px; left: -5px; bottom: -5px; }
.ui-resizable-nw { cursor: nw-resize; width: 9px; height: 9px; left: -5px; top: -5px; }
.ui-resizable-ne { cursor: ne-resize; width: 9px; height: 9px; right: -5px; top: -5px;}/*
* jQuery UI Selectable 1.8.16
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Selectable#theming
*/
.ui-selectable-helper { position: absolute; z-index: 100; border:1px dotted black; }
/*
* jQuery UI Slider 1.8.16
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Slider#theming
*/
.ui-slider { position: relative; text-align: left; }
.ui-slider .ui-slider-handle { position: absolute; z-index: 2; width: 1.2em; height: 1.2em; cursor: default; }
.ui-slider .ui-slider-range { position: absolute; z-index: 1; font-size: .7em; display: block; border: 0; background-position: 0 0; }
.ui-slider-horizontal { height: .8em; }
.ui-slider-horizontal .ui-slider-handle { top: -.3em; margin-left: -.6em; }
.ui-slider-horizontal .ui-slider-range { top: 0; height: 100%; }
.ui-slider-horizontal .ui-slider-range-min { left: 0; }
.ui-slider-horizontal .ui-slider-range-max { right: 0; }
.ui-slider-vertical { width: .8em; height: 100px; }
.ui-slider-vertical .ui-slider-handle { left: -.3em; margin-left: 0; margin-bottom: -.6em; }
.ui-slider-vertical .ui-slider-range { left: 0; width: 100%; }
.ui-slider-vertical .ui-slider-range-min { bottom: 0; }
.ui-slider-vertical .ui-slider-range-max { top: 0; }/*
* jQuery UI Datepicker 1.8.16
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Datepicker#theming
*/
.ui-datepicker { width: 17em; padding: .2em .2em 0; display: none; }
.ui-datepicker .ui-datepicker-header { position:relative; padding:.2em 0; }
.ui-datepicker .ui-datepicker-prev, .ui-datepicker .ui-datepicker-next { position:absolute; top: 2px; width: 1.8em; height: 1.8em; }
.ui-datepicker .ui-datepicker-prev-hover, .ui-datepicker .ui-datepicker-next-hover { top: 1px; }
.ui-datepicker .ui-datepicker-prev { left:2px; }
.ui-datepicker .ui-datepicker-next { right:2px; }
.ui-datepicker .ui-datepicker-prev-hover { left:1px; }
.ui-datepicker .ui-datepicker-next-hover { right:1px; }
.ui-datepicker .ui-datepicker-prev span, .ui-datepicker .ui-datepicker-next span { display: block; position: absolute; left: 50%; margin-left: -8px; top: 50%; margin-top: -8px; }
.ui-datepicker .ui-datepicker-title { margin: 0 2.3em; line-height: 1.8em; text-align: center; }
.ui-datepicker .ui-datepicker-title select { font-size:1em; margin:1px 0; }
.ui-datepicker select.ui-datepicker-month-year {width: 100%;}
.ui-datepicker select.ui-datepicker-month,
.ui-datepicker select.ui-datepicker-year { width: 49%;}
.ui-datepicker table {width: 100%; font-size: .9em; border-collapse: collapse; margin:0 0 .4em; }
.ui-datepicker th { padding: .7em .3em; text-align: center; font-weight: bold; border: 0; }
.ui-datepicker td { border: 0; padding: 1px; }
.ui-datepicker td span, .ui-datepicker td a { display: block; padding: .2em; text-align: right; text-decoration: none; }
.ui-datepicker .ui-datepicker-buttonpane { background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0; }
.ui-datepicker .ui-datepicker-buttonpane button { float: right; margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; }
.ui-datepicker .ui-datepicker-buttonpane button.ui-datepicker-current { float:left; }
/* with multiple calendars */
.ui-datepicker.ui-datepicker-multi { width:auto; }
.ui-datepicker-multi .ui-datepicker-group { float:left; }
.ui-datepicker-multi .ui-datepicker-group table { width:95%; margin:0 auto .4em; }
.ui-datepicker-multi-2 .ui-datepicker-group { width:50%; }
.ui-datepicker-multi-3 .ui-datepicker-group { width:33.3%; }
.ui-datepicker-multi-4 .ui-datepicker-group { width:25%; }
.ui-datepicker-multi .ui-datepicker-group-last .ui-datepicker-header { border-left-width:0; }
.ui-datepicker-multi .ui-datepicker-group-middle .ui-datepicker-header { border-left-width:0; }
.ui-datepicker-multi .ui-datepicker-buttonpane { clear:left; }
.ui-datepicker-row-break { clear:both; width:100%; font-size:0em; }
/* RTL support */
.ui-datepicker-rtl { direction: rtl; }
.ui-datepicker-rtl .ui-datepicker-prev { right: 2px; left: auto; }
.ui-datepicker-rtl .ui-datepicker-next { left: 2px; right: auto; }
.ui-datepicker-rtl .ui-datepicker-prev:hover { right: 1px; left: auto; }
.ui-datepicker-rtl .ui-datepicker-next:hover { left: 1px; right: auto; }
.ui-datepicker-rtl .ui-datepicker-buttonpane { clear:right; }
.ui-datepicker-rtl .ui-datepicker-buttonpane button { float: left; }
.ui-datepicker-rtl .ui-datepicker-buttonpane button.ui-datepicker-current { float:right; }
.ui-datepicker-rtl .ui-datepicker-group { float:right; }
.ui-datepicker-rtl .ui-datepicker-group-last .ui-datepicker-header { border-right-width:0; border-left-width:1px; }
.ui-datepicker-rtl .ui-datepicker-group-middle .ui-datepicker-header { border-right-width:0; border-left-width:1px; }
/* IE6 IFRAME FIX (taken from datepicker 1.5.3 */
.ui-datepicker-cover {
display: none; /*sorry for IE5*/
display/**/: block; /*sorry for IE5*/
position: absolute; /*must have*/
z-index: -1; /*must have*/
filter: mask(); /*must have*/
top: -4px; /*must have*/
left: -4px; /*must have*/
width: 200px; /*must have*/
height: 200px; /*must have*/
}
\ No newline at end of file
/*!
* jquery.event.drag - v 2.2
* Copyright (c) 2010 Three Dub Media - http://threedubmedia.com
* Open Source MIT License - http://threedubmedia.com/code/license
*/
// Created: 2008-06-04
// Updated: 2012-05-21
// REQUIRES: jquery 1.7.x
;(function( $ ){
// add the jquery instance method
$.fn.drag = function( str, arg, opts ){
// figure out the event type
var type = typeof str == "string" ? str : "",
// figure out the event handler...
fn = $.isFunction( str ) ? str : $.isFunction( arg ) ? arg : null;
// fix the event type
if ( type.indexOf("drag") !== 0 )
type = "drag"+ type;
// were options passed
opts = ( str == fn ? arg : opts ) || {};
// trigger or bind event handler
return fn ? this.bind( type, opts, fn ) : this.trigger( type );
};
// local refs (increase compression)
var $event = $.event,
$special = $event.special,
// configure the drag special event
drag = $special.drag = {
// these are the default settings
defaults: {
which: 1, // mouse button pressed to start drag sequence
distance: 0, // distance dragged before dragstart
not: ':input', // selector to suppress dragging on target elements
handle: null, // selector to match handle target elements
relative: false, // true to use "position", false to use "offset"
drop: true, // false to suppress drop events, true or selector to allow
click: false // false to suppress click events after dragend (no proxy)
},
// the key name for stored drag data
datakey: "dragdata",
// prevent bubbling for better performance
noBubble: true,
// count bound related events
add: function( obj ){
// read the interaction data
var data = $.data( this, drag.datakey ),
// read any passed options
opts = obj.data || {};
// count another realted event
data.related += 1;
// extend data options bound with this event
// don't iterate "opts" in case it is a node
$.each( drag.defaults, function( key, def ){
if ( opts[ key ] !== undefined )
data[ key ] = opts[ key ];
});
},
// forget unbound related events
remove: function(){
$.data( this, drag.datakey ).related -= 1;
},
// configure interaction, capture settings
setup: function(){
// check for related events
if ( $.data( this, drag.datakey ) )
return;
// initialize the drag data with copied defaults
var data = $.extend({ related:0 }, drag.defaults );
// store the interaction data
$.data( this, drag.datakey, data );
// bind the mousedown event, which starts drag interactions
$event.add( this, "touchstart mousedown", drag.init, data );
// prevent image dragging in IE...
if ( this.attachEvent )
this.attachEvent("ondragstart", drag.dontstart );
},
// destroy configured interaction
teardown: function(){
var data = $.data( this, drag.datakey ) || {};
// check for related events
if ( data.related )
return;
// remove the stored data
$.removeData( this, drag.datakey );
// remove the mousedown event
$event.remove( this, "touchstart mousedown", drag.init );
// enable text selection
drag.textselect( true );
// un-prevent image dragging in IE...
if ( this.detachEvent )
this.detachEvent("ondragstart", drag.dontstart );
},
// initialize the interaction
init: function( event ){
// sorry, only one touch at a time
if ( drag.touched )
return;
// the drag/drop interaction data
var dd = event.data, results;
// check the which directive
if ( event.which != 0 && dd.which > 0 && event.which != dd.which )
return;
// check for suppressed selector
if ( $( event.target ).is( dd.not ) )
return;
// check for handle selector
if ( dd.handle && !$( event.target ).closest( dd.handle, event.currentTarget ).length )
return;
drag.touched = event.type == 'touchstart' ? this : null;
dd.propagates = 1;
dd.mousedown = this;
dd.interactions = [ drag.interaction( this, dd ) ];
dd.target = event.target;
dd.pageX = event.pageX;
dd.pageY = event.pageY;
dd.dragging = null;
// handle draginit event...
results = drag.hijack( event, "draginit", dd );
// early cancel
if ( !dd.propagates )
return;
// flatten the result set
results = drag.flatten( results );
// insert new interaction elements
if ( results && results.length ){
dd.interactions = [];
$.each( results, function(){
dd.interactions.push( drag.interaction( this, dd ) );
});
}
// remember how many interactions are propagating
dd.propagates = dd.interactions.length;
// locate and init the drop targets
if ( dd.drop !== false && $special.drop )
$special.drop.handler( event, dd );
// disable text selection
drag.textselect( false );
// bind additional events...
if ( drag.touched )
$event.add( drag.touched, "touchmove touchend", drag.handler, dd );
else
$event.add( document, "mousemove mouseup", drag.handler, dd );
// helps prevent text selection or scrolling
if ( !drag.touched || dd.live )
return false;
},
// returns an interaction object
interaction: function( elem, dd ){
var offset = $( elem )[ dd.relative ? "position" : "offset" ]() || { top:0, left:0 };
return {
drag: elem,
callback: new drag.callback(),
droppable: [],
offset: offset
};
},
// handle drag-releatd DOM events
handler: function( event ){
// read the data before hijacking anything
var dd = event.data;
// handle various events
switch ( event.type ){
// mousemove, check distance, start dragging
case !dd.dragging && 'touchmove':
event.preventDefault();
case !dd.dragging && 'mousemove':
// drag tolerance, x² + y² = distance²
if ( Math.pow( event.pageX-dd.pageX, 2 ) + Math.pow( event.pageY-dd.pageY, 2 ) < Math.pow( dd.distance, 2 ) )
break; // distance tolerance not reached
event.target = dd.target; // force target from "mousedown" event (fix distance issue)
drag.hijack( event, "dragstart", dd ); // trigger "dragstart"
if ( dd.propagates ) // "dragstart" not rejected
dd.dragging = true; // activate interaction
// mousemove, dragging
case 'touchmove':
event.preventDefault();
case 'mousemove':
if ( dd.dragging ){
// trigger "drag"
drag.hijack( event, "drag", dd );
if ( dd.propagates ){
// manage drop events
if ( dd.drop !== false && $special.drop )
$special.drop.handler( event, dd ); // "dropstart", "dropend"
break; // "drag" not rejected, stop
}
event.type = "mouseup"; // helps "drop" handler behave
}
// mouseup, stop dragging
case 'touchend':
case 'mouseup':
default:
if ( drag.touched )
$event.remove( drag.touched, "touchmove touchend", drag.handler ); // remove touch events
else
$event.remove( document, "mousemove mouseup", drag.handler ); // remove page events
if ( dd.dragging ){
if ( dd.drop !== false && $special.drop )
$special.drop.handler( event, dd ); // "drop"
drag.hijack( event, "dragend", dd ); // trigger "dragend"
}
drag.textselect( true ); // enable text selection
// if suppressing click events...
if ( dd.click === false && dd.dragging )
$.data( dd.mousedown, "suppress.click", new Date().getTime() + 5 );
dd.dragging = drag.touched = false; // deactivate element
break;
}
},
// re-use event object for custom events
hijack: function( event, type, dd, x, elem ){
// not configured
if ( !dd )
return;
// remember the original event and type
var orig = { event:event.originalEvent, type:event.type },
// is the event drag related or drog related?
mode = type.indexOf("drop") ? "drag" : "drop",
// iteration vars
result, i = x || 0, ia, $elems, callback,
len = !isNaN( x ) ? x : dd.interactions.length;
// modify the event type
event.type = type;
// remove the original event
event.originalEvent = null;
// initialize the results
dd.results = [];
// handle each interacted element
do if ( ia = dd.interactions[ i ] ){
// validate the interaction
if ( type !== "dragend" && ia.cancelled )
continue;
// set the dragdrop properties on the event object
callback = drag.properties( event, dd, ia );
// prepare for more results
ia.results = [];
// handle each element
$( elem || ia[ mode ] || dd.droppable ).each(function( p, subject ){
// identify drag or drop targets individually
callback.target = subject;
// force propagtion of the custom event
event.isPropagationStopped = function(){ return false; };
// handle the event
result = subject ? $event.dispatch.call( subject, event, callback ) : null;
// stop the drag interaction for this element
if ( result === false ){
if ( mode == "drag" ){
ia.cancelled = true;
dd.propagates -= 1;
}
if ( type == "drop" ){
ia[ mode ][p] = null;
}
}
// assign any dropinit elements
else if ( type == "dropinit" )
ia.droppable.push( drag.element( result ) || subject );
// accept a returned proxy element
if ( type == "dragstart" )
ia.proxy = $( drag.element( result ) || ia.drag )[0];
// remember this result
ia.results.push( result );
// forget the event result, for recycling
delete event.result;
// break on cancelled handler
if ( type !== "dropinit" )
return result;
});
// flatten the results
dd.results[ i ] = drag.flatten( ia.results );
// accept a set of valid drop targets
if ( type == "dropinit" )
ia.droppable = drag.flatten( ia.droppable );
// locate drop targets
if ( type == "dragstart" && !ia.cancelled )
callback.update();
}
while ( ++i < len )
// restore the original event & type
event.type = orig.type;
event.originalEvent = orig.event;
// return all handler results
return drag.flatten( dd.results );
},
// extend the callback object with drag/drop properties...
properties: function( event, dd, ia ){
var obj = ia.callback;
// elements
obj.drag = ia.drag;
obj.proxy = ia.proxy || ia.drag;
// starting mouse position
obj.startX = dd.pageX;
obj.startY = dd.pageY;
// current distance dragged
obj.deltaX = event.pageX - dd.pageX;
obj.deltaY = event.pageY - dd.pageY;
// original element position
obj.originalX = ia.offset.left;
obj.originalY = ia.offset.top;
// adjusted element position
obj.offsetX = obj.originalX + obj.deltaX;
obj.offsetY = obj.originalY + obj.deltaY;
// assign the drop targets information
obj.drop = drag.flatten( ( ia.drop || [] ).slice() );
obj.available = drag.flatten( ( ia.droppable || [] ).slice() );
return obj;
},
// determine is the argument is an element or jquery instance
element: function( arg ){
if ( arg && ( arg.jquery || arg.nodeType == 1 ) )
return arg;
},
// flatten nested jquery objects and arrays into a single dimension array
flatten: function( arr ){
return $.map( arr, function( member ){
return member && member.jquery ? $.makeArray( member ) :
member && member.length ? drag.flatten( member ) : member;
});
},
// toggles text selection attributes ON (true) or OFF (false)
textselect: function( bool ){
$( document )[ bool ? "unbind" : "bind" ]("selectstart", drag.dontstart )
.css("MozUserSelect", bool ? "" : "none" );
// .attr("unselectable", bool ? "off" : "on" )
document.unselectable = bool ? "off" : "on";
},
// suppress "selectstart" and "ondragstart" events
dontstart: function(){
return false;
},
// a callback instance contructor
callback: function(){}
};
// callback methods
drag.callback.prototype = {
update: function(){
if ( $special.drop && this.available.length )
$.each( this.available, function( i ){
$special.drop.locate( this, i );
});
}
};
// patch $.event.$dispatch to allow suppressing clicks
var $dispatch = $event.dispatch;
$event.dispatch = function( event ){
if ( $.data( this, "suppress."+ event.type ) - new Date().getTime() > 0 ){
$.removeData( this, "suppress."+ event.type );
return;
}
return $dispatch.apply( this, arguments );
};
// event fix hooks for touch events...
var touchHooks =
$event.fixHooks.touchstart =
$event.fixHooks.touchmove =
$event.fixHooks.touchend =
$event.fixHooks.touchcancel = {
props: "clientX clientY pageX pageY screenX screenY".split( " " ),
filter: function( event, orig ) {
if ( orig ){
var touched = ( orig.touches && orig.touches[0] )
|| ( orig.changedTouches && orig.changedTouches[0] )
|| null;
// iOS webkit: touchstart, touchmove, touchend
if ( touched )
$.each( touchHooks.props, function( i, prop ){
event[ prop ] = touched[ prop ];
});
}
return event;
}
};
// share the same special event configuration with related events...
$special.draginit = $special.dragstart = $special.dragend = drag;
})( jQuery );
\ No newline at end of file
/*!
* jquery.event.drop - v 2.2
* Copyright (c) 2010 Three Dub Media - http://threedubmedia.com
* Open Source MIT License - http://threedubmedia.com/code/license
*/
// Created: 2008-06-04
// Updated: 2012-05-21
// REQUIRES: jquery 1.7.x, event.drag 2.2
;(function($){ // secure $ jQuery alias
// Events: drop, dropstart, dropend
// add the jquery instance method
$.fn.drop = function( str, arg, opts ){
// figure out the event type
var type = typeof str == "string" ? str : "",
// figure out the event handler...
fn = $.isFunction( str ) ? str : $.isFunction( arg ) ? arg : null;
// fix the event type
if ( type.indexOf("drop") !== 0 )
type = "drop"+ type;
// were options passed
opts = ( str == fn ? arg : opts ) || {};
// trigger or bind event handler
return fn ? this.bind( type, opts, fn ) : this.trigger( type );
};
// DROP MANAGEMENT UTILITY
// returns filtered drop target elements, caches their positions
$.drop = function( opts ){
opts = opts || {};
// safely set new options...
drop.multi = opts.multi === true ? Infinity :
opts.multi === false ? 1 : !isNaN( opts.multi ) ? opts.multi : drop.multi;
drop.delay = opts.delay || drop.delay;
drop.tolerance = $.isFunction( opts.tolerance ) ? opts.tolerance :
opts.tolerance === null ? null : drop.tolerance;
drop.mode = opts.mode || drop.mode || 'intersect';
};
// local refs (increase compression)
var $event = $.event,
$special = $event.special,
// configure the drop special event
drop = $.event.special.drop = {
// these are the default settings
multi: 1, // allow multiple drop winners per dragged element
delay: 20, // async timeout delay
mode: 'overlap', // drop tolerance mode
// internal cache
targets: [],
// the key name for stored drop data
datakey: "dropdata",
// prevent bubbling for better performance
noBubble: true,
// count bound related events
add: function( obj ){
// read the interaction data
var data = $.data( this, drop.datakey );
// count another realted event
data.related += 1;
},
// forget unbound related events
remove: function(){
$.data( this, drop.datakey ).related -= 1;
},
// configure the interactions
setup: function(){
// check for related events
if ( $.data( this, drop.datakey ) )
return;
// initialize the drop element data
var data = {
related: 0,
active: [],
anyactive: 0,
winner: 0,
location: {}
};
// store the drop data on the element
$.data( this, drop.datakey, data );
// store the drop target in internal cache
drop.targets.push( this );
},
// destroy the configure interaction
teardown: function(){
var data = $.data( this, drop.datakey ) || {};
// check for related events
if ( data.related )
return;
// remove the stored data
$.removeData( this, drop.datakey );
// reference the targeted element
var element = this;
// remove from the internal cache
drop.targets = $.grep( drop.targets, function( target ){
return ( target !== element );
});
},
// shared event handler
handler: function( event, dd ){
// local vars
var results, $targets;
// make sure the right data is available
if ( !dd )
return;
// handle various events
switch ( event.type ){
// draginit, from $.event.special.drag
case 'mousedown': // DROPINIT >>
case 'touchstart': // DROPINIT >>
// collect and assign the drop targets
$targets = $( drop.targets );
if ( typeof dd.drop == "string" )
$targets = $targets.filter( dd.drop );
// reset drop data winner properties
$targets.each(function(){
var data = $.data( this, drop.datakey );
data.active = [];
data.anyactive = 0;
data.winner = 0;
});
// set available target elements
dd.droppable = $targets;
// activate drop targets for the initial element being dragged
$special.drag.hijack( event, "dropinit", dd );
break;
// drag, from $.event.special.drag
case 'mousemove': // TOLERATE >>
case 'touchmove': // TOLERATE >>
drop.event = event; // store the mousemove event
if ( !drop.timer )
// monitor drop targets
drop.tolerate( dd );
break;
// dragend, from $.event.special.drag
case 'mouseup': // DROP >> DROPEND >>
case 'touchend': // DROP >> DROPEND >>
drop.timer = clearTimeout( drop.timer ); // delete timer
if ( dd.propagates ){
$special.drag.hijack( event, "drop", dd );
$special.drag.hijack( event, "dropend", dd );
}
break;
}
},
// returns the location positions of an element
locate: function( elem, index ){
var data = $.data( elem, drop.datakey ),
$elem = $( elem ),
posi = $elem.offset() || {},
height = $elem.outerHeight(),
width = $elem.outerWidth(),
location = {
elem: elem,
width: width,
height: height,
top: posi.top,
left: posi.left,
right: posi.left + width,
bottom: posi.top + height
};
// drag elements might not have dropdata
if ( data ){
data.location = location;
data.index = index;
data.elem = elem;
}
return location;
},
// test the location positions of an element against another OR an X,Y coord
contains: function( target, test ){ // target { location } contains test [x,y] or { location }
return ( ( test[0] || test.left ) >= target.left && ( test[0] || test.right ) <= target.right
&& ( test[1] || test.top ) >= target.top && ( test[1] || test.bottom ) <= target.bottom );
},
// stored tolerance modes
modes: { // fn scope: "$.event.special.drop" object
// target with mouse wins, else target with most overlap wins
'intersect': function( event, proxy, target ){
return this.contains( target, [ event.pageX, event.pageY ] ) ? // check cursor
1e9 : this.modes.overlap.apply( this, arguments ); // check overlap
},
// target with most overlap wins
'overlap': function( event, proxy, target ){
// calculate the area of overlap...
return Math.max( 0, Math.min( target.bottom, proxy.bottom ) - Math.max( target.top, proxy.top ) )
* Math.max( 0, Math.min( target.right, proxy.right ) - Math.max( target.left, proxy.left ) );
},
// proxy is completely contained within target bounds
'fit': function( event, proxy, target ){
return this.contains( target, proxy ) ? 1 : 0;
},
// center of the proxy is contained within target bounds
'middle': function( event, proxy, target ){
return this.contains( target, [ proxy.left + proxy.width * .5, proxy.top + proxy.height * .5 ] ) ? 1 : 0;
}
},
// sort drop target cache by by winner (dsc), then index (asc)
sort: function( a, b ){
return ( b.winner - a.winner ) || ( a.index - b.index );
},
// async, recursive tolerance execution
tolerate: function( dd ){
// declare local refs
var i, drp, drg, data, arr, len, elem,
// interaction iteration variables
x = 0, ia, end = dd.interactions.length,
// determine the mouse coords
xy = [ drop.event.pageX, drop.event.pageY ],
// custom or stored tolerance fn
tolerance = drop.tolerance || drop.modes[ drop.mode ];
// go through each passed interaction...
do if ( ia = dd.interactions[x] ){
// check valid interaction
if ( !ia )
return;
// initialize or clear the drop data
ia.drop = [];
// holds the drop elements
arr = [];
len = ia.droppable.length;
// determine the proxy location, if needed
if ( tolerance )
drg = drop.locate( ia.proxy );
// reset the loop
i = 0;
// loop each stored drop target
do if ( elem = ia.droppable[i] ){
data = $.data( elem, drop.datakey );
drp = data.location;
if ( !drp ) continue;
// find a winner: tolerance function is defined, call it
data.winner = tolerance ? tolerance.call( drop, drop.event, drg, drp )
// mouse position is always the fallback
: drop.contains( drp, xy ) ? 1 : 0;
arr.push( data );
} while ( ++i < len ); // loop
// sort the drop targets
arr.sort( drop.sort );
// reset the loop
i = 0;
// loop through all of the targets again
do if ( data = arr[ i ] ){
// winners...
if ( data.winner && ia.drop.length < drop.multi ){
// new winner... dropstart
if ( !data.active[x] && !data.anyactive ){
// check to make sure that this is not prevented
if ( $special.drag.hijack( drop.event, "dropstart", dd, x, data.elem )[0] !== false ){
data.active[x] = 1;
data.anyactive += 1;
}
// if false, it is not a winner
else
data.winner = 0;
}
// if it is still a winner
if ( data.winner )
ia.drop.push( data.elem );
}
// losers...
else if ( data.active[x] && data.anyactive == 1 ){
// former winner... dropend
$special.drag.hijack( drop.event, "dropend", dd, x, data.elem );
data.active[x] = 0;
data.anyactive -= 1;
}
} while ( ++i < len ); // loop
} while ( ++x < end ) // loop
// check if the mouse is still moving or is idle
if ( drop.last && xy[0] == drop.last.pageX && xy[1] == drop.last.pageY )
delete drop.timer; // idle, don't recurse
else // recurse
drop.timer = setTimeout(function(){
drop.tolerate( dd );
}, drop.delay );
// remember event, to compare idleness
drop.last = drop.event;
}
};
// share the same special event configuration with related events...
$special.dropinit = $special.dropstart = $special.dropend = drop;
})(jQuery); // confine scope
\ No newline at end of file
/***
* Contains core SlickGrid classes.
* @module Core
* @namespace Slick
*/
(function ($) {
// register namespace
$.extend(true, window, {
"Slick": {
"Event": Event,
"EventData": EventData,
"EventHandler": EventHandler,
"Range": Range,
"NonDataRow": NonDataItem,
"Group": Group,
"GroupTotals": GroupTotals,
"EditorLock": EditorLock,
/***
* A global singleton editor lock.
* @class GlobalEditorLock
* @static
* @constructor
*/
"GlobalEditorLock": new EditorLock()
}
});
/***
* An event object for passing data to event handlers and letting them control propagation.
* <p>This is pretty much identical to how W3C and jQuery implement events.</p>
* @class EventData
* @constructor
*/
function EventData() {
var isPropagationStopped = false;
var isImmediatePropagationStopped = false;
/***
* Stops event from propagating up the DOM tree.
* @method stopPropagation
*/
this.stopPropagation = function () {
isPropagationStopped = true;
};
/***
* Returns whether stopPropagation was called on this event object.
* @method isPropagationStopped
* @return {Boolean}
*/
this.isPropagationStopped = function () {
return isPropagationStopped;
};
/***
* Prevents the rest of the handlers from being executed.
* @method stopImmediatePropagation
*/
this.stopImmediatePropagation = function () {
isImmediatePropagationStopped = true;
};
/***
* Returns whether stopImmediatePropagation was called on this event object.\
* @method isImmediatePropagationStopped
* @return {Boolean}
*/
this.isImmediatePropagationStopped = function () {
return isImmediatePropagationStopped;
}
}
/***
* A simple publisher-subscriber implementation.
* @class Event
* @constructor
*/
function Event() {
var handlers = [];
/***
* Adds an event handler to be called when the event is fired.
* <p>Event handler will receive two arguments - an <code>EventData</code> and the <code>data</code>
* object the event was fired with.<p>
* @method subscribe
* @param fn {Function} Event handler.
*/
this.subscribe = function (fn) {
handlers.push(fn);
};
/***
* Removes an event handler added with <code>subscribe(fn)</code>.
* @method unsubscribe
* @param fn {Function} Event handler to be removed.
*/
this.unsubscribe = function (fn) {
for (var i = handlers.length - 1; i >= 0; i--) {
if (handlers[i] === fn) {
handlers.splice(i, 1);
}
}
};
/***
* Fires an event notifying all subscribers.
* @method notify
* @param args {Object} Additional data object to be passed to all handlers.
* @param e {EventData}
* Optional.
* An <code>EventData</code> object to be passed to all handlers.
* For DOM events, an existing W3C/jQuery event object can be passed in.
* @param scope {Object}
* Optional.
* The scope ("this") within which the handler will be executed.
* If not specified, the scope will be set to the <code>Event</code> instance.
*/
this.notify = function (args, e, scope) {
e = e || new EventData();
scope = scope || this;
var returnValue;
for (var i = 0; i < handlers.length && !(e.isPropagationStopped() || e.isImmediatePropagationStopped()); i++) {
returnValue = handlers[i].call(scope, e, args);
}
return returnValue;
};
}
function EventHandler() {
var handlers = [];
this.subscribe = function (event, handler) {
handlers.push({
event: event,
handler: handler
});
event.subscribe(handler);
return this; // allow chaining
};
this.unsubscribe = function (event, handler) {
var i = handlers.length;
while (i--) {
if (handlers[i].event === event &&
handlers[i].handler === handler) {
handlers.splice(i, 1);
event.unsubscribe(handler);
return;
}
}
return this; // allow chaining
};
this.unsubscribeAll = function () {
var i = handlers.length;
while (i--) {
handlers[i].event.unsubscribe(handlers[i].handler);
}
handlers = [];
return this; // allow chaining
}
}
/***
* A structure containing a range of cells.
* @class Range
* @constructor
* @param fromRow {Integer} Starting row.
* @param fromCell {Integer} Starting cell.
* @param toRow {Integer} Optional. Ending row. Defaults to <code>fromRow</code>.
* @param toCell {Integer} Optional. Ending cell. Defaults to <code>fromCell</code>.
*/
function Range(fromRow, fromCell, toRow, toCell) {
if (toRow === undefined && toCell === undefined) {
toRow = fromRow;
toCell = fromCell;
}
/***
* @property fromRow
* @type {Integer}
*/
this.fromRow = Math.min(fromRow, toRow);
/***
* @property fromCell
* @type {Integer}
*/
this.fromCell = Math.min(fromCell, toCell);
/***
* @property toRow
* @type {Integer}
*/
this.toRow = Math.max(fromRow, toRow);
/***
* @property toCell
* @type {Integer}
*/
this.toCell = Math.max(fromCell, toCell);
/***
* Returns whether a range represents a single row.
* @method isSingleRow
* @return {Boolean}
*/
this.isSingleRow = function () {
return this.fromRow == this.toRow;
};
/***
* Returns whether a range represents a single cell.
* @method isSingleCell
* @return {Boolean}
*/
this.isSingleCell = function () {
return this.fromRow == this.toRow && this.fromCell == this.toCell;
};
/***
* Returns whether a range contains a given cell.
* @method contains
* @param row {Integer}
* @param cell {Integer}
* @return {Boolean}
*/
this.contains = function (row, cell) {
return row >= this.fromRow && row <= this.toRow &&
cell >= this.fromCell && cell <= this.toCell;
};
/***
* Returns a readable representation of a range.
* @method toString
* @return {String}
*/
this.toString = function () {
if (this.isSingleCell()) {
return "(" + this.fromRow + ":" + this.fromCell + ")";
}
else {
return "(" + this.fromRow + ":" + this.fromCell + " - " + this.toRow + ":" + this.toCell + ")";
}
}
}
/***
* A base class that all special / non-data rows (like Group and GroupTotals) derive from.
* @class NonDataItem
* @constructor
*/
function NonDataItem() {
this.__nonDataRow = true;
}
/***
* Information about a group of rows.
* @class Group
* @extends Slick.NonDataItem
* @constructor
*/
function Group() {
this.__group = true;
/**
* Grouping level, starting with 0.
* @property level
* @type {Number}
*/
this.level = 0;
/***
* Number of rows in the group.
* @property count
* @type {Integer}
*/
this.count = 0;
/***
* Grouping value.
* @property value
* @type {Object}
*/
this.value = null;
/***
* Formatted display value of the group.
* @property title
* @type {String}
*/
this.title = null;
/***
* Whether a group is collapsed.
* @property collapsed
* @type {Boolean}
*/
this.collapsed = false;
/***
* GroupTotals, if any.
* @property totals
* @type {GroupTotals}
*/
this.totals = null;
/**
* Rows that are part of the group.
* @property rows
* @type {Array}
*/
this.rows = [];
/**
* Sub-groups that are part of the group.
* @property groups
* @type {Array}
*/
this.groups = null;
/**
* A unique key used to identify the group. This key can be used in calls to DataView
* collapseGroup() or expandGroup().
* @property groupingKey
* @type {Object}
*/
this.groupingKey = null;
}
Group.prototype = new NonDataItem();
/***
* Compares two Group instances.
* @method equals
* @return {Boolean}
* @param group {Group} Group instance to compare to.
*/
Group.prototype.equals = function (group) {
return this.value === group.value &&
this.count === group.count &&
this.collapsed === group.collapsed;
};
/***
* Information about group totals.
* An instance of GroupTotals will be created for each totals row and passed to the aggregators
* so that they can store arbitrary data in it. That data can later be accessed by group totals
* formatters during the display.
* @class GroupTotals
* @extends Slick.NonDataItem
* @constructor
*/
function GroupTotals() {
this.__groupTotals = true;
/***
* Parent Group.
* @param group
* @type {Group}
*/
this.group = null;
}
GroupTotals.prototype = new NonDataItem();
/***
* A locking helper to track the active edit controller and ensure that only a single controller
* can be active at a time. This prevents a whole class of state and validation synchronization
* issues. An edit controller (such as SlickGrid) can query if an active edit is in progress
* and attempt a commit or cancel before proceeding.
* @class EditorLock
* @constructor
*/
function EditorLock() {
var activeEditController = null;
/***
* Returns true if a specified edit controller is active (has the edit lock).
* If the parameter is not specified, returns true if any edit controller is active.
* @method isActive
* @param editController {EditController}
* @return {Boolean}
*/
this.isActive = function (editController) {
return (editController ? activeEditController === editController : activeEditController !== null);
};
/***
* Sets the specified edit controller as the active edit controller (acquire edit lock).
* If another edit controller is already active, and exception will be thrown.
* @method activate
* @param editController {EditController} edit controller acquiring the lock
*/
this.activate = function (editController) {
if (editController === activeEditController) { // already activated?
return;
}
if (activeEditController !== null) {
throw "SlickGrid.EditorLock.activate: an editController is still active, can't activate another editController";
}
if (!editController.commitCurrentEdit) {
throw "SlickGrid.EditorLock.activate: editController must implement .commitCurrentEdit()";
}
if (!editController.cancelCurrentEdit) {
throw "SlickGrid.EditorLock.activate: editController must implement .cancelCurrentEdit()";
}
activeEditController = editController;
};
/***
* Unsets the specified edit controller as the active edit controller (release edit lock).
* If the specified edit controller is not the active one, an exception will be thrown.
* @method deactivate
* @param editController {EditController} edit controller releasing the lock
*/
this.deactivate = function (editController) {
if (activeEditController !== editController) {
throw "SlickGrid.EditorLock.deactivate: specified editController is not the currently active one";
}
activeEditController = null;
};
/***
* Attempts to commit the current edit by calling "commitCurrentEdit" method on the active edit
* controller and returns whether the commit attempt was successful (commit may fail due to validation
* errors, etc.). Edit controller's "commitCurrentEdit" must return true if the commit has succeeded
* and false otherwise. If no edit controller is active, returns true.
* @method commitCurrentEdit
* @return {Boolean}
*/
this.commitCurrentEdit = function () {
return (activeEditController ? activeEditController.commitCurrentEdit() : true);
};
/***
* Attempts to cancel the current edit by calling "cancelCurrentEdit" method on the active edit
* controller and returns whether the edit was successfully cancelled. If no edit controller is
* active, returns true.
* @method cancelCurrentEdit
* @return {Boolean}
*/
this.cancelCurrentEdit = function cancelCurrentEdit() {
return (activeEditController ? activeEditController.cancelCurrentEdit() : true);
};
}
})(jQuery);
(function ($) {
$.extend(true, window, {
Slick: {
Data: {
DataView: DataView,
Aggregators: {
Avg: AvgAggregator,
Min: MinAggregator,
Max: MaxAggregator,
Sum: SumAggregator
}
}
}
});
/***
* A sample Model implementation.
* Provides a filtered view of the underlying data.
*
* Relies on the data item having an "id" property uniquely identifying it.
*/
function DataView(options) {
var self = this;
var defaults = {
groupItemMetadataProvider: null,
inlineFilters: false
};
// private
var idProperty = "id"; // property holding a unique row id
var items = []; // data by index
var rows = []; // data by row
var idxById = {}; // indexes by id
var rowsById = null; // rows by id; lazy-calculated
var filter = null; // filter function
var updated = null; // updated item ids
var suspend = false; // suspends the recalculation
var sortAsc = true;
var fastSortField;
var sortComparer;
var refreshHints = {};
var prevRefreshHints = {};
var filterArgs;
var filteredItems = [];
var compiledFilter;
var compiledFilterWithCaching;
var filterCache = [];
// grouping
var groupingInfoDefaults = {
getter: null,
formatter: null,
comparer: function(a, b) { return a.value - b.value; },
predefinedValues: [],
aggregators: [],
aggregateEmpty: false,
aggregateCollapsed: false,
aggregateChildGroups: false,
collapsed: false,
displayTotalsRow: true
};
var groupingInfos = [];
var groups = [];
var toggledGroupsByLevel = [];
var groupingDelimiter = ':|:';
var pagesize = 0;
var pagenum = 0;
var totalRows = 0;
// events
var onRowCountChanged = new Slick.Event();
var onRowsChanged = new Slick.Event();
var onPagingInfoChanged = new Slick.Event();
options = $.extend(true, {}, defaults, options);
function beginUpdate() {
suspend = true;
}
function endUpdate() {
suspend = false;
refresh();
}
function setRefreshHints(hints) {
refreshHints = hints;
}
function setFilterArgs(args) {
filterArgs = args;
}
function updateIdxById(startingIndex) {
startingIndex = startingIndex || 0;
var id;
for (var i = startingIndex, l = items.length; i < l; i++) {
id = items[i][idProperty];
if (id === undefined) {
throw "Each data element must implement a unique 'id' property";
}
idxById[id] = i;
}
}
function ensureIdUniqueness() {
var id;
for (var i = 0, l = items.length; i < l; i++) {
id = items[i][idProperty];
if (id === undefined || idxById[id] !== i) {
throw "Each data element must implement a unique 'id' property";
}
}
}
function getItems() {
return items;
}
function setItems(data, objectIdProperty) {
if (objectIdProperty !== undefined) {
idProperty = objectIdProperty;
}
items = filteredItems = data;
idxById = {};
updateIdxById();
ensureIdUniqueness();
refresh();
}
function setPagingOptions(args) {
if (args.pageSize != undefined) {
pagesize = args.pageSize;
pagenum = pagesize ? Math.min(pagenum, Math.max(0, Math.ceil(totalRows / pagesize) - 1)) : 0;
}
if (args.pageNum != undefined) {
pagenum = Math.min(args.pageNum, Math.max(0, Math.ceil(totalRows / pagesize) - 1));
}
onPagingInfoChanged.notify(getPagingInfo(), null, self);
refresh();
}
function getPagingInfo() {
var totalPages = pagesize ? Math.max(1, Math.ceil(totalRows / pagesize)) : 1;
return {pageSize: pagesize, pageNum: pagenum, totalRows: totalRows, totalPages: totalPages};
}
function sort(comparer, ascending) {
sortAsc = ascending;
sortComparer = comparer;
fastSortField = null;
if (ascending === false) {
items.reverse();
}
items.sort(comparer);
if (ascending === false) {
items.reverse();
}
idxById = {};
updateIdxById();
refresh();
}
/***
* Provides a workaround for the extremely slow sorting in IE.
* Does a [lexicographic] sort on a give column by temporarily overriding Object.prototype.toString
* to return the value of that field and then doing a native Array.sort().
*/
function fastSort(field, ascending) {
sortAsc = ascending;
fastSortField = field;
sortComparer = null;
var oldToString = Object.prototype.toString;
Object.prototype.toString = (typeof field == "function") ? field : function () {
return this[field]
};
// an extra reversal for descending sort keeps the sort stable
// (assuming a stable native sort implementation, which isn't true in some cases)
if (ascending === false) {
items.reverse();
}
items.sort();
Object.prototype.toString = oldToString;
if (ascending === false) {
items.reverse();
}
idxById = {};
updateIdxById();
refresh();
}
function reSort() {
if (sortComparer) {
sort(sortComparer, sortAsc);
} else if (fastSortField) {
fastSort(fastSortField, sortAsc);
}
}
function setFilter(filterFn) {
filter = filterFn;
if (options.inlineFilters) {
compiledFilter = compileFilter();
compiledFilterWithCaching = compileFilterWithCaching();
}
refresh();
}
function getGrouping() {
return groupingInfos;
}
function setGrouping(groupingInfo) {
if (!options.groupItemMetadataProvider) {
options.groupItemMetadataProvider = new Slick.Data.GroupItemMetadataProvider();
}
groups = [];
toggledGroupsByLevel = [];
groupingInfo = groupingInfo || [];
groupingInfos = (groupingInfo instanceof Array) ? groupingInfo : [groupingInfo];
for (var i = 0; i < groupingInfos.length; i++) {
var gi = groupingInfos[i] = $.extend(true, {}, groupingInfoDefaults, groupingInfos[i]);
gi.getterIsAFn = typeof gi.getter === "function";
// pre-compile accumulator loops
gi.compiledAccumulators = [];
var idx = gi.aggregators.length;
while (idx--) {
gi.compiledAccumulators[idx] = compileAccumulatorLoop(gi.aggregators[idx]);
}
toggledGroupsByLevel[i] = {};
}
refresh();
}
/**
* @deprecated Please use {@link setGrouping}.
*/
function groupBy(valueGetter, valueFormatter, sortComparer) {
if (valueGetter == null) {
setGrouping([]);
return;
}
setGrouping({
getter: valueGetter,
formatter: valueFormatter,
comparer: sortComparer
});
}
/**
* @deprecated Please use {@link setGrouping}.
*/
function setAggregators(groupAggregators, includeCollapsed) {
if (!groupingInfos.length) {
throw new Error("At least one grouping must be specified before calling setAggregators().");
}
groupingInfos[0].aggregators = groupAggregators;
groupingInfos[0].aggregateCollapsed = includeCollapsed;
setGrouping(groupingInfos);
}
function getItemByIdx(i) {
return items[i];
}
function getIdxById(id) {
return idxById[id];
}
function ensureRowsByIdCache() {
if (!rowsById) {
rowsById = {};
for (var i = 0, l = rows.length; i < l; i++) {
rowsById[rows[i][idProperty]] = i;
}
}
}
function getRowById(id) {
ensureRowsByIdCache();
return rowsById[id];
}
function getItemById(id) {
return items[idxById[id]];
}
function mapIdsToRows(idArray) {
var rows = [];
ensureRowsByIdCache();
for (var i = 0; i < idArray.length; i++) {
var row = rowsById[idArray[i]];
if (row != null) {
rows[rows.length] = row;
}
}
return rows;
}
function mapRowsToIds(rowArray) {
var ids = [];
for (var i = 0; i < rowArray.length; i++) {
if (rowArray[i] < rows.length) {
ids[ids.length] = rows[rowArray[i]][idProperty];
}
}
return ids;
}
function updateItem(id, item) {
if (idxById[id] === undefined || id !== item[idProperty]) {
throw "Invalid or non-matching id";
}
items[idxById[id]] = item;
if (!updated) {
updated = {};
}
updated[id] = true;
refresh();
}
function insertItem(insertBefore, item) {
items.splice(insertBefore, 0, item);
updateIdxById(insertBefore);
refresh();
}
function addItem(item) {
items.push(item);
updateIdxById(items.length - 1);
refresh();
}
function deleteItem(id) {
var idx = idxById[id];
if (idx === undefined) {
throw "Invalid id";
}
delete idxById[id];
items.splice(idx, 1);
updateIdxById(idx);
refresh();
}
function getLength() {
return rows.length;
}
function getItem(i) {
return rows[i];
}
function getItemMetadata(i) {
var item = rows[i];
if (item === undefined) {
return null;
}
// overrides for grouping rows
if (item.__group) {
return options.groupItemMetadataProvider.getGroupRowMetadata(item);
}
// overrides for totals rows
if (item.__groupTotals) {
return options.groupItemMetadataProvider.getTotalsRowMetadata(item);
}
return null;
}
function expandCollapseAllGroups(level, collapse) {
if (level == null) {
for (var i = 0; i < groupingInfos.length; i++) {
toggledGroupsByLevel[i] = {};
groupingInfos[i].collapsed = collapse;
}
} else {
toggledGroupsByLevel[level] = {};
groupingInfos[level].collapsed = collapse;
}
refresh();
}
/**
* @param level {Number} Optional level to collapse. If not specified, applies to all levels.
*/
function collapseAllGroups(level) {
expandCollapseAllGroups(level, true);
}
/**
* @param level {Number} Optional level to expand. If not specified, applies to all levels.
*/
function expandAllGroups(level) {
expandCollapseAllGroups(level, false);
}
function expandCollapseGroup(level, groupingKey, collapse) {
toggledGroupsByLevel[level][groupingKey] = groupingInfos[level].collapsed ^ collapse;
refresh();
}
/**
* @param varArgs Either a Slick.Group's "groupingKey" property, or a
* variable argument list of grouping values denoting a unique path to the row. For
* example, calling collapseGroup('high', '10%') will collapse the '10%' subgroup of
* the 'high' setGrouping.
*/
function collapseGroup(varArgs) {
var args = Array.prototype.slice.call(arguments);
var arg0 = args[0];
if (args.length == 1 && arg0.indexOf(groupingDelimiter) != -1) {
expandCollapseGroup(arg0.split(groupingDelimiter).length - 1, arg0, true);
} else {
expandCollapseGroup(args.length - 1, args.join(groupingDelimiter), true);
}
}
/**
* @param varArgs Either a Slick.Group's "groupingKey" property, or a
* variable argument list of grouping values denoting a unique path to the row. For
* example, calling expandGroup('high', '10%') will expand the '10%' subgroup of
* the 'high' setGrouping.
*/
function expandGroup(varArgs) {
var args = Array.prototype.slice.call(arguments);
var arg0 = args[0];
if (args.length == 1 && arg0.indexOf(groupingDelimiter) != -1) {
expandCollapseGroup(arg0.split(groupingDelimiter).length - 1, arg0, false);
} else {
expandCollapseGroup(args.length - 1, args.join(groupingDelimiter), false);
}
}
function getGroups() {
return groups;
}
function extractGroups(rows, parentGroup) {
var group;
var val;
var groups = [];
var groupsByVal = [];
var r;
var level = parentGroup ? parentGroup.level + 1 : 0;
var gi = groupingInfos[level];
for (var i = 0, l = gi.predefinedValues.length; i < l; i++) {
val = gi.predefinedValues[i];
group = groupsByVal[val];
if (!group) {
group = new Slick.Group();
group.value = val;
group.level = level;
group.groupingKey = (parentGroup ? parentGroup.groupingKey + groupingDelimiter : '') + val;
groups[groups.length] = group;
groupsByVal[val] = group;
}
}
for (var i = 0, l = rows.length; i < l; i++) {
r = rows[i];
val = gi.getterIsAFn ? gi.getter(r) : r[gi.getter];
group = groupsByVal[val];
if (!group) {
group = new Slick.Group();
group.value = val;
group.level = level;
group.groupingKey = (parentGroup ? parentGroup.groupingKey + groupingDelimiter : '') + val;
groups[groups.length] = group;
groupsByVal[val] = group;
}
group.rows[group.count++] = r;
}
if (level < groupingInfos.length - 1) {
for (var i = 0; i < groups.length; i++) {
group = groups[i];
group.groups = extractGroups(group.rows, group);
}
}
groups.sort(groupingInfos[level].comparer);
return groups;
}
// TODO: lazy totals calculation
function calculateGroupTotals(group) {
// TODO: try moving iterating over groups into compiled accumulator
var gi = groupingInfos[group.level];
var isLeafLevel = (group.level == groupingInfos.length);
var totals = new Slick.GroupTotals();
var agg, idx = gi.aggregators.length;
while (idx--) {
agg = gi.aggregators[idx];
agg.init();
gi.compiledAccumulators[idx].call(agg,
(!isLeafLevel && gi.aggregateChildGroups) ? group.groups : group.rows);
agg.storeResult(totals);
}
totals.group = group;
group.totals = totals;
}
function calculateTotals(groups, level) {
level = level || 0;
var gi = groupingInfos[level];
var idx = groups.length, g;
while (idx--) {
g = groups[idx];
if (g.collapsed && !gi.aggregateCollapsed) {
continue;
}
// Do a depth-first aggregation so that parent setGrouping aggregators can access subgroup totals.
if (g.groups) {
calculateTotals(g.groups, level + 1);
}
if (gi.aggregators.length && (
gi.aggregateEmpty || g.rows.length || (g.groups && g.groups.length))) {
calculateGroupTotals(g);
}
}
}
function finalizeGroups(groups, level) {
level = level || 0;
var gi = groupingInfos[level];
var groupCollapsed = gi.collapsed;
var toggledGroups = toggledGroupsByLevel[level];
var idx = groups.length, g;
while (idx--) {
g = groups[idx];
g.collapsed = groupCollapsed ^ toggledGroups[g.groupingKey];
g.title = gi.formatter ? gi.formatter(g) : g.value;
if (g.groups) {
finalizeGroups(g.groups, level + 1);
// Let the non-leaf setGrouping rows get garbage-collected.
// They may have been used by aggregates that go over all of the descendants,
// but at this point they are no longer needed.
g.rows = [];
}
}
}
function flattenGroupedRows(groups, level) {
level = level || 0;
var gi = groupingInfos[level];
var groupedRows = [], rows, gl = 0, g;
for (var i = 0, l = groups.length; i < l; i++) {
g = groups[i];
groupedRows[gl++] = g;
if (!g.collapsed) {
rows = g.groups ? flattenGroupedRows(g.groups, level + 1) : g.rows;
for (var j = 0, jj = rows.length; j < jj; j++) {
groupedRows[gl++] = rows[j];
}
}
if (g.totals && gi.displayTotalsRow && (!g.collapsed || gi.aggregateCollapsed)) {
groupedRows[gl++] = g.totals;
}
}
return groupedRows;
}
function getFunctionInfo(fn) {
var fnRegex = /^function[^(]*\(([^)]*)\)\s*{([\s\S]*)}$/;
var matches = fn.toString().match(fnRegex);
return {
params: matches[1].split(","),
body: matches[2]
};
}
function compileAccumulatorLoop(aggregator) {
var accumulatorInfo = getFunctionInfo(aggregator.accumulate);
var fn = new Function(
"_items",
"for (var " + accumulatorInfo.params[0] + ", _i=0, _il=_items.length; _i<_il; _i++) {" +
accumulatorInfo.params[0] + " = _items[_i]; " +
accumulatorInfo.body +
"}"
);
fn.displayName = fn.name = "compiledAccumulatorLoop";
return fn;
}
function compileFilter() {
var filterInfo = getFunctionInfo(filter);
var filterBody = filterInfo.body
.replace(/return false[;}]/gi, "{ continue _coreloop; }")
.replace(/return true[;}]/gi, "{ _retval[_idx++] = $item$; continue _coreloop; }")
.replace(/return ([^;}]+?);/gi,
"{ if ($1) { _retval[_idx++] = $item$; }; continue _coreloop; }");
// This preserves the function template code after JS compression,
// so that replace() commands still work as expected.
var tpl = [
//"function(_items, _args) { ",
"var _retval = [], _idx = 0; ",
"var $item$, $args$ = _args; ",
"_coreloop: ",
"for (var _i = 0, _il = _items.length; _i < _il; _i++) { ",
"$item$ = _items[_i]; ",
"$filter$; ",
"} ",
"return _retval; "
//"}"
].join("");
tpl = tpl.replace(/\$filter\$/gi, filterBody);
tpl = tpl.replace(/\$item\$/gi, filterInfo.params[0]);
tpl = tpl.replace(/\$args\$/gi, filterInfo.params[1]);
var fn = new Function("_items,_args", tpl);
fn.displayName = fn.name = "compiledFilter";
return fn;
}
function compileFilterWithCaching() {
var filterInfo = getFunctionInfo(filter);
var filterBody = filterInfo.body
.replace(/return false[;}]/gi, "{ continue _coreloop; }")
.replace(/return true[;}]/gi, "{ _cache[_i] = true;_retval[_idx++] = $item$; continue _coreloop; }")
.replace(/return ([^;}]+?);/gi,
"{ if ((_cache[_i] = $1)) { _retval[_idx++] = $item$; }; continue _coreloop; }");
// This preserves the function template code after JS compression,
// so that replace() commands still work as expected.
var tpl = [
//"function(_items, _args, _cache) { ",
"var _retval = [], _idx = 0; ",
"var $item$, $args$ = _args; ",
"_coreloop: ",
"for (var _i = 0, _il = _items.length; _i < _il; _i++) { ",
"$item$ = _items[_i]; ",
"if (_cache[_i]) { ",
"_retval[_idx++] = $item$; ",
"continue _coreloop; ",
"} ",
"$filter$; ",
"} ",
"return _retval; "
//"}"
].join("");
tpl = tpl.replace(/\$filter\$/gi, filterBody);
tpl = tpl.replace(/\$item\$/gi, filterInfo.params[0]);
tpl = tpl.replace(/\$args\$/gi, filterInfo.params[1]);
var fn = new Function("_items,_args,_cache", tpl);
fn.displayName = fn.name = "compiledFilterWithCaching";
return fn;
}
function uncompiledFilter(items, args) {
var retval = [], idx = 0;
for (var i = 0, ii = items.length; i < ii; i++) {
if (filter(items[i], args)) {
retval[idx++] = items[i];
}
}
return retval;
}
function uncompiledFilterWithCaching(items, args, cache) {
var retval = [], idx = 0, item;
for (var i = 0, ii = items.length; i < ii; i++) {
item = items[i];
if (cache[i]) {
retval[idx++] = item;
} else if (filter(item, args)) {
retval[idx++] = item;
cache[i] = true;
}
}
return retval;
}
function getFilteredAndPagedItems(items) {
if (filter) {
var batchFilter = options.inlineFilters ? compiledFilter : uncompiledFilter;
var batchFilterWithCaching = options.inlineFilters ? compiledFilterWithCaching : uncompiledFilterWithCaching;
if (refreshHints.isFilterNarrowing) {
filteredItems = batchFilter(filteredItems, filterArgs);
} else if (refreshHints.isFilterExpanding) {
filteredItems = batchFilterWithCaching(items, filterArgs, filterCache);
} else if (!refreshHints.isFilterUnchanged) {
filteredItems = batchFilter(items, filterArgs);
}
} else {
// special case: if not filtering and not paging, the resulting
// rows collection needs to be a copy so that changes due to sort
// can be caught
filteredItems = pagesize ? items : items.concat();
}
// get the current page
var paged;
if (pagesize) {
if (filteredItems.length < pagenum * pagesize) {
pagenum = Math.floor(filteredItems.length / pagesize);
}
paged = filteredItems.slice(pagesize * pagenum, pagesize * pagenum + pagesize);
} else {
paged = filteredItems;
}
return {totalRows: filteredItems.length, rows: paged};
}
function getRowDiffs(rows, newRows) {
var item, r, eitherIsNonData, diff = [];
var from = 0, to = newRows.length;
if (refreshHints && refreshHints.ignoreDiffsBefore) {
from = Math.max(0,
Math.min(newRows.length, refreshHints.ignoreDiffsBefore));
}
if (refreshHints && refreshHints.ignoreDiffsAfter) {
to = Math.min(newRows.length,
Math.max(0, refreshHints.ignoreDiffsAfter));
}
for (var i = from, rl = rows.length; i < to; i++) {
if (i >= rl) {
diff[diff.length] = i;
} else {
item = newRows[i];
r = rows[i];
if ((groupingInfos.length && (eitherIsNonData = (item.__nonDataRow) || (r.__nonDataRow)) &&
item.__group !== r.__group ||
item.__group && !item.equals(r))
|| (eitherIsNonData &&
// no good way to compare totals since they are arbitrary DTOs
// deep object comparison is pretty expensive
// always considering them 'dirty' seems easier for the time being
(item.__groupTotals || r.__groupTotals))
|| item[idProperty] != r[idProperty]
|| (updated && updated[item[idProperty]])
) {
diff[diff.length] = i;
}
}
}
return diff;
}
function recalc(_items) {
rowsById = null;
if (refreshHints.isFilterNarrowing != prevRefreshHints.isFilterNarrowing ||
refreshHints.isFilterExpanding != prevRefreshHints.isFilterExpanding) {
filterCache = [];
}
var filteredItems = getFilteredAndPagedItems(_items);
totalRows = filteredItems.totalRows;
var newRows = filteredItems.rows;
groups = [];
if (groupingInfos.length) {
groups = extractGroups(newRows);
if (groups.length) {
calculateTotals(groups);
finalizeGroups(groups);
newRows = flattenGroupedRows(groups);
}
}
var diff = getRowDiffs(rows, newRows);
rows = newRows;
return diff;
}
function refresh() {
if (suspend) {
return;
}
var countBefore = rows.length;
var totalRowsBefore = totalRows;
var diff = recalc(items, filter); // pass as direct refs to avoid closure perf hit
// if the current page is no longer valid, go to last page and recalc
// we suffer a performance penalty here, but the main loop (recalc) remains highly optimized
if (pagesize && totalRows < pagenum * pagesize) {
pagenum = Math.max(0, Math.ceil(totalRows / pagesize) - 1);
diff = recalc(items, filter);
}
updated = null;
prevRefreshHints = refreshHints;
refreshHints = {};
if (totalRowsBefore != totalRows) {
onPagingInfoChanged.notify(getPagingInfo(), null, self);
}
if (countBefore != rows.length) {
onRowCountChanged.notify({previous: countBefore, current: rows.length}, null, self);
}
if (diff.length > 0) {
onRowsChanged.notify({rows: diff}, null, self);
}
}
function syncGridSelection(grid, preserveHidden) {
var self = this;
var selectedRowIds = self.mapRowsToIds(grid.getSelectedRows());;
var inHandler;
function update() {
if (selectedRowIds.length > 0) {
inHandler = true;
var selectedRows = self.mapIdsToRows(selectedRowIds);
if (!preserveHidden) {
selectedRowIds = self.mapRowsToIds(selectedRows);
}
grid.setSelectedRows(selectedRows);
inHandler = false;
}
}
grid.onSelectedRowsChanged.subscribe(function(e, args) {
if (inHandler) { return; }
selectedRowIds = self.mapRowsToIds(grid.getSelectedRows());
});
this.onRowsChanged.subscribe(update);
this.onRowCountChanged.subscribe(update);
}
function syncGridCellCssStyles(grid, key) {
var hashById;
var inHandler;
// since this method can be called after the cell styles have been set,
// get the existing ones right away
storeCellCssStyles(grid.getCellCssStyles(key));
function storeCellCssStyles(hash) {
hashById = {};
for (var row in hash) {
var id = rows[row][idProperty];
hashById[id] = hash[row];
}
}
function update() {
if (hashById) {
inHandler = true;
ensureRowsByIdCache();
var newHash = {};
for (var id in hashById) {
var row = rowsById[id];
if (row != undefined) {
newHash[row] = hashById[id];
}
}
grid.setCellCssStyles(key, newHash);
inHandler = false;
}
}
grid.onCellCssStylesChanged.subscribe(function(e, args) {
if (inHandler) { return; }
if (key != args.key) { return; }
if (args.hash) {
storeCellCssStyles(args.hash);
}
});
this.onRowsChanged.subscribe(update);
this.onRowCountChanged.subscribe(update);
}
$.extend(this, {
// methods
"beginUpdate": beginUpdate,
"endUpdate": endUpdate,
"setPagingOptions": setPagingOptions,
"getPagingInfo": getPagingInfo,
"getItems": getItems,
"setItems": setItems,
"setFilter": setFilter,
"sort": sort,
"fastSort": fastSort,
"reSort": reSort,
"setGrouping": setGrouping,
"getGrouping": getGrouping,
"groupBy": groupBy,
"setAggregators": setAggregators,
"collapseAllGroups": collapseAllGroups,
"expandAllGroups": expandAllGroups,
"collapseGroup": collapseGroup,
"expandGroup": expandGroup,
"getGroups": getGroups,
"getIdxById": getIdxById,
"getRowById": getRowById,
"getItemById": getItemById,
"getItemByIdx": getItemByIdx,
"mapRowsToIds": mapRowsToIds,
"mapIdsToRows": mapIdsToRows,
"setRefreshHints": setRefreshHints,
"setFilterArgs": setFilterArgs,
"refresh": refresh,
"updateItem": updateItem,
"insertItem": insertItem,
"addItem": addItem,
"deleteItem": deleteItem,
"syncGridSelection": syncGridSelection,
"syncGridCellCssStyles": syncGridCellCssStyles,
// data provider methods
"getLength": getLength,
"getItem": getItem,
"getItemMetadata": getItemMetadata,
// events
"onRowCountChanged": onRowCountChanged,
"onRowsChanged": onRowsChanged,
"onPagingInfoChanged": onPagingInfoChanged
});
}
function AvgAggregator(field) {
this.field_ = field;
this.init = function () {
this.count_ = 0;
this.nonNullCount_ = 0;
this.sum_ = 0;
};
this.accumulate = function (item) {
var val = item[this.field_];
this.count_++;
if (val != null && val !== "" && val !== NaN) {
this.nonNullCount_++;
this.sum_ += parseFloat(val);
}
};
this.storeResult = function (groupTotals) {
if (!groupTotals.avg) {
groupTotals.avg = {};
}
if (this.nonNullCount_ != 0) {
groupTotals.avg[this.field_] = this.sum_ / this.nonNullCount_;
}
};
}
function MinAggregator(field) {
this.field_ = field;
this.init = function () {
this.min_ = null;
};
this.accumulate = function (item) {
var val = item[this.field_];
if (val != null && val !== "" && val !== NaN) {
if (this.min_ == null || val < this.min_) {
this.min_ = val;
}
}
};
this.storeResult = function (groupTotals) {
if (!groupTotals.min) {
groupTotals.min = {};
}
groupTotals.min[this.field_] = this.min_;
}
}
function MaxAggregator(field) {
this.field_ = field;
this.init = function () {
this.max_ = null;
};
this.accumulate = function (item) {
var val = item[this.field_];
if (val != null && val !== "" && val !== NaN) {
if (this.max_ == null || val > this.max_) {
this.max_ = val;
}
}
};
this.storeResult = function (groupTotals) {
if (!groupTotals.max) {
groupTotals.max = {};
}
groupTotals.max[this.field_] = this.max_;
}
}
function SumAggregator(field) {
this.field_ = field;
this.init = function () {
this.sum_ = null;
};
this.accumulate = function (item) {
var val = item[this.field_];
if (val != null && val !== "" && val !== NaN) {
this.sum_ += parseFloat(val);
}
};
this.storeResult = function (groupTotals) {
if (!groupTotals.sum) {
groupTotals.sum = {};
}
groupTotals.sum[this.field_] = this.sum_;
}
}
// TODO: add more built-in aggregators
// TODO: merge common aggregators in one to prevent needles iterating
})(jQuery);
/***
* Contains basic SlickGrid editors.
* @module Editors
* @namespace Slick
*/
(function ($) {
// register namespace
$.extend(true, window, {
"Slick": {
"Editors": {
"Text": TextEditor,
"Integer": IntegerEditor,
"Date": DateEditor,
"YesNoSelect": YesNoSelectEditor,
"Checkbox": CheckboxEditor,
"PercentComplete": PercentCompleteEditor,
"LongText": LongTextEditor
}
}
});
function TextEditor(args) {
var $input;
var defaultValue;
var scope = this;
this.init = function () {
$input = $("<INPUT type=text class='editor-text' />")
.appendTo(args.container)
.bind("keydown.nav", function (e) {
if (e.keyCode === $.ui.keyCode.LEFT || e.keyCode === $.ui.keyCode.RIGHT) {
e.stopImmediatePropagation();
}
})
.focus()
.select();
};
this.destroy = function () {
$input.remove();
};
this.focus = function () {
$input.focus();
};
this.getValue = function () {
return $input.val();
};
this.setValue = function (val) {
$input.val(val);
};
this.loadValue = function (item) {
defaultValue = item[args.column.field] || "";
$input.val(defaultValue);
$input[0].defaultValue = defaultValue;
$input.select();
};
this.serializeValue = function () {
return $input.val();
};
this.applyValue = function (item, state) {
item[args.column.field] = state;
};
this.isValueChanged = function () {
return (!($input.val() == "" && defaultValue == null)) && ($input.val() != defaultValue);
};
this.validate = function () {
if (args.column.validator) {
var validationResults = args.column.validator($input.val());
if (!validationResults.valid) {
return validationResults;
}
}
return {
valid: true,
msg: null
};
};
this.init();
}
function IntegerEditor(args) {
var $input;
var defaultValue;
var scope = this;
this.init = function () {
$input = $("<INPUT type=text class='editor-text' />");
$input.bind("keydown.nav", function (e) {
if (e.keyCode === $.ui.keyCode.LEFT || e.keyCode === $.ui.keyCode.RIGHT) {
e.stopImmediatePropagation();
}
});
$input.appendTo(args.container);
$input.focus().select();
};
this.destroy = function () {
$input.remove();
};
this.focus = function () {
$input.focus();
};
this.loadValue = function (item) {
defaultValue = item[args.column.field];
$input.val(defaultValue);
$input[0].defaultValue = defaultValue;
$input.select();
};
this.serializeValue = function () {
return parseInt($input.val(), 10) || 0;
};
this.applyValue = function (item, state) {
item[args.column.field] = state;
};
this.isValueChanged = function () {
return (!($input.val() == "" && defaultValue == null)) && ($input.val() != defaultValue);
};
this.validate = function () {
if (isNaN($input.val())) {
return {
valid: false,
msg: "Please enter a valid integer"
};
}
return {
valid: true,
msg: null
};
};
this.init();
}
function DateEditor(args) {
var $input;
var defaultValue;
var scope = this;
var calendarOpen = false;
this.init = function () {
$input = $("<INPUT type=text class='editor-text' />");
$input.appendTo(args.container);
$input.focus().select();
$input.datepicker({
showOn: "button",
buttonImageOnly: true,
buttonImage: "../images/calendar.gif",
beforeShow: function () {
calendarOpen = true
},
onClose: function () {
calendarOpen = false
}
});
$input.width($input.width() - 18);
};
this.destroy = function () {
$.datepicker.dpDiv.stop(true, true);
$input.datepicker("hide");
$input.datepicker("destroy");
$input.remove();
};
this.show = function () {
if (calendarOpen) {
$.datepicker.dpDiv.stop(true, true).show();
}
};
this.hide = function () {
if (calendarOpen) {
$.datepicker.dpDiv.stop(true, true).hide();
}
};
this.position = function (position) {
if (!calendarOpen) {
return;
}
$.datepicker.dpDiv
.css("top", position.top + 30)
.css("left", position.left);
};
this.focus = function () {
$input.focus();
};
this.loadValue = function (item) {
defaultValue = item[args.column.field];
$input.val(defaultValue);
$input[0].defaultValue = defaultValue;
$input.select();
};
this.serializeValue = function () {
return $input.val();
};
this.applyValue = function (item, state) {
item[args.column.field] = state;
};
this.isValueChanged = function () {
return (!($input.val() == "" && defaultValue == null)) && ($input.val() != defaultValue);
};
this.validate = function () {
return {
valid: true,
msg: null
};
};
this.init();
}
function YesNoSelectEditor(args) {
var $select;
var defaultValue;
var scope = this;
this.init = function () {
$select = $("<SELECT tabIndex='0' class='editor-yesno'><OPTION value='yes'>Yes</OPTION><OPTION value='no'>No</OPTION></SELECT>");
$select.appendTo(args.container);
$select.focus();
};
this.destroy = function () {
$select.remove();
};
this.focus = function () {
$select.focus();
};
this.loadValue = function (item) {
$select.val((defaultValue = item[args.column.field]) ? "yes" : "no");
$select.select();
};
this.serializeValue = function () {
return ($select.val() == "yes");
};
this.applyValue = function (item, state) {
item[args.column.field] = state;
};
this.isValueChanged = function () {
return ($select.val() != defaultValue);
};
this.validate = function () {
return {
valid: true,
msg: null
};
};
this.init();
}
function CheckboxEditor(args) {
var $select;
var defaultValue;
var scope = this;
this.init = function () {
$select = $("<INPUT type=checkbox value='true' class='editor-checkbox' hideFocus>");
$select.appendTo(args.container);
$select.focus();
};
this.destroy = function () {
$select.remove();
};
this.focus = function () {
$select.focus();
};
this.loadValue = function (item) {
defaultValue = !!item[args.column.field];
if (defaultValue) {
$select.attr("checked", "checked");
} else {
$select.removeAttr("checked");
}
};
this.serializeValue = function () {
return !!$select.attr("checked");
};
this.applyValue = function (item, state) {
item[args.column.field] = state;
};
this.isValueChanged = function () {
return (this.serializeValue() !== defaultValue);
};
this.validate = function () {
return {
valid: true,
msg: null
};
};
this.init();
}
function PercentCompleteEditor(args) {
var $input, $picker;
var defaultValue;
var scope = this;
this.init = function () {
$input = $("<INPUT type=text class='editor-percentcomplete' />");
$input.width($(args.container).innerWidth() - 25);
$input.appendTo(args.container);
$picker = $("<div class='editor-percentcomplete-picker' />").appendTo(args.container);
$picker.append("<div class='editor-percentcomplete-helper'><div class='editor-percentcomplete-wrapper'><div class='editor-percentcomplete-slider' /><div class='editor-percentcomplete-buttons' /></div></div>");
$picker.find(".editor-percentcomplete-buttons").append("<button val=0>Not started</button><br/><button val=50>In Progress</button><br/><button val=100>Complete</button>");
$input.focus().select();
$picker.find(".editor-percentcomplete-slider").slider({
orientation: "vertical",
range: "min",
value: defaultValue,
slide: function (event, ui) {
$input.val(ui.value)
}
});
$picker.find(".editor-percentcomplete-buttons button").bind("click", function (e) {
$input.val($(this).attr("val"));
$picker.find(".editor-percentcomplete-slider").slider("value", $(this).attr("val"));
})
};
this.destroy = function () {
$input.remove();
$picker.remove();
};
this.focus = function () {
$input.focus();
};
this.loadValue = function (item) {
$input.val(defaultValue = item[args.column.field]);
$input.select();
};
this.serializeValue = function () {
return parseInt($input.val(), 10) || 0;
};
this.applyValue = function (item, state) {
item[args.column.field] = state;
};
this.isValueChanged = function () {
return (!($input.val() == "" && defaultValue == null)) && ((parseInt($input.val(), 10) || 0) != defaultValue);
};
this.validate = function () {
if (isNaN(parseInt($input.val(), 10))) {
return {
valid: false,
msg: "Please enter a valid positive number"
};
}
return {
valid: true,
msg: null
};
};
this.init();
}
/*
* An example of a "detached" editor.
* The UI is added onto document BODY and .position(), .show() and .hide() are implemented.
* KeyDown events are also handled to provide handling for Tab, Shift-Tab, Esc and Ctrl-Enter.
*/
function LongTextEditor(args) {
var $input, $wrapper;
var defaultValue;
var scope = this;
this.init = function () {
var $container = $("body");
$wrapper = $("<DIV style='z-index:10000;position:absolute;background:white;padding:5px;border:3px solid gray; -moz-border-radius:10px; border-radius:10px;'/>")
.appendTo($container);
$input = $("<TEXTAREA hidefocus rows=5 style='backround:white;width:250px;height:80px;border:0;outline:0'>")
.appendTo($wrapper);
$("<DIV style='text-align:right'><BUTTON>Save</BUTTON><BUTTON>Cancel</BUTTON></DIV>")
.appendTo($wrapper);
$wrapper.find("button:first").bind("click", this.save);
$wrapper.find("button:last").bind("click", this.cancel);
$input.bind("keydown", this.handleKeyDown);
scope.position(args.position);
$input.focus().select();
};
this.handleKeyDown = function (e) {
if (e.which == $.ui.keyCode.ENTER && e.ctrlKey) {
scope.save();
} else if (e.which == $.ui.keyCode.ESCAPE) {
e.preventDefault();
scope.cancel();
} else if (e.which == $.ui.keyCode.TAB && e.shiftKey) {
e.preventDefault();
args.grid.navigatePrev();
} else if (e.which == $.ui.keyCode.TAB) {
e.preventDefault();
args.grid.navigateNext();
}
};
this.save = function () {
args.commitChanges();
};
this.cancel = function () {
$input.val(defaultValue);
args.cancelChanges();
};
this.hide = function () {
$wrapper.hide();
};
this.show = function () {
$wrapper.show();
};
this.position = function (position) {
$wrapper
.css("top", position.top - 5)
.css("left", position.left - 5)
};
this.destroy = function () {
$wrapper.remove();
};
this.focus = function () {
$input.focus();
};
this.loadValue = function (item) {
$input.val(defaultValue = item[args.column.field]);
$input.select();
};
this.serializeValue = function () {
return $input.val();
};
this.applyValue = function (item, state) {
item[args.column.field] = state;
};
this.isValueChanged = function () {
return (!($input.val() == "" && defaultValue == null)) && ($input.val() != defaultValue);
};
this.validate = function () {
return {
valid: true,
msg: null
};
};
this.init();
}
})(jQuery);
/***
* Contains basic SlickGrid formatters.
*
* NOTE: These are merely examples. You will most likely need to implement something more
* robust/extensible/localizable/etc. for your use!
*
* @module Formatters
* @namespace Slick
*/
(function ($) {
// register namespace
$.extend(true, window, {
"Slick": {
"Formatters": {
"PercentComplete": PercentCompleteFormatter,
"PercentCompleteBar": PercentCompleteBarFormatter,
"YesNo": YesNoFormatter,
"Checkmark": CheckmarkFormatter
}
}
});
function PercentCompleteFormatter(row, cell, value, columnDef, dataContext) {
if (value == null || value === "") {
return "-";
} else if (value < 50) {
return "<span style='color:red;font-weight:bold;'>" + value + "%</span>";
} else {
return "<span style='color:green'>" + value + "%</span>";
}
}
function PercentCompleteBarFormatter(row, cell, value, columnDef, dataContext) {
if (value == null || value === "") {
return "";
}
var color;
if (value < 30) {
color = "red";
} else if (value < 70) {
color = "silver";
} else {
color = "green";
}
return "<span class='percent-complete-bar' style='background:" + color + ";width:" + value + "%'></span>";
}
function YesNoFormatter(row, cell, value, columnDef, dataContext) {
return value ? "Yes" : "No";
}
function CheckmarkFormatter(row, cell, value, columnDef, dataContext) {
return value ? "<img src='../images/tick.png'>" : "";
}
})(jQuery);
This source diff could not be displayed because it is too large. You can view the blob instead.
(function ($) {
$.extend(true, window, {
Slick: {
Data: {
GroupItemMetadataProvider: GroupItemMetadataProvider
}
}
});
/***
* Provides item metadata for group (Slick.Group) and totals (Slick.Totals) rows produced by the DataView.
* This metadata overrides the default behavior and formatting of those rows so that they appear and function
* correctly when processed by the grid.
*
* This class also acts as a grid plugin providing event handlers to expand & collapse groups.
* If "grid.registerPlugin(...)" is not called, expand & collapse will not work.
*
* @class GroupItemMetadataProvider
* @module Data
* @namespace Slick.Data
* @constructor
* @param options
*/
function GroupItemMetadataProvider(options) {
var _grid;
var _defaults = {
groupCssClass: "slick-group",
groupTitleCssClass: "slick-group-title",
totalsCssClass: "slick-group-totals",
groupFocusable: true,
totalsFocusable: false,
toggleCssClass: "slick-group-toggle",
toggleExpandedCssClass: "expanded",
toggleCollapsedCssClass: "collapsed",
enableExpandCollapse: true
};
options = $.extend(true, {}, _defaults, options);
function defaultGroupCellFormatter(row, cell, value, columnDef, item) {
if (!options.enableExpandCollapse) {
return item.title;
}
var indentation = item.level * 15 + "px";
return "<span class='" + options.toggleCssClass + " " +
(item.collapsed ? options.toggleCollapsedCssClass : options.toggleExpandedCssClass) +
"' style='margin-left:" + indentation +"'>" +
"</span>" +
"<span class='" + options.groupTitleCssClass + "' level='" + item.level + "'>" +
item.title +
"</span>";
}
function defaultTotalsCellFormatter(row, cell, value, columnDef, item) {
return (columnDef.groupTotalsFormatter && columnDef.groupTotalsFormatter(item, columnDef)) || "";
}
function init(grid) {
_grid = grid;
_grid.onClick.subscribe(handleGridClick);
_grid.onKeyDown.subscribe(handleGridKeyDown);
}
function destroy() {
if (_grid) {
_grid.onClick.unsubscribe(handleGridClick);
_grid.onKeyDown.unsubscribe(handleGridKeyDown);
}
}
function handleGridClick(e, args) {
var item = this.getDataItem(args.row);
if (item && item instanceof Slick.Group && $(e.target).hasClass(options.toggleCssClass)) {
if (item.collapsed) {
this.getData().expandGroup(item.groupingKey);
} else {
this.getData().collapseGroup(item.groupingKey);
}
e.stopImmediatePropagation();
e.preventDefault();
}
}
// TODO: add -/+ handling
function handleGridKeyDown(e, args) {
if (options.enableExpandCollapse && (e.which == $.ui.keyCode.SPACE)) {
var activeCell = this.getActiveCell();
if (activeCell) {
var item = this.getDataItem(activeCell.row);
if (item && item instanceof Slick.Group) {
if (item.collapsed) {
this.getData().expandGroup(item.groupingKey);
} else {
this.getData().collapseGroup(item.groupingKey);
}
e.stopImmediatePropagation();
e.preventDefault();
}
}
}
}
function getGroupRowMetadata(item) {
return {
selectable: false,
focusable: options.groupFocusable,
cssClasses: options.groupCssClass,
columns: {
0: {
colspan: "*",
formatter: defaultGroupCellFormatter,
editor: null
}
}
};
}
function getTotalsRowMetadata(item) {
return {
selectable: false,
focusable: options.totalsFocusable,
cssClasses: options.totalsCssClass,
formatter: defaultTotalsCellFormatter,
editor: null
};
}
return {
"init": init,
"destroy": destroy,
"getGroupRowMetadata": getGroupRowMetadata,
"getTotalsRowMetadata": getTotalsRowMetadata
};
}
})(jQuery);
(function ($) {
/***
* A sample AJAX data store implementation.
* Right now, it's hooked up to load all Apple-related Digg stories, but can
* easily be extended to support and JSONP-compatible backend that accepts paging parameters.
*/
function RemoteModel() {
// private
var PAGESIZE = 50;
var data = {length: 0};
var searchstr = "apple";
var sortcol = null;
var sortdir = 1;
var h_request = null;
var req = null; // ajax request
// events
var onDataLoading = new Slick.Event();
var onDataLoaded = new Slick.Event();
function init() {
}
function isDataLoaded(from, to) {
for (var i = from; i <= to; i++) {
if (data[i] == undefined || data[i] == null) {
return false;
}
}
return true;
}
function clear() {
for (var key in data) {
delete data[key];
}
data.length = 0;
}
function ensureData(from, to) {
if (req) {
req.abort();
for (var i = req.fromPage; i <= req.toPage; i++)
data[i * PAGESIZE] = undefined;
}
if (from < 0) {
from = 0;
}
var fromPage = Math.floor(from / PAGESIZE);
var toPage = Math.floor(to / PAGESIZE);
while (data[fromPage * PAGESIZE] !== undefined && fromPage < toPage)
fromPage++;
while (data[toPage * PAGESIZE] !== undefined && fromPage < toPage)
toPage--;
if (fromPage > toPage || ((fromPage == toPage) && data[fromPage * PAGESIZE] !== undefined)) {
// TODO: look-ahead
return;
}
var url = "http://services.digg.com/search/stories?query=" + searchstr + "&offset=" + (fromPage * PAGESIZE) + "&count=" + (((toPage - fromPage) * PAGESIZE) + PAGESIZE) + "&appkey=http://slickgrid.googlecode.com&type=javascript";
switch (sortcol) {
case "diggs":
url += ("&sort=" + ((sortdir > 0) ? "digg_count-asc" : "digg_count-desc"));
break;
}
if (h_request != null) {
clearTimeout(h_request);
}
h_request = setTimeout(function () {
for (var i = fromPage; i <= toPage; i++)
data[i * PAGESIZE] = null; // null indicates a 'requested but not available yet'
onDataLoading.notify({from: from, to: to});
req = $.jsonp({
url: url,
callbackParameter: "callback",
cache: true, // Digg doesn't accept the autogenerated cachebuster param
success: onSuccess,
error: function () {
onError(fromPage, toPage)
}
});
req.fromPage = fromPage;
req.toPage = toPage;
}, 50);
}
function onError(fromPage, toPage) {
alert("error loading pages " + fromPage + " to " + toPage);
}
function onSuccess(resp) {
var from = this.fromPage * PAGESIZE, to = from + resp.count;
data.length = parseInt(resp.total);
for (var i = 0; i < resp.stories.length; i++) {
data[from + i] = resp.stories[i];
data[from + i].index = from + i;
}
req = null;
onDataLoaded.notify({from: from, to: to});
}
function reloadData(from, to) {
for (var i = from; i <= to; i++)
delete data[i];
ensureData(from, to);
}
function setSort(column, dir) {
sortcol = column;
sortdir = dir;
clear();
}
function setSearch(str) {
searchstr = str;
clear();
}
init();
return {
// properties
"data": data,
// methods
"clear": clear,
"isDataLoaded": isDataLoaded,
"ensureData": ensureData,
"reloadData": reloadData,
"setSort": setSort,
"setSearch": setSearch,
// events
"onDataLoading": onDataLoading,
"onDataLoaded": onDataLoaded
};
}
// Slick.Data.RemoteModel
$.extend(true, window, { Slick: { Data: { RemoteModel: RemoteModel }}});
})(jQuery);
\ No newline at end of file
"""
Student and course analytics.
Serve miscellaneous course and student data
"""
from django.contrib.auth.models import User
import xmodule.graders as xmgraders
STUDENT_FEATURES = ('username', 'first_name', 'last_name', 'is_staff', 'email')
PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender',
'level_of_education', 'mailing_address', 'goals')
AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES
def enrolled_students_features(course_id, features):
"""
Return list of student features as dictionaries.
enrolled_students_features(course_id, ['username, first_name'])
would return [
{'username': 'username1', 'first_name': 'firstname1'}
{'username': 'username2', 'first_name': 'firstname2'}
{'username': 'username3', 'first_name': 'firstname3'}
]
"""
students = User.objects.filter(courseenrollment__course_id=course_id)\
.order_by('username').select_related('profile')
def extract_student(student, features):
""" convert student to dictionary """
student_features = [x for x in STUDENT_FEATURES if x in features]
profile_features = [x for x in PROFILE_FEATURES if x in features]
student_dict = dict((feature, getattr(student, feature))
for feature in student_features)
profile = student.profile
if profile is not None:
profile_dict = dict((feature, getattr(profile, feature))
for feature in profile_features)
student_dict.update(profile_dict)
return student_dict
return [extract_student(student, features) for student in students]
def dump_grading_context(course):
"""
Render information about course grading context
(e.g. which problems are graded in what assignments)
Useful for debugging grading_policy.json and policy.json
Returns HTML string
"""
hbar = "{}\n".format("-" * 77)
msg = hbar
msg += "Course grader:\n"
msg += '%s\n' % course.grader.__class__
graders = {}
if isinstance(course.grader, xmgraders.WeightedSubsectionsGrader):
msg += '\n'
msg += "Graded sections:\n"
for subgrader, category, weight in course.grader.sections:
msg += " subgrader=%s, type=%s, category=%s, weight=%s\n"\
% (subgrader.__class__, subgrader.type, category, weight)
subgrader.index = 1
graders[subgrader.type] = subgrader
msg += hbar
msg += "Listing grading context for course %s\n" % course.id
gcontext = course.grading_context
msg += "graded sections:\n"
msg += '%s\n' % gcontext['graded_sections'].keys()
for (gsomething, gsvals) in gcontext['graded_sections'].items():
msg += "--> Section %s:\n" % (gsomething)
for sec in gsvals:
sdesc = sec['section_descriptor']
frmat = getattr(sdesc.lms, 'format', None)
aname = ''
if frmat in graders:
gform = graders[frmat]
aname = '%s %02d' % (gform.short_label, gform.index)
gform.index += 1
elif sdesc.display_name in graders:
gform = graders[sdesc.display_name]
aname = '%s' % gform.short_label
notes = ''
if getattr(sdesc, 'score_by_attempt', False):
notes = ', score by attempt!'
msg += " %s (format=%s, Assignment=%s%s)\n"\
% (sdesc.display_name, frmat, aname, notes)
msg += "all descriptors:\n"
msg += "length=%d\n" % len(gcontext['all_descriptors'])
msg = '<pre>%s</pre>' % msg.replace('<', '&lt;')
return msg
"""
Student and course analytics.
Format and create csv responses
"""
import csv
from django.http import HttpResponse
def create_csv_response(filename, header, datarows):
"""
Create an HttpResponse with an attached .csv file
header e.g. ['Name', 'Email']
datarows e.g. [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ...]
"""
response = HttpResponse(mimetype='text/csv')
response['Content-Disposition'] = 'attachment; filename={0}'\
.format(filename)
csvwriter = csv.writer(
response,
dialect='excel',
quotechar='"',
quoting=csv.QUOTE_ALL)
csvwriter.writerow(header)
for datarow in datarows:
encoded_row = [unicode(s).encode('utf-8') for s in datarow]
csvwriter.writerow(encoded_row)
return response
def format_dictlist(dictlist, features):
"""
Convert a list of dictionaries to be compatible with create_csv_response
`dictlist` is a list of dictionaries
all dictionaries should have keys from features
`features` is a list of features
example code:
dictlist = [
{
'label1': 'value-1,1',
'label2': 'value-1,2',
'label3': 'value-1,3',
'label4': 'value-1,4',
},
{
'label1': 'value-2,1',
'label2': 'value-2,2',
'label3': 'value-2,3',
'label4': 'value-2,4',
}
]
header, datarows = format_dictlist(dictlist, ['label1', 'label4'])
# results in
header = ['label1', 'label4']
datarows = [['value-1,1', 'value-1,4'],
['value-2,1', 'value-2,4']]
}
"""
def dict_to_entry(dct):
""" Convert dictionary to a list for a csv row """
relevant_items = [(k, v) for (k, v) in dct.items() if k in features]
ordered = sorted(relevant_items, key=lambda (k, v): header.index(k))
vals = [v for (_, v) in ordered]
return vals
header = features
datarows = map(dict_to_entry, dictlist)
return header, datarows
def format_instances(instances, features):
"""
Convert a list of instances into a header list and datarows list.
`header` is just `features` e.g. ['username', 'email']
`datarows` is a list of lists, each sublist representing a row in a table
e.g. [['username1', 'email1@email.com'], ['username2', 'email2@email.com']]
for `instances` of length 2.
`instances` is a list of instances, e.g. list of User's
`features` is a list of features
a feature is a string for which getattr(obj, feature) is valid
Returns header and datarows, formatted for input in create_csv_response
"""
header = features
datarows = [[getattr(x, f) for f in features] for x in instances]
return header, datarows
"""
Profile Distributions
Aggregate sums for values of fields in students profiles.
For example:
The distribution in a course for gender might look like:
'gender': {
'type': 'EASY_CHOICE',
'data': {
'no_data': 1234,
'm': 5678,
'o': 2134,
'f': 5678
},
'display_names': {
'no_data': 'No Data',
'm': 'Male',
'o': 'Other',
'f': 'Female'
}
"""
from django.db.models import Count
from student.models import CourseEnrollment, UserProfile
# choices with a restricted domain, e.g. level_of_education
_EASY_CHOICE_FEATURES = ('gender', 'level_of_education')
# choices with a larger domain e.g. year_of_birth
_OPEN_CHOICE_FEATURES = ('year_of_birth',)
AVAILABLE_PROFILE_FEATURES = _EASY_CHOICE_FEATURES + _OPEN_CHOICE_FEATURES
DISPLAY_NAMES = {
'gender': 'Gender',
'level_of_education': 'Level of Education',
'year_of_birth': 'Year Of Birth',
}
class ProfileDistribution(object):
"""
Container for profile distribution data
`feature` is the name of the distribution feature
`feature_display_name` is the display name of feature
`data` is a dictionary of the distribution
`type` is either 'EASY_CHOICE' or 'OPEN_CHOICE'
`choices_display_names` is a dict if the distribution is an 'EASY_CHOICE'
"""
class ValidationError(ValueError):
""" Error thrown if validation fails. """
pass
def __init__(self, feature):
self.feature = feature
self.feature_display_name = DISPLAY_NAMES.get(feature, feature)
# to be set later
self.type = None
self.data = None
self.choices_display_names = None
def validate(self):
"""
Validate this profile distribution.
Throws ProfileDistribution.ValidationError
"""
def validation_assert(predicate):
""" Throw a ValidationError if false. """
if not predicate:
raise ProfileDistribution.ValidationError()
validation_assert(isinstance(self.feature, str))
validation_assert(self.feature in DISPLAY_NAMES)
validation_assert(isinstance(self.feature_display_name, str))
validation_assert(self.type in ['EASY_CHOICE', 'OPEN_CHOICE'])
validation_assert(isinstance(self.data, dict))
if self.type == 'EASY_CHOICE':
validation_assert(isinstance(self.choices_display_names, dict))
def profile_distribution(course_id, feature):
"""
Retrieve distribution of students over a given feature.
feature is one of AVAILABLE_PROFILE_FEATURES.
Returns a ProfileDistribution instance.
NOTE: no_data will appear as a key instead of None/null to adhere to the json spec.
data types are EASY_CHOICE or OPEN_CHOICE
"""
if not feature in AVAILABLE_PROFILE_FEATURES:
raise ValueError(
"unsupported feature requested for distribution '{}'".format(
feature)
)
prd = ProfileDistribution(feature)
if feature in _EASY_CHOICE_FEATURES:
prd.type = 'EASY_CHOICE'
if feature == 'gender':
raw_choices = UserProfile.GENDER_CHOICES
elif feature == 'level_of_education':
raw_choices = UserProfile.LEVEL_OF_EDUCATION_CHOICES
# short name and display name (full) of the choices.
choices = [(short, full)
for (short, full) in raw_choices] + [('no_data', 'No Data')]
def get_filter(feature, value):
""" Get the orm filter parameters for a feature. """
return {
'gender': {'user__profile__gender': value},
'level_of_education': {'user__profile__level_of_education': value},
}[feature]
def get_count(feature, value):
""" Get the count of enrolled students matching the feature value. """
return CourseEnrollment.objects.filter(
course_id=course_id,
**get_filter(feature, value)
).count()
distribution = {}
for (short, full) in choices:
# handle no data case
if short == 'no_data':
distribution['no_data'] = 0
distribution['no_data'] += get_count(feature, None)
distribution['no_data'] += get_count(feature, '')
else:
distribution[short] = get_count(feature, short)
prd.data = distribution
prd.choices_display_names = dict(choices)
elif feature in _OPEN_CHOICE_FEATURES:
prd.type = 'OPEN_CHOICE'
profiles = UserProfile.objects.filter(
user__courseenrollment__course_id=course_id
)
query_distribution = profiles.values(
feature).annotate(Count(feature)).order_by()
# query_distribution is of the form [{'featureval': 'value1', 'featureval__count': 4},
# {'featureval': 'value2', 'featureval__count': 2}, ...]
distribution = dict((vald[feature], vald[feature + '__count'])
for vald in query_distribution)
# distribution is of the form {'value1': 4, 'value2': 2, ...}
# change none to no_data for valid json key
if None in distribution:
# django does not properly count NULL values when using annotate Count
# so
# distribution['no_data'] = distribution.pop(None)
# would always be 0.
# Correctly count null values
distribution['no_data'] = profiles.filter(
**{feature: None}
).count()
prd.data = distribution
prd.validate()
return prd
"""
Tests for instructor.basic
"""
from django.test import TestCase
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from analytics.basic import enrolled_students_features, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES
class TestAnalyticsBasic(TestCase):
""" Test basic analytics functions. """
def setUp(self):
self.course_id = 'some/robot/course/id'
self.users = tuple(UserFactory() for _ in xrange(30))
self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users)
def test_enrolled_students_features_username(self):
self.assertIn('username', AVAILABLE_FEATURES)
userreports = enrolled_students_features(self.course_id, ['username'])
self.assertEqual(len(userreports), len(self.users))
for userreport in userreports:
self.assertEqual(userreport.keys(), ['username'])
self.assertIn(userreport['username'], [user.username for user in self.users])
def test_enrolled_students_features_keys(self):
query_features = ('username', 'name', 'email')
for feature in query_features:
self.assertIn(feature, AVAILABLE_FEATURES)
userreports = enrolled_students_features(self.course_id, query_features)
self.assertEqual(len(userreports), len(self.users))
for userreport in userreports:
self.assertEqual(set(userreport.keys()), set(query_features))
self.assertIn(userreport['username'], [user.username for user in self.users])
self.assertIn(userreport['email'], [user.email for user in self.users])
self.assertIn(userreport['name'], [user.profile.name for user in self.users])
def test_available_features(self):
self.assertEqual(len(AVAILABLE_FEATURES), len(STUDENT_FEATURES + PROFILE_FEATURES))
self.assertEqual(set(AVAILABLE_FEATURES), set(STUDENT_FEATURES + PROFILE_FEATURES))
""" Tests for analytics.csvs """
from django.test import TestCase
from nose.tools import raises
from analytics.csvs import create_csv_response, format_dictlist, format_instances
class TestAnalyticsCSVS(TestCase):
""" Test analytics rendering of csv files."""
def test_create_csv_response_nodata(self):
header = ['Name', 'Email']
datarows = []
res = create_csv_response('robot.csv', header, datarows)
self.assertEqual(res['Content-Type'], 'text/csv')
self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv'))
self.assertEqual(res.content.strip(), '"Name","Email"')
def test_create_csv_response(self):
header = ['Name', 'Email']
datarows = [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ['Jeeves', 'jeeves@edy.org']]
res = create_csv_response('robot.csv', header, datarows)
self.assertEqual(res['Content-Type'], 'text/csv')
self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv'))
self.assertEqual(res.content.strip(), '"Name","Email"\r\n"Jim","jim@edy.org"\r\n"Jake","jake@edy.org"\r\n"Jeeves","jeeves@edy.org"')
def test_create_csv_response_empty(self):
header = []
datarows = []
res = create_csv_response('robot.csv', header, datarows)
self.assertEqual(res['Content-Type'], 'text/csv')
self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv'))
self.assertEqual(res.content.strip(), '')
class TestAnalyticsFormatDictlist(TestCase):
""" Test format_dictlist method """
def test_format_dictlist(self):
dictlist = [
{
'label1': 'value-1,1',
'label2': 'value-1,2',
'label3': 'value-1,3',
'label4': 'value-1,4',
},
{
'label1': 'value-2,1',
'label2': 'value-2,2',
'label3': 'value-2,3',
'label4': 'value-2,4',
}
]
features = ['label1', 'label4']
header, datarows = format_dictlist(dictlist, features)
ideal_header = ['label1', 'label4']
ideal_datarows = [['value-1,1', 'value-1,4'],
['value-2,1', 'value-2,4']]
self.assertEqual(header, ideal_header)
self.assertEqual(datarows, ideal_datarows)
def test_format_dictlist_empty(self):
header, datarows = format_dictlist([], [])
self.assertEqual(header, [])
self.assertEqual(datarows, [])
def test_create_csv_response(self):
header = ['Name', 'Email']
datarows = [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ['Jeeves', 'jeeves@edy.org']]
res = create_csv_response('robot.csv', header, datarows)
self.assertEqual(res['Content-Type'], 'text/csv')
self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv'))
self.assertEqual(res.content.strip(), '"Name","Email"\r\n"Jim","jim@edy.org"\r\n"Jake","jake@edy.org"\r\n"Jeeves","jeeves@edy.org"')
class TestAnalyticsFormatInstances(TestCase):
""" test format_instances method """
class TestDataClass(object):
""" Test class to generate objects for format_instances """
def __init__(self):
self.a_var = 'aval'
self.b_var = 'bval'
self.c_var = 'cval'
@property
def d_var(self):
""" accessor to see if they work too """
return 'dval'
def setUp(self):
self.instances = [self.TestDataClass() for _ in xrange(5)]
def test_format_instances_response(self):
features = ['a_var', 'c_var', 'd_var']
header, datarows = format_instances(self.instances, features)
self.assertEqual(header, ['a_var', 'c_var', 'd_var'])
self.assertEqual(datarows, [[
'aval',
'cval',
'dval',
] for _ in xrange(len(self.instances))])
def test_format_instances_response_noinstances(self):
features = ['a_var']
header, datarows = format_instances([], features)
self.assertEqual(header, features)
self.assertEqual(datarows, [])
def test_format_instances_response_nofeatures(self):
header, datarows = format_instances(self.instances, [])
self.assertEqual(header, [])
self.assertEqual(datarows, [[] for _ in xrange(len(self.instances))])
@raises(AttributeError)
def test_format_instances_response_nonexistantfeature(self):
format_instances(self.instances, ['robot_not_a_real_feature'])
""" Tests for analytics.distributions """
from django.test import TestCase
from nose.tools import raises
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from analytics.distributions import profile_distribution, AVAILABLE_PROFILE_FEATURES
class TestAnalyticsDistributions(TestCase):
'''Test analytics distribution gathering.'''
def setUp(self):
self.course_id = 'some/robot/course/id'
self.users = [UserFactory(
profile__gender=['m', 'f', 'o'][i % 3],
profile__year_of_birth=i + 1930
) for i in xrange(30)]
self.ces = [CourseEnrollment.objects.create(
course_id=self.course_id,
user=user
) for user in self.users]
@raises(ValueError)
def test_profile_distribution_bad_feature(self):
feature = 'robot-not-a-real-feature'
self.assertNotIn(feature, AVAILABLE_PROFILE_FEATURES)
profile_distribution(self.course_id, feature)
def test_profile_distribution_easy_choice(self):
feature = 'gender'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
self.assertEqual(distribution.type, 'EASY_CHOICE')
self.assertEqual(distribution.data['no_data'], 0)
self.assertEqual(distribution.data['m'], len(self.users) / 3)
self.assertEqual(distribution.choices_display_names['m'], 'Male')
def test_profile_distribution_open_choice(self):
feature = 'year_of_birth'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
print distribution
self.assertEqual(distribution.type, 'OPEN_CHOICE')
self.assertTrue(hasattr(distribution, 'choices_display_names'))
self.assertEqual(distribution.choices_display_names, None)
self.assertNotIn('no_data', distribution.data)
self.assertEqual(distribution.data[1930], 1)
class TestAnalyticsDistributionsNoData(TestCase):
'''Test analytics distribution gathering.'''
def setUp(self):
self.course_id = 'some/robot/course/id'
self.users = [UserFactory(
profile__year_of_birth=i + 1930,
) for i in xrange(5)]
self.nodata_users = [UserFactory(
profile__year_of_birth=None,
profile__gender=[None, ''][i % 2]
) for i in xrange(4)]
self.users += self.nodata_users
self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users)
def test_profile_distribution_easy_choice_nodata(self):
feature = 'gender'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
print distribution
self.assertEqual(distribution.type, 'EASY_CHOICE')
self.assertTrue(hasattr(distribution, 'choices_display_names'))
self.assertNotEqual(distribution.choices_display_names, None)
self.assertIn('no_data', distribution.data)
self.assertEqual(distribution.data['no_data'], len(self.nodata_users))
def test_profile_distribution_open_choice_nodata(self):
feature = 'year_of_birth'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
print distribution
self.assertEqual(distribution.type, 'OPEN_CHOICE')
self.assertTrue(hasattr(distribution, 'choices_display_names'))
self.assertEqual(distribution.choices_display_names, None)
self.assertIn('no_data', distribution.data)
self.assertEqual(distribution.data['no_data'], len(self.nodata_users))
...@@ -305,6 +305,7 @@ def get_course_tabs(user, course, active_page): ...@@ -305,6 +305,7 @@ def get_course_tabs(user, course, active_page):
tabs.append(CourseTab('Instructor', tabs.append(CourseTab('Instructor',
reverse('instructor_dashboard', args=[course.id]), reverse('instructor_dashboard', args=[course.id]),
active_page == 'instructor')) active_page == 'instructor'))
return tabs return tabs
......
"""
Access control operations for use by instructor APIs.
Does not include any access control, be sure to check access before calling.
TO DO sync instructor and staff flags
e.g. should these be possible?
{instructor: true, staff: false}
{instructor: true, staff: true}
"""
import logging
from django.contrib.auth.models import Group
from courseware.access import (get_access_group_name,
course_beta_test_group_name)
from django_comment_common.models import Role
log = logging.getLogger(__name__)
def list_with_level(course, level):
"""
List users who have 'level' access.
`level` is in ['instructor', 'staff', 'beta'] for standard courses.
There could be other levels specific to the course.
If there is no Group for that course-level, returns an empty list
"""
if level == 'beta':
grpname = course_beta_test_group_name(course.location)
else:
grpname = get_access_group_name(course, level)
try:
return Group.objects.get(name=grpname).user_set.all()
except Group.DoesNotExist:
log.info("list_with_level called with non-existant group named {}".format(grpname))
return []
def allow_access(course, user, level):
"""
Allow user access to course modification.
`level` is one of ['instructor', 'staff', 'beta']
"""
_change_access(course, user, level, 'allow')
def revoke_access(course, user, level):
"""
Revoke access from user to course modification.
`level` is one of ['instructor', 'staff', 'beta']
"""
_change_access(course, user, level, 'revoke')
def _change_access(course, user, level, action):
"""
Change access of user.
`level` is one of ['instructor', 'staff', 'beta']
action is one of ['allow', 'revoke']
NOTE: will create a group if it does not yet exist.
"""
if level == 'beta':
grpname = course_beta_test_group_name(course.location)
elif level in ['instructor', 'staff']:
grpname = get_access_group_name(course, level)
else:
raise ValueError("unrecognized level '{}'".format(level))
group, _ = Group.objects.get_or_create(name=grpname)
if action == 'allow':
user.groups.add(group)
elif action == 'revoke':
user.groups.remove(group)
else:
raise ValueError("unrecognized action '{}'".format(action))
def update_forum_role_membership(course_id, user, rolename, action):
"""
Change forum access of user.
`rolename` is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
`action` is one of ['allow', 'revoke']
if `action` is bad, raises ValueError
if `rolename` does not exist, raises Role.DoesNotExist
"""
role = Role.objects.get(course_id=course_id, name=rolename)
if action == 'allow':
role.users.add(user)
elif action == 'revoke':
role.users.remove(user)
else:
raise ValueError("unrecognized action '{}'".format(action))
"""
Enrollment operations for use by instructor APIs.
Does not include any access control, be sure to check access before calling.
"""
import json
from django.contrib.auth.models import User
from student.models import CourseEnrollment, CourseEnrollmentAllowed
from courseware.models import StudentModule
class EmailEnrollmentState(object):
""" Store the complete enrollment state of an email in a class """
def __init__(self, course_id, email):
exists_user = User.objects.filter(email=email).exists()
exists_ce = CourseEnrollment.objects.filter(course_id=course_id, user__email=email).exists()
ceas = CourseEnrollmentAllowed.objects.filter(course_id=course_id, email=email).all()
exists_allowed = len(ceas) > 0
state_auto_enroll = exists_allowed and ceas[0].auto_enroll
self.user = exists_user
self.enrollment = exists_ce
self.allowed = exists_allowed
self.auto_enroll = bool(state_auto_enroll)
def __repr__(self):
return "{}(user={}, enrollment={}, allowed={}, auto_enroll={})".format(
self.__class__.__name__,
self.user,
self.enrollment,
self.allowed,
self.auto_enroll,
)
def to_dict(self):
"""
example: {
'user': False,
'enrollment': False,
'allowed': True,
'auto_enroll': True,
}
"""
return {
'user': self.user,
'enrollment': self.enrollment,
'allowed': self.allowed,
'auto_enroll': self.auto_enroll,
}
def enroll_email(course_id, student_email, auto_enroll=False):
"""
Enroll a student by email.
`student_email` is student's emails e.g. "foo@bar.com"
`auto_enroll` determines what is put in CourseEnrollmentAllowed.auto_enroll
if auto_enroll is set, then when the email registers, they will be
enrolled in the course automatically.
returns two EmailEnrollmentState's
representing state before and after the action.
"""
previous_state = EmailEnrollmentState(course_id, student_email)
if previous_state.user:
user = User.objects.get(email=student_email)
CourseEnrollment.objects.get_or_create(course_id=course_id, user=user)
else:
cea, _ = CourseEnrollmentAllowed.objects.get_or_create(course_id=course_id, email=student_email)
cea.auto_enroll = auto_enroll
cea.save()
after_state = EmailEnrollmentState(course_id, student_email)
return previous_state, after_state
def unenroll_email(course_id, student_email):
"""
Unenroll a student by email.
`student_email` is student's emails e.g. "foo@bar.com"
returns two EmailEnrollmentState's
representing state before and after the action.
"""
previous_state = EmailEnrollmentState(course_id, student_email)
if previous_state.enrollment:
CourseEnrollment.objects.get(course_id=course_id, user__email=student_email).delete()
if previous_state.allowed:
CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email).delete()
after_state = EmailEnrollmentState(course_id, student_email)
return previous_state, after_state
def reset_student_attempts(course_id, student, module_state_key, delete_module=False):
"""
Reset student attempts for a problem. Optionally deletes all student state for the specified problem.
In the previous instructor dashboard it was possible to modify/delete
modules that were not problems. That has been disabled for safety.
`student` is a User
`problem_to_reset` is the name of a problem e.g. 'L2Node1'.
To build the module_state_key 'problem/' and course information will be appended to `problem_to_reset`.
Throws ValueError if `problem_state` is invalid JSON.
"""
module_to_reset = StudentModule.objects.get(student_id=student.id,
course_id=course_id,
module_state_key=module_state_key)
if delete_module:
module_to_reset.delete()
else:
_reset_module_attempts(module_to_reset)
def _reset_module_attempts(studentmodule):
"""
Reset the number of attempts on a studentmodule.
Throws ValueError if `problem_state` is invalid JSON.
"""
# load the state json
problem_state = json.loads(studentmodule.state)
# old_number_of_attempts = problem_state["attempts"]
problem_state["attempts"] = 0
# save
studentmodule.state = json.dumps(problem_state)
studentmodule.save()
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
import csv import csv
from instructor.views import get_student_grade_summary_data from instructor.views.legacy import get_student_grade_summary_data
from courseware.courses import get_course_by_id from courseware.courses import get_course_by_id
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
......
"""
Test instructor.access
"""
from nose.tools import raises
from django.contrib.auth.models import Group
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from django.test.utils import override_settings
from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE
from courseware.access import get_access_group_name
from django_comment_common.models import (Role,
FORUM_ROLE_MODERATOR)
from instructor.access import (allow_access,
revoke_access,
list_with_level,
update_forum_role_membership)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAccessList(ModuleStoreTestCase):
""" Test access listings. """
def setUp(self):
self.course = CourseFactory.create()
self.instructors = [UserFactory.create() for _ in xrange(4)]
for user in self.instructors:
allow_access(self.course, user, 'instructor')
self.beta_testers = [UserFactory.create() for _ in xrange(4)]
for user in self.beta_testers:
allow_access(self.course, user, 'beta')
def test_list_instructors(self):
instructors = list_with_level(self.course, 'instructor')
self.assertEqual(set(instructors), set(self.instructors))
def test_list_beta(self):
beta_testers = list_with_level(self.course, 'beta')
self.assertEqual(set(beta_testers), set(self.beta_testers))
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAccessAllow(ModuleStoreTestCase):
""" Test access allow. """
def setUp(self):
self.course = CourseFactory.create()
def test_allow(self):
user = UserFactory()
allow_access(self.course, user, 'staff')
group = Group.objects.get(
name=get_access_group_name(self.course, 'staff')
)
self.assertIn(user, group.user_set.all())
def test_allow_twice(self):
user = UserFactory()
allow_access(self.course, user, 'staff')
allow_access(self.course, user, 'staff')
group = Group.objects.get(
name=get_access_group_name(self.course, 'staff')
)
self.assertIn(user, group.user_set.all())
def test_allow_beta(self):
""" Test allow beta against list beta. """
user = UserFactory()
allow_access(self.course, user, 'beta')
self.assertIn(user, list_with_level(self.course, 'beta'))
@raises(ValueError)
def test_allow_badlevel(self):
user = UserFactory()
allow_access(self.course, user, 'robot-not-a-level')
group = Group.objects.get(name=get_access_group_name(self.course, 'robot-not-a-level'))
self.assertIn(user, group.user_set.all())
@raises(Exception)
def test_allow_noneuser(self):
user = None
allow_access(self.course, user, 'staff')
group = Group.objects.get(name=get_access_group_name(self.course, 'staff'))
self.assertIn(user, group.user_set.all())
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAccessRevoke(ModuleStoreTestCase):
""" Test access revoke. """
def setUp(self):
self.course = CourseFactory.create()
self.staff = [UserFactory.create() for _ in xrange(4)]
for user in self.staff:
allow_access(self.course, user, 'staff')
self.beta_testers = [UserFactory.create() for _ in xrange(4)]
for user in self.beta_testers:
allow_access(self.course, user, 'beta')
def test_revoke(self):
user = self.staff[0]
revoke_access(self.course, user, 'staff')
group = Group.objects.get(
name=get_access_group_name(self.course, 'staff')
)
self.assertNotIn(user, group.user_set.all())
def test_revoke_twice(self):
user = self.staff[0]
revoke_access(self.course, user, 'staff')
group = Group.objects.get(
name=get_access_group_name(self.course, 'staff')
)
self.assertNotIn(user, group.user_set.all())
def test_revoke_beta(self):
user = self.beta_testers[0]
revoke_access(self.course, user, 'beta')
self.assertNotIn(user, list_with_level(self.course, 'beta'))
@raises(ValueError)
def test_revoke_badrolename(self):
user = UserFactory()
revoke_access(self.course, user, 'robot-not-a-level')
group = Group.objects.get(
name=get_access_group_name(self.course, 'robot-not-a-level')
)
self.assertNotIn(user, group.user_set.all())
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAccessForum(ModuleStoreTestCase):
"""
Test forum access control.
"""
def setUp(self):
self.course = CourseFactory.create()
self.mod_role = Role.objects.create(
course_id=self.course.id,
name=FORUM_ROLE_MODERATOR
)
self.moderators = [UserFactory.create() for _ in xrange(4)]
for user in self.moderators:
self.mod_role.users.add(user)
def test_allow(self):
user = UserFactory.create()
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'allow')
self.assertIn(user, self.mod_role.users.all())
def test_allow_twice(self):
user = UserFactory.create()
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'allow')
self.assertIn(user, self.mod_role.users.all())
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'allow')
self.assertIn(user, self.mod_role.users.all())
@raises(Role.DoesNotExist)
def test_allow_badrole(self):
user = UserFactory.create()
update_forum_role_membership(self.course.id, user, 'robot-not-a-real-role', 'allow')
def test_revoke(self):
user = self.moderators[0]
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke')
self.assertNotIn(user, self.mod_role.users.all())
def test_revoke_twice(self):
user = self.moderators[0]
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke')
self.assertNotIn(user, self.mod_role.users.all())
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke')
self.assertNotIn(user, self.mod_role.users.all())
def test_revoke_notallowed(self):
user = UserFactory()
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke')
self.assertNotIn(user, self.mod_role.users.all())
@raises(Role.DoesNotExist)
def test_revoke_badrole(self):
user = self.moderators[0]
update_forum_role_membership(self.course.id, user, 'robot-not-a-real-role', 'allow')
@raises(ValueError)
def test_bad_mode(self):
user = UserFactory()
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'robot-not-a-mode')
"""
Unit tests for instructor.api methods.
"""
import json
from urllib import quote
from django.test import TestCase
from nose.tools import raises
from mock import Mock
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User
from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.helpers import LoginEnrollmentTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, AdminFactory
from student.models import CourseEnrollment
from courseware.models import StudentModule
from instructor.access import allow_access
from instructor.views.api import _split_input_list, _msk_from_problem_urlname
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Ensure that users cannot access endpoints they shouldn't be able to.
"""
def setUp(self):
self.user = UserFactory.create()
self.course = CourseFactory.create()
CourseEnrollment.objects.create(user=self.user, course_id=self.course.id)
self.client.login(username=self.user.username, password='test')
def test_deny_students_update_enrollment(self):
url = reverse('students_update_enrollment', kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
self.assertEqual(response.status_code, 403)
def test_staff_level(self):
"""
Ensure that an enrolled student can't access staff or instructor endpoints.
"""
staff_level_endpoints = [
'students_update_enrollment',
'modify_access',
'list_course_role_members',
'get_grading_config',
'get_students_features',
'get_distribution',
'get_student_progress_url',
'reset_student_attempts',
'rescore_problem',
'list_instructor_tasks',
'list_forum_members',
'update_forum_role_membership',
]
for endpoint in staff_level_endpoints:
url = reverse(endpoint, kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
self.assertEqual(response.status_code, 403)
def test_instructor_level(self):
"""
Ensure that a staff member can't access instructor endpoints.
"""
instructor_level_endpoints = [
'modify_access',
'list_course_role_members',
'reset_student_attempts',
'list_instructor_tasks',
'update_forum_role_membership',
]
for endpoint in instructor_level_endpoints:
url = reverse(endpoint, kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
self.assertEqual(response.status_code, 403)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAPIEnrollment(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test enrollment modification endpoint.
This test does NOT exhaustively test state changes, that is the
job of test_enrollment. This tests the response and action switch.
"""
def setUp(self):
self.instructor = AdminFactory.create()
self.course = CourseFactory.create()
self.client.login(username=self.instructor.username, password='test')
self.enrolled_student = UserFactory()
CourseEnrollment.objects.create(
user=self.enrolled_student,
course_id=self.course.id
)
self.notenrolled_student = UserFactory()
self.notregistered_email = 'robot-not-an-email-yet@robot.org'
self.assertEqual(User.objects.filter(email=self.notregistered_email).count(), 0)
# uncomment to enable enable printing of large diffs
# from failed assertions in the event of a test failure.
# (comment because pylint C0103)
# self.maxDiff = None
def test_missing_params(self):
""" Test missing all query parameters. """
url = reverse('students_update_enrollment', kwargs={'course_id': self.course.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 400)
def test_bad_action(self):
""" Test with an invalid action. """
action = 'robot-not-an-action'
url = reverse('students_update_enrollment', kwargs={'course_id': self.course.id})
response = self.client.get(url, {'emails': self.enrolled_student.email, 'action': action})
self.assertEqual(response.status_code, 400)
def test_enroll(self):
url = reverse('students_update_enrollment', kwargs={'course_id': self.course.id})
response = self.client.get(url, {'emails': self.notenrolled_student.email, 'action': 'enroll'})
print "type(self.notenrolled_student.email): {}".format(type(self.notenrolled_student.email))
self.assertEqual(response.status_code, 200)
# test that the user is now enrolled
self.assertEqual(
self.notenrolled_student.courseenrollment_set.filter(
course_id=self.course.id
).count(),
1
)
# test the response data
expected = {
"action": "enroll",
"auto_enroll": False,
"results": [
{
"email": self.notenrolled_student.email,
"before": {
"enrollment": False,
"auto_enroll": False,
"user": True,
"allowed": False,
},
"after": {
"enrollment": True,
"auto_enroll": False,
"user": True,
"allowed": False,
}
}
]
}
res_json = json.loads(response.content)
self.assertEqual(res_json, expected)
def test_unenroll(self):
url = reverse('students_update_enrollment', kwargs={'course_id': self.course.id})
response = self.client.get(url, {'emails': self.enrolled_student.email, 'action': 'unenroll'})
print "type(self.enrolled_student.email): {}".format(type(self.enrolled_student.email))
self.assertEqual(response.status_code, 200)
# test that the user is now unenrolled
self.assertEqual(
self.enrolled_student.courseenrollment_set.filter(
course_id=self.course.id
).count(),
0
)
# test the response data
expected = {
"action": "unenroll",
"auto_enroll": False,
"results": [
{
"email": self.enrolled_student.email,
"before": {
"enrollment": True,
"auto_enroll": False,
"user": True,
"allowed": False,
},
"after": {
"enrollment": False,
"auto_enroll": False,
"user": True,
"allowed": False,
}
}
]
}
res_json = json.loads(response.content)
self.assertEqual(res_json, expected)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAPILevelsAccess(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test endpoints whereby instructors can change permissions
of other users.
This test does NOT test whether the actions had an effect on the
database, that is the job of test_access.
This tests the response and action switch.
Actually, modify_access does not having a very meaningful
response yet, so only the status code is tested.
"""
def setUp(self):
self.instructor = AdminFactory.create()
self.course = CourseFactory.create()
self.client.login(username=self.instructor.username, password='test')
self.other_instructor = UserFactory()
allow_access(self.course, self.other_instructor, 'instructor')
self.other_staff = UserFactory()
allow_access(self.course, self.other_staff, 'staff')
self.other_user = UserFactory()
def test_modify_access_noparams(self):
""" Test missing all query parameters. """
url = reverse('modify_access', kwargs={'course_id': self.course.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 400)
def test_modify_access_bad_action(self):
""" Test with an invalid action parameter. """
url = reverse('modify_access', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'email': self.other_staff.email,
'rolename': 'staff',
'action': 'robot-not-an-action',
})
self.assertEqual(response.status_code, 400)
def test_modify_access_bad_role(self):
""" Test with an invalid action parameter. """
url = reverse('modify_access', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'email': self.other_staff.email,
'rolename': 'robot-not-a-roll',
'action': 'revoke',
})
self.assertEqual(response.status_code, 400)
def test_modify_access_allow(self):
url = reverse('modify_access', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'email': self.other_instructor.email,
'rolename': 'staff',
'action': 'allow',
})
self.assertEqual(response.status_code, 200)
def test_modify_access_revoke(self):
url = reverse('modify_access', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'email': self.other_staff.email,
'rolename': 'staff',
'action': 'revoke',
})
self.assertEqual(response.status_code, 200)
def test_modify_access_revoke_not_allowed(self):
""" Test revoking access that a user does not have. """
url = reverse('modify_access', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'email': self.other_staff.email,
'rolename': 'instructor',
'action': 'revoke',
})
self.assertEqual(response.status_code, 200)
def test_modify_access_revoke_self(self):
"""
Test that an instructor cannot remove instructor privelages from themself.
"""
url = reverse('modify_access', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'email': self.instructor.email,
'rolename': 'instructor',
'action': 'revoke',
})
self.assertEqual(response.status_code, 400)
def test_list_course_role_members_noparams(self):
""" Test missing all query parameters. """
url = reverse('list_course_role_members', kwargs={'course_id': self.course.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 400)
def test_list_course_role_members_bad_rolename(self):
""" Test with an invalid rolename parameter. """
url = reverse('list_course_role_members', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'rolename': 'robot-not-a-rolename',
})
print response
self.assertEqual(response.status_code, 400)
def test_list_course_role_members_staff(self):
url = reverse('list_course_role_members', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'rolename': 'staff',
})
print response
self.assertEqual(response.status_code, 200)
# check response content
expected = {
'course_id': self.course.id,
'staff': [
{
'username': self.other_staff.username,
'email': self.other_staff.email,
'first_name': self.other_staff.first_name,
'last_name': self.other_staff.last_name,
}
]
}
res_json = json.loads(response.content)
self.assertEqual(res_json, expected)
def test_list_course_role_members_beta(self):
url = reverse('list_course_role_members', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'rolename': 'beta',
})
print response
self.assertEqual(response.status_code, 200)
# check response content
expected = {
'course_id': self.course.id,
'beta': []
}
res_json = json.loads(response.content)
self.assertEqual(res_json, expected)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test endpoints that show data without side effects.
"""
def setUp(self):
self.instructor = AdminFactory.create()
self.course = CourseFactory.create()
self.client.login(username=self.instructor.username, password='test')
self.students = [UserFactory() for _ in xrange(6)]
for student in self.students:
CourseEnrollment.objects.create(user=student, course_id=self.course.id)
def test_get_students_features(self):
"""
Test that some minimum of information is formatted
correctly in the response to get_students_features.
"""
url = reverse('get_students_features', kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
res_json = json.loads(response.content)
self.assertIn('students', res_json)
for student in self.students:
student_json = [
x for x in res_json['students']
if x['username'] == student.username
][0]
self.assertEqual(student_json['username'], student.username)
self.assertEqual(student_json['email'], student.email)
def test_get_students_features_csv(self):
"""
Test that some minimum of information is formatted
correctly in the response to get_students_features.
"""
url = reverse('get_students_features', kwargs={'course_id': self.course.id})
response = self.client.get(url + '/csv', {})
self.assertEqual(response['Content-Type'], 'text/csv')
def test_get_distribution_no_feature(self):
"""
Test that get_distribution lists available features
when supplied no feature quparameter.
"""
url = reverse('get_distribution', kwargs={'course_id': self.course.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
self.assertEqual(type(res_json['available_features']), list)
url = reverse('get_distribution', kwargs={'course_id': self.course.id})
response = self.client.get(url + u'?feature=')
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
self.assertEqual(type(res_json['available_features']), list)
def test_get_distribution_unavailable_feature(self):
"""
Test that get_distribution fails gracefully with
an unavailable feature.
"""
url = reverse('get_distribution', kwargs={'course_id': self.course.id})
response = self.client.get(url, {'feature': 'robot-not-a-real-feature'})
self.assertEqual(response.status_code, 400)
def test_get_distribution_gender(self):
"""
Test that get_distribution fails gracefully with
an unavailable feature.
"""
url = reverse('get_distribution', kwargs={'course_id': self.course.id})
response = self.client.get(url, {'feature': 'gender'})
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
print res_json
self.assertEqual(res_json['feature_results']['data']['m'], 6)
self.assertEqual(res_json['feature_results']['choices_display_names']['m'], 'Male')
self.assertEqual(res_json['feature_results']['data']['no_data'], 0)
self.assertEqual(res_json['feature_results']['choices_display_names']['no_data'], 'No Data')
def test_get_student_progress_url(self):
""" Test that progress_url is in the successful response. """
url = reverse('get_student_progress_url', kwargs={'course_id': self.course.id})
url += "?student_email={}".format(
quote(self.students[0].email.encode("utf-8"))
)
print url
response = self.client.get(url)
print response
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
self.assertIn('progress_url', res_json)
def test_get_student_progress_url_noparams(self):
""" Test that the endpoint 404's without the required query params. """
url = reverse('get_student_progress_url', kwargs={'course_id': self.course.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 400)
def test_get_student_progress_url_nostudent(self):
""" Test that the endpoint 400's when requesting an unknown email. """
url = reverse('get_student_progress_url', kwargs={'course_id': self.course.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 400)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test endpoints whereby instructors can change student grades.
This includes resetting attempts and starting rescore tasks.
This test does NOT test whether the actions had an effect on the
database, that is the job of task tests and test_enrollment.
"""
def setUp(self):
self.instructor = AdminFactory.create()
self.course = CourseFactory.create()
self.client.login(username=self.instructor.username, password='test')
self.student = UserFactory()
CourseEnrollment.objects.create(course_id=self.course.id, user=self.student)
self.problem_urlname = 'robot-some-problem-urlname'
self.module_to_reset = StudentModule.objects.create(
student=self.student,
course_id=self.course.id,
module_state_key=_msk_from_problem_urlname(
self.course.id,
self.problem_urlname
),
state=json.dumps({'attempts': 10}),
)
def test_reset_student_attempts_deletall(self):
""" Make sure no one can delete all students state on a problem. """
url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': self.problem_urlname,
'all_students': True,
'delete_module': True,
})
print response.content
self.assertEqual(response.status_code, 400)
def test_reset_student_attempts_single(self):
""" Test reset single student attempts. """
url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': self.problem_urlname,
'student_email': self.student.email,
})
print response.content
self.assertEqual(response.status_code, 200)
# make sure problem attempts have been reset.
changed_module = StudentModule.objects.get(pk=self.module_to_reset.pk)
self.assertEqual(
json.loads(changed_module.state)['attempts'],
0
)
def test_reset_student_attempts_all(self):
""" Test reset all student attempts. """
# mock out the function which should be called to execute the action.
import instructor_task.api
act = Mock(return_value=None)
instructor_task.api.submit_reset_problem_attempts_for_all_students = act
url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': self.problem_urlname,
'all_students': True,
})
print response.content
self.assertEqual(response.status_code, 200)
self.assertTrue(act.called)
def test_reset_student_attempts_missingmodule(self):
""" Test reset for non-existant problem. """
url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': 'robot-not-a-real-module',
'student_email': self.student.email,
})
print response.content
self.assertEqual(response.status_code, 400)
def test_reset_student_attempts_delete(self):
""" Test delete single student state. """
url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': self.problem_urlname,
'student_email': self.student.email,
'delete_module': True,
})
print response.content
self.assertEqual(response.status_code, 200)
# make sure the module has been deleted
self.assertEqual(
StudentModule.objects.filter(
student=self.module_to_reset.student,
course_id=self.module_to_reset.course_id,
# module_state_key=self.module_to_reset.module_state_key,
).count(),
0
)
def test_reset_student_attempts_nonsense(self):
""" Test failure with both student_email and all_students. """
url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': self.problem_urlname,
'student_email': self.student.email,
'all_students': True,
})
print response.content
self.assertEqual(response.status_code, 400)
def test_rescore_problem_single(self):
""" Test rescoring of a single student. """
import instructor_task.api
act = Mock(return_value=None)
instructor_task.api.submit_rescore_problem_for_student = act
url = reverse('rescore_problem', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': self.problem_urlname,
'student_email': self.student.email,
})
print response.content
self.assertEqual(response.status_code, 200)
self.assertTrue(act.called)
def test_rescore_problem_all(self):
""" Test rescoring for all students. """
import instructor_task.api
act = Mock(return_value=None)
instructor_task.api.submit_rescore_problem_for_all_students = act
url = reverse('rescore_problem', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': self.problem_urlname,
'all_students': True,
})
print response.content
self.assertEqual(response.status_code, 200)
self.assertTrue(act.called)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test instructor task list endpoint.
"""
class FakeTask(object):
""" Fake task object """
FEATURES = ['task_type', 'task_input', 'task_id', 'requester', 'created', 'task_state']
def __init__(self):
for feature in self.FEATURES:
setattr(self, feature, 'expected')
def to_dict(self):
""" Convert fake task to dictionary representation. """
return {key: 'expected' for key in self.FEATURES}
def setUp(self):
self.instructor = AdminFactory.create()
self.course = CourseFactory.create()
self.client.login(username=self.instructor.username, password='test')
self.student = UserFactory()
CourseEnrollment.objects.create(course_id=self.course.id, user=self.student)
self.problem_urlname = 'robot-some-problem-urlname'
self.module = StudentModule.objects.create(
student=self.student,
course_id=self.course.id,
module_state_key=_msk_from_problem_urlname(
self.course.id,
self.problem_urlname
),
state=json.dumps({'attempts': 10}),
)
self.tasks = [self.FakeTask() for _ in xrange(6)]
def test_list_instructor_tasks_running(self):
""" Test list of all running tasks. """
import instructor_task.api
act = Mock(return_value=self.tasks)
instructor_task.api.get_running_instructor_tasks = act
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
print response.content
self.assertEqual(response.status_code, 200)
# check response
self.assertTrue(act.called)
expected_tasks = [ftask.to_dict() for ftask in self.tasks]
expected_res = {'tasks': expected_tasks}
self.assertEqual(json.loads(response.content), expected_res)
def test_list_instructor_tasks_problem(self):
""" Test list task history for problem. """
import instructor_task.api
act = Mock(return_value=self.tasks)
instructor_task.api.get_instructor_task_history = act
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_urlname': self.problem_urlname,
})
print response.content
self.assertEqual(response.status_code, 200)
# check response
self.assertTrue(act.called)
expected_tasks = [ftask.to_dict() for ftask in self.tasks]
expected_res = {'tasks': expected_tasks}
self.assertEqual(json.loads(response.content), expected_res)
def test_list_instructor_tasks_problem_student(self):
""" Test list task history for problem AND student. """
import instructor_task.api
act = Mock(return_value=self.tasks)
instructor_task.api.get_instructor_task_history = act
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_urlname': self.problem_urlname,
'student_email': self.student.email,
})
print response.content
self.assertEqual(response.status_code, 200)
# check response
self.assertTrue(act.called)
expected_tasks = [ftask.to_dict() for ftask in self.tasks]
expected_res = {'tasks': expected_tasks}
self.assertEqual(json.loads(response.content), expected_res)
# class TestInstructorAPILevelsForums
# # list_forum_members
# # update_forum_role_membership
class TestInstructorAPIHelpers(TestCase):
""" Test helpers for instructor.api """
def test_split_input_list(self):
strings = []
lists = []
strings.append("Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed")
lists.append(['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed'])
for (stng, lst) in zip(strings, lists):
self.assertEqual(_split_input_list(stng), lst)
def test_split_input_list_unicode(self):
self.assertEqual(_split_input_list('robot@robot.edu, robot2@robot.edu'), ['robot@robot.edu', 'robot2@robot.edu'])
self.assertEqual(_split_input_list(u'robot@robot.edu, robot2@robot.edu'), ['robot@robot.edu', 'robot2@robot.edu'])
self.assertEqual(_split_input_list(u'robot@robot.edu, robot2@robot.edu'), [u'robot@robot.edu', 'robot2@robot.edu'])
scary_unistuff = unichr(40960) + u'abcd' + unichr(1972)
self.assertEqual(_split_input_list(scary_unistuff), [scary_unistuff])
def test_msk_from_problem_urlname(self):
args = ('MITx/6.002x/2013_Spring', 'L2Node1')
output = 'i4x://MITx/6.002x/problem/L2Node1'
self.assertEqual(_msk_from_problem_urlname(*args), output)
@raises(ValueError)
def test_msk_from_problem_urlname_error(self):
args = ('notagoodcourse', 'L2Node1')
_msk_from_problem_urlname(*args)
""" """
Unit tests for enrollment methods in views.py Unit tests for instructor.enrollment methods.
""" """
from django.test.utils import override_settings import json
from abc import ABCMeta
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from courseware.models import StudentModule
from courseware.tests.helpers import LoginEnrollmentTestCase from django.test import TestCase
from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.models import CourseEnrollment, CourseEnrollmentAllowed
from instructor.views import get_and_clean_student_list, send_mail_to_student
from django.core import mail
USER_COUNT = 4 from student.models import CourseEnrollment, CourseEnrollmentAllowed
from instructor.enrollment import (EmailEnrollmentState,
enroll_email, unenroll_email,
reset_student_attempts)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestSettableEnrollmentState(TestCase):
class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Test the basis class for enrollment tests. """
"""
Check Enrollment/Unenrollment with/without auto-enrollment on activation and with/without email notification
"""
def setUp(self): def setUp(self):
self.course_id = 'robot:/a/fake/c::rse/id'
instructor = AdminFactory.create() def test_mes_create(self):
self.client.login(username=instructor.username, password='test')
self.course = CourseFactory.create()
self.users = [
UserFactory.create(username="student%d" % i, email="student%d@test.com" % i)
for i in xrange(USER_COUNT)
]
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
# Empty the test outbox
mail.outbox = []
def test_unenrollment_email_off(self):
"""
Do un-enrollment email off test
"""
course = self.course
#Run the Un-enroll students command
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student0@test.com student1@test.com'})
#Check the page output
self.assertContains(response, '<td>student0@test.com</td>')
self.assertContains(response, '<td>student1@test.com</td>')
self.assertContains(response, '<td>un-enrolled</td>')
#Check the enrollment table
user = User.objects.get(email='student0@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
user = User.objects.get(email='student1@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
#Check the outbox
self.assertEqual(len(mail.outbox), 0)
def test_enrollment_new_student_autoenroll_on_email_off(self):
"""
Do auto-enroll on, email off test
"""
course = self.course
#Run the Enroll students command
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student1_1@test.com, student1_2@test.com', 'auto_enroll': 'on'})
#Check the page output
self.assertContains(response, '<td>student1_1@test.com</td>')
self.assertContains(response, '<td>student1_2@test.com</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on</td>')
#Check the outbox
self.assertEqual(len(mail.outbox), 0)
#Check the enrollmentallowed db entries
cea = CourseEnrollmentAllowed.objects.filter(email='student1_1@test.com', course_id=course.id)
self.assertEqual(1, cea[0].auto_enroll)
cea = CourseEnrollmentAllowed.objects.filter(email='student1_2@test.com', course_id=course.id)
self.assertEqual(1, cea[0].auto_enroll)
#Check there is no enrollment db entry other than for the other students
ce = CourseEnrollment.objects.filter(course_id=course.id)
self.assertEqual(4, len(ce))
#Create and activate student accounts with same email
self.student1 = 'student1_1@test.com'
self.password = 'bar'
self.create_account('s1_1', self.student1, self.password)
self.activate_user(self.student1)
self.student2 = 'student1_2@test.com'
self.create_account('s1_2', self.student2, self.password)
self.activate_user(self.student2)
#Check students are enrolled
user = User.objects.get(email='student1_1@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(1, len(ce))
user = User.objects.get(email='student1_2@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(1, len(ce))
def test_repeat_enroll(self):
""" """
Try to enroll an already enrolled student Test SettableEnrollmentState creation of user.
""" """
mes = SettableEnrollmentState(
user=True,
enrollment=True,
allowed=False,
auto_enroll=False
)
# enrollment objects
eobjs = mes.create_user(self.course_id)
ees = EmailEnrollmentState(self.course_id, eobjs.email)
self.assertEqual(mes, ees)
class TestEnrollmentChangeBase(TestCase):
"""
Test instructor enrollment administration against database effects.
course = self.course Test methods in derived classes follow a strict format.
`action` is a function which is run
the test will pass if `action` mutates state from `before_ideal` to `after_ideal`
"""
url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) __metaclass__ = ABCMeta
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student0@test.com', 'auto_enroll': 'on'})
self.assertContains(response, '<td>student0@test.com</td>')
self.assertContains(response, '<td>already enrolled</td>')
def test_enrollmemt_new_student_autoenroll_off_email_off(self): def setUp(self):
""" self.course_id = 'robot:/a/fake/c::rse/id'
Do auto-enroll off, email off test
"""
course = self.course def _run_state_change_test(self, before_ideal, after_ideal, action):
#Run the Enroll students command
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student2_1@test.com, student2_2@test.com'})
#Check the page output
self.assertContains(response, '<td>student2_1@test.com</td>')
self.assertContains(response, '<td>student2_2@test.com</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment off</td>')
#Check the outbox
self.assertEqual(len(mail.outbox), 0)
#Check the enrollmentallowed db entries
cea = CourseEnrollmentAllowed.objects.filter(email='student2_1@test.com', course_id=course.id)
self.assertEqual(0, cea[0].auto_enroll)
cea = CourseEnrollmentAllowed.objects.filter(email='student2_2@test.com', course_id=course.id)
self.assertEqual(0, cea[0].auto_enroll)
#Check there is no enrollment db entry other than for the setup instructor and students
ce = CourseEnrollment.objects.filter(course_id=course.id)
self.assertEqual(4, len(ce))
#Create and activate student accounts with same email
self.student = 'student2_1@test.com'
self.password = 'bar'
self.create_account('s2_1', self.student, self.password)
self.activate_user(self.student)
self.student = 'student2_2@test.com'
self.create_account('s2_2', self.student, self.password)
self.activate_user(self.student)
#Check students are not enrolled
user = User.objects.get(email='student2_1@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
user = User.objects.get(email='student2_2@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
def test_get_and_clean_student_list(self):
"""
Clean user input test
""" """
Runs a state change test.
string = "abc@test.com, def@test.com ghi@test.com \n \n jkl@test.com \n mno@test.com " `before_ideal` and `after_ideal` are SettableEnrollmentState's
cleaned_string, cleaned_string_lc = get_and_clean_student_list(string) `action` is a function which will be run in the middle.
self.assertEqual(cleaned_string, ['abc@test.com', 'def@test.com', 'ghi@test.com', 'jkl@test.com', 'mno@test.com']) `action` should transition the world from before_ideal to after_ideal
`action` will be supplied the following arguments (None-able arguments)
def test_enrollment_email_on(self): `email` is an email string
"""
Do email on enroll test
""" """
# initialize & check before
print "checking initialization..."
eobjs = before_ideal.create_user(self.course_id)
before = EmailEnrollmentState(self.course_id, eobjs.email)
self.assertEqual(before, before_ideal)
# do action
print "running action..."
action(eobjs.email)
# check after
print "checking effects..."
after = EmailEnrollmentState(self.course_id, eobjs.email)
self.assertEqual(after, after_ideal)
class TestInstructorEnrollDB(TestEnrollmentChangeBase):
""" Test instructor.enrollment.enroll_email """
def test_enroll(self):
before_ideal = SettableEnrollmentState(
user=True,
enrollment=False,
allowed=False,
auto_enroll=False
)
after_ideal = SettableEnrollmentState(
user=True,
enrollment=True,
allowed=False,
auto_enroll=False
)
action = lambda email: enroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_enroll_again(self):
before_ideal = SettableEnrollmentState(
user=True,
enrollment=True,
allowed=False,
auto_enroll=False,
)
after_ideal = SettableEnrollmentState(
user=True,
enrollment=True,
allowed=False,
auto_enroll=False,
)
action = lambda email: enroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_enroll_nouser(self):
before_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=False,
auto_enroll=False,
)
after_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=False,
)
action = lambda email: enroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_enroll_nouser_again(self):
before_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=False
)
after_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=False,
)
action = lambda email: enroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_enroll_nouser_autoenroll(self):
before_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=False,
auto_enroll=False,
)
after_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=True,
)
action = lambda email: enroll_email(self.course_id, email, auto_enroll=True)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_enroll_nouser_change_autoenroll(self):
before_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=True,
)
after_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=False,
)
action = lambda email: enroll_email(self.course_id, email, auto_enroll=False)
return self._run_state_change_test(before_ideal, after_ideal, action)
class TestInstructorUnenrollDB(TestEnrollmentChangeBase):
""" Test instructor.enrollment.unenroll_email """
def test_unenroll(self):
before_ideal = SettableEnrollmentState(
user=True,
enrollment=True,
allowed=False,
auto_enroll=False
)
after_ideal = SettableEnrollmentState(
user=True,
enrollment=False,
allowed=False,
auto_enroll=False
)
action = lambda email: unenroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_unenroll_notenrolled(self):
before_ideal = SettableEnrollmentState(
user=True,
enrollment=False,
allowed=False,
auto_enroll=False
)
after_ideal = SettableEnrollmentState(
user=True,
enrollment=False,
allowed=False,
auto_enroll=False
)
action = lambda email: unenroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_unenroll_disallow(self):
before_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=True,
auto_enroll=True
)
after_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=False,
auto_enroll=False
)
action = lambda email: unenroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
def test_unenroll_norecord(self):
before_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=False,
auto_enroll=False
)
after_ideal = SettableEnrollmentState(
user=False,
enrollment=False,
allowed=False,
auto_enroll=False
)
action = lambda email: unenroll_email(self.course_id, email)
return self._run_state_change_test(before_ideal, after_ideal, action)
class TestInstructorEnrollmentStudentModule(TestCase):
""" Test student module manipulations. """
def setUp(self):
self.course_id = 'robot:/a/fake/c::rse/id'
def test_reset_student_attempts(self):
user = UserFactory()
msk = 'robot/module/state/key'
original_state = json.dumps({'attempts': 32, 'otherstuff': 'alsorobots'})
module = StudentModule.objects.create(student=user, course_id=self.course_id, module_state_key=msk, state=original_state)
# lambda to reload the module state from the database
module = lambda: StudentModule.objects.get(student=user, course_id=self.course_id, module_state_key=msk)
self.assertEqual(json.loads(module().state)['attempts'], 32)
reset_student_attempts(self.course_id, user, msk)
self.assertEqual(json.loads(module().state)['attempts'], 0)
def test_delete_student_attempts(self):
user = UserFactory()
msk = 'robot/module/state/key'
original_state = json.dumps({'attempts': 32, 'otherstuff': 'alsorobots'})
StudentModule.objects.create(student=user, course_id=self.course_id, module_state_key=msk, state=original_state)
self.assertEqual(StudentModule.objects.filter(student=user, course_id=self.course_id, module_state_key=msk).count(), 1)
reset_student_attempts(self.course_id, user, msk, delete_module=True)
self.assertEqual(StudentModule.objects.filter(student=user, course_id=self.course_id, module_state_key=msk).count(), 0)
class EnrollmentObjects(object):
"""
Container for enrollment objects.
course = self.course `email` - student email
`user` - student User object
#Create activated, but not enrolled, user `cenr` - CourseEnrollment object
UserFactory.create(username="student3_0", email="student3_0@test.com", first_name="Jim", last_name="Tester") `cea` - CourseEnrollmentAllowed object
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student3_0@test.com, student3_1@test.com, student3_2@test.com', 'auto_enroll': 'on', 'email_students': 'on'})
#Check the page output
self.assertContains(response, '<td>student3_0@test.com</td>')
self.assertContains(response, '<td>student3_1@test.com</td>')
self.assertContains(response, '<td>student3_2@test.com</td>')
self.assertContains(response, '<td>added, email sent</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on, email sent</td>')
#Check the outbox
self.assertEqual(len(mail.outbox), 3)
self.assertEqual(mail.outbox[0].subject, 'You have been enrolled in MITx/999/Robot_Super_Course')
self.assertEqual(mail.outbox[0].body, "Dear Jim Tester\n\nYou have been enrolled in MITx/999/Robot_Super_Course at edx.org by a member of the course staff. " +
"The course should now appear on your edx.org dashboard.\n\n" +
"To start accessing course materials, please visit https://edx.org/courses/MITx/999/Robot_Super_Course\n\n" +
"----\nThis email was automatically sent from edx.org to Jim Tester")
self.assertEqual(mail.outbox[1].subject, 'You have been invited to register for MITx/999/Robot_Super_Course')
self.assertEqual(mail.outbox[1].body, "Dear student,\n\nYou have been invited to join MITx/999/Robot_Super_Course at edx.org by a member of the course staff.\n\n" +
"To finish your registration, please visit https://edx.org/register and fill out the registration form.\n" +
"Once you have registered and activated your account, you will see MITx/999/Robot_Super_Course listed on your dashboard.\n\n" +
"----\nThis email was automatically sent from edx.org to student3_1@test.com")
def test_unenrollment_email_on(self):
"""
Do email on unenroll test
"""
course = self.course Any of the objects except email can be None.
"""
def __init__(self, email, user, cenr, cea):
self.email = email
self.user = user
self.cenr = cenr
self.cea = cea
#Create invited, but not registered, user
cea = CourseEnrollmentAllowed(email='student4_0@test.com', course_id=course.id)
cea.save()
url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) class SettableEnrollmentState(EmailEnrollmentState):
response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student4_0@test.com, student2@test.com, student3@test.com', 'email_students': 'on'}) """
Settable enrollment state.
Used for testing state changes.
SettableEnrollmentState can be constructed and then
a call to create_user will make objects which
correspond to the state represented in the SettableEnrollmentState.
"""
def __init__(self, user=False, enrollment=False, allowed=False, auto_enroll=False): # pylint: disable=W0231
self.user = user
self.enrollment = enrollment
self.allowed = allowed
self.auto_enroll = auto_enroll
#Check the page output def __eq__(self, other):
self.assertContains(response, '<td>student2@test.com</td>') return self.to_dict() == other.to_dict()
self.assertContains(response, '<td>student3@test.com</td>')
self.assertContains(response, '<td>un-enrolled, email sent</td>')
#Check the outbox def __neq__(self, other):
self.assertEqual(len(mail.outbox), 3) return not self == other
self.assertEqual(mail.outbox[0].subject, 'You have been un-enrolled from MITx/999/Robot_Super_Course')
self.assertEqual(mail.outbox[0].body, "Dear Student,\n\nYou have been un-enrolled from course MITx/999/Robot_Super_Course by a member of the course staff. " +
"Please disregard the invitation previously sent.\n\n" +
"----\nThis email was automatically sent from edx.org to student4_0@test.com")
self.assertEqual(mail.outbox[1].subject, 'You have been un-enrolled from MITx/999/Robot_Super_Course')
def test_send_mail_to_student(self): def create_user(self, course_id=None):
""" """
Do invalid mail template test Utility method to possibly create and possibly enroll a user.
Creates a state matching the SettableEnrollmentState properties.
Returns a tuple of (
email,
User, (optionally None)
CourseEnrollment, (optionally None)
CourseEnrollmentAllowed, (optionally None)
)
""" """
# if self.user=False, then this will just be used to generate an email.
d = {'message': 'message_type_that_doesn\'t_exist'} email = "robot_no_user_exists_with_this_email@edx.org"
if self.user:
send_mail_ret = send_mail_to_student('student0@test.com', d) user = UserFactory()
self.assertFalse(send_mail_ret) email = user.email
if self.enrollment:
cenr = CourseEnrollment.objects.create(
user=user,
course_id=course_id
)
return EnrollmentObjects(email, user, cenr, None)
else:
return EnrollmentObjects(email, user, None, None)
elif self.allowed:
cea = CourseEnrollmentAllowed.objects.create(
email=email,
course_id=course_id,
auto_enroll=self.auto_enroll,
)
return EnrollmentObjects(email, None, None, cea)
else:
return EnrollmentObjects(email, None, None, None)
...@@ -44,9 +44,10 @@ class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase): ...@@ -44,9 +44,10 @@ class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase):
self.activate_user(self.instructor) self.activate_user(self.instructor)
def make_instructor(course): def make_instructor(course):
""" Create an instructor for the course. """
group_name = _course_staff_group_name(course.location) group_name = _course_staff_group_name(course.location)
g = Group.objects.create(name=group_name) group = Group.objects.create(name=group_name)
g.user_set.add(User.objects.get(email=self.instructor)) group.user_set.add(User.objects.get(email=self.instructor))
make_instructor(self.toy) make_instructor(self.toy)
......
"""
Unit tests for enrollment methods in views.py
"""
from django.test.utils import override_settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from courseware.tests.helpers import LoginEnrollmentTestCase
from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.models import CourseEnrollment, CourseEnrollmentAllowed
from instructor.views.legacy import get_and_clean_student_list, send_mail_to_student
from django.core import mail
USER_COUNT = 4
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Check Enrollment/Unenrollment with/without auto-enrollment on activation and with/without email notification
"""
def setUp(self):
instructor = AdminFactory.create()
self.client.login(username=instructor.username, password='test')
self.course = CourseFactory.create()
self.users = [
UserFactory.create(username="student%d" % i, email="student%d@test.com" % i)
for i in xrange(USER_COUNT)
]
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
# Empty the test outbox
mail.outbox = []
def test_unenrollment_email_off(self):
"""
Do un-enrollment email off test
"""
course = self.course
# Run the Un-enroll students command
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student0@test.com student1@test.com'})
# Check the page output
self.assertContains(response, '<td>student0@test.com</td>')
self.assertContains(response, '<td>student1@test.com</td>')
self.assertContains(response, '<td>un-enrolled</td>')
# Check the enrollment table
user = User.objects.get(email='student0@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
user = User.objects.get(email='student1@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
# Check the outbox
self.assertEqual(len(mail.outbox), 0)
def test_enrollment_new_student_autoenroll_on_email_off(self):
"""
Do auto-enroll on, email off test
"""
course = self.course
# Run the Enroll students command
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student1_1@test.com, student1_2@test.com', 'auto_enroll': 'on'})
# Check the page output
self.assertContains(response, '<td>student1_1@test.com</td>')
self.assertContains(response, '<td>student1_2@test.com</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on</td>')
# Check the outbox
self.assertEqual(len(mail.outbox), 0)
# Check the enrollmentallowed db entries
cea = CourseEnrollmentAllowed.objects.filter(email='student1_1@test.com', course_id=course.id)
self.assertEqual(1, cea[0].auto_enroll)
cea = CourseEnrollmentAllowed.objects.filter(email='student1_2@test.com', course_id=course.id)
self.assertEqual(1, cea[0].auto_enroll)
# Check there is no enrollment db entry other than for the other students
ce = CourseEnrollment.objects.filter(course_id=course.id)
self.assertEqual(4, len(ce))
# Create and activate student accounts with same email
self.student1 = 'student1_1@test.com'
self.password = 'bar'
self.create_account('s1_1', self.student1, self.password)
self.activate_user(self.student1)
self.student2 = 'student1_2@test.com'
self.create_account('s1_2', self.student2, self.password)
self.activate_user(self.student2)
# Check students are enrolled
user = User.objects.get(email='student1_1@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(1, len(ce))
user = User.objects.get(email='student1_2@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(1, len(ce))
def test_repeat_enroll(self):
"""
Try to enroll an already enrolled student
"""
course = self.course
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student0@test.com', 'auto_enroll': 'on'})
self.assertContains(response, '<td>student0@test.com</td>')
self.assertContains(response, '<td>already enrolled</td>')
def test_enrollmemt_new_student_autoenroll_off_email_off(self):
"""
Do auto-enroll off, email off test
"""
course = self.course
# Run the Enroll students command
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student2_1@test.com, student2_2@test.com'})
# Check the page output
self.assertContains(response, '<td>student2_1@test.com</td>')
self.assertContains(response, '<td>student2_2@test.com</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment off</td>')
# Check the outbox
self.assertEqual(len(mail.outbox), 0)
# Check the enrollmentallowed db entries
cea = CourseEnrollmentAllowed.objects.filter(email='student2_1@test.com', course_id=course.id)
self.assertEqual(0, cea[0].auto_enroll)
cea = CourseEnrollmentAllowed.objects.filter(email='student2_2@test.com', course_id=course.id)
self.assertEqual(0, cea[0].auto_enroll)
# Check there is no enrollment db entry other than for the setup instructor and students
ce = CourseEnrollment.objects.filter(course_id=course.id)
self.assertEqual(4, len(ce))
# Create and activate student accounts with same email
self.student = 'student2_1@test.com'
self.password = 'bar'
self.create_account('s2_1', self.student, self.password)
self.activate_user(self.student)
self.student = 'student2_2@test.com'
self.create_account('s2_2', self.student, self.password)
self.activate_user(self.student)
# Check students are not enrolled
user = User.objects.get(email='student2_1@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
user = User.objects.get(email='student2_2@test.com')
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
self.assertEqual(0, len(ce))
def test_get_and_clean_student_list(self):
"""
Clean user input test
"""
string = "abc@test.com, def@test.com ghi@test.com \n \n jkl@test.com \n mno@test.com "
cleaned_string, cleaned_string_lc = get_and_clean_student_list(string)
self.assertEqual(cleaned_string, ['abc@test.com', 'def@test.com', 'ghi@test.com', 'jkl@test.com', 'mno@test.com'])
def test_enrollment_email_on(self):
"""
Do email on enroll test
"""
course = self.course
# Create activated, but not enrolled, user
UserFactory.create(username="student3_0", email="student3_0@test.com", first_name="Jim", last_name="Tester")
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student3_0@test.com, student3_1@test.com, student3_2@test.com', 'auto_enroll': 'on', 'email_students': 'on'})
# Check the page output
self.assertContains(response, '<td>student3_0@test.com</td>')
self.assertContains(response, '<td>student3_1@test.com</td>')
self.assertContains(response, '<td>student3_2@test.com</td>')
self.assertContains(response, '<td>added, email sent</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on, email sent</td>')
# Check the outbox
self.assertEqual(len(mail.outbox), 3)
self.assertEqual(mail.outbox[0].subject, 'You have been enrolled in MITx/999/Robot_Super_Course')
self.assertEqual(mail.outbox[0].body, "Dear Jim Tester\n\nYou have been enrolled in MITx/999/Robot_Super_Course at edx.org by a member of the course staff. " +
"The course should now appear on your edx.org dashboard.\n\n" +
"To start accessing course materials, please visit https://edx.org/courses/MITx/999/Robot_Super_Course\n\n" +
"----\nThis email was automatically sent from edx.org to Jim Tester")
self.assertEqual(mail.outbox[1].subject, 'You have been invited to register for MITx/999/Robot_Super_Course')
self.assertEqual(mail.outbox[1].body, "Dear student,\n\nYou have been invited to join MITx/999/Robot_Super_Course at edx.org by a member of the course staff.\n\n" +
"To finish your registration, please visit https://edx.org/register and fill out the registration form.\n" +
"Once you have registered and activated your account, you will see MITx/999/Robot_Super_Course listed on your dashboard.\n\n" +
"----\nThis email was automatically sent from edx.org to student3_1@test.com")
def test_unenrollment_email_on(self):
"""
Do email on unenroll test
"""
course = self.course
# Create invited, but not registered, user
cea = CourseEnrollmentAllowed(email='student4_0@test.com', course_id=course.id)
cea.save()
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student4_0@test.com, student2@test.com, student3@test.com', 'email_students': 'on'})
# Check the page output
self.assertContains(response, '<td>student2@test.com</td>')
self.assertContains(response, '<td>student3@test.com</td>')
self.assertContains(response, '<td>un-enrolled, email sent</td>')
# Check the outbox
self.assertEqual(len(mail.outbox), 3)
self.assertEqual(mail.outbox[0].subject, 'You have been un-enrolled from MITx/999/Robot_Super_Course')
self.assertEqual(mail.outbox[0].body, "Dear Student,\n\nYou have been un-enrolled from course MITx/999/Robot_Super_Course by a member of the course staff. " +
"Please disregard the invitation previously sent.\n\n" +
"----\nThis email was automatically sent from edx.org to student4_0@test.com")
self.assertEqual(mail.outbox[1].subject, 'You have been un-enrolled from MITx/999/Robot_Super_Course')
def test_send_mail_to_student(self):
"""
Do invalid mail template test
"""
d = {'message': 'message_type_that_doesn\'t_exist'}
send_mail_ret = send_mail_to_student('student0@test.com', d)
self.assertFalse(send_mail_ret)
...@@ -42,7 +42,7 @@ class TestGradebook(ModuleStoreTestCase): ...@@ -42,7 +42,7 @@ class TestGradebook(ModuleStoreTestCase):
metadata={'graded': True, 'format': 'Homework'} metadata={'graded': True, 'format': 'Homework'}
) )
self.users = [UserFactory() for _ in xrange(USER_COUNT)] self.users = [UserFactory.create() for _ in xrange(USER_COUNT)]
for user in self.users: for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id) CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
......
...@@ -12,7 +12,7 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory ...@@ -12,7 +12,7 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from instructor import views from instructor.views import legacy
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestXss(ModuleStoreTestCase): class TestXss(ModuleStoreTestCase):
...@@ -47,7 +47,7 @@ class TestXss(ModuleStoreTestCase): ...@@ -47,7 +47,7 @@ class TestXss(ModuleStoreTestCase):
) )
req.user = self._instructor req.user = self._instructor
req.session = {} req.session = {}
resp = views.instructor_dashboard(req, self._course.id) resp = legacy.instructor_dashboard(req, self._course.id)
respUnicode = resp.content.decode(settings.DEFAULT_CHARSET) respUnicode = resp.content.decode(settings.DEFAULT_CHARSET)
self.assertNotIn(self._evil_student.profile.name, respUnicode) self.assertNotIn(self._evil_student.profile.name, respUnicode)
self.assertIn(escape(self._evil_student.profile.name), respUnicode) self.assertIn(escape(self._evil_student.profile.name), respUnicode)
......
"""
Instructor Dashboard API views
JSON views which the instructor dashboard requests.
Many of these GETs may become PUTs in the future.
"""
import re
import json
import logging
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from courseware.access import has_access
from courseware.courses import get_course_with_access, get_course_by_id
from django.contrib.auth.models import User
from django_comment_client.utils import has_forum_access
from django_comment_common.models import (Role,
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA)
from courseware.models import StudentModule
import instructor_task.api
from instructor_task.api_helper import AlreadyRunningError
import instructor.enrollment as enrollment
from instructor.enrollment import enroll_email, unenroll_email
import instructor.access as access
import analytics.basic
import analytics.distributions
import analytics.csvs
log = logging.getLogger(__name__)
def common_exceptions_400(func):
"""
Catches common exceptions and renders matching 400 errors.
(decorator without arguments)
"""
def wrapped(*args, **kwargs): # pylint: disable=C0111
try:
return func(*args, **kwargs)
except User.DoesNotExist:
return HttpResponseBadRequest("User does not exist.")
except AlreadyRunningError:
return HttpResponseBadRequest("Task already running.")
return wrapped
def require_query_params(*args, **kwargs):
"""
Checks for required paremters or renders a 400 error.
(decorator with arguments)
`args` is a *list of required GET parameter names.
`kwargs` is a **dict of required GET parameter names
to string explanations of the parameter
"""
required_params = []
required_params += [(arg, None) for arg in args]
required_params += [(key, kwargs[key]) for key in kwargs]
# required_params = e.g. [('action', 'enroll or unenroll'), ['emails', None]]
def decorator(func): # pylint: disable=C0111
def wrapped(*args, **kwargs): # pylint: disable=C0111
request = args[0]
error_response_data = {
'error': 'Missing required query parameter(s)',
'parameters': [],
'info': {},
}
for (param, extra) in required_params:
default = object()
if request.GET.get(param, default) == default:
error_response_data['parameters'] += [param]
error_response_data['info'][param] = extra
if len(error_response_data['parameters']) > 0:
return HttpResponseBadRequest(
json.dumps(error_response_data),
mimetype="application/json",
)
else:
return func(*args, **kwargs)
return wrapped
return decorator
def require_level(level):
"""
Decorator with argument that requires an access level of the requesting
user. If the requirement is not satisfied, returns an
HttpResponseForbidden (403).
Assumes that request is in args[0].
Assumes that course_id is in kwargs['course_id'].
`level` is in ['instructor', 'staff']
if `level` is 'staff', instructors will also be allowed, even
if they are not int he staff group.
"""
if level not in ['instructor', 'staff']:
raise ValueError("unrecognized level '{}'".format(level))
def decorator(func): # pylint: disable=C0111
def wrapped(*args, **kwargs): # pylint: disable=C0111
request = args[0]
course = get_course_by_id(kwargs['course_id'])
if has_access(request.user, course, level):
return func(*args, **kwargs)
else:
return HttpResponseForbidden()
return wrapped
return decorator
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params(action="enroll or unenroll", emails="stringified list of emails")
def students_update_enrollment(request, course_id):
"""
Enroll or unenroll students by email.
Requires staff access.
Query Parameters:
- action in ['enroll', 'unenroll']
- emails is string containing a list of emails separated by anything split_input_list can handle.
- auto_enroll is a boolean (defaults to false)
If auto_enroll is false, students will be allowed to enroll.
If auto_enroll is true, students will be enroled as soon as they register.
Returns an analog to this JSON structure: {
"action": "enroll",
"auto_enroll": false,
"results": [
{
"email": "testemail@test.org",
"before": {
"enrollment": false,
"auto_enroll": false,
"user": true,
"allowed": false
},
"after": {
"enrollment": true,
"auto_enroll": false,
"user": true,
"allowed": false
}
}
]
}
"""
action = request.GET.get('action')
emails_raw = request.GET.get('emails')
emails = _split_input_list(emails_raw)
auto_enroll = request.GET.get('auto_enroll') in ['true', 'True', True]
results = []
for email in emails:
try:
if action == 'enroll':
before, after = enroll_email(course_id, email, auto_enroll)
elif action == 'unenroll':
before, after = unenroll_email(course_id, email)
else:
return HttpResponseBadRequest("Unrecognized action '{}'".format(action))
results.append({
'email': email,
'before': before.to_dict(),
'after': after.to_dict(),
})
# catch and log any exceptions
# so that one error doesn't cause a 500.
except Exception as exc:
log.exception("Error while #{}ing student")
log.exception(exc)
results.append({
'email': email,
'error': True,
})
response_payload = {
'action': action,
'results': results,
'auto_enroll': auto_enroll,
}
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('instructor')
@common_exceptions_400
@require_query_params(
email="user email",
rolename="'instructor', 'staff', or 'beta'",
action="'allow' or 'revoke'"
)
def modify_access(request, course_id):
"""
Modify staff/instructor access of other user.
Requires instructor access.
NOTE: instructors cannot remove their own instructor access.
Query parameters:
email is the target users email
rolename is one of ['instructor', 'staff', 'beta']
action is one of ['allow', 'revoke']
"""
course = get_course_with_access(
request.user, course_id, 'instructor', depth=None
)
email = request.GET.get('email')
rolename = request.GET.get('rolename')
action = request.GET.get('action')
if not rolename in ['instructor', 'staff', 'beta']:
return HttpResponseBadRequest(
"unknown rolename '{}'".format(rolename)
)
user = User.objects.get(email=email)
# disallow instructors from removing their own instructor access.
if rolename == 'instructor' and user == request.user and action != 'allow':
return HttpResponseBadRequest(
"An instructor cannot remove their own instructor access."
)
if action == 'allow':
access.allow_access(course, user, rolename)
elif action == 'revoke':
access.revoke_access(course, user, rolename)
else:
return HttpResponseBadRequest("unrecognized action '{}'".format(action))
response_payload = {
'email': email,
'rolename': rolename,
'action': action,
'success': 'yes',
}
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('instructor')
@require_query_params(rolename="'instructor', 'staff', or 'beta'")
def list_course_role_members(request, course_id):
"""
List instructors and staff.
Requires instructor access.
rolename is one of ['instructor', 'staff', 'beta']
Returns JSON of the form {
"course_id": "some/course/id",
"staff": [
{
"username": "staff1",
"email": "staff1@example.org",
"first_name": "Joe",
"last_name": "Shmoe",
}
]
}
"""
course = get_course_with_access(
request.user, course_id, 'instructor', depth=None
)
rolename = request.GET.get('rolename')
if not rolename in ['instructor', 'staff', 'beta']:
return HttpResponseBadRequest()
def extract_user_info(user):
""" convert user into dicts for json view """
return {
'username': user.username,
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
}
response_payload = {
'course_id': course_id,
rolename: map(extract_user_info, access.list_with_level(
course, rolename
)),
}
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
def get_grading_config(request, course_id):
"""
Respond with json which contains a html formatted grade summary.
"""
course = get_course_with_access(
request.user, course_id, 'staff', depth=None
)
grading_config_summary = analytics.basic.dump_grading_context(course)
response_payload = {
'course_id': course_id,
'grading_config_summary': grading_config_summary,
}
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
def get_students_features(request, course_id, csv=False): # pylint: disable=W0613
"""
Respond with json which contains a summary of all enrolled students profile information.
Responds with JSON
{"students": [{-student-info-}, ...]}
TO DO accept requests for different attribute sets.
"""
available_features = analytics.basic.AVAILABLE_FEATURES
query_features = ['username', 'name', 'email', 'language', 'location', 'year_of_birth', 'gender',
'level_of_education', 'mailing_address', 'goals']
student_data = analytics.basic.enrolled_students_features(course_id, query_features)
if not csv:
response_payload = {
'course_id': course_id,
'students': student_data,
'students_count': len(student_data),
'queried_features': query_features,
'available_features': available_features,
}
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
else:
header, datarows = analytics.csvs.format_dictlist(student_data, query_features)
return analytics.csvs.create_csv_response("enrolled_profiles.csv", header, datarows)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
def get_distribution(request, course_id):
"""
Respond with json of the distribution of students over selected features which have choices.
Ask for a feature through the `feature` query parameter.
If no `feature` is supplied, will return response with an
empty response['feature_results'] object.
A list of available will be available in the response['available_features']
"""
feature = request.GET.get('feature')
# alternate notations of None
if feature in (None, 'null', ''):
feature = None
else:
feature = str(feature)
available_features = analytics.distributions.AVAILABLE_PROFILE_FEATURES
# allow None so that requests for no feature can list available features
if not feature in available_features + (None,):
return HttpResponseBadRequest(
"feature '{}' not available.".format(feature)
)
response_payload = {
'course_id': course_id,
'queried_feature': feature,
'available_features': available_features,
'feature_display_names': analytics.distributions.DISPLAY_NAMES,
}
p_dist = None
if not feature is None:
p_dist = analytics.distributions.profile_distribution(course_id, feature)
response_payload['feature_results'] = {
'feature': p_dist.feature,
'feature_display_name': p_dist.feature_display_name,
'data': p_dist.data,
'type': p_dist.type,
}
if p_dist.type == 'EASY_CHOICE':
response_payload['feature_results']['choices_display_names'] = p_dist.choices_display_names
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@common_exceptions_400
@require_level('staff')
@require_query_params(
student_email="email of student for whom to get progress url"
)
def get_student_progress_url(request, course_id):
"""
Get the progress url of a student.
Limited to staff access.
Takes query paremeter student_email and if the student exists
returns e.g. {
'progress_url': '/../...'
}
"""
student_email = request.GET.get('student_email')
user = User.objects.get(email=student_email)
progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': user.id})
response_payload = {
'course_id': course_id,
'progress_url': progress_url,
}
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params(
problem_to_reset="problem urlname to reset"
)
@common_exceptions_400
def reset_student_attempts(request, course_id):
"""
Resets a students attempts counter or starts a task to reset all students
attempts counters. Optionally deletes student state for a problem. Limited
to staff access. Some sub-methods limited to instructor access.
Takes some of the following query paremeters
- problem_to_reset is a urlname of a problem
- student_email is an email
- all_students is a boolean
requires instructor access
mutually exclusive with delete_module
mutually exclusive with delete_module
- delete_module is a boolean
requires instructor access
mutually exclusive with all_students
"""
course = get_course_with_access(
request.user, course_id, 'staff', depth=None
)
problem_to_reset = request.GET.get('problem_to_reset')
student_email = request.GET.get('student_email')
all_students = request.GET.get('all_students', False) in ['true', 'True', True]
delete_module = request.GET.get('delete_module', False) in ['true', 'True', True]
# parameter combinations
if all_students and student_email:
return HttpResponseBadRequest(
"all_students and student_email are mutually exclusive."
)
if all_students and delete_module:
return HttpResponseBadRequest(
"all_students and delete_module are mutually exclusive."
)
# instructor authorization
if all_students or delete_module:
if not has_access(request.user, course, 'instructor'):
return HttpResponseForbidden("Requires instructor access.")
module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset)
response_payload = {}
response_payload['problem_to_reset'] = problem_to_reset
if student_email:
try:
student = User.objects.get(email=student_email)
enrollment.reset_student_attempts(course_id, student, module_state_key, delete_module=delete_module)
except StudentModule.DoesNotExist:
return HttpResponseBadRequest("Module does not exist.")
elif all_students:
instructor_task.api.submit_reset_problem_attempts_for_all_students(request, course_id, module_state_key)
response_payload['task'] = 'created'
else:
return HttpResponseBadRequest()
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('instructor')
@require_query_params(problem_to_reset="problem urlname to reset")
@common_exceptions_400
def rescore_problem(request, course_id):
"""
Starts a background process a students attempts counter. Optionally deletes student state for a problem.
Limited to instructor access.
Takes either of the following query paremeters
- problem_to_reset is a urlname of a problem
- student_email is an email
- all_students is a boolean
all_students and student_email cannot both be present.
"""
problem_to_reset = request.GET.get('problem_to_reset')
student_email = request.GET.get('student_email', False)
all_students = request.GET.get('all_students') in ['true', 'True', True]
if not (problem_to_reset and (all_students or student_email)):
return HttpResponseBadRequest("Missing query parameters.")
if all_students and student_email:
return HttpResponseBadRequest(
"Cannot rescore with all_students and student_email."
)
module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset)
response_payload = {}
response_payload['problem_to_reset'] = problem_to_reset
if student_email:
response_payload['student_email'] = student_email
student = User.objects.get(email=student_email)
instructor_task.api.submit_rescore_problem_for_student(request, course_id, module_state_key, student)
response_payload['task'] = 'created'
elif all_students:
instructor_task.api.submit_rescore_problem_for_all_students(request, course_id, module_state_key)
response_payload['task'] = 'created'
else:
return HttpResponseBadRequest()
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('instructor')
def list_instructor_tasks(request, course_id):
"""
List instructor tasks.
Limited to instructor access.
Takes optional query paremeters.
- With no arguments, lists running tasks.
- `problem_urlname` lists task history for problem
- `problem_urlname` and `student_email` lists task
history for problem AND student (intersection)
"""
problem_urlname = request.GET.get('problem_urlname', False)
student_email = request.GET.get('student_email', False)
if student_email and not problem_urlname:
return HttpResponseBadRequest(
"student_email must accompany problem_urlname"
)
if problem_urlname:
module_state_key = _msk_from_problem_urlname(course_id, problem_urlname)
if student_email:
student = User.objects.get(email=student_email)
tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key, student)
else:
tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key)
else:
tasks = instructor_task.api.get_running_instructor_tasks(course_id)
def extract_task_features(task):
""" Convert task to dict for json rendering """
features = ['task_type', 'task_input', 'task_id', 'requester', 'created', 'task_state']
return dict((feature, str(getattr(task, feature))) for feature in features)
response_payload = {
'tasks': map(extract_task_features, tasks),
}
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params('rolename')
def list_forum_members(request, course_id):
"""
Lists forum members of a certain rolename.
Limited to staff access.
The requesting user must be at least staff.
Staff forum admins can access all roles EXCEPT for FORUM_ROLE_ADMINISTRATOR
which is limited to instructors.
Takes query parameter `rolename`.
"""
course = get_course_by_id(course_id)
has_instructor_access = has_access(request.user, course, 'instructor')
has_forum_admin = has_forum_access(
request.user, course_id, FORUM_ROLE_ADMINISTRATOR
)
rolename = request.GET.get('rolename')
# default roles require either (staff & forum admin) or (instructor)
if not (has_forum_admin or has_instructor_access):
return HttpResponseBadRequest(
"Operation requires staff & forum admin or instructor access"
)
# EXCEPT FORUM_ROLE_ADMINISTRATOR requires (instructor)
if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access:
return HttpResponseBadRequest("Operation requires instructor access.")
# filter out unsupported for roles
if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
return HttpResponseBadRequest("Unrecognized rolename '{}'.".format(rolename))
try:
role = Role.objects.get(name=rolename, course_id=course_id)
users = role.users.all().order_by('username')
except Role.DoesNotExist:
users = []
def extract_user_info(user):
""" Convert user to dict for json rendering. """
return {
'username': user.username,
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
}
response_payload = {
'course_id': course_id,
rolename: map(extract_user_info, users),
}
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params(
email="the target users email",
rolename="the forum role",
action="'allow' or 'revoke'",
)
@common_exceptions_400
def update_forum_role_membership(request, course_id):
"""
Modify user's forum role.
The requesting user must be at least staff.
Staff forum admins can access all roles EXCEPT for FORUM_ROLE_ADMINISTRATOR
which is limited to instructors.
No one can revoke an instructors FORUM_ROLE_ADMINISTRATOR status.
Query parameters:
- `email` is the target users email
- `rolename` is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
- `action` is one of ['allow', 'revoke']
"""
course = get_course_by_id(course_id)
has_instructor_access = has_access(request.user, course, 'instructor')
has_forum_admin = has_forum_access(
request.user, course_id, FORUM_ROLE_ADMINISTRATOR
)
email = request.GET.get('email')
rolename = request.GET.get('rolename')
action = request.GET.get('action')
# default roles require either (staff & forum admin) or (instructor)
if not (has_forum_admin or has_instructor_access):
return HttpResponseBadRequest(
"Operation requires staff & forum admin or instructor access"
)
# EXCEPT FORUM_ROLE_ADMINISTRATOR requires (instructor)
if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access:
return HttpResponseBadRequest("Operation requires instructor access.")
if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
return HttpResponseBadRequest("Unrecognized rolename '{}'.".format(rolename))
user = User.objects.get(email=email)
target_is_instructor = has_access(user, course, 'instructor')
# cannot revoke instructor
if target_is_instructor and action == 'revoke' and rolename == FORUM_ROLE_ADMINISTRATOR:
return HttpResponseBadRequest("Cannot revoke instructor forum admin privelages.")
try:
access.update_forum_role_membership(course_id, user, rolename, action)
except Role.DoesNotExist:
return HttpResponseBadRequest("Role does not exist.")
response_payload = {
'course_id': course_id,
'action': action,
}
response = HttpResponse(
json.dumps(response_payload), content_type="application/json"
)
return response
def _split_input_list(str_list):
"""
Separate out individual student email from the comma, or space separated string.
e.g.
in: "Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed"
out: ['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed']
`str_list` is a string coming from an input text area
returns a list of separated values
"""
new_list = re.split(r'[\n\r\s,]', str_list)
new_list = [s.strip() for s in new_list]
new_list = [s for s in new_list if s != '']
return new_list
def _msk_from_problem_urlname(course_id, urlname):
"""
Convert a 'problem urlname' (name that instructor's input into dashboard)
to a module state key (db field)
"""
if urlname.endswith(".xml"):
urlname = urlname[:-4]
urlname = "problem/" + urlname
(org, course_name, _) = course_id.split("/")
module_state_key = "i4x://" + org + "/" + course_name + "/" + urlname
return module_state_key
"""
Instructor API endpoint urls.
"""
from django.conf.urls import patterns, url
urlpatterns = patterns('', # nopep8
url(r'^students_update_enrollment$',
'instructor.views.api.students_update_enrollment', name="students_update_enrollment"),
url(r'^list_course_role_members$',
'instructor.views.api.list_course_role_members', name="list_course_role_members"),
url(r'^modify_access$',
'instructor.views.api.modify_access', name="modify_access"),
url(r'^get_grading_config$',
'instructor.views.api.get_grading_config', name="get_grading_config"),
url(r'^get_students_features(?P<csv>/csv)?$',
'instructor.views.api.get_students_features', name="get_students_features"),
url(r'^get_distribution$',
'instructor.views.api.get_distribution', name="get_distribution"),
url(r'^get_student_progress_url$',
'instructor.views.api.get_student_progress_url', name="get_student_progress_url"),
url(r'^reset_student_attempts$',
'instructor.views.api.reset_student_attempts', name="reset_student_attempts"),
url(r'^rescore_problem$',
'instructor.views.api.rescore_problem', name="rescore_problem"),
url(r'^list_instructor_tasks$',
'instructor.views.api.list_instructor_tasks', name="list_instructor_tasks"),
url(r'^list_forum_members$',
'instructor.views.api.list_forum_members', name="list_forum_members"),
url(r'^update_forum_role_membership$',
'instructor.views.api.update_forum_role_membership', name="update_forum_role_membership"),
)
"""
Instructor Dashboard Views
"""
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from mitxmako.shortcuts import render_to_response
from django.core.urlresolvers import reverse
from django.utils.html import escape
from django.http import Http404
from courseware.access import has_access
from courseware.courses import get_course_by_id
from django_comment_client.utils import has_forum_access
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
from xmodule.modulestore.django import modulestore
from student.models import CourseEnrollment
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def instructor_dashboard_2(request, course_id):
""" Display the instructor dashboard for a course. """
course = get_course_by_id(course_id, depth=None)
access = {
'admin': request.user.is_staff,
'instructor': has_access(request.user, course, 'instructor'),
'staff': has_access(request.user, course, 'staff'),
'forum_admin': has_forum_access(
request.user, course_id, FORUM_ROLE_ADMINISTRATOR
),
}
if not access['staff']:
raise Http404()
sections = [
_section_course_info(course_id),
_section_membership(course_id, access),
_section_student_admin(course_id, access),
_section_data_download(course_id),
_section_analytics(course_id),
]
context = {
'course': course,
'old_dashboard_url': reverse('instructor_dashboard', kwargs={'course_id': course_id}),
'sections': sections,
}
return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context)
"""
Section functions starting with _section return a dictionary of section data.
The dictionary must include at least {
'section_key': 'circus_expo'
'section_display_name': 'Circus Expo'
}
section_key will be used as a css attribute, javascript tie-in, and template import filename.
section_display_name will be used to generate link titles in the nav bar.
""" # pylint: disable=W0105
def _section_course_info(course_id):
""" Provide data for the corresponding dashboard section """
course = get_course_by_id(course_id, depth=None)
section_data = {}
section_data['section_key'] = 'course_info'
section_data['section_display_name'] = 'Course Info'
section_data['course_id'] = course_id
section_data['course_display_name'] = course.display_name
section_data['enrollment_count'] = CourseEnrollment.objects.filter(course_id=course_id).count()
section_data['has_started'] = course.has_started()
section_data['has_ended'] = course.has_ended()
try:
advance = lambda memo, (letter, score): "{}: {}, ".format(letter, score) + memo
section_data['grade_cutoffs'] = reduce(advance, course.grade_cutoffs.items(), "")[:-2]
except Exception:
section_data['grade_cutoffs'] = "Not Available"
# section_data['offline_grades'] = offline_grades_available(course_id)
try:
section_data['course_errors'] = [(escape(a), '') for (a, _) in modulestore().get_item_errors(course.location)]
except Exception:
section_data['course_errors'] = [('Error fetching errors', '')]
return section_data
def _section_membership(course_id, access):
""" Provide data for the corresponding dashboard section """
section_data = {
'section_key': 'membership',
'section_display_name': 'Membership',
'access': access,
'enroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'unenroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'list_course_role_members_url': reverse('list_course_role_members', kwargs={'course_id': course_id}),
'modify_access_url': reverse('modify_access', kwargs={'course_id': course_id}),
'list_forum_members_url': reverse('list_forum_members', kwargs={'course_id': course_id}),
'update_forum_role_membership_url': reverse('update_forum_role_membership', kwargs={'course_id': course_id}),
}
return section_data
def _section_student_admin(course_id, access):
""" Provide data for the corresponding dashboard section """
section_data = {
'section_key': 'student_admin',
'section_display_name': 'Student Admin',
'access': access,
'get_student_progress_url_url': reverse('get_student_progress_url', kwargs={'course_id': course_id}),
'enrollment_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'reset_student_attempts_url': reverse('reset_student_attempts', kwargs={'course_id': course_id}),
'rescore_problem_url': reverse('rescore_problem', kwargs={'course_id': course_id}),
'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': course_id}),
}
return section_data
def _section_data_download(course_id):
""" Provide data for the corresponding dashboard section """
section_data = {
'section_key': 'data_download',
'section_display_name': 'Data Download',
'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': course_id}),
'get_students_features_url': reverse('get_students_features', kwargs={'course_id': course_id}),
}
return section_data
def _section_analytics(course_id):
""" Provide data for the corresponding dashboard section """
section_data = {
'section_key': 'analytics',
'section_display_name': 'Analytics',
'get_distribution_url': reverse('get_distribution', kwargs={'course_id': course_id}),
}
return section_data
...@@ -769,14 +769,15 @@ def instructor_dashboard(request, course_id): ...@@ -769,14 +769,15 @@ def instructor_dashboard(request, course_id):
'plots': plots, # psychometrics 'plots': plots, # psychometrics
'course_errors': modulestore().get_item_errors(course.location), 'course_errors': modulestore().get_item_errors(course.location),
'instructor_tasks': instructor_tasks, 'instructor_tasks': instructor_tasks,
'djangopid': os.getpid(),
'mitx_version': getattr(settings, 'MITX_VERSION_STRING', ''),
'offline_grade_log': offline_grades_available(course_id), 'offline_grade_log': offline_grades_available(course_id),
'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}),
'analytics_results': analytics_results, 'analytics_results': analytics_results,
} }
if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'):
context['beta_dashboard_url'] = reverse('instructor_dashboard_2', kwargs={'course_id': course_id})
return render_to_response('courseware/instructor_dashboard.html', context) return render_to_response('courseware/instructor_dashboard.html', context)
......
...@@ -144,6 +144,9 @@ MITX_FEATURES = { ...@@ -144,6 +144,9 @@ MITX_FEATURES = {
# Enable instructor dash to submit background tasks # Enable instructor dash to submit background tasks
'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True, 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True,
# Enable instructor dash beta version link
'ENABLE_INSTRUCTOR_BETA_DASHBOARD': False,
# Allow use of the hint managment instructor view. # Allow use of the hint managment instructor view.
'ENABLE_HINTER_INSTRUCTOR_VIEW': False, 'ENABLE_HINTER_INSTRUCTOR_VIEW': False,
...@@ -152,7 +155,7 @@ MITX_FEATURES = { ...@@ -152,7 +155,7 @@ MITX_FEATURES = {
# Toggle to enable chat availability (configured on a per-course # Toggle to enable chat availability (configured on a per-course
# basis in Studio) # basis in Studio)
'ENABLE_CHAT': False 'ENABLE_CHAT': False,
} }
# Used for A/B testing # Used for A/B testing
......
...@@ -29,6 +29,7 @@ MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg i ...@@ -29,6 +29,7 @@ MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg i
MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = False
WIKI_ENABLED = True WIKI_ENABLED = True
......
...@@ -29,6 +29,8 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True ...@@ -29,6 +29,8 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True WIKI_ENABLED = True
......
# Analytics Section
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
# Analytics Section
class Analytics
constructor: (@$section) ->
@$section.data 'wrapper', @
# gather elements
@$display = @$section.find '.distribution-display'
@$display_text = @$display.find '.distribution-display-text'
@$display_graph = @$display.find '.distribution-display-graph'
@$display_table = @$display.find '.distribution-display-table'
@$distribution_select = @$section.find 'select#distributions'
@$request_response_error = @$display.find '.request-response-error'
@populate_selector => @$distribution_select.change => @on_selector_change()
reset_display: ->
@$display_text.empty()
@$display_graph.empty()
@$display_table.empty()
@$request_response_error.empty()
# fetch and list available distributions
# `cb` is a callback to be run after
populate_selector: (cb) ->
# ask for no particular distribution to get list of available distribuitions.
@get_profile_distributions undefined,
# on error, print to console and dom.
error: std_ajax_err => @$request_response_error.text "Error getting available distributions."
success: (data) =>
# replace loading text in drop-down with "-- Select Distribution --"
@$distribution_select.find('option').eq(0).text "-- Select Distribution --"
# add all fetched available features to drop-down
for feature in data.available_features
opt = $ '<option/>',
text: data.feature_display_names[feature]
data:
feature: feature
@$distribution_select.append opt
# call callback if one was supplied
cb?()
# display data
on_selector_change: ->
opt = @$distribution_select.children('option:selected')
feature = opt.data 'feature'
@reset_display()
# only proceed if there is a feature attached to the selected option.
return unless feature
@get_profile_distributions feature,
error: std_ajax_err => @$request_response_error.text "Error getting distribution for '#{feature}'."
success: (data) =>
feature_res = data.feature_results
if feature_res.type is 'EASY_CHOICE'
# display on SlickGrid
options =
enableCellNavigation: true
enableColumnReorder: false
forceFitColumns: true
columns = [
id: feature
field: feature
name: feature
,
id: 'count'
field: 'count'
name: 'Count'
]
grid_data = _.map feature_res.data, (value, key) ->
datapoint = {}
datapoint[feature] = feature_res.choices_display_names[key]
datapoint['count'] = value
datapoint
table_placeholder = $ '<div/>', class: 'slickgrid'
@$display_table.append table_placeholder
grid = new Slick.Grid(table_placeholder, grid_data, columns, options)
else if feature_res.feature is 'year_of_birth'
graph_placeholder = $ '<div/>', class: 'year-of-birth'
@$display_graph.append graph_placeholder
graph_data = _.map feature_res.data, (value, key) -> [parseInt(key), value]
$.plot graph_placeholder, [
data: graph_data
]
else
console.warn("unable to show distribution #{feature_res.type}")
@$display_text.text 'Unavailable Metric Display\n' + JSON.stringify(feature_res)
# fetch distribution data from server.
# `handler` can be either a callback for success
# or a mapping e.g. {success: ->, error: ->, complete: ->}
get_profile_distributions: (feature, handler) ->
settings =
dataType: 'json'
url: @$distribution_select.data 'endpoint'
data: feature: feature
if typeof handler is 'function'
_.extend settings, success: handler
else
_.extend settings, handler
$.ajax settings
# slickgrid's layout collapses when rendered
# in an invisible div. use this method to reload
# the AuthList widget
refresh: ->
@on_selector_change()
# handler for when the section title is clicked.
onClickTitle: ->
@refresh()
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
Analytics: Analytics
# Course Info Section
# This is the implementation of the simplest section
# of the instructor dashboard.
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
# A typical section object.
# constructed with $section, a jquery object
# which holds the section body container.
class CourseInfo
constructor: (@$section) ->
@$course_errors_wrapper = @$section.find '.course-errors-wrapper'
# if there are errors
if @$course_errors_wrapper.length
@$course_error_toggle = @$course_errors_wrapper.find '.toggle-wrapper'
@$course_error_toggle_text = @$course_error_toggle.find 'h2'
@$course_error_visibility_wrapper = @$course_errors_wrapper.find '.course-errors-visibility-wrapper'
@$course_errors = @$course_errors_wrapper.find '.course-error'
# append "(34)" to the course errors label
@$course_error_toggle_text.text @$course_error_toggle_text.text() + " (#{@$course_errors.length})"
# toggle .open class on errors
# to show and hide them.
@$course_error_toggle.click (e) =>
e.preventDefault()
if @$course_errors_wrapper.hasClass 'open'
@$course_errors_wrapper.removeClass 'open'
else
@$course_errors_wrapper.addClass 'open'
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
CourseInfo: CourseInfo
# Data Download Section
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
# Data Download Section
class DataDownload
constructor: (@$section) ->
# gather elements
@$display = @$section.find '.data-display'
@$display_text = @$display.find '.data-display-text'
@$display_table = @$display.find '.data-display-table'
@$request_response_error = @$display.find '.request-response-error'
@$list_studs_btn = @$section.find("input[name='list-profiles']'")
@$grade_config_btn = @$section.find("input[name='dump-gradeconf']'")
# attach click handlers
# this handler binds to both the download
# and the csv button
@$list_studs_btn.click (e) =>
url = @$list_studs_btn.data 'endpoint'
# handle csv special case
if $(e.target).data 'csv'
# redirect the document to the csv file.
url += '/csv'
location.href = url
else
@clear_display()
@$display_table.text 'Loading...'
# fetch user list
$.ajax
dataType: 'json'
url: url
error: std_ajax_err =>
@clear_display()
@$request_response_error.text "Error getting student list."
success: (data) =>
@clear_display()
# display on a SlickGrid
options =
enableCellNavigation: true
enableColumnReorder: false
forceFitColumns: true
columns = ({id: feature, field: feature, name: feature} for feature in data.queried_features)
grid_data = data.students
$table_placeholder = $ '<div/>', class: 'slickgrid'
@$display_table.append $table_placeholder
grid = new Slick.Grid($table_placeholder, grid_data, columns, options)
# grid.autosizeColumns()
@$grade_config_btn.click (e) =>
url = @$grade_config_btn.data 'endpoint'
# display html from grading config endpoint
$.ajax
dataType: 'json'
url: url
error: std_ajax_err =>
@clear_display()
@$request_response_error.text "Error getting grading configuration."
success: (data) =>
@clear_display()
@$display_text.html data['grading_config_summary']
clear_display: ->
@$display_text.empty()
@$display_table.empty()
@$request_response_error.empty()
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
DataDownload: DataDownload
# Instructor Dashboard Tab Manager
# The instructor dashboard is broken into sections.
# Only one section is visible at a time,
# and is responsible for its own functionality.
#
# NOTE: plantTimeout (which is just setTimeout from util.coffee)
# is used frequently in the instructor dashboard to isolate
# failures. If one piece of code under a plantTimeout fails
# then it will not crash the rest of the dashboard.
#
# NOTE: The instructor dashboard currently does not
# use backbone. Just lots of jquery. This should be fixed.
#
# NOTE: Server endpoints in the dashboard are stored in
# the 'data-endpoint' attribute of relevant html elements.
# The urls are rendered there by a template.
#
# NOTE: For an example of what a section object should look like
# see course_info.coffee
# imports from other modules
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
# CSS classes
CSS_INSTRUCTOR_CONTENT = 'instructor-dashboard-content-2'
CSS_ACTIVE_SECTION = 'active-section'
CSS_IDASH_SECTION = 'idash-section'
CSS_INSTRUCTOR_NAV = 'instructor-nav'
# prefix for deep-linking
HASH_LINK_PREFIX = '#view-'
# once we're ready, check if this page is the instructor dashboard
$ =>
instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}"
if instructor_dashboard_content.length > 0
setup_instructor_dashboard instructor_dashboard_content
setup_instructor_dashboard_sections instructor_dashboard_content
# enable navigation bar
# handles hiding and showing sections
setup_instructor_dashboard = (idash_content) =>
# clickable section titles
links = idash_content.find(".#{CSS_INSTRUCTOR_NAV}").find('a')
for link in ($ link for link in links)
link.click (e) ->
e.preventDefault()
# deactivate all link & section styles
idash_content.find(".#{CSS_INSTRUCTOR_NAV}").children().removeClass CSS_ACTIVE_SECTION
idash_content.find(".#{CSS_IDASH_SECTION}").removeClass CSS_ACTIVE_SECTION
# discover section paired to link
section_name = $(this).data 'section'
section = idash_content.find "##{section_name}"
# activate link & section styling
$(this).addClass CSS_ACTIVE_SECTION
section.addClass CSS_ACTIVE_SECTION
# tracking
analytics.pageview "instructor_section:#{section_name}"
# deep linking
# write to url
location.hash = "#{HASH_LINK_PREFIX}#{section_name}"
plantTimeout 0, -> section.data('wrapper')?.onClickTitle?()
# plantTimeout 0, -> section.data('wrapper')?.onExit?()
# activate an initial section by 'clicking' on it.
# check for a deep-link, or click the first link.
click_first_link = ->
link = links.eq(0)
link.click()
link.data('wrapper')?.onClickTitle?()
if (new RegExp "^#{HASH_LINK_PREFIX}").test location.hash
rmatch = (new RegExp "^#{HASH_LINK_PREFIX}(.*)").exec location.hash
section_name = rmatch[1]
link = links.filter "[data-section='#{section_name}']"
if link.length == 1
link.click()
link.data('wrapper')?.onClickTitle?()
else
click_first_link()
else
click_first_link()
# enable sections
setup_instructor_dashboard_sections = (idash_content) ->
# see fault isolation NOTE at top of file.
# an error thrown in one section will not block other sections from exectuing.
plantTimeout 0, -> new window.InstructorDashboard.sections.CourseInfo idash_content.find ".#{CSS_IDASH_SECTION}#course_info"
plantTimeout 0, -> new window.InstructorDashboard.sections.DataDownload idash_content.find ".#{CSS_IDASH_SECTION}#data_download"
plantTimeout 0, -> new window.InstructorDashboard.sections.Membership idash_content.find ".#{CSS_IDASH_SECTION}#membership"
plantTimeout 0, -> new window.InstructorDashboard.sections.StudentAdmin idash_content.find ".#{CSS_IDASH_SECTION}#student_admin"
plantTimeout 0, -> new window.InstructorDashboard.sections.Analytics idash_content.find ".#{CSS_IDASH_SECTION}#analytics"
# Membership Section
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
class MemberListWidget
# create a MemberListWidget `$container` is a jquery object to embody.
# `params` holds template parameters. `params` should look like the defaults below.
constructor: (@$container, params={}) ->
params = _.defaults params,
title: "Member List"
info: """
Use this list to manage members.
"""
labels: ["field1", "field2", "field3"]
add_placeholder: "Enter name"
add_btn_label: "Add Member"
add_handler: (input) ->
template_html = $("#member-list-widget-template").html()
@$container.html Mustache.render template_html, params
# bind info toggle
@$('.info-badge').click => @toggle_info()
# bind add button
@$('input[type="button"].add').click =>
params.add_handler? @$('.add-field').val()
show_info: ->
@$('.info').show()
@$('.member-list').hide()
show_list: ->
@$('.info').hide()
@$('.member-list').show()
toggle_info: ->
@$('.info').toggle()
@$('.member-list').toggle()
# clear the input text field
clear_input: -> @$('.add-field').val ''
# clear all table rows
clear_rows: -> @$('table tbody').empty()
# takes a table row as an array items are inserted as text, unless detected
# as a jquery objects in which case they are inserted directly. if an
# element is a jquery object
add_row: (row_array) ->
$tbody = @$('table tbody')
$tr = $ '<tr>'
for item in row_array
$td = $ '<td>'
if item instanceof jQuery
$td.append item
else
$td.text item
$tr.append $td
$tbody.append $tr
# local selector
$: (selector) ->
if @debug?
s = @$container.find selector
if s?.length != 1
console.warn "local selector '#{selector}' found (#{s.length}) results"
s
else
@$container.find selector
class AuthListWidget extends MemberListWidget
constructor: ($container, @rolename, @$error_section) ->
super $container,
title: $container.data 'display-name'
info: $container.data 'info-text'
labels: ["username", "email", "revoke access"]
add_placeholder: "Enter email"
add_btn_label: $container.data 'add-button-label'
add_handler: (input) => @add_handler input
@debug = true
@list_endpoint = $container.data 'list-endpoint'
@modify_endpoint = $container.data 'modify-endpoint'
unless @rolename?
throw "AuthListWidget missing @rolename"
@reload_list()
# action to do when is reintroduced into user's view
re_view: ->
@clear_errors()
@clear_input()
@reload_list()
@$('.info').hide()
@$('.member-list').show()
# handle clicks on the add button
add_handler: (input) ->
if input? and input isnt ''
@modify_member_access input, 'allow', (error) =>
# abort on error
return @show_errors error unless error is null
@clear_errors()
@clear_input()
@reload_list()
else
@show_errors "Enter an email."
# reload the list of members
reload_list: ->
# @clear_rows()
# @show_info()
@get_member_list (error, member_list) =>
# abort on error
return @show_errors error unless error is null
# only show the list of there are members
@clear_rows()
@show_info()
# @show_info()
# use _.each instead of 'for' so that member
# is bound in the button callback.
_.each member_list, (member) =>
# if there are members, show the list
# create revoke button and insert it into the row
$revoke_btn = $ '<div/>',
class: 'revoke'
click: =>
@modify_member_access member.email, 'revoke', (error) =>
# abort on error
return @show_errors error unless error is null
@clear_errors()
@reload_list()
@add_row [member.username, member.email, $revoke_btn]
# make sure the list is shown because there are members.
@show_list()
# clear error display
clear_errors: -> @$error_section?.text ''
# set error display
show_errors: (msg) -> @$error_section?.text msg
# send ajax request to list members
# `cb` is called with cb(error, member_list)
get_member_list: (cb) ->
$.ajax
dataType: 'json'
url: @list_endpoint
data: rolename: @rolename
success: (data) => cb? null, data[@rolename]
error: std_ajax_err => cb? "Error fetching list for role '#{@rolename}'"
# send ajax request to modify access
# (add or remove them from the list)
# `action` can be 'allow' or 'revoke'
# `cb` is called with cb(error, data)
modify_member_access: (email, action, cb) ->
$.ajax
dataType: 'json'
url: @modify_endpoint
data:
email: email
rolename: @rolename
action: action
success: (data) => cb? null, data
error: std_ajax_err => cb? "Error changing user's permissions."
# Wrapper for the batch enrollment subsection.
# This object handles buttons, success and failure reporting,
# and server communication.
class BatchEnrollment
constructor: (@$container) ->
# gather elements
@$emails_input = @$container.find("textarea[name='student-emails']'")
@$btn_enroll = @$container.find("input[name='enroll']'")
@$btn_unenroll = @$container.find("input[name='unenroll']'")
@$checkbox_autoenroll = @$container.find("input[name='auto-enroll']'")
@$task_response = @$container.find(".request-response")
@$request_response_error = @$container.find(".request-response-error")
# attach click handlers
@$btn_enroll.click =>
send_data =
action: 'enroll'
emails: @$emails_input.val()
auto_enroll: @$checkbox_autoenroll.is(':checked')
$.ajax
dataType: 'json'
url: @$btn_enroll.data 'endpoint'
data: send_data
success: (data) => @display_response data
error: std_ajax_err => @fail_with_error "Error enrolling/unenrolling students."
@$btn_unenroll.click =>
send_data =
action: 'unenroll'
emails: @$emails_input.val()
auto_enroll: @$checkbox_autoenroll.is(':checked')
$.ajax
dataType: 'json'
url: @$btn_unenroll.data 'endpoint'
data: send_data
success: (data) => @display_response data
error: std_ajax_err => @fail_with_error "Error enrolling/unenrolling students."
fail_with_error: (msg) ->
console.warn msg
@$task_response.empty()
@$request_response_error.empty()
@$request_response_error.text msg
display_response: (data_from_server) ->
@$task_response.empty()
@$request_response_error.empty()
# these results arrays contain student_results
# only populated arrays will be rendered
#
# students for which there was an error during the action
errors = []
# students who are now enrolled in the course
enrolled = []
# students who are now allowed to enroll in the course
allowed = []
# students who will be autoenrolled on registration
autoenrolled = []
# students who are now not enrolled in the course
notenrolled = []
# categorize student results into the above arrays.
for student_results in data_from_server.results
# for a successful action.
# student_results is of the form {
# "email": "jd405@edx.org",
# "before": {
# "enrollment": true,
# "auto_enroll": false,
# "user": true,
# "allowed": false
# }
# "after": {
# "enrollment": true,
# "auto_enroll": false,
# "user": true,
# "allowed": false
# },
# }
#
# for an action error.
# student_results is of the form {
# 'email': email,
# 'error': True,
# }
if student_results.error
errors.push student_results
else if student_results.after.enrollment
enrolled.push student_results
else if student_results.after.allowed
if student_results.after.auto_enroll
autoenrolled.push student_results
else
allowed.push student_results
else if not student_results.after.enrollment
notenrolled.push student_results
else
console.warn 'student results not reported to user'
console.warn student_results
# render populated result arrays
render_list = (label, emails) =>
task_res_section = $ '<div/>', class: 'request-res-section'
task_res_section.append $ '<h3/>', text: label
email_list = $ '<ul/>'
task_res_section.append email_list
for email in emails
email_list.append $ '<li/>', text: email
@$task_response.append task_res_section
if errors.length
errors_label = do ->
if data_from_server.action is 'enroll'
"There was an error enrolling:"
else if data_from_server.action is 'unenroll'
"There was an error unenrolling:"
else
console.warn "unknown action from server '#{data_from_server.action}'"
"There was an error processing:"
for student_results in errors
render_list errors_label, (sr.email for sr in errors)
if enrolled.length
render_list "Students Enrolled:", (sr.email for sr in enrolled)
if allowed.length
render_list "These students will be allowed to enroll once they register:",
(sr.email for sr in allowed)
if autoenrolled.length
render_list "These students will be enrolled once they register:",
(sr.email for sr in autoenrolled)
if notenrolled.length
render_list "These students are now not enrolled:",
(sr.email for sr in notenrolled)
# Wrapper for auth list subsection.
# manages a list of users who have special access.
# these could be instructors, staff, beta users, or forum roles.
# uses slickgrid to display list.
class AuthList
# rolename is one of ['instructor', 'staff'] for instructor_staff endpoints
# rolename is the name of Role for forums for the forum endpoints
constructor: (@$container, @rolename) ->
# gather elements
@$display_table = @$container.find('.auth-list-table')
@$request_response_error = @$container.find('.request-response-error')
@$add_section = @$container.find('.auth-list-add')
@$allow_field = @$add_section.find("input[name='email']")
@$allow_button = @$add_section.find("input[name='allow']")
# attach click handler
@$allow_button.click =>
@access_change @$allow_field.val(), 'allow', => @reload_auth_list()
@$allow_field.val ''
@reload_auth_list()
# fetch and display list of users who match criteria
reload_auth_list: ->
# helper function to display server data in the list
load_auth_list = (data) =>
# clear existing data
@$request_response_error.empty()
@$display_table.empty()
# setup slickgrid
options =
enableCellNavigation: true
enableColumnReorder: false
# autoHeight: true
forceFitColumns: true
# this is a hack to put a button/link in a slick grid cell
# if you change columns, then you must update
# WHICH_CELL_IS_REVOKE to have the index
# of the revoke column (left to right).
WHICH_CELL_IS_REVOKE = 3
columns = [
id: 'username'
field: 'username'
name: 'Username'
,
id: 'email'
field: 'email'
name: 'Email'
,
id: 'first_name'
field: 'first_name'
name: 'First Name'
,
# id: 'last_name'
# field: 'last_name'
# name: 'Last Name'
# ,
id: 'revoke'
field: 'revoke'
name: 'Revoke'
formatter: (row, cell, value, columnDef, dataContext) ->
"<span class='revoke-link'>Revoke Access</span>"
]
table_data = data[@rolename]
$table_placeholder = $ '<div/>', class: 'slickgrid'
@$display_table.append $table_placeholder
grid = new Slick.Grid($table_placeholder, table_data, columns, options)
# click handler part of the revoke button/link hack.
grid.onClick.subscribe (e, args) =>
item = args.grid.getDataItem(args.row)
if args.cell is WHICH_CELL_IS_REVOKE
@access_change item.email, 'revoke', => @reload_auth_list()
# fetch data from the endpoint
# the endpoint comes from data-endpoint of the table
$.ajax
dataType: 'json'
url: @$display_table.data 'endpoint'
data: rolename: @rolename
success: load_auth_list
error: std_ajax_err => @$request_response_error.text "Error fetching list for '#{@rolename}'"
# slickgrid's layout collapses when rendered
# in an invisible div. use this method to reload
# the AuthList widget
refresh: ->
@$display_table.empty()
@reload_auth_list()
# update the access of a user.
# (add or remove them from the list)
# action should be one of ['allow', 'revoke']
access_change: (email, action, cb) ->
$.ajax
dataType: 'json'
url: @$add_section.data 'endpoint'
data:
email: email
rolename: @rolename
action: action
success: (data) -> cb?(data)
error: std_ajax_err => @$request_response_error.text "Error changing user's permissions."
# Membership Section
class Membership
# enable subsections.
constructor: (@$section) ->
# attach self to html
# so that instructor_dashboard.coffee can find this object
# to call event handlers like 'onClickTitle'
@$section.data 'wrapper', @
# isolate # initialize BatchEnrollment subsection
plantTimeout 0, => new BatchEnrollment @$section.find '.batch-enrollment'
# gather elements
@$list_selector = @$section.find 'select#member-lists-selector'
@$auth_list_containers = @$section.find '.auth-list-container'
@$auth_list_errors = @$section.find '.member-lists-management .request-response-error'
# initialize & store AuthList subsections
# one for each .auth-list-container in the section.
@auth_lists = _.map (@$auth_list_containers), (auth_list_container) =>
rolename = $(auth_list_container).data 'rolename'
new AuthListWidget $(auth_list_container), rolename, @$auth_list_errors
# populate selector
@$list_selector.empty()
for auth_list in @auth_lists
@$list_selector.append $ '<option/>',
text: auth_list.$container.data 'display-name'
data:
auth_list: auth_list
@$list_selector.change =>
$opt = @$list_selector.children('option:selected')
return unless $opt.length > 0
for auth_list in @auth_lists
auth_list.$container.removeClass 'active'
auth_list = $opt.data('auth_list')
auth_list.$container.addClass 'active'
auth_list.re_view()
# one-time first selection of top list.
@$list_selector.change()
# handler for when the section title is clicked.
onClickTitle: ->
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
Membership: Membership
# Student Admin Section
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
plantInterval = -> window.InstructorDashboard.util.plantInterval.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
# wrap window.confirm
# display `msg`
# run `ok` or `cancel` depending on response
confirm_then = ({msg, ok, cancel}) ->
if window.confirm msg then ok?() else cancel?()
# get jquery element and assert its existance
find_and_assert = ($root, selector) ->
item = $root.find selector
if item.length != 1
console.error "element selection failed for '#{selector}' resulted in length #{item.length}"
throw "Failed Element Selection"
else
item
# render a task list table to the DOM
# `$table_tasks` the $element in which to put the table
# `tasks_data`
create_task_list_table = ($table_tasks, tasks_data) ->
$table_tasks.empty()
options =
enableCellNavigation: true
enableColumnReorder: false
autoHeight: true
rowHeight: 60
forceFitColumns: true
columns = [
id: 'task_type'
field: 'task_type'
name: 'Task Type'
,
id: 'requester'
field: 'requester'
name: 'Requester'
width: 30
,
id: 'task_input'
field: 'task_input'
name: 'Input'
,
id: 'task_state'
field: 'task_state'
name: 'State'
width: 30
,
id: 'task_id'
field: 'task_id'
name: 'Task ID'
width: 50
,
id: 'created'
field: 'created'
name: 'Created'
]
table_data = tasks_data
$table_placeholder = $ '<div/>', class: 'slickgrid'
$table_tasks.append $table_placeholder
grid = new Slick.Grid($table_placeholder, table_data, columns, options)
class StudentAdmin
constructor: (@$section) ->
@$section.data 'wrapper', @
# gather buttons
# some buttons are optional because they can be flipped by the instructor task feature switch
# student-specific
@$field_student_select = find_and_assert @$section, "input[name='student-select']"
@$progress_link = find_and_assert @$section, "a.progress-link"
@$btn_enroll = find_and_assert @$section, "input[name='enroll']"
@$btn_unenroll = find_and_assert @$section, "input[name='unenroll']"
@$field_problem_select_single = find_and_assert @$section, "input[name='problem-select-single']"
@$btn_reset_attempts_single = find_and_assert @$section, "input[name='reset-attempts-single']"
@$btn_delete_state_single = @$section.find "input[name='delete-state-single']"
@$btn_rescore_problem_single = @$section.find "input[name='rescore-problem-single']"
@$btn_task_history_single = @$section.find "input[name='task-history-single']"
@$table_task_history_single = @$section.find ".task-history-single-table"
# course-specific
@$field_problem_select_all = @$section.find "input[name='problem-select-all']"
@$btn_reset_attempts_all = @$section.find "input[name='reset-attempts-all']"
@$btn_rescore_problem_all = @$section.find "input[name='rescore-problem-all']"
@$btn_task_history_all = @$section.find "input[name='task-history-all']"
@$table_task_history_all = @$section.find ".task-history-all-table"
@$table_running_tasks = @$section.find ".running-tasks-table"
# response areas
@$request_response_error_single = find_and_assert @$section, ".student-specific-container .request-response-error"
@$request_response_error_all = @$section.find ".course-specific-container .request-response-error"
# start polling for task list
if @$table_running_tasks.length > 0
@start_refresh_running_task_poll_loop()
# attach click handlers
# go to student progress page
@$progress_link.click (e) =>
e.preventDefault()
email = @$field_student_select.val()
$.ajax
dataType: 'json'
url: @$progress_link.data 'endpoint'
data: student_email: email
success: @clear_errors_then (data) ->
window.location = data.progress_url
error: std_ajax_err => @$request_response_error_single.text "Error getting student progress url for '#{email}'."
# enroll student
@$btn_enroll.click =>
send_data =
action: 'enroll'
emails: @$field_student_select.val()
auto_enroll: false
$.ajax
dataType: 'json'
url: @$btn_enroll.data 'endpoint'
data: send_data
success: @clear_errors_then -> console.log "student #{send_data.emails} enrolled"
error: std_ajax_err => @$request_response_error_single.text "Error enrolling student '#{send_data.emails}'."
# unenroll student
@$btn_unenroll.click =>
send_data =
action: 'unenroll'
emails: @$field_student_select.val()
$.ajax
dataType: 'json'
url: @$btn_unenroll.data 'endpoint'
data: send_data
success: @clear_errors_then -> console.log "student #{send_data.emails} unenrolled"
error: std_ajax_err => @$request_response_error_single.text "Error unenrolling student '#{send_data.emails}'."
# reset attempts for student on problem
@$btn_reset_attempts_single.click =>
send_data =
student_email: @$field_student_select.val()
problem_to_reset: @$field_problem_select_single.val()
delete_module: false
$.ajax
dataType: 'json'
url: @$btn_reset_attempts_single.data 'endpoint'
data: send_data
success: @clear_errors_then -> console.log 'problem attempts reset'
error: std_ajax_err => @$request_response_error_single.text "Error resetting problem attempts."
# delete state for student on problem
@$btn_delete_state_single.click => confirm_then
msg: "Delete student '#{@$field_student_select.val()}'s state on problem '#{@$field_problem_select_single.val()}'?"
ok: =>
send_data =
student_email: @$field_student_select.val()
problem_to_reset: @$field_problem_select_single.val()
delete_module: true
$.ajax
dataType: 'json'
url: @$btn_delete_state_single.data 'endpoint'
data: send_data
success: @clear_errors_then -> console.log 'module state deleted'
error: std_ajax_err => @$request_response_error_single.text "Error deleting problem state."
# start task to rescore problem for student
@$btn_rescore_problem_single.click =>
send_data =
student_email: @$field_student_select.val()
problem_to_reset: @$field_problem_select_single.val()
$.ajax
dataType: 'json'
url: @$btn_rescore_problem_single.data 'endpoint'
data: send_data
success: @clear_errors_then -> console.log 'started rescore problem task'
error: std_ajax_err => @$request_response_error_single.text "Error starting a task to rescore student's problem."
# list task history for student+problem
@$btn_task_history_single.click =>
send_data =
student_email: @$field_student_select.val()
problem_urlname: @$field_problem_select_single.val()
if not send_data.student_email
return @$request_response_error_single.text "Enter a student email."
if not send_data.problem_urlname
return @$request_response_error_single.text "Enter a problem urlname."
$.ajax
dataType: 'json'
url: @$btn_task_history_single.data 'endpoint'
data: send_data
success: @clear_errors_then (data) =>
create_task_list_table @$table_task_history_single, data.tasks
error: std_ajax_err => @$request_response_error_single.text "Error getting task history for student+problem"
# start task to reset attempts on problem for all students
@$btn_reset_attempts_all.click => confirm_then
msg: "Reset attempts for all students on problem '#{@$field_problem_select_all.val()}'?"
ok: =>
send_data =
all_students: true
problem_to_reset: @$field_problem_select_all.val()
$.ajax
dataType: 'json'
url: @$btn_reset_attempts_all.data 'endpoint'
data: send_data
success: @clear_errors_then -> console.log 'started reset attempts task'
error: std_ajax_err => @$request_response_error_all.text "Error starting a task to reset attempts for all students on this problem."
# start task to rescore problem for all students
@$btn_rescore_problem_all.click => confirm_then
msg: "Rescore problem '#{@$field_problem_select_all.val()}' for all students?"
ok: =>
send_data =
all_students: true
problem_to_reset: @$field_problem_select_all.val()
$.ajax
dataType: 'json'
url: @$btn_rescore_problem_all.data 'endpoint'
data: send_data
success: @clear_errors_then -> console.log 'started rescore problem task'
error: std_ajax_err => @$request_response_error_all.text "Error starting a task to rescore this problem for all students."
# list task history for problem
@$btn_task_history_all.click =>
send_data =
problem_urlname: @$field_problem_select_all.val()
if not send_data.problem_urlname
return @$request_response_error_all.text "Enter a problem urlname."
$.ajax
dataType: 'json'
url: @$btn_task_history_all.data 'endpoint'
data: send_data
success: @clear_errors_then (data) =>
create_task_list_table @$table_task_history_all, data.tasks
error: std_ajax_err => @$request_response_error_all.text "Error listing task history for this student and problem."
reload_running_tasks_list: =>
list_endpoint = @$table_running_tasks.data 'endpoint'
$.ajax
dataType: 'json'
url: list_endpoint
success: (data) => create_task_list_table @$table_running_tasks, data.tasks
error: std_ajax_err => console.warn "error listing all instructor tasks"
start_refresh_running_task_poll_loop: ->
@reload_running_tasks_list()
if @$section.hasClass 'active-section'
# poll every 20 seconds
plantTimeout 20000, => @start_refresh_running_task_poll_loop()
# wraps a function, but first clear the error displays
clear_errors_then: (cb) ->
@$request_response_error_single.empty()
@$request_response_error_all.empty()
->
cb?.apply this, arguments
# handler for when the section title is clicked.
onClickTitle: ->
if @$table_running_tasks.length > 0
@start_refresh_running_task_poll_loop()
# handler for when the section is closed
# not working yet.
# onExit: ->
# clearInterval @reload_running_task_list_slot
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
StudentAdmin: StudentAdmin
# Common utilities for instructor dashboard components.
# reverse arguments on common functions to enable
# better coffeescript with callbacks at the end.
plantTimeout = (ms, cb) -> setTimeout cb, ms
plantInterval = (ms, cb) -> setInterval cb, ms
# standard ajax error wrapper
#
# wraps a `handler` function so that first
# it prints basic error information to the console.
std_ajax_err = (handler) -> (jqXHR, textStatus, errorThrown) ->
console.warn """ajax error
textStatus: #{textStatus}
errorThrown: #{errorThrown}"""
handler.apply this, arguments
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
window.InstructorDashboard.util =
plantTimeout: plantTimeout
plantInterval: plantInterval
std_ajax_err: std_ajax_err
...@@ -29,6 +29,7 @@ $pink: rgb(182,37,104); ...@@ -29,6 +29,7 @@ $pink: rgb(182,37,104);
$yellow: rgb(255, 252, 221); $yellow: rgb(255, 252, 221);
$red: rgb(178, 6, 16); $red: rgb(178, 6, 16);
$error-red: rgb(253, 87, 87); $error-red: rgb(253, 87, 87);
$danger-red: rgb(212, 64, 64);
$light-gray: rgb(221, 221, 221); $light-gray: rgb(221, 221, 221);
$dark-gray: rgb(51, 51, 51); $dark-gray: rgb(51, 51, 51);
$border-color: rgb(200, 200, 200); $border-color: rgb(200, 200, 200);
......
...@@ -64,6 +64,7 @@ ...@@ -64,6 +64,7 @@
// instructor // instructor
@import "course/instructor/instructor"; @import "course/instructor/instructor";
@import "course/instructor/instructor_2";
// discussion // discussion
@import "course/discussion/form-wmd-toolbar"; @import "course/discussion/form-wmd-toolbar";
.instructor-dashboard-wrapper { .instructor-dashboard-wrapper {
display: table; display: table;
position: relative;
.beta-button-wrapper {
position: absolute;
top: 2em;
right: 2em;
}
section.instructor-dashboard-content { section.instructor-dashboard-content {
@extend .content; @extend .content;
......
@mixin idashbutton ($color) {
@include button(simple, $color);
@extend .button-reset;
margin-bottom: 1em;
padding: 8px 17px 8px 17px;
font-size: em(13);
line-height: 1.3em;
}
.instructor-dashboard-wrapper-2 {
position: relative;
// display: table;
.olddash-button-wrapper {
position: absolute;
top: 17px;
right: 15px;
font-size: 11pt;
}
}
section.instructor-dashboard-content-2 {
@extend .content;
// position: relative;
padding: 40px;
width: 100%;
// .has-event-handler-for-click {
// border: 1px solid blue;
// }
.request-response-error {
margin-top: 1em;
margin-bottom: 1em;
color: $error-red;
}
.slickgrid {
margin-left: 1px;
color:#333333;
font-size:11px;
font-family: verdana,arial,sans-serif;
.slick-header-column {
// height: 100%
}
.slick-cell {
border: 1px dotted silver;
border-collapse: collapse;
white-space: normal;
word-wrap: break-word;
}
}
h1 {
@extend .top-header;
padding-bottom: 0;
border-bottom: 0;
}
input[type="button"] {
@include idashbutton(#eee);
&.molly-guard {
// @include idashbutton($danger-red);
// @include idashbutton($black);
// border: 2px solid $danger-red;
}
}
.instructor_dash_glob_info {
position: absolute;
top: 46px;
right: 50px;
text-align: right;
}
.instructor-nav {
padding-bottom: 1em;
border-bottom: 1px solid #C8C8C8;
a {
margin-right: 1.2em;
}
.active-section {
color: #551A8B;
}
}
section.idash-section {
display: none;
// background-color: #0f0;
&.active-section {
display: block;
// background-color: #ff0;
}
.basic-data {
padding: 6px;
}
}
}
.instructor-dashboard-wrapper-2 section.idash-section#course_info {
.course-errors-wrapper {
margin-top: 2em;
h2 {
color: #D60000;
}
&.open {
.toggle-wrapper {
.triangle {
background-image: url('/static/images/bullet-open.png');
}
}
.course-errors-visibility-wrapper {
display: block;
}
}
.toggle-wrapper {
width: 300px;
cursor: pointer;
div {
float:left;
}
h2 {
float: left;
}
.triangle {
float: left;
width: 20px;
height: 20px;
background-image: url('/static/images/bullet-closed.png');
background-position: 8px 6px;
background-repeat: no-repeat;
}
}
.course-errors-visibility-wrapper {
display: none;
clear: both;
.course-error {
margin-bottom: 1em;
margin-left: 0.5em;
code {
&.course-error-first {
color: #111;
}
&.course-error-second {
color: #111;
}
}
}
}
}
}
.instructor-dashboard-wrapper-2 section.idash-section#membership {
$half_width: $baseline * 20;
.vert-left {
float: left;
width: $half_width;
}
.vert-right {
float: right;
width: $half_width;
}
select {
margin-bottom: 1em;
}
.revoke-link {
color: $danger-red;
text-decoration: underline;
cursor: pointer;
}
label[for="auto-enroll"]:hover + .auto-enroll-hint {
display: block;
}
.auto-enroll-hint {
position: absolute;
display: none;
padding: $baseline;
width: $half_width;
border: 1px solid $light-gray;
background-color: $white;
span.emph {
font-weight: bold;
}
}
.batch-enrollment {
textarea {
margin-top: 0.2em;
margin-bottom: 1em;
width: 500px;
height: 100px;
}
input {
margin-right: 5px;
}
.request-res-section {
margin-top: 1.5em;
h3 {
color: #646464;
}
ul {
margin: 0;
margin-top: 0.5em;
padding: 0;
list-style-type: none;
line-height: 1.5em;
li {
}
}
}
}
.auth-list-container {
display: none;
margin-bottom: 1.5em;
&.active {
display: block;
}
.revoke {
width: 10px;
height: 10px;
background: url('../images/moderator-delete-icon.png') left center no-repeat;
opacity: 0.7;
&:hover { opacity: 0.8; }
&:active { opacity: 0.9; }
// @include idashbutton($danger-red);
// line-height: 0.6em;
// margin-top: 5px;
// padding: 6px 9px;
// font-size: 9pt;
// border-radius: 10px;
}
}
}
.instructor-dashboard-wrapper-2 section.idash-section#student_admin > {
.progress-link-wrapper {
margin-top: 0.7em;
}
// .task-history-single-table { .slickgrid
// max-height: 500px;
// } }
// .running-tasks-table { .slickgrid {
// max-height: 500px;
// } }
.task-history-all-table {
margin-top: 1em;
// height: 300px;
// overflow-y: scroll
}
.task-history-single-table {
margin-top: 1em;
// height: 300px;
// overflow-y: scroll
}
.running-tasks-table {
margin-top: 1em;
// height: 500px;
// overflow-y: scroll
}
}
.instructor-dashboard-wrapper-2 section.idash-section#data_download {
input {
// display: block;
margin-bottom: 1em;
line-height: 1.3em;
}
.data-display {
.data-display-table {
.slickgrid {
height: 400px;
}
}
}
}
.instructor-dashboard-wrapper-2 section.idash-section#analytics {
.distribution-display {
margin-top: 1.2em;
.distribution-display-graph {
.year-of-birth {
width: 750px;
height: 250px;
}
}
.distribution-display-table {
.slickgrid {
height: 400px;
}
}
}
}
.member-list-widget {
$width: 20 * $baseline;
$height: 25 * $baseline;
$header-height: 3 * $baseline;
$bottom-bar-height: 3 * $baseline;
$content-height: $height - $header-height - $bottom-bar-height;
$border-radius: 3px;
width: $width;
height: $height;
.header {
@include box-sizing(border-box);
@include border-top-radius($border-radius);
position: relative;
padding: $baseline;
width: $width;
height: $header-height;
background-color: #efefef;
border: 1px solid $light-gray;
}
.title {
font-size: 16pt;
}
.label {
color: $lighter-base-font-color;
font-size: $body-font-size * 4/5;
}
.info-badge {
// float: right;
position: absolute;
top: $baseline / 2;
right: $baseline / 2;
width: 17px;
height: 17px;
background: url('../images/info-icon-dark.png') left center no-repeat;
opacity: 0.35;
&:hover { opacity: 0.45; }
&:active { opacity: 0.5; }
}
.info {
display: none;
@include box-sizing(border-box);
max-height: $content-height;
padding: $baseline;
border: 1px solid $light-gray;
border-top: none;
color: $lighter-base-font-color;
line-height: 1.3em;
}
.member-list {
@include box-sizing(border-box);
overflow: auto;
padding-top: 0;
width: $width;
max-height: $content-height;
table {
width: 100%;
}
tr {
border-bottom: 1px solid $light-gray;
}
td {
table-layout: fixed;
vertical-align: middle;
word-wrap: break-word;
padding-left: 15px;
border-left: 1px solid $light-gray;
border-right: 1px solid $light-gray;
font-size: 3/4 *$body-font-size;
}
}
.bottom-bar {
@include box-sizing(border-box);
@include border-bottom-radius($border-radius);
position: relative;
width: $width;
height: $bottom-bar-height;
padding: $baseline / 2;
// border-top: none;
margin-top: -1px;
border: 1px solid $light-gray;
background-color: #efefef;
box-shadow: inset #bbb 0px 1px 1px 0px;
}
// .add-field
input[type="button"].add {
@include idashbutton($blue);
position: absolute;
right: $baseline;
}
}
...@@ -102,6 +102,10 @@ function goto( mode) ...@@ -102,6 +102,10 @@ function goto( mode)
<section class="container"> <section class="container">
<div class="instructor-dashboard-wrapper"> <div class="instructor-dashboard-wrapper">
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'):
<div class="beta-button-wrapper"><a href="${ beta_dashboard_url }"> Try New Beta Dashboard </a></div>
%endif
<section class="instructor-dashboard-content"> <section class="instructor-dashboard-content">
<h1>${_("Instructor Dashboard")}</h1> <h1>${_("Instructor Dashboard")}</h1>
...@@ -120,9 +124,6 @@ function goto( mode) ...@@ -120,9 +124,6 @@ function goto( mode)
] ]
</h2> </h2>
<div style="text-align:right"><span id="djangopid">${djangopid}</span>
| <span id="mitxver">${mitx_version}</span></div>
<form name="idashform" method="POST"> <form name="idashform" method="POST">
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }"> <input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }">
<input type="hidden" name="idash_mode" value=""> <input type="hidden" name="idash_mode" value="">
......
<%page args="section_data"/>
<h2>Distributions</h2>
<select id="distributions" data-endpoint="${ section_data['get_distribution_url'] }">
<option> Getting available distributions... </option>
</select>
<div class="distribution-display">
<div class="distribution-display-text"></div>
<div class="distribution-display-graph"></div>
<div class="distribution-display-table"></div>
<div class="request-response-error"></div>
</div>
<%page args="section_data"/>
<h2>Course Information</h2>
<div class="basic-data">
Course Name:
${ section_data['course_display_name'] }
</div>
<div class="basic-data">
Course ID:
${ section_data['course_id'] }
</div>
<div class="basic-data">
Students Enrolled:
${ section_data['enrollment_count'] }
</div>
<div class="basic-data">
Started:
${ section_data['has_started'] }
</div>
<div class="basic-data">
Ended:
${ section_data['has_ended'] }
</div>
<div class="basic-data">
Grade Cutoffs:
${ section_data['grade_cutoffs'] }
</div>
## <div class="basic-data">
## Offline Grades Available:
## ${ section_data['offline_grades'] }
## </div>
%if len(section_data['course_errors']):
<div class="course-errors-wrapper">
<div class="toggle-wrapper">
<h2 class="title">Course Warnings:</h2>
<div class="triangle"></div>
</div>
<div class="course-errors-visibility-wrapper">
%for error in section_data['course_errors']:
<div class="course-error">
<code class=course-error-first> ${ error[0] } </code><br>
<code class=course-error-second> ${ error[1] } </code>
</div>
%endfor
</div>
</div>
%endif
<%page args="section_data"/>
<input type="button" name="list-profiles" value="List enrolled students with profile information" data-endpoint="${ section_data['get_students_features_url'] }" >
<input type="button" name="list-profiles" value="CSV" data-csv="true" class="csv" data-endpoint="${ section_data['get_students_features_url'] }" >
<br>
## <input type="button" name="list-grades" value="Student grades">
## <input type="button" name="list-profiles" value="CSV" data-csv="true" class="csv">
## <br>
## <input type="button" name="list-answer-distributions" value="Answer distributions (x students got y points)">
## <br>
<input type="button" name="dump-gradeconf" value="Grading Configuration" data-endpoint="${ section_data['get_grading_config_url'] }">
<div class="data-display">
<div class="data-display-text"></div>
<div class="data-display-table"></div>
<div class="request-response-error"></div>
</div>
<%inherit file="/main.html" />
<%! from django.core.urlresolvers import reverse %>
<%namespace name='static' file='/static_content.html'/>
<%block name="headextra">
<%static:css group='course'/>
<script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/mustache.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.axislabels.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-1.1.1.min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-world-mill-en.js')}"></script>
<script type="text/javascript" src="${static.url('js/course_groups/cohorts.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/jquery.event.drag-2.2.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/jquery.event.drop-2.2.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/slick.core.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/slick.grid.js')}"></script>
<link rel="stylesheet" href="${static.url('css/vendor/slickgrid/smoothness/jquery-ui-1.8.16.custom.css')}">
<link rel="stylesheet" href="${static.url('css/vendor/slickgrid/slick.grid.css')}">
</%block>
## NOTE that instructor is set as the active page so that the instructor button lights up, even though this is the instructor_2 page.
<%include file="/courseware/course_navigation.html" args="active_page='instructor'" />
<style type="text/css"></style>
<script language="JavaScript" type="text/javascript"></script>
<section class="container">
<div class="instructor-dashboard-wrapper-2">
<div class="olddash-button-wrapper"><a href="${ old_dashboard_url }"> Back to Standard Dashboard </a></div>
<section class="instructor-dashboard-content-2">
## <h1>Instructor Dashboard</h1>
## links which are tied to idash-sections below.
## the links are acativated and handled in instructor_dashboard.coffee
## when the javascript loads, it clicks on the first section
<h2 class="instructor-nav">
% for section_data in sections:
<a href="" data-section="${ section_data['section_key'] }">${ section_data['section_display_name'] }</a>
% endfor
</h2>
## each section corresponds to a section_data sub-dictionary provided by the view
## to keep this short, sections can be pulled out into their own files
% for section_data in sections:
<section id="${ section_data['section_key'] }" class="idash-section">
<%include file="${ section_data['section_key'] }.html" args="section_data=section_data" />
</section>
% endfor
</section>
</div>
</section>
<%page args="section_data"/>
<script type="text/template" id="member-list-widget-template">
<div class="member-list-widget">
<div class="header">
<div class="title"> {{title}} </div>
<div class="info-badge"></div>
</div>
<div class="info"> {{info}} </div>
<div class="member-list">
<table>
<thead>
<tr>
{{#labels}}
<td class="label">{{.}}</td>
{{/labels}}
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="bottom-bar">
<input type="text" name="add-field" class="add-field" placeholder="{{add_placeholder}}">
<input type="button" name="add" class="add" value="{{add_btn_label}}">
</div>
</div>
</script>
<div class="vert-left batch-enrollment">
<h2>Batch Enrollment</h2>
<p>Enter student emails separated by new lines or commas.</p>
<textarea rows="6" cols="50" name="student-emails" placeholder="Student Emails" spellcheck="false"></textarea>
<br>
<input type="button" name="enroll" value="Enroll" data-endpoint="${ section_data['enroll_button_url'] }" >
<input type="button" name="unenroll" value="Unenroll" data-endpoint="${ section_data['unenroll_button_url'] }" >
<input type="checkbox" name="auto-enroll" value="Auto-Enroll" style="margin-top: 1em;">
<label for="auto-enroll">Auto Enroll</label>
<div class="auto-enroll-hint">
<p> If auto enroll is <span class="emph">checked</span>, students who have not yet registered for edX will be automatically enrolled.
If auto enroll is left <span class="emph">unchecked</span>, students who have not yet registered for edX will not be enrolled,
but will be allowed to enroll.
</p>
</div>
<div class="request-response"></div>
<div class="request-response-error"></div>
</div>
<div class="vert-right member-lists-management">
<h2> Administration List Management </h2>
<select id="member-lists-selector">
<option> Getting available lists... </option>
</select>
<div class="request-response-error"></div>
%if section_data['access']['instructor']:
<div class="auth-list-container"
data-rolename="staff"
data-display-name="Course Staff"
data-info-text="
Course staff can help you manage limited aspects of your course. Staff can
enroll and unenroll students, as well as modify their grades and see all
course data. Course staff are not given access to Studio will not be able to
edit your course."
data-list-endpoint="${ section_data['list_course_role_members_url'] }"
data-modify-endpoint="${ section_data['modify_access_url'] }"
data-add-button-label="Add Staff"
></div>
%if section_data['access']['instructor']:
<div class="auth-list-container"
data-rolename="instructor"
data-display-name="Instructors"
data-info-text="
Instructors are the core administration of your course. Instructors can
add and remove course staff, as well as administer forum access.
"
data-list-endpoint="${ section_data['list_course_role_members_url'] }"
data-modify-endpoint="${ section_data['modify_access_url'] }"
data-add-button-label="Add Instructor"
></div>
%endif
<div class="auth-list-container"
data-rolename="beta"
data-display-name="Beta Testers"
data-info-text="
Beta testers can see course content before the rest of the students.
They can make sure that the content works, but have no additional
privelages."
data-list-endpoint="${ section_data['list_course_role_members_url'] }"
data-modify-endpoint="${ section_data['modify_access_url'] }"
data-add-button-label="Add Beta Tester"
></div>
%endif
%if section_data['access']['instructor']:
<div class="auth-list-container"
data-rolename="Administrator"
data-display-name="Forum Admins"
data-list-endpoint="${ section_data['list_forum_members_url'] }"
data-modify-endpoint="${ section_data['update_forum_role_membership_url'] }"
data-add-button-label="Add Forum Admin"
></div>
%endif
%if section_data['access']['instructor'] or section_data['access']['forum_admin']:
<div class="auth-list-container"
data-rolename="Moderator"
data-display-name="Forum Moderators"
data-list-endpoint="${ section_data['list_forum_members_url'] }"
data-modify-endpoint="${ section_data['update_forum_role_membership_url'] }"
data-add-button-label="Add Moderator"
></div>
<div class="auth-list-container"
data-rolename="Community TA"
data-display-name="Forum Community TAs"
data-list-endpoint="${ section_data['list_forum_members_url'] }"
data-modify-endpoint="${ section_data['update_forum_role_membership_url'] }"
data-add-button-label="Add Community TA"
></div>
%endif
</div>
<%page args="section_data"/>
<div class="student-specific-container">
<H2>Student-specific grade adjustment</h2>
<div class="request-response-error"></div>
<input type="text" name="student-select" placeholder="Student Email">
<br>
<div class="progress-link-wrapper">
<a href="" class="progress-link" data-endpoint="${ section_data['get_student_progress_url_url'] }">Student Progress Page</a>
</div>
<br>
<input type="button" name="enroll" value="Enroll" data-endpoint="${ section_data['enrollment_url'] }">
<input type="button" name="unenroll" value="Unenroll" data-endpoint="${ section_data['enrollment_url'] }">
## <select class="problems">
## <option>Getting problems...</option>
## </select>
<p> Specify a particular problem in the course here by its url: </p>
<input type="text" name="problem-select-single" placeholder="Problem urlname">
<p>
You may use just the "urlname" if a problem, or "modulename/urlname" if not.
(For example, if the location is <tt>i4x://university/course/problem/problemname</tt>,
then just provide the <tt>problemname</tt>.
If the location is <tt>i4x://university/course/notaproblem/someothername</tt>, then
provide <tt>notaproblem/someothername</tt>.)
</p>
<input type="button" name="reset-attempts-single" value="Reset Student Attempts" data-endpoint="${ section_data['reset_student_attempts_url'] }">
%if section_data['access']['instructor']:
<p> You may also delete the entire state of a student for the specified module: </p>
<input type="button" class="molly-guard" name="delete-state-single" value="Delete Student State for Module" data-endpoint="${ section_data['reset_student_attempts_url'] }">
%endif
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<input type="button" name="rescore-problem-single" value="Rescore Student Submission" data-endpoint="${ section_data['rescore_problem_url'] }">
%endif
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<p>
Rescoring runs in the background, and status for active tasks will appear in a table below.
To see status for all tasks submitted for this course and student, click on this button:
</p>
<input type="button" name="task-history-single" value="Show Background Task History for Student" data-endpoint="${ section_data['list_instructor_tasks_url'] }">
<div class="task-history-single-table"></div>
%endif
</div>
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<hr>
<div class="course-specific-container">
<H2>Course-specific grade adjustment</h2>
<div class="request-response-error"></div>
<p>
Specify a particular problem in the course here by its url:
<input type="text" name="problem-select-all" size="60">
</p>
<p>
You may use just the "urlname" if a problem, or "modulename/urlname" if not.
(For example, if the location is <tt>i4x://university/course/problem/problemname</tt>,
then just provide the <tt>problemname</tt>.
If the location is <tt>i4x://university/course/notaproblem/someothername</tt>, then
provide <tt>notaproblem/someothername</tt>.)
</p>
<p>
Then select an action:
<input type="button" class="molly-guard" name="reset-attempts-all" value="Reset ALL students' attempts" data-endpoint="${ section_data['reset_student_attempts_url'] }">
<input type="button" class="molly-guard" name="rescore-problem-all" value="Rescore ALL students' problem submissions" data-endpoint="${ section_data['rescore_problem_url'] }">
</p>
<p>
<p>These actions run in the background, and status for active tasks will appear in a table below.
To see status for all tasks submitted for this problem, click on this button:
</p>
<input type="button" name="task-history-all" value="Show Background Task History for Problem" data-endpoint="${ section_data['list_instructor_tasks_url'] }">
<div class="task-history-all-table"></div>
</p>
</div>
<hr>
<div class="running-tasks-container">
<h2> Pending Instructor Tasks </h2>
<div class="running-tasks-table" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></div>
</div>
%endif
...@@ -249,12 +249,14 @@ if settings.COURSEWARE_ENABLED: ...@@ -249,12 +249,14 @@ if settings.COURSEWARE_ENABLED:
# For the instructor # For the instructor
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor$',
'instructor.views.instructor_dashboard', name="instructor_dashboard"), 'instructor.views.legacy.instructor_dashboard', name="instructor_dashboard"),
# see ENABLE_INSTRUCTOR_BETA_DASHBOARD section for more urls
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/gradebook$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/gradebook$',
'instructor.views.gradebook', name='gradebook'), 'instructor.views.legacy.gradebook', name='gradebook'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/grade_summary$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/grade_summary$',
'instructor.views.grade_summary', name='grade_summary'), 'instructor.views.legacy.grade_summary', name='grade_summary'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/staff_grading$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/staff_grading$',
'open_ended_grading.views.staff_grading', name='staff_grading'), 'open_ended_grading.views.staff_grading', name='staff_grading'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/staff_grading/get_next$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/staff_grading/get_next$',
...@@ -338,6 +340,14 @@ if settings.COURSEWARE_ENABLED: ...@@ -338,6 +340,14 @@ if settings.COURSEWARE_ENABLED:
name='submission_history'), name='submission_history'),
) )
if settings.COURSEWARE_ENABLED and settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'):
urlpatterns += (
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard$',
'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard_2"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor_dashboard/api/',
include('instructor.views.api_urls'))
)
if settings.ENABLE_JASMINE: if settings.ENABLE_JASMINE:
urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),) urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),)
......
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