diff --git a/conf/locale/eo/LC_MESSAGES/django.mo b/conf/locale/eo/LC_MESSAGES/django.mo index 546fca37da..42aadaac75 100644 Binary files a/conf/locale/eo/LC_MESSAGES/django.mo and b/conf/locale/eo/LC_MESSAGES/django.mo differ diff --git a/conf/locale/eo/LC_MESSAGES/django.po b/conf/locale/eo/LC_MESSAGES/django.po index 1558b6aff3..5b958a225c 100644 --- a/conf/locale/eo/LC_MESSAGES/django.po +++ b/conf/locale/eo/LC_MESSAGES/django.po @@ -37,8 +37,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2014-02-27 08:57-0500\n" -"PO-Revision-Date: 2014-02-27 13:57:20.220825\n" +"POT-Creation-Date: 2014-02-28 13:57-0800\n" +"PO-Revision-Date: 2014-02-28 21:57:20.716655\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "MIME-Version: 1.0\n" @@ -1294,6 +1294,40 @@ msgstr "Séärçh Ⱡ'σяєм ιρѕ#" msgid "Copyright" msgstr "Çöpýrïght #" +#: lms/djangoapps/class_dashboard/dashboard_data.py +msgid "" +"{label} {problem_name} - {count_grade} {students} ({percent:.0f}%: " +"{grade:.0f}/{max_grade:.0f} {questions})" +msgstr "" +"{label} {problem_name} - {count_grade} {students} ({percent:.0f}%: " +"{grade:.0f}/{max_grade:.0f} {questions}) Ⱡ'σяєм ιρѕ#" + +#: lms/djangoapps/class_dashboard/dashboard_data.py +#: lms/djangoapps/class_dashboard/dashboard_data.py +msgid "students" +msgstr "stüdénts #" + +#: lms/djangoapps/class_dashboard/dashboard_data.py +#: lms/djangoapps/class_dashboard/dashboard_data.py +msgid "questions" +msgstr "qüéstïöns #" + +#: lms/djangoapps/class_dashboard/dashboard_data.py +msgid "" +"{num_students} student(s) opened Subsection {subsection_num}: " +"{subsection_name}" +msgstr "" +"{num_students} stüdént(s) öpénéd Süßséçtïön {subsection_num}: " +"{subsection_name} Ⱡ'σяєм ιρѕυ#" + +#: lms/djangoapps/class_dashboard/dashboard_data.py +msgid "" +"{problem_info_x} {problem_info_n} - {count_grade} {students} " +"({percent:.0f}%: {grade:.0f}/{max_grade:.0f} {questions})" +msgstr "" +"{problem_info_x} {problem_info_n} - {count_grade} {students} " +"({percent:.0f}%: {grade:.0f}/{max_grade:.0f} {questions}) Ⱡ'σяєм ιρѕ#" + #. Translators: this string includes wiki markup. Leave the ** and the _ #. alone. #: lms/djangoapps/course_wiki/views.py @@ -2961,7 +2995,7 @@ msgstr "Délété ärtïçlé Ⱡ'#" #: lms/templates/wiki/delete.html #: lms/templates/wiki/plugins/attachments/index.html -#: cms/templates/component.html +#: cms/templates/component.html cms/templates/studio_xblock_wrapper.html #: lms/templates/discussion/_underscore_templates.html #: lms/templates/discussion/_underscore_templates.html #: lms/templates/discussion/mustache/_inline_thread_show.mustache @@ -3003,6 +3037,7 @@ msgid "You are deleting an article. Please confirm." msgstr "Ýöü äré délétïng än ärtïçlé. Pléäsé çönfïrm. Ⱡ'σяєм ιρѕυм#" #: lms/templates/wiki/edit.html cms/templates/component.html +#: cms/templates/studio_xblock_wrapper.html #: lms/templates/discussion/_underscore_templates.html #: lms/templates/discussion/_underscore_templates.html #: lms/templates/discussion/_underscore_templates.html @@ -3032,6 +3067,7 @@ msgstr "Prévïéw #" #: lms/templates/help_modal.html lms/templates/login_modal.html #: lms/templates/signup_modal.html #: lms/templates/modal/_modal-settings-language.html +#: lms/templates/modal/accessible_confirm.html msgid "Close Modal" msgstr "Çlösé Mödäl Ⱡ#" @@ -3048,6 +3084,7 @@ msgstr "Wïkï Prévïéw Ⱡ#" #: lms/templates/dashboard.html lms/templates/dashboard.html #: lms/templates/dashboard.html #: lms/templates/modal/_modal-settings-language.html +#: lms/templates/modal/accessible_confirm.html msgid "modal open" msgstr "mödäl öpén Ⱡ#" @@ -3713,6 +3750,11 @@ msgstr "Püßlïç Ûsérnämé Ⱡ'#" msgid "Preferred Language" msgstr "Préférréd Längüägé Ⱡ'σ#" +#: cms/templates/unit_container_xblock_component.html +#: lms/templates/wiki/includes/article_menu.html +msgid "View" +msgstr "Vïéw Ⱡ'σяєм#" + #: cms/templates/registration/activation_complete.html #: lms/templates/registration/activation_complete.html msgid "Thanks for activating your account." @@ -4172,9 +4214,6 @@ msgstr "" msgid "Change your name" msgstr "Çhängé ýöür nämé Ⱡ'σ#" -#. Translators: note that {platform} {cert_name_short} will look something -#. like: "edX certificate". Please do not change the order of these -#. placeholders. #: lms/templates/dashboard.html msgid "" "To uphold the credibility of your {platform} {cert_name_short}, all name " @@ -4183,9 +4222,6 @@ msgstr "" "Tö üphöld thé çrédïßïlïtý öf ýöür {platform} {cert_name_short}, äll nämé " "çhängés wïll ßé löggéd änd réçördéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт#" -#. Translators: note that {platform} {cert_name_short} will look something -#. like: "edX certificate". Please do not change the order of these -#. placeholders. #: lms/templates/dashboard.html msgid "" "Enter your desired full name, as it will appear on your {platform} " @@ -4847,7 +4883,7 @@ msgstr "Réjéçtéd #" msgid "Pending name changes" msgstr "Péndïng nämé çhängés Ⱡ'σя#" -#: lms/templates/name_changes.html +#: lms/templates/name_changes.html lms/templates/modal/accessible_confirm.html msgid "Confirm" msgstr "Çönfïrm #" @@ -5710,20 +5746,10 @@ msgstr "Tögglé Füll Rüßrïç Ⱡ'σ#" msgid "{result_of_task} from grader {number}" msgstr "{result_of_task} fröm grädér {number} Ⱡ'σя#" -#. Translators: "See full feedback" is the text of -#. a link that allows a user to see more detailed -#. feedback from a self, peer, or instructor -#. graded openended problem #: lms/templates/combinedopenended/open_ended_result_table.html msgid "See full feedback" msgstr "Séé füll féédßäçk Ⱡ'σ#" -#. Translators: this text forms a link that, when -#. clicked, allows a user to respond to the feedback -#. the user received on his or her openended problem -#. Translators: when "Respond to Feedback" is clicked, a survey -#. appears on which a user can respond to the feedback the user -#. received on an openended problem #: lms/templates/combinedopenended/open_ended_result_table.html #: lms/templates/combinedopenended/openended/open_ended_evaluation.html msgid "Respond to Feedback" @@ -6137,6 +6163,10 @@ msgstr "DätäDümp #" msgid "Manage Groups" msgstr "Mänägé Gröüps Ⱡ'#" +#: lms/templates/courseware/instructor_dashboard.html +msgid "Metrics" +msgstr "Métrïçs #" + #: lms/templates/courseware/instructor_dashboard.html msgid "Grade Downloads" msgstr "Grädé Döwnlöäds Ⱡ'#" @@ -6320,6 +6350,8 @@ msgid "Pull enrollment from remote gradebook" msgstr "Püll énröllmént fröm rémöté grädéßöök Ⱡ'σяєм ιρѕ#" #: lms/templates/courseware/instructor_dashboard.html +#: lms/templates/courseware/instructor_dashboard.html +#: lms/templates/instructor/instructor_dashboard_2/metrics.html msgid "Section:" msgstr "Séçtïön: #" @@ -6485,6 +6517,40 @@ msgstr "Mäx Ⱡ'σя#" msgid "Points Earned (Num Students)" msgstr "Pöïnts Éärnéd (Nüm Stüdénts) Ⱡ'σяєм #" +#: lms/templates/courseware/instructor_dashboard.html +#: lms/templates/instructor/instructor_dashboard_2/metrics.html +msgid "There is no data available to display at this time." +msgstr "Théré ïs nö dätä äväïläßlé tö dïspläý ät thïs tïmé. Ⱡ'σяєм ιρѕυм ∂#" + +#: lms/templates/courseware/instructor_dashboard.html +#: lms/templates/instructor/instructor_dashboard_2/metrics.html +msgid "" +"Loading the latest graphs for you; depending on your class size, this may " +"take a few minutes." +msgstr "" +"Löädïng thé lätést gräphs för ýöü; dépéndïng ön ýöür çläss sïzé, thïs mäý " +"täké ä féw mïnütés. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#" + +#: lms/templates/courseware/instructor_dashboard.html +msgid "Count of Students that Opened a Subsection" +msgstr "Çöünt öf Stüdénts thät Öpénéd ä Süßséçtïön Ⱡ'σяєм ιρѕυ#" + +#: lms/templates/courseware/instructor_dashboard.html +#: lms/templates/courseware/instructor_dashboard.html +#: lms/templates/instructor/instructor_dashboard_2/metrics.html +msgid "Loading..." +msgstr "Löädïng... Ⱡ#" + +#: lms/templates/courseware/instructor_dashboard.html +#: lms/templates/instructor/instructor_dashboard_2/metrics.html +msgid "Grade Distribution per Problem" +msgstr "Grädé Dïstrïßütïön pér Prößlém Ⱡ'σяєм #" + +#: lms/templates/courseware/instructor_dashboard.html +#: lms/templates/instructor/instructor_dashboard_2/metrics.html +msgid "There are no problems in this section." +msgstr "Théré äré nö prößléms ïn thïs séçtïön. Ⱡ'σяєм ιρѕ#" + #: lms/templates/courseware/instructor_dashboard.html msgid "Students answering correctly" msgstr "Stüdénts änswérïng çörréçtlý Ⱡ'σяєм #" @@ -6782,12 +6848,10 @@ msgstr "Vïéw Àrçhïvéd Çöürsé Ⱡ'σя#" msgid "View Course" msgstr "Vïéw Çöürsé Ⱡ#" -#. Translators: The course's name will be added to the end of this sentence. #: lms/templates/dashboard/_dashboard_course_listing.html msgid "Are you sure you want to unregister from" msgstr "Àré ýöü süré ýöü wänt tö ünrégïstér fröm Ⱡ'σяєм ιρѕυ#" -#. Translators: The course's name will be added to the end of this sentence. #: lms/templates/dashboard/_dashboard_course_listing.html #: lms/templates/dashboard/_dashboard_course_listing.html msgid "" @@ -7850,6 +7914,14 @@ msgstr "" "çöhörts. Théïr pösts äré märkéd 'Çömmünïtý TÀ'. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт," " ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя ιη¢ι#" +#: lms/templates/instructor/instructor_dashboard_2/metrics.html +msgid "Reload Graphs" +msgstr "Rélöäd Gräphs Ⱡ'#" + +#: lms/templates/instructor/instructor_dashboard_2/metrics.html +msgid "Count of Students Opened a Subsection" +msgstr "Çöünt öf Stüdénts Öpénéd ä Süßséçtïön Ⱡ'σяєм ιρѕ#" + #: lms/templates/instructor/instructor_dashboard_2/send_email.html #: lms/templates/instructor/instructor_dashboard_2/send_email.html msgid "Send Email" @@ -8624,6 +8696,27 @@ msgstr "" "héré för pössïßlé üsé ßý ïnställätïöns öf Öpén édX. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт " "αмєт, ¢σηѕє¢тєтυ#" +#: lms/templates/static_templates/embargo.html +msgid "This Course Unavailable In Your Country" +msgstr "Thïs Çöürsé Ûnäväïläßlé Ìn Ýöür Çöüntrý Ⱡ'σяєм ιρѕ#" + +#: lms/templates/static_templates/embargo.html +msgid "" +"Our system indicates that you are trying to access an edX course from an IP " +"address associated with a country currently subjected to U.S. economic and " +"trade sanctions. Unfortunately, at this time edX must comply with export " +"controls, and we cannot allow you to access this particular course. Feel " +"free to browse our catalogue to find other courses you may be interested in " +"taking." +msgstr "" +"Öür sýstém ïndïçätés thät ýöü äré trýïng tö äççéss än édX çöürsé fröm än ÌP " +"äddréss ässöçïätéd wïth ä çöüntrý çürréntlý süßjéçtéd tö Û.S. éçönömïç änd " +"trädé sänçtïöns. Ûnförtünätélý, ät thïs tïmé édX müst çömplý wïth éxpört " +"çöntröls, änd wé çännöt ällöw ýöü tö äççéss thïs pärtïçülär çöürsé. Féél " +"fréé tö ßröwsé öür çätälögüé tö fïnd öthér çöürsés ýöü mäý ßé ïntéréstéd ïn " +"täkïng. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ " +"єιυѕмσ∂ тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυ#" + #: lms/templates/static_templates/honor.html #: lms/templates/static_templates/honor.html msgid "Honor Code" @@ -9721,10 +9814,6 @@ msgstr "" msgid "You have decided to pay $ " msgstr "Ýöü hävé déçïdéd tö päý $ Ⱡ'σяєм#" -#: lms/templates/wiki/includes/article_menu.html -msgid "View" -msgstr "Vïéw Ⱡ'σяєм#" - #: lms/templates/wiki/includes/article_menu.html #: lms/templates/wiki/includes/article_menu.html #: lms/templates/wiki/includes/article_menu.html @@ -9872,10 +9961,10 @@ msgstr "Löäd Ànöthér Fïlé Ⱡ'σ#" msgid "Content" msgstr "Çöntént #" -#: cms/templates/asset_index.html cms/templates/course_info.html -#: cms/templates/edit-tabs.html cms/templates/index.html -#: cms/templates/manage_users.html cms/templates/overview.html -#: cms/templates/textbooks.html +#: cms/templates/asset_index.html cms/templates/container.html +#: cms/templates/course_info.html cms/templates/edit-tabs.html +#: cms/templates/index.html cms/templates/manage_users.html +#: cms/templates/overview.html cms/templates/textbooks.html msgid "Page Actions" msgstr "Pägé Àçtïöns Ⱡ#" @@ -9916,8 +10005,8 @@ msgstr "" "öf ýöür çöürsé. Dö nöt üsé thé Éxtérnäl ÛRL äs ä lïnk välüé wïthïn ýöür " "çöürsé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι#" -#: cms/templates/asset_index.html cms/templates/overview.html -#: cms/templates/settings_graders.html +#: cms/templates/asset_index.html cms/templates/container.html +#: cms/templates/overview.html cms/templates/settings_graders.html msgid "What can I do on this page?" msgstr "Whät çän Ì dö ön thïs pägé? Ⱡ'σяєм#" @@ -9979,23 +10068,43 @@ msgstr "" msgid "Editor" msgstr "Édïtör Ⱡ'σяєм ιρѕ#" -#: cms/templates/component.html +#: cms/templates/component.html cms/templates/studio_xblock_wrapper.html msgid "Duplicate" msgstr "Düplïçäté #" -#: cms/templates/component.html +#: cms/templates/component.html cms/templates/studio_xblock_wrapper.html msgid "Duplicate this component" msgstr "Düplïçäté thïs çömpönént Ⱡ'σяє#" -#: cms/templates/component.html +#: cms/templates/component.html cms/templates/studio_xblock_wrapper.html msgid "Delete this component" msgstr "Délété thïs çömpönént Ⱡ'σя#" #: cms/templates/component.html cms/templates/overview.html #: cms/templates/overview.html +#: cms/templates/unit_container_xblock_component.html msgid "Drag to reorder" msgstr "Dräg tö réördér Ⱡ'#" +#: cms/templates/container.html cms/templates/ux/reference/container.html +msgid "Container" +msgstr "Çöntäïnér #" + +#: cms/templates/container.html cms/templates/studio_vertical_wrapper.html +msgid "No Actions" +msgstr "Nö Àçtïöns Ⱡ#" + +#: cms/templates/container.html +msgid "" +"You can view course components that contain other components on this page. " +"In the case of experiment blocks, this allows you to confirm that you have " +"properly configured your experiment groups." +msgstr "" +"Ýöü çän vïéw çöürsé çömpönénts thät çöntäïn öthér çömpönénts ön thïs pägé. " +"Ìn thé çäsé öf éxpérïmént ßlöçks, thïs ällöws ýöü tö çönfïrm thät ýöü hävé " +"pröpérlý çönfïgüréd ýöür éxpérïmént gröüps. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє#" + #: cms/templates/course_info.html cms/templates/course_info.html msgid "Course Updates" msgstr "Çöürsé Ûpdätés Ⱡ'#" @@ -10205,7 +10314,6 @@ msgstr "Çöürsé Éxpört Ⱡ'#" msgid "About Exporting Courses" msgstr "Àßöüt Éxpörtïng Çöürsés Ⱡ'σяє#" -#. Translators: ".tar.gz" is a file extension, and should not be translated #: cms/templates/export.html msgid "" "You can export courses and edit them outside of Studio. The exported file is" @@ -10310,7 +10418,6 @@ msgstr "" msgid "Opening the downloaded file" msgstr "Öpénïng thé döwnlöädéd fïlé Ⱡ'σяєм#" -#. Translators: ".tar.gz" is a file extension, and should not be translated #: cms/templates/export.html msgid "" "Use an archive program to extract the data from the .tar.gz file. Extracted " @@ -10646,8 +10753,6 @@ msgstr "" "çürrént çöürsé, sö ýöü hävé ä ßäçküp çöpý öf ït. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт " "αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя #" -#. Translators: ".tar.gz" is a file extension, and files with that extension -#. are called "gzipped tar files": these terms should not be translated #: cms/templates/import.html msgid "" "The course that you import must be in a .tar.gz file (that is, a .tar file " @@ -10672,8 +10777,6 @@ msgstr "" "ýöür çöürsé üntïl thé ïmpört öpérätïön häs çömplétéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт" " αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя ιη¢ι∂ι∂#" -#. Translators: ".tar.gz" is a file extension, and files with that extension -#. are called "gzipped tar files": these terms should not be translated #: cms/templates/import.html msgid "Select a .tar.gz File to Replace Your Course Content" msgstr "Séléçt ä .tär.gz Fïlé tö Répläçé Ýöür Çöürsé Çöntént Ⱡ'σяєм ιρѕυм ∂σ#" @@ -11919,6 +12022,11 @@ msgstr "" "éxäms, änd spéçïfý höw müçh öf ä stüdént's grädé éäçh ässïgnmént týpé ïs " "wörth. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι#" +#: cms/templates/studio_vertical_wrapper.html +#: cms/templates/studio_vertical_wrapper.html +msgid "Expand or Collapse" +msgstr "Éxpänd ör Çölläpsé Ⱡ'σ#" + #: cms/templates/textbooks.html cms/templates/textbooks.html #: cms/templates/widgets/header.html msgid "Textbooks" @@ -12137,10 +12245,6 @@ msgstr "" "Àn äçtïvätïön lïnk häs ßéén sént tö {email}, älöng wïth ïnstrüçtïöns för " "äçtïvätïng ýöür äççöünt. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#" -#: cms/templates/ux/reference/container.html -msgid "Container" -msgstr "Çöntäïnér #" - #: cms/templates/widgets/footer.html msgid "All rights reserved." msgstr "Àll rïghts résérvéd. Ⱡ'σя#" diff --git a/conf/locale/eo/LC_MESSAGES/djangojs.mo b/conf/locale/eo/LC_MESSAGES/djangojs.mo index 1d3cb49f1f..64a3717909 100644 Binary files a/conf/locale/eo/LC_MESSAGES/djangojs.mo and b/conf/locale/eo/LC_MESSAGES/djangojs.mo differ diff --git a/conf/locale/eo/LC_MESSAGES/djangojs.po b/conf/locale/eo/LC_MESSAGES/djangojs.po index 1bbdc7d737..6cca9dc5f3 100644 --- a/conf/locale/eo/LC_MESSAGES/djangojs.po +++ b/conf/locale/eo/LC_MESSAGES/djangojs.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2014-02-27 08:56-0500\n" -"PO-Revision-Date: 2014-02-27 13:57:20.650962\n" +"POT-Creation-Date: 2014-02-28 13:56-0800\n" +"PO-Revision-Date: 2014-02-28 21:57:20.936431\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "MIME-Version: 1.0\n" @@ -1257,6 +1257,15 @@ msgstr "Lïst ïtém #" msgid "Heading" msgstr "Héädïng #" +#: lms/templates/class_dashboard/all_section_metrics.js +#: lms/templates/class_dashboard/all_section_metrics.js +msgid "Unable to retrieve data, please try again later." +msgstr "Ûnäßlé tö rétrïévé dätä, pléäsé trý ägäïn lätér. Ⱡ'σяєм ιρѕυм #" + +#: lms/templates/class_dashboard/d3_stacked_bar_graph.js +msgid "Number of Students" +msgstr "Nümßér öf Stüdénts Ⱡ'σ#" + #: cms/static/coffee/src/main.js msgid "" "This may be happening because of an error with our server or your internet " @@ -1276,6 +1285,7 @@ msgstr "Édïtïng: %s Ⱡ'σ#" #: cms/static/coffee/src/views/module_edit.js #: cms/static/coffee/src/views/tabs.js cms/static/coffee/src/views/unit.js +#: cms/static/coffee/src/xblock/cms.runtime.v1.js #: cms/static/js/models/section.js cms/static/js/views/asset.js #: cms/static/js/views/course_info_handout.js #: cms/static/js/views/course_info_update.js cms/static/js/views/overview.js diff --git a/lms/djangoapps/class_dashboard/__init__.py b/lms/djangoapps/class_dashboard/__init__.py new file mode 100644 index 0000000000..71ff059ee1 --- /dev/null +++ b/lms/djangoapps/class_dashboard/__init__.py @@ -0,0 +1,3 @@ +""" +init.py file for class_dashboard +""" diff --git a/lms/djangoapps/class_dashboard/dashboard_data.py b/lms/djangoapps/class_dashboard/dashboard_data.py new file mode 100644 index 0000000000..62db1c821f --- /dev/null +++ b/lms/djangoapps/class_dashboard/dashboard_data.py @@ -0,0 +1,401 @@ +""" +Computes the data to display on the Instructor Dashboard +""" + +from courseware import models +from django.db.models import Count +from django.utils.translation import ugettext as _ + +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.inheritance import own_metadata + + +def get_problem_grade_distribution(course_id): + """ + Returns the grade distribution per problem for the course + + `course_id` the course ID for the course interested in + + Output is a dict, where the key is the problem 'module_id' and the value is a dict with: + 'max_grade' - max grade for this problem + 'grade_distrib' - array of tuples (`grade`,`count`). + """ + + # Aggregate query on studentmodule table for grade data for all problems in course + db_query = models.StudentModule.objects.filter( + course_id__exact=course_id, + grade__isnull=False, + module_type__exact="problem", + ).values('module_state_key', 'grade', 'max_grade').annotate(count_grade=Count('grade')) + + prob_grade_distrib = {} + + # Loop through resultset building data for each problem + for row in db_query: + curr_problem = row['module_state_key'] + + # Build set of grade distributions for each problem that has student responses + if curr_problem in prob_grade_distrib: + prob_grade_distrib[curr_problem]['grade_distrib'].append((row['grade'], row['count_grade'])) + + if (prob_grade_distrib[curr_problem]['max_grade'] != row['max_grade']) and \ + (prob_grade_distrib[curr_problem]['max_grade'] < row['max_grade']): + prob_grade_distrib[curr_problem]['max_grade'] = row['max_grade'] + + else: + prob_grade_distrib[curr_problem] = { + 'max_grade': row['max_grade'], + 'grade_distrib': [(row['grade'], row['count_grade'])] + } + + return prob_grade_distrib + + +def get_sequential_open_distrib(course_id): + """ + Returns the number of students that opened each subsection/sequential of the course + + `course_id` the course ID for the course interested in + + Outputs a dict mapping the 'module_id' to the number of students that have opened that subsection/sequential. + """ + + # Aggregate query on studentmodule table for "opening a subsection" data + db_query = models.StudentModule.objects.filter( + course_id__exact=course_id, + module_type__exact="sequential", + ).values('module_state_key').annotate(count_sequential=Count('module_state_key')) + + # Build set of "opened" data for each subsection that has "opened" data + sequential_open_distrib = {} + for row in db_query: + sequential_open_distrib[row['module_state_key']] = row['count_sequential'] + + return sequential_open_distrib + + +def get_problem_set_grade_distrib(course_id, problem_set): + """ + Returns the grade distribution for the problems specified in `problem_set`. + + `course_id` the course ID for the course interested in + + `problem_set` an array of strings representing problem module_id's. + + Requests from the database the a count of each grade for each problem in the `problem_set`. + + Returns a dict, where the key is the problem 'module_id' and the value is a dict with two parts: + 'max_grade' - the maximum grade possible for the course + 'grade_distrib' - array of tuples (`grade`,`count`) ordered by `grade` + """ + + # Aggregate query on studentmodule table for grade data for set of problems in course + db_query = models.StudentModule.objects.filter( + course_id__exact=course_id, + grade__isnull=False, + module_type__exact="problem", + module_state_key__in=problem_set, + ).values( + 'module_state_key', + 'grade', + 'max_grade', + ).annotate(count_grade=Count('grade')).order_by('module_state_key', 'grade') + + prob_grade_distrib = {} + + # Loop through resultset building data for each problem + for row in db_query: + if row['module_state_key'] not in prob_grade_distrib: + prob_grade_distrib[row['module_state_key']] = { + 'max_grade': 0, + 'grade_distrib': [], + } + + curr_grade_distrib = prob_grade_distrib[row['module_state_key']] + curr_grade_distrib['grade_distrib'].append((row['grade'], row['count_grade'])) + + if curr_grade_distrib['max_grade'] < row['max_grade']: + curr_grade_distrib['max_grade'] = row['max_grade'] + + return prob_grade_distrib + + +def get_d3_problem_grade_distrib(course_id): + """ + Returns problem grade distribution information for each section, data already in format for d3 function. + + `course_id` the course ID for the course interested in + + Returns an array of dicts in the order of the sections. Each dict has: + 'display_name' - display name for the section + 'data' - data for the d3_stacked_bar_graph function of the grade distribution for that problem + """ + + prob_grade_distrib = get_problem_grade_distribution(course_id) + d3_data = [] + + # Retrieve course object down to problems + course = modulestore().get_instance(course_id, CourseDescriptor.id_to_location(course_id), depth=4) + + # Iterate through sections, subsections, units, problems + for section in course.get_children(): + curr_section = {} + curr_section['display_name'] = own_metadata(section).get('display_name', '') + data = [] + c_subsection = 0 + for subsection in section.get_children(): + c_subsection += 1 + c_unit = 0 + for unit in subsection.get_children(): + c_unit += 1 + c_problem = 0 + for child in unit.get_children(): + + # Student data is at the problem level + if child.location.category == 'problem': + c_problem += 1 + stack_data = [] + + # Construct label to display for this problem + label = "P{0}.{1}.{2}".format(c_subsection, c_unit, c_problem) + + # Only problems in prob_grade_distrib have had a student submission. + if child.location.url() in prob_grade_distrib: + + # Get max_grade, grade_distribution for this problem + problem_info = prob_grade_distrib[child.location.url()] + + # Get problem_name for tooltip + problem_name = own_metadata(child).get('display_name', '') + + # Compute percent of this grade over max_grade + max_grade = float(problem_info['max_grade']) + for (grade, count_grade) in problem_info['grade_distrib']: + percent = 0.0 + if max_grade > 0: + percent = (grade * 100.0) / max_grade + + # Construct tooltip for problem in grade distibution view + tooltip = _("{label} {problem_name} - {count_grade} {students} ({percent:.0f}%: {grade:.0f}/{max_grade:.0f} {questions})").format( + label=label, + problem_name=problem_name, + count_grade=count_grade, + students=_("students"), + percent=percent, + grade=grade, + max_grade=max_grade, + questions=_("questions"), + ) + + # Construct data to be sent to d3 + stack_data.append({ + 'color': percent, + 'value': count_grade, + 'tooltip': tooltip, + }) + + problem = { + 'xValue': label, + 'stackData': stack_data, + } + data.append(problem) + curr_section['data'] = data + + d3_data.append(curr_section) + + return d3_data + + +def get_d3_sequential_open_distrib(course_id): + """ + Returns how many students opened a sequential/subsection for each section, data already in format for d3 function. + + `course_id` the course ID for the course interested in + + Returns an array in the order of the sections and each dict has: + 'display_name' - display name for the section + 'data' - data for the d3_stacked_bar_graph function of how many students opened each sequential/subsection + """ + sequential_open_distrib = get_sequential_open_distrib(course_id) + + d3_data = [] + + # Retrieve course object down to subsection + course = modulestore().get_instance(course_id, CourseDescriptor.id_to_location(course_id), depth=2) + + # Iterate through sections, subsections + for section in course.get_children(): + curr_section = {} + curr_section['display_name'] = own_metadata(section).get('display_name', '') + data = [] + c_subsection = 0 + + # Construct data for each subsection to be sent to d3 + for subsection in section.get_children(): + c_subsection += 1 + subsection_name = own_metadata(subsection).get('display_name', '') + + num_students = 0 + if subsection.location.url() in sequential_open_distrib: + num_students = sequential_open_distrib[subsection.location.url()] + + stack_data = [] + tooltip = _("{num_students} student(s) opened Subsection {subsection_num}: {subsection_name}").format( + num_students=num_students, + subsection_num=c_subsection, + subsection_name=subsection_name, + ) + + stack_data.append({ + 'color': 0, + 'value': num_students, + 'tooltip': tooltip, + }) + subsection = { + 'xValue': "SS {0}".format(c_subsection), + 'stackData': stack_data, + } + data.append(subsection) + + curr_section['data'] = data + d3_data.append(curr_section) + + return d3_data + + +def get_d3_section_grade_distrib(course_id, section): + """ + Returns the grade distribution for the problems in the `section` section in a format for the d3 code. + + `course_id` a string that is the course's ID. + + `section` an int that is a zero-based index into the course's list of sections. + + Navigates to the section specified to find all the problems associated with that section and then finds the grade + distribution for those problems. Finally returns an object formated the way the d3_stacked_bar_graph.js expects its + data object to be in. + + If this is requested multiple times quickly for the same course, it is better to call + get_d3_problem_grade_distrib and pick out the sections of interest. + + Returns an array of dicts with the following keys (taken from d3_stacked_bar_graph.js's documentation) + 'xValue' - Corresponding value for the x-axis + 'stackData' - Array of objects with key, value pairs that represent a bar: + 'color' - Defines what "color" the bar will map to + 'value' - Maps to the height of the bar, along the y-axis + 'tooltip' - (Optional) Text to display on mouse hover + """ + + # Retrieve course object down to problems + course = modulestore().get_instance(course_id, CourseDescriptor.id_to_location(course_id), depth=4) + + problem_set = [] + problem_info = {} + c_subsection = 0 + for subsection in course.get_children()[section].get_children(): + c_subsection += 1 + c_unit = 0 + for unit in subsection.get_children(): + c_unit += 1 + c_problem = 0 + for child in unit.get_children(): + if (child.location.category == 'problem'): + c_problem += 1 + problem_set.append(child.location.url()) + problem_info[child.location.url()] = { + 'id': child.location.url(), + 'x_value': "P{0}.{1}.{2}".format(c_subsection, c_unit, c_problem), + 'display_name': own_metadata(child).get('display_name', ''), + } + + # Retrieve grade distribution for these problems + grade_distrib = get_problem_set_grade_distrib(course_id, problem_set) + + d3_data = [] + + # Construct data for each problem to be sent to d3 + for problem in problem_set: + stack_data = [] + + if problem in grade_distrib: # Some problems have no data because students have not tried them yet. + max_grade = float(grade_distrib[problem]['max_grade']) + for (grade, count_grade) in grade_distrib[problem]['grade_distrib']: + percent = 0.0 + if max_grade > 0: + percent = (grade * 100.0) / max_grade + + # Construct tooltip for problem in grade distibution view + tooltip = _("{problem_info_x} {problem_info_n} - {count_grade} {students} ({percent:.0f}%: {grade:.0f}/{max_grade:.0f} {questions})").format( + problem_info_x=problem_info[problem]['x_value'], + count_grade=count_grade, + students=_("students"), + percent=percent, + problem_info_n=problem_info[problem]['display_name'], + grade=grade, + max_grade=max_grade, + questions=_("questions"), + ) + + stack_data.append({ + 'color': percent, + 'value': count_grade, + 'tooltip': tooltip, + }) + + d3_data.append({ + 'xValue': problem_info[problem]['x_value'], + 'stackData': stack_data, + }) + + return d3_data + + +def get_section_display_name(course_id): + """ + Returns an array of the display names for each section in the course. + + `course_id` the course ID for the course interested in + + The ith string in the array is the display name of the ith section in the course. + """ + + course = modulestore().get_instance(course_id, CourseDescriptor.id_to_location(course_id), depth=4) + + section_display_name = [""] * len(course.get_children()) + i = 0 + for section in course.get_children(): + section_display_name[i] = own_metadata(section).get('display_name', '') + i += 1 + + return section_display_name + + +def get_array_section_has_problem(course_id): + """ + Returns an array of true/false whether each section has problems. + + `course_id` the course ID for the course interested in + + The ith value in the array is true if the ith section in the course contains problems and false otherwise. + """ + + course = modulestore().get_instance(course_id, CourseDescriptor.id_to_location(course_id), depth=4) + + b_section_has_problem = [False] * len(course.get_children()) + i = 0 + for section in course.get_children(): + for subsection in section.get_children(): + for unit in subsection.get_children(): + for child in unit.get_children(): + if child.location.category == 'problem': + b_section_has_problem[i] = True + break # out of child loop + if b_section_has_problem[i]: + break # out of unit loop + if b_section_has_problem[i]: + break # out of subsection loop + + i += 1 + + return b_section_has_problem diff --git a/lms/djangoapps/class_dashboard/test/test_dashboard_data.py b/lms/djangoapps/class_dashboard/test/test_dashboard_data.py new file mode 100644 index 0000000000..1e511bd607 --- /dev/null +++ b/lms/djangoapps/class_dashboard/test/test_dashboard_data.py @@ -0,0 +1,180 @@ +""" +Tests for class dashboard (Metrics tab in instructor dashboard) +""" + +import json + +from django.test.utils import override_settings +from django.core.urlresolvers import reverse + +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.factories import StudentModuleFactory +from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory +from capa.tests.response_xml_factory import StringResponseXMLFactory +from xmodule.modulestore import Location + +from class_dashboard.dashboard_data import (get_problem_grade_distribution, get_sequential_open_distrib, + get_problem_set_grade_distrib, get_d3_problem_grade_distrib, + get_d3_sequential_open_distrib, get_d3_section_grade_distrib, + get_section_display_name, get_array_section_has_problem + ) +from class_dashboard.views import has_instructor_access_for_class + +USER_COUNT = 11 + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestGetProblemGradeDistribution(ModuleStoreTestCase): + """ + Tests related to class_dashboard/dashboard_data.py + """ + + def setUp(self): + + self.instructor = AdminFactory.create() + self.client.login(username=self.instructor.username, password='test') + self.attempts = 3 + self.course = CourseFactory.create( + display_name=u"test course omega \u03a9", + ) + + section = ItemFactory.create( + parent_location=self.course.location, + category="chapter", + display_name=u"test factory section omega \u03a9", + ) + sub_section = ItemFactory.create( + parent_location=section.location, + category="sequential", + display_name=u"test subsection omega \u03a9", + ) + + unit = ItemFactory.create( + parent_location=sub_section.location, + category="vertical", + metadata={'graded': True, 'format': 'Homework'}, + display_name=u"test unit omega \u03a9", + ) + + self.users = [UserFactory.create() for _ in xrange(USER_COUNT)] + + for user in self.users: + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) + + for i in xrange(USER_COUNT - 1): + category = "problem" + item = ItemFactory.create( + parent_location=unit.location, + category=category, + data=StringResponseXMLFactory().build_xml(answer='foo'), + metadata={'rerandomize': 'always'}, + display_name=u"test problem omega \u03a9 " + str(i) + ) + + for j, user in enumerate(self.users): + StudentModuleFactory.create( + grade=1 if i < j else 0, + max_grade=1 if i < j else 0.5, + student=user, + course_id=self.course.id, + module_state_key=Location(item.location).url(), + state=json.dumps({'attempts': self.attempts}), + ) + + for j, user in enumerate(self.users): + StudentModuleFactory.create( + course_id=self.course.id, + module_type='sequential', + module_state_key=Location(item.location).url(), + ) + + def test_get_problem_grade_distribution(self): + + prob_grade_distrib = get_problem_grade_distribution(self.course.id) + + for problem in prob_grade_distrib: + max_grade = prob_grade_distrib[problem]['max_grade'] + self.assertEquals(1, max_grade) + + def test_get_sequential_open_distibution(self): + + sequential_open_distrib = get_sequential_open_distrib(self.course.id) + + for problem in sequential_open_distrib: + num_students = sequential_open_distrib[problem] + self.assertEquals(USER_COUNT, num_students) + + def test_get_problemset_grade_distrib(self): + + prob_grade_distrib = get_problem_grade_distribution(self.course.id) + probset_grade_distrib = get_problem_set_grade_distrib(self.course.id, prob_grade_distrib) + + for problem in probset_grade_distrib: + max_grade = probset_grade_distrib[problem]['max_grade'] + self.assertEquals(1, max_grade) + + grade_distrib = probset_grade_distrib[problem]['grade_distrib'] + sum_attempts = 0 + for item in grade_distrib: + sum_attempts += item[1] + self.assertEquals(USER_COUNT, sum_attempts) + + def test_get_d3_problem_grade_distrib(self): + + d3_data = get_d3_problem_grade_distrib(self.course.id) + for data in d3_data: + for stack_data in data['data']: + sum_values = 0 + for problem in stack_data['stackData']: + sum_values += problem['value'] + self.assertEquals(USER_COUNT, sum_values) + + def test_get_d3_sequential_open_distrib(self): + + d3_data = get_d3_sequential_open_distrib(self.course.id) + + for data in d3_data: + for stack_data in data['data']: + for problem in stack_data['stackData']: + value = problem['value'] + self.assertEquals(0, value) + + def test_get_d3_section_grade_distrib(self): + + d3_data = get_d3_section_grade_distrib(self.course.id, 0) + + for stack_data in d3_data: + sum_values = 0 + for problem in stack_data['stackData']: + sum_values += problem['value'] + self.assertEquals(USER_COUNT, sum_values) + + def test_get_section_display_name(self): + + section_display_name = get_section_display_name(self.course.id) + self.assertMultiLineEqual(section_display_name[0], u"test factory section omega \u03a9") + + def test_get_array_section_has_problem(self): + + b_section_has_problem = get_array_section_has_problem(self.course.id) + self.assertEquals(b_section_has_problem[0], True) + + def test_dashboard(self): + + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + response = self.client.post( + url, + { + 'idash_mode': 'Metrics' + } + ) + self.assertContains(response, '

Course Statistics At A Glance

') + + def test_has_instructor_access_for_class(self): + """ + Test for instructor access + """ + ret_val = has_instructor_access_for_class(self.instructor, self.course.id) + self.assertEquals(ret_val, True) diff --git a/lms/djangoapps/class_dashboard/test/test_views.py b/lms/djangoapps/class_dashboard/test/test_views.py new file mode 100644 index 0000000000..4903fddb47 --- /dev/null +++ b/lms/djangoapps/class_dashboard/test/test_views.py @@ -0,0 +1,83 @@ +""" +Tests for class dashboard (Metrics tab in instructor dashboard) +""" +from mock import patch + +from django.test import TestCase +from django.test.client import RequestFactory +from django.utils import simplejson + +from class_dashboard import views + + +class TestViews(TestCase): + """ + Tests related to class_dashboard/views.py + """ + + def setUp(self): + + self.request_factory = RequestFactory() + self.request = self.request_factory.get('') + self.request.user = None + self.simple_data = {'error': 'error'} + + @patch('class_dashboard.views.has_instructor_access_for_class') + def test_all_problem_grade_distribution_has_access(self, has_access): + """ + Test returns proper value when have proper access + """ + has_access.return_value = True + response = views.all_problem_grade_distribution(self.request, 'test/test/test') + + self.assertEqual(simplejson.dumps(self.simple_data), response.content) + + @patch('class_dashboard.views.has_instructor_access_for_class') + def test_all_problem_grade_distribution_no_access(self, has_access): + """ + Test for no access + """ + has_access.return_value = False + response = views.all_problem_grade_distribution(self.request, 'test/test/test') + + self.assertEqual("{\"error\": \"Access Denied: User does not have access to this course\'s data\"}", response.content) + + @patch('class_dashboard.views.has_instructor_access_for_class') + def test_all_sequential_open_distribution_has_access(self, has_access): + """ + Test returns proper value when have proper access + """ + has_access.return_value = True + response = views.all_sequential_open_distrib(self.request, 'test/test/test') + + self.assertEqual(simplejson.dumps(self.simple_data), response.content) + + @patch('class_dashboard.views.has_instructor_access_for_class') + def test_all_sequential_open_distribution_no_access(self, has_access): + """ + Test for no access + """ + has_access.return_value = False + response = views.all_sequential_open_distrib(self.request, 'test/test/test') + + self.assertEqual("{\"error\": \"Access Denied: User does not have access to this course\'s data\"}", response.content) + + @patch('class_dashboard.views.has_instructor_access_for_class') + def test_section_problem_grade_distribution_has_access(self, has_access): + """ + Test returns proper value when have proper access + """ + has_access.return_value = True + response = views.section_problem_grade_distrib(self.request, 'test/test/test', '1') + + self.assertEqual(simplejson.dumps(self.simple_data), response.content) + + @patch('class_dashboard.views.has_instructor_access_for_class') + def test_section_problem_grade_distribution_no_access(self, has_access): + """ + Test for no access + """ + has_access.return_value = False + response = views.section_problem_grade_distrib(self.request, 'test/test/test', '1') + + self.assertEqual("{\"error\": \"Access Denied: User does not have access to this course\'s data\"}", response.content) diff --git a/lms/djangoapps/class_dashboard/views.py b/lms/djangoapps/class_dashboard/views.py new file mode 100644 index 0000000000..0b8de65855 --- /dev/null +++ b/lms/djangoapps/class_dashboard/views.py @@ -0,0 +1,104 @@ +""" +Handles requests for data, returning a json +""" + +import logging + +from django.utils import simplejson +from django.http import HttpResponse + +from courseware.courses import get_course_with_access +from courseware.access import has_access +from class_dashboard import dashboard_data + +log = logging.getLogger(__name__) + + +def has_instructor_access_for_class(user, course_id): + """ + Returns true if the `user` is an instructor for the course. + """ + + course = get_course_with_access(user, course_id, 'staff', depth=None) + return has_access(user, course, 'staff') + + +def all_sequential_open_distrib(request, course_id): + """ + Creates a json with the open distribution for all the subsections in the course. + + `request` django request + + `course_id` the course ID for the course interested in + + Returns the format in dashboard_data.get_d3_sequential_open_distrib + """ + + json = {} + + # Only instructor for this particular course can request this information + if has_instructor_access_for_class(request.user, course_id): + try: + json = dashboard_data.get_d3_sequential_open_distrib(course_id) + except Exception as ex: # pylint: disable=broad-except + log.error('Generating metrics failed with exception: %s', ex) + json = {'error': "error"} + else: + json = {'error': "Access Denied: User does not have access to this course's data"} + + return HttpResponse(simplejson.dumps(json), mimetype="application/json") + + +def all_problem_grade_distribution(request, course_id): + """ + Creates a json with the grade distribution for all the problems in the course. + + `Request` django request + + `course_id` the course ID for the course interested in + + Returns the format in dashboard_data.get_d3_problem_grade_distrib + """ + json = {} + + # Only instructor for this particular course can request this information + if has_instructor_access_for_class(request.user, course_id): + try: + json = dashboard_data.get_d3_problem_grade_distrib(course_id) + except Exception as ex: # pylint: disable=broad-except + log.error('Generating metrics failed with exception: %s', ex) + json = {'error': "error"} + else: + json = {'error': "Access Denied: User does not have access to this course's data"} + + return HttpResponse(simplejson.dumps(json), mimetype="application/json") + + +def section_problem_grade_distrib(request, course_id, section): + """ + Creates a json with the grade distribution for the problems in the specified section. + + `request` django request + + `course_id` the course ID for the course interested in + + `section` The zero-based index of the section for the course + + Returns the format in dashboard_data.get_d3_section_grade_distrib + + If this is requested multiple times quickly for the same course, it is better to call all_problem_grade_distribution + and pick out the sections of interest. + """ + json = {} + + # Only instructor for this particular course can request this information + if has_instructor_access_for_class(request.user, course_id): + try: + json = dashboard_data.get_d3_section_grade_distrib(course_id, section) + except Exception as ex: # pylint: disable=broad-except + log.error('Generating metrics failed with exception: %s', ex) + json = {'error': "error"} + else: + json = {'error': "Access Denied: User does not have access to this course's data"} + + return HttpResponse(simplejson.dumps(json), mimetype="application/json") diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index a6caea63c7..675be2799a 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -23,7 +23,7 @@ from django_comment_client.utils import has_forum_access from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR from student.models import CourseEnrollment from bulk_email.models import CourseAuthorization - +from class_dashboard.dashboard_data import get_section_display_name, get_array_section_has_problem from .tools import get_units_with_due_date, title_or_url @@ -31,7 +31,7 @@ from .tools import get_units_with_due_date, title_or_url @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.""" + """ Display the instructor dashboard for a course. """ course = get_course_by_id(course_id, depth=None) is_studio_course = (modulestore().get_modulestore_type(course_id) != XML_MODULESTORE_TYPE) @@ -64,6 +64,10 @@ def instructor_dashboard_2(request, course_id): is_studio_course and CourseAuthorization.instructor_email_enabled(course_id): sections.append(_section_send_email(course_id, access, course)) + # Gate access to Metrics tab by featue flag and staff authorization + if settings.FEATURES['CLASS_DASHBOARD'] and access['staff']: + sections.append(_section_metrics(course_id, access)) + studio_url = None if is_studio_course: studio_url = get_cms_course_link(course) @@ -228,3 +232,15 @@ def _section_analytics(course_id, access): 'proxy_legacy_analytics_url': reverse('proxy_legacy_analytics', kwargs={'course_id': course_id}), } return section_data + + +def _section_metrics(course_id, access): + """Provide data for the corresponding dashboard section """ + section_data = { + 'section_key': 'metrics', + 'section_display_name': ('Metrics'), + 'access': access, + 'sub_section_display_name': get_section_display_name(course_id), + 'section_has_problem': get_array_section_has_problem(course_id) + } + return section_data diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index dccf1da79b..2b6daf349b 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -53,6 +53,7 @@ from instructor_task.api import ( ) from instructor_task.views import get_task_completion_info from edxmako.shortcuts import render_to_response, render_to_string +from class_dashboard import dashboard_data from psychometrics import psychoanalyze from student.models import CourseEnrollment, CourseEnrollmentAllowed, unique_id_for_user from student.views import course_from_id @@ -817,6 +818,14 @@ def instructor_dashboard(request, course_id): for analytic_name in DASHBOARD_ANALYTICS: analytics_results[analytic_name] = get_analytics_result(analytic_name) + #---------------------------------------- + # Metrics + + metrics_results = {} + if settings.FEATURES.get('CLASS_DASHBOARD') and idash_mode == 'Metrics': + metrics_results['section_display_name'] = dashboard_data.get_section_display_name(course_id) + metrics_results['section_has_problem'] = dashboard_data.get_array_section_has_problem(course_id) + #---------------------------------------- # offline grades? @@ -900,7 +909,8 @@ def instructor_dashboard(request, course_id): 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), 'analytics_results': analytics_results, - 'disable_buttons': disable_buttons + 'disable_buttons': disable_buttons, + 'metrics_results': metrics_results, } if settings.FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'): diff --git a/lms/envs/common.py b/lms/envs/common.py index fb2d5582a8..fd8172d4fe 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1246,6 +1246,11 @@ VERIFY_STUDENT = { "DAYS_GOOD_FOR": 365, # How many days is a verficiation good for? } +### This enables the Metrics tab for the Instructor dashboard ########### +FEATURES['CLASS_DASHBOARD'] = False +if FEATURES.get('CLASS_DASHBOARD'): + INSTALLED_APPS += ('class_dashboard',) + ######################## CAS authentication ########################### if FEATURES.get('AUTH_USE_CAS'): diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 40013ee42e..add0d48c12 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -279,10 +279,12 @@ CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_P ########################## USER API ######################## EDX_API_KEY = None - ####################### Shoppingcart ########################### FEATURES['ENABLE_SHOPPING_CART'] = True +### This enables the Metrics tab for the Instructor dashboard ########### +FEATURES['CLASS_DASHBOARD'] = True + ##################################################################### # Lastly, see if the developer has any local overrides. try: diff --git a/lms/envs/test.py b/lms/envs/test.py index fafa1244ee..f72609050b 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -269,6 +269,9 @@ PASSWORD_HASHERS = ( # 'django.contrib.auth.hashers.CryptPasswordHasher', ) +### This enables the Metrics tab for the Instructor dashboard ########### +FEATURES['CLASS_DASHBOARD'] = True + ################### Make tests quieter # OpenID spews messages like this to stderr, we don't need to see them: diff --git a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee index 9d25ce670c..4459e407df 100644 --- a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee @@ -170,6 +170,9 @@ setup_instructor_dashboard_sections = (idash_content) -> , constructor: window.InstructorDashboard.sections.Analytics $element: idash_content.find ".#{CSS_IDASH_SECTION}#analytics" + , + constructor: window.InstructorDashboard.sections.Metrics + $element: idash_content.find ".#{CSS_IDASH_SECTION}#metrics" ] sections_to_initialize.map ({constructor, $element}) -> diff --git a/lms/static/coffee/src/instructor_dashboard/metrics.coffee b/lms/static/coffee/src/instructor_dashboard/metrics.coffee new file mode 100644 index 0000000000..ec28e48670 --- /dev/null +++ b/lms/static/coffee/src/instructor_dashboard/metrics.coffee @@ -0,0 +1,25 @@ +# METRICS 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 + +#Metrics Section +class Metrics + constructor: (@$section) -> + @$section.data 'wrapper', @ + + + # 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, + Metrics: Metrics diff --git a/lms/static/sass/course/instructor/_instructor.scss b/lms/static/sass/course/instructor/_instructor.scss index fe24fc6e9a..059db7a3ee 100644 --- a/lms/static/sass/course/instructor/_instructor.scss +++ b/lms/static/sass/course/instructor/_instructor.scss @@ -121,5 +121,55 @@ } } } + + //Metrics tab + + .metrics-container { + position: relative; + width: 100%; + float: left; + clear: both; + margin-top: 25px; + } + .metrics-left { + position: relative; + width: 30%; + height: 640px; + float: left; + margin-right: 2.5%; + } + .metrics-right { + position: relative; + width: 65%; + height: 295px; + float: left; + margin-left: 2.5%; + margin-bottom: 25px; + } + .metrics-tooltip { + width: 250px; + background-color: lightgray; + padding: 3px; + } + .stacked-bar-graph-legend { + fill: white; + } + + p.loading { + padding-top: 100px; + text-align: center; + } + + p.nothing { + padding-top: 25px; + } + + h3.attention { + padding: 10px; + border: 1px solid #999; + border-radius: 5px; + margin-top: 25px; + } + } diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 03edd97d03..7912d854b1 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -458,6 +458,69 @@ section.instructor-dashboard-content-2 { } +.instructor-dashboard-wrapper-2 section.idash-section#metrics { + + .metrics-container { + position: relative; + width: 100%; + float: left; + clear: both; + margin-top: 25px; + } + .metrics-left { + position: relative; + width: 30%; + height: 640px; + float: left; + margin-right: 2.5%; + } + .metrics-left svg { + width: 100%; + } + .metrics-right { + position: relative; + width: 65%; + height: 295px; + float: left; + margin-left: 2.5%; + margin-bottom: 25px; + } + .metrics-right svg { + width: 100%; + } + + .metrics-tooltip { + width: 250px; + background-color: lightgray; + padding: 3px; + } + + .stacked-bar-graph-legend { + fill: white; + } + + p.loading { + padding-top: 100px; + text-align: center; + } + + p.nothing { + padding-top: 25px; + } + + h3.attention { + padding: 10px; + border: 1px solid #999; + border-radius: 5px; + margin-top: 25px; + } + + input#graph_reload { + display: none; + } +} + + .profile-distribution-widget { margin-bottom: $baseline * 2; diff --git a/lms/templates/class_dashboard/all_section_metrics.js b/lms/templates/class_dashboard/all_section_metrics.js new file mode 100644 index 0000000000..fc417255c7 --- /dev/null +++ b/lms/templates/class_dashboard/all_section_metrics.js @@ -0,0 +1,88 @@ +<%page args="id_opened_prefix, id_grade_prefix, id_attempt_prefix, id_tooltip_prefix, course_id, **kwargs"/> +<%! + import json + from django.core.urlresolvers import reverse +%> + +$(function () { + + d3.json("${reverse('all_sequential_open_distrib', kwargs=dict(course_id=course_id))}", function(error, json) { + var section, paramOpened, barGraphOpened, error; + var i, curr_id; + var errorMessage = gettext('Unable to retrieve data, please try again later.'); + + error = json.error; + if (error) { + $('.metrics-left .loading').text(errorMessage); + return + } + + i = 0; + for (section in json) { + curr_id = "#${id_opened_prefix}"+i; + paramOpened = { + data: json[section].data, + width: $(curr_id).width(), + height: $(curr_id).height()-25, // Account for header + tag: "opened"+i, + bVerticalXAxisLabel : true, + bLegend : false, + margin: {left:0}, + }; + + barGraphOpened = edx_d3CreateStackedBarGraph(paramOpened, d3.select(curr_id).append("svg"), + d3.select("#${id_tooltip_prefix}"+i)); + barGraphOpened.scale.stackColor.range(["#555555","#555555"]); + + if (paramOpened.data.length > 0) { + barGraphOpened.drawGraph(); + + $('svg').siblings('.loading').remove(); + } else { + $('svg').siblings('.loading').text(errorMessage); + } + + i+=1; + } + }); + + d3.json("${reverse('all_problem_grade_distribution', kwargs=dict(course_id=course_id))}", function(error, json) { + var section, paramGrade, barGraphGrade, error; + var i, curr_id; + var errorMessage = gettext('Unable to retrieve data, please try again later.'); + + error = json.error; + if (error) { + $('.metrics-right .loading').text(errorMessage); + return + } + + i = 0; + for (section in json) { + curr_id = "#${id_grade_prefix}"+i; + paramGrade = { + data: json[section].data, + width: $(curr_id).width(), + height: $(curr_id).height()-25, // Account for header + tag: "grade"+i, + bVerticalXAxisLabel : true, + }; + + barGraphGrade = edx_d3CreateStackedBarGraph(paramGrade, d3.select(curr_id).append("svg"), + d3.select("#${id_tooltip_prefix}"+i)); + barGraphGrade.scale.stackColor.domain([0,50,100]).range(["#e13f29","#cccccc","#17a74d"]); + barGraphGrade.legend.width += 2; + + if ( paramGrade.data.length > 0 ) { + barGraphGrade.drawGraph(); + + $('svg').siblings('.loading').remove(); + } else { + $('svg').siblings('.loading').text(errorMessage); + } + + i+=1; + } + }); + +}); \ No newline at end of file diff --git a/lms/templates/class_dashboard/d3_stacked_bar_graph.js b/lms/templates/class_dashboard/d3_stacked_bar_graph.js new file mode 100644 index 0000000000..97079113fa --- /dev/null +++ b/lms/templates/class_dashboard/d3_stacked_bar_graph.js @@ -0,0 +1,428 @@ +/* +There are three parameters: +(1) Parameter is of type object. Inside can include (* marks required): + data* - Array of objects with key, value pairs that represent a single stack of bars: + xValue - Corresponding value for the x-axis + stackData - Array of objects with key, value pairs that represent a bar: + color - Defines what "color" the bar will map to + value - Maps to the height of the bar, along the y-axis + tooltip - (Optional) Text to display on mouse hover + + height - Height of the SVG the graph will be displayed in (default: 500) + + width - Width of the SVG the graph will be displayed in (default: 500) + + margin - Object with key, value pairs for the graph's margins within the SVG (default for all: 10) + top - Top margin + bottom - Bottom margin + right - Right margin + left - Left margin + + yRange - Array of two values, representing the min and max respectively. (default: [0, ]) + + xRange - Array of either the min and max or ordered ordinals (default: calculated min and max or ordered ordinals given in data) + + colorRange - Array of either the min and max or ordered ordinals (default: calculated min and max or ordered ordinals given in data) + + bVerticalXAxisLabel - Boolean whether to make the labels in the x-axis veritcal (default: false) + + bLegend - Boolean if false does not create the graph with a legend (default: true) + +(2) Parameter is a d3 pointer to the SVG the graph will draw itself in. + +(3) Parameter is a d3 pointer to a div that will be used for the graph's tooltip. + +****Does not actually draw graph.**** Returns an object that includes a function + drawGraph, for when ready to draw graph. Reason for this is, because of all + the defaults, some changes may be needed before drawing the graph + +returns an object with the following: + state - All information that can be put in parameters and adding: + margin.axisX - margin to accomodate the x-axis + margin.axisY - margin to acommodate the y-axis + + drawGraph - function to call when ready to draw graph + + scale - Object containing three d3 scales + x - d3 scale for the x-axis + y - d3 scale for the y-axis + stackColor - d3 scale for the stack color + + axis - Object containg the graph's two d3 axis + x - d3 axis for the x-axis + y - d3 axis for the y-axis + + svg - d3 pointer to the svg holding the graph + + svgGroup - object holding the svg groups + main - svg group holding all other groups + xAxis - svg group holding the x-axis + yAxis - svg group holding the x-axis + bars - svg groups holding the bars + + yAxisLabel - d3 pointer to the text component that holds the y axis label + + divTooltip - d3 pointer to the div that is used as the tooltip for the graph + + rects - d3 collection of the rects used in the bars + + legend - object containing information for the legend + height - height of the legend + width - width of the legend (if change, need to update state.margin.axisY also) + range - array of values that appears in the legend + barHeight - height of a bar in the legend, based on height and length of range +*/ + +edx_d3CreateStackedBarGraph = function(parameters, svg, divTooltip) { + var graph = { + svg : svg, + state : { + data : undefined, + height : 500, + width : 500, + margin: {top: 10, bottom: 10, right: 10, left: 10}, + yRange: [0], + xRange : undefined, + colorRange : undefined, + tag : "", + bVerticalXAxisLabel : false, + bLegend : true, + }, + divTooltip : divTooltip, + }; + + var state = graph.state; + + // Handle parameters + state.data = parameters.data; + + if (parameters.margin != undefined) { + for (var key in state.margin) { + if ((state.margin.hasOwnProperty(key) && + (parameters.margin[key] != undefined))) { + state.margin[key] = parameters.margin[key]; + } + } + } + + for (var key in state) { + if ((key != "data") && (key != "margin")) { + if (state.hasOwnProperty(key) && (parameters[key] != undefined)) { + state[key] = parameters[key]; + } + } + } + + if (state.tag != "") + state.tag = state.tag+"-"; + + if ((state.xRange == undefined) || (state.yRange.length < 2 || + state.colorRange == undefined)) { + var aryXRange = []; + var bXIsOrdinal = false; + var maxYRange = 0; + var aryColorRange = []; + var bColorIsOrdinal = false; + + for (var stackKey in state.data) { + var stack = state.data[stackKey]; + aryXRange.push(stack.xValue); + if (isNaN(stack.xValue)) + bXIsOrdinal = true; + + var valueTotal = 0; + for (var barKey in stack.stackData) { + var bar = stack.stackData[barKey]; + valueTotal += bar.value; + + if (isNaN(bar.color)) + bColorIsOrdinal = true; + + if (aryColorRange.indexOf(bar.color) < 0) + aryColorRange.push(bar.color); + } + if (maxYRange < valueTotal) + maxYRange = valueTotal; + } + + if (state.xRange == undefined){ + if (bXIsOrdinal) + state.xRange = aryXRange; + else + state.xRange = [ + Math.min.apply(null,aryXRange), + Math.max.apply(null,aryXRange) + ]; + } + + if (state.yRange.length < 2) + state.yRange[1] = maxYRange; + + if (state.colorRange == undefined){ + if (bColorIsOrdinal) + state.colorRange = aryColorRange; + else + state.colorRange = [ + Math.min.apply(null,aryColorRange), + Math.max.apply(null,aryColorRange) + ]; + } + } + + // Find needed spacing for axes + var tmpEl = graph.svg.append("text").text(state.yRange[1]+"1234") + .attr("id",state.tag+"stacked-bar-graph-long-str"); + state.margin.axisY = document.getElementById(state.tag+"stacked-bar-graph-long-str") + .getComputedTextLength()+state.margin.left; + + var longestXAxisStr = ""; + if (isNaN(state.xRange[0])) { + for (var i in state.xRange) { + if (longestXAxisStr.length < state.xRange[i].length) + longestXAxisStr = state.xRange[i]+"1234"; + } + } else { + longestXAxisStr = state.xRange[1]+"1234"; + } + + tmpEl.text(longestXAxisStr); + if (state.bVerticalXAxisLabel) { + state.margin.axisX = document.getElementById(state.tag+"stacked-bar-graph-long-str") + .getComputedTextLength()+state.margin.bottom; + } else { + state.margin.axisX = document.getElementById(state.tag+"stacked-bar-graph-long-str") + .clientHeight+state.margin.bottom; + } + + tmpEl.remove(); + + // Add y0 and y1 of the y-axis based on the count and order of the colorRange. + // First, case if color is a number range + if ((state.colorRange.length == 2) && !(isNaN(state.colorRange[0])) && + !(isNaN(state.colorRange[1]))) { + for (var stackKey in state.data) { + var stack = state.data[stackKey]; + stack.stackData.sort(function(a,b) { return a.color - b.color; }); + + var currTotal = 0; + for (var barKey in stack.stackData) { + var bar = stack.stackData[barKey]; + bar.y0 = currTotal; + currTotal += bar.value; + bar.y1 = currTotal; + } + } + } else { + for (var stackKey in state.data) { + var stack = state.data[stackKey]; + + var tmpStackData = []; + for (var barKey in stack.stackData) { + var bar = stack.stackData[barKey]; + tmpStackData[state.colorRange.indexOf(bar.color)] = bar; + } + stack.stackData = tmpStackData; + + var currTotal = 0; + for (var barKey in stack.stackData) { + var bar = stack.stackData[barKey]; + bar.y0 = currTotal; + currTotal += bar.value; + bar.y1 = currTotal; + } + } + } + + // Add information to create legend + if (state.bLegend) { + graph.legend = { + height : (state.height-state.margin.top-state.margin.axisX), + width : 30, + range : state.colorRange, + }; + if ((state.colorRange.length == 2) && !(isNaN(state.colorRange[0])) && + !(isNaN(state.colorRange[1]))) { + graph.legend.range = []; + + var i = 0; + var min = state.colorRange[0]; + var max = state.colorRange[1]; + while (i <= 10) { + graph.legend.range[i] = min+((max-min)/10)*i; + i += 1; + } + } + graph.legend.barHeight = graph.legend.height/graph.legend.range.length; + + // Shifting the axis over to make room + graph.state.margin.axisY += graph.legend.width; + } + + // Make the scales + graph.scale = { + x: d3.scale.ordinal() + .domain(graph.state.xRange) + .rangeRoundBands([ + (graph.state.margin.axisY), + (graph.state.width-graph.state.margin.right)], + .3), + + y: d3.scale.linear() + .domain(graph.state.yRange) // yRange is the range of the y-axis values + .range([ + (graph.state.height-graph.state.margin.axisX), + graph.state.margin.top + ]), + + stackColor: d3.scale.ordinal() + .domain(graph.state.colorRange) + .range(["#ffeeee","#ffebeb","#ffd8d8","#ffc4c4","#ffb1b1","#ff9d9d","#ff8989","#ff7676","#ff6262","#ff4e4e","#ff3b3b"]) + }; + + if ((state.colorRange.length == 2) && !(isNaN(state.colorRange[0])) && + !(isNaN(state.colorRange[1]))) { + graph.scale.stackColor = d3.scale.linear() + .domain(state.colorRange) + .range(["#e13f29","#17a74d"]); + } + + // Setup axes + graph.axis = { + x: d3.svg.axis() + .scale(graph.scale.x), + y: d3.svg.axis() + .scale(graph.scale.y), + } + + graph.axis.x.orient("bottom"); + graph.axis.y.orient("left"); + + // Draw graph function, to call when ready. + graph.drawGraph = function() { + var graph = this; + + // Steup SVG + graph.svg.attr("id", graph.state.tag+"stacked-bar-graph") + .attr("class", "stacked-bar-graph") + .attr("width", graph.state.width) + .attr("height", graph.state.height); + graph.svgGroup = {}; + + graph.svgGroup.main = graph.svg.append("g"); + + // Draw Bars + graph.svgGroup.bars = graph.svgGroup.main.selectAll(".stacked-bar") + .data(graph.state.data) + .enter().append("g") + .attr("class", "stacked-bar") + .attr("transform", function(d) { + return "translate("+graph.scale.x(d.xValue)+",0)"; + }); + + graph.rects = graph.svgGroup.bars.selectAll("rect") + .data(function(d) { return d.stackData; }) + .enter().append("rect") + .attr("width", function(d) { + return graph.scale.x.rangeBand() + }) + .attr("y", function(d) { return graph.scale.y(d.y1); }) + .attr("height", function(d) { + return graph.scale.y(d.y0) - graph.scale.y(d.y1); + }) + .style("fill", function(d) { return graph.scale.stackColor(d.color); }) + .style("stroke", "white") + .style("stroke-width", "0.5px"); + + // Setup tooltip + if (graph.divTooltip != undefined) { + graph.divTooltip + .style("position", "absolute") + .style("z-index", "10") + .style("visibility", "hidden"); + } + + graph.rects + .on("mouseover", function(d) { + var pos = d3.mouse(graph.divTooltip.node().parentNode); + var left = pos[0]+10; + var top = pos[1]-10; + var width = $('#'+graph.divTooltip.attr("id")).width(); + + graph.divTooltip.style("visibility", "visible") + .text(d.tooltip); + + if ((left+width+30) > $("#"+graph.divTooltip.node().parentNode.id).width()) + left -= (width+30); + + graph.divTooltip.style("top", top+"px") + .style("left", left+"px"); + }) + .on("mouseout", function(d){ + graph.divTooltip.style("visibility", "hidden") + }); + + // Add legend + if (graph.state.bLegend) { + graph.svgGroup.legendG = graph.svgGroup.main.append("g") + .attr("class","stacked-bar-graph-legend") + .attr("transform","translate("+graph.state.margin.left+","+ + graph.state.margin.top+")"); + graph.svgGroup.legendGs = graph.svgGroup.legendG.selectAll(".stacked-bar-graph-legend-g") + .data(graph.legend.range) + .enter().append("g") + .attr("class","stacked-bar-graph-legend-g") + .attr("id",function(d,i) { return graph.state.tag+"legend-"+i; }) + .attr("transform", function(d,i) { + return "translate(0,"+ + (graph.state.height-graph.state.margin.axisX-((i+1)*(graph.legend.barHeight))) + ")"; + }); + + graph.svgGroup.legendGs.append("rect") + .attr("class","stacked-bar-graph-legend-rect") + .attr("height", graph.legend.barHeight) + .attr("width", graph.legend.width) + .style("fill", graph.scale.stackColor) + .style("stroke", "white"); + + graph.svgGroup.legendGs.append("text") + .attr("class","axis-label") + .attr("transform", function(d) { + var str = "translate("+(graph.legend.width/2)+","+ + (graph.legend.barHeight/2)+")"; + return str; + }) + .attr("dy", ".35em") + .attr("dx", "-1px") + .style("text-anchor", "middle") + .text(function(d,i) { return d; }); + } + + + // Draw Axes + graph.svgGroup.xAxis = graph.svgGroup.main.append("g") + .attr("class","stacked-bar-graph-axis") + .attr("id",graph.state.tag+"x-axis"); + + var tmpS = "translate(0,"+(graph.state.height-graph.state.margin.axisX)+")"; + if (graph.state.bVerticalXAxisLabel) { + graph.axis.x.orient("left"); + tmpS = "rotate(270), translate(-"+(graph.state.height-graph.state.margin.axisX)+",0)"; + } + graph.svgGroup.xAxis.attr("transform", tmpS) + .call(graph.axis.x); + + graph.svgGroup.yAxis = graph.svgGroup.main.append("g") + .attr("class","stacked-bar-graph-axis") + .attr("id",graph.state.tag+"y-axis") + .attr("transform","translate("+ + (graph.state.margin.axisY)+",0)") + .call(graph.axis.y); + graph.yAxisLabel = graph.svgGroup.yAxis.append("text") + .attr("dy","1em") + .attr("transform","rotate(-90)") + .style("text-anchor","end") + .text(gettext("Number of Students")); + }; + + return graph; +}; \ No newline at end of file diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 4bb00356b1..38ae4eba4e 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -144,6 +144,9 @@ function goto( mode) %if settings.FEATURES.get('ENABLE_INSTRUCTOR_ANALYTICS'): | ${_("Analytics")} %endif + %if settings.FEATURES.get('CLASS_DASHBOARD'): + | ${_("Metrics")} + %endif ] @@ -669,6 +672,46 @@ function goto( mode) %endif %endif +%if modeflag.get('Metrics'): + %if not any (metrics_results.values()): +

${_("There is no data available to display at this time.")}

+ %else: + <%namespace name="d3_stacked_bar_graph" file="/class_dashboard/d3_stacked_bar_graph.js"/> + <%namespace name="all_section_metrics" file="/class_dashboard/all_section_metrics.js"/> + + + +
+ +

${_("Loading the latest graphs for you; depending on your class size, this may take a few minutes.")}

+ + %for i in range(0,len(metrics_results['section_display_name'])): +
+

${_("Section:")} ${metrics_results['section_display_name'][i]}

+
+
+

${_("Count of Students that Opened a Subsection")}

+

${_("Loading...")}

+
+
+

${_("Grade Distribution per Problem")}

+ %if not metrics_results['section_has_problem'][i]: +

${_("There are no problems in this section.")}

+ %else: +

${_("Loading...")}

+ %endif +
+
+ %endfor + + + %endif +%endif + %if modeflag.get('Analytics In Progress'): ##This is not as helpful as it could be -- let's give full point distribution diff --git a/lms/templates/instructor/instructor_dashboard_2/metrics.html b/lms/templates/instructor/instructor_dashboard_2/metrics.html new file mode 100644 index 0000000000..584869c1da --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/metrics.html @@ -0,0 +1,80 @@ +<%! from django.utils.translation import ugettext as _ %> +<%page args="section_data"/> + + + + %if not any (section_data.values()): +

${_("There is no data available to display at this time.")}

+ %else: + <%namespace name="d3_stacked_bar_graph" file="/class_dashboard/d3_stacked_bar_graph.js"/> + <%namespace name="all_section_metrics" file="/class_dashboard/all_section_metrics.js"/> + +

${_("Loading the latest graphs for you; depending on your class size, this may take a few minutes.")}

+ + + %for i in range(0,len(section_data['sub_section_display_name'])): +
+

${_("Section:")} ${section_data['sub_section_display_name'][i]}

+
+
+

${_("Count of Students Opened a Subsection")}

+
+
+

${_("Grade Distribution per Problem")}

+
+
+ %endfor + + + %endif diff --git a/lms/urls.py b/lms/urls.py index bcda5d4c51..4a45c1df97 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -375,6 +375,19 @@ if settings.COURSEWARE_ENABLED and settings.FEATURES.get('ENABLE_INSTRUCTOR_BETA include('instructor.views.api_urls')) ) +if settings.FEATURES.get('CLASS_DASHBOARD'): + urlpatterns += ( + # Json request data for metrics for entire course + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/all_sequential_open_distrib$', + 'class_dashboard.views.all_sequential_open_distrib', name="all_sequential_open_distrib"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/all_problem_grade_distribution$', + 'class_dashboard.views.all_problem_grade_distribution', name="all_problem_grade_distribution"), + + # Json request data for metrics for particular section + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/problem_grade_distribution/(?P
\d+)$', + 'class_dashboard.views.section_problem_grade_distrib', name="section_problem_grade_distrib"), + ) + if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): ## Jasmine and admin urlpatterns += (url(r'^admin/', include(admin.site.urls)),)