From 3881ffdc0d64d2eae360c4bd8f71805f82f44ccc Mon Sep 17 00:00:00 2001 From: Kristin Stephens Date: Fri, 2 Aug 2013 14:41:42 -0700 Subject: [PATCH] New tab (Metrics) in instructor dashboard Metrics tab shows student data: -Count of students opened a subsection -Grade distribution per problem for each section/subsection of the course. Implemented for both the old and beta dashboard Controlled by a feature flag 'CLASS_DASHBOARD' Data is aggregated across all students Aggregate data computed from courseware_studentmodule --- conf/locale/eo/LC_MESSAGES/django.mo | Bin 372026 -> 376030 bytes conf/locale/eo/LC_MESSAGES/django.po | 194 ++++++-- conf/locale/eo/LC_MESSAGES/djangojs.mo | Bin 42893 -> 43121 bytes conf/locale/eo/LC_MESSAGES/djangojs.po | 14 +- lms/djangoapps/class_dashboard/__init__.py | 3 + .../class_dashboard/dashboard_data.py | 401 ++++++++++++++++ .../test/test_dashboard_data.py | 180 ++++++++ .../class_dashboard/test/test_views.py | 83 ++++ lms/djangoapps/class_dashboard/views.py | 104 +++++ .../instructor/views/instructor_dashboard.py | 20 +- lms/djangoapps/instructor/views/legacy.py | 12 +- lms/envs/common.py | 5 + lms/envs/dev.py | 4 +- lms/envs/test.py | 3 + .../instructor_dashboard.coffee | 3 + .../src/instructor_dashboard/metrics.coffee | 25 + .../sass/course/instructor/_instructor.scss | 50 ++ .../sass/course/instructor/_instructor_2.scss | 63 +++ .../class_dashboard/all_section_metrics.js | 88 ++++ .../class_dashboard/d3_stacked_bar_graph.js | 428 ++++++++++++++++++ .../courseware/instructor_dashboard.html | 43 ++ .../instructor_dashboard_2/metrics.html | 80 ++++ lms/urls.py | 13 + 23 files changed, 1765 insertions(+), 51 deletions(-) create mode 100644 lms/djangoapps/class_dashboard/__init__.py create mode 100644 lms/djangoapps/class_dashboard/dashboard_data.py create mode 100644 lms/djangoapps/class_dashboard/test/test_dashboard_data.py create mode 100644 lms/djangoapps/class_dashboard/test/test_views.py create mode 100644 lms/djangoapps/class_dashboard/views.py create mode 100644 lms/static/coffee/src/instructor_dashboard/metrics.coffee create mode 100644 lms/templates/class_dashboard/all_section_metrics.js create mode 100644 lms/templates/class_dashboard/d3_stacked_bar_graph.js create mode 100644 lms/templates/instructor/instructor_dashboard_2/metrics.html diff --git a/conf/locale/eo/LC_MESSAGES/django.mo b/conf/locale/eo/LC_MESSAGES/django.mo index 546fca37daa2a6a84b56c38252f7a4e0f703fa27..42aadaac75be392fe931942640201f9d7ff156c5 100644 GIT binary patch delta 53614 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

^O!eqkbEIGWI^UlH%u6^B@jm$>Q)Qlj%rD zt^bmx<<*0s0KD0%F2jmrX94=nfWi3v@pq6PkH1qJh2#_DOCk7+Tyk_E_;2WTbRT#T zU+z1Vz==5BdfyP=Bd!l_oc#Z9NdOHy*f=M{egmjXp#Y#k_{){xFoZS0w7_0x*etMP zEduNp4)aE_Ylt`FR{&oboCM4+SsFvY#f6{< zemZLJANoY8m>-YyHYDoZUeqZYWVJe`}h3QA&>=ADCr?L`GM4_g*k1HUp*6KX9{JV8k;-d=xhUdVgg^03;T1%d zB+(5UkZ+8Bq>z{)l{CKr;@X@<23@Q(iKgplilz`#^c2X-u97X;DDsqF|W(F-rlcJejX=5#mJ%%Sc0~^v~ zBfdrbD?As#Y@zQ3{$+S7U~ORhIZhw&>5dL71 zu7C;y-bAi7fEzmK4)FVzky1E~B=ad?@)*x_CRaCFt!yJ~>Mq zg{!cgAX*9Pk*G*>Bz74mO^xk|eGSIEQ~WvL%<~_m7$8Kz1JEt_Q^?h4Xe2s-I9j>d&{U1)TI8ZwE`WMt^ag$oYG3h1 zuHYBZQ>VxOLH!||-55TX-X36b$>aZRkR_q%BjAF%VLcl5;`b!?f!a0vttKrG9PuCF z2Vr+X`WZhJ{x8TyD(g^J#7)G&VhsER&l8d*!BvNU9{G&&{BKh3WEV*;B`K1U!g?C& z&=5d=1LSKUJ%tTW%yt*ln8*TIz>3_Vo|bw!awT<%A=q-{o#Z~kC6b(ZYv3*<7`^|Q zU&B6Bn|1>pNz++CfjUqcFX168Q!_x7AiEE#C9A-Pkh7#P@m>x3gR`N>=yNbUsh+sA){n@7J86*@|V$h)7Xl{rCI(7*ih8Vdi}4LZrTp}ksG^ji?tV zE>GN#A%XbsumjL8EVU7?5^!3wni>A+b$R};H!6|_TMfGea1{vF5>KMJ9CoY@-Dx7Z z*E7&V7d#3k1DI?Kh)?d6Vg=flS^?te42z}SO8S)JHTk?(DeNGVl;k0Xii{%H0pLI6 zUt=Qxc7o`H$BuNzPgHXjNMf_6cb*KjMf*FKM%XT7ah&U5^LM`JV5{rxjOb=NSfaVEx=&bc7QHqpZoli4GMV^gdxwR3W;?PMJ1;E%Sml^r?4 zJ>oeNhkFDTaD?>o*x`KJ%cG>X^W^}K5*}+74)(D5g#`r{E?O`wPjErU>L`zNiNbXDzEcijuRWaA{-0qcxP~29_s1kXtK@A z-*J9}SGcqP2Cs$*9XX@D0vvx0@d|WQiuUq%*4yW`WU6Ctq_@9wbfkBgmd@K7y@%PH VZ8v*I`8uyVy-#>Lvh4TX`ybCjNWK67 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 1d3cb49f1f0810b65fd03dd59f52ba223f295913..64a371790915079231238ed89b2ad73bf2f85c82 100644 GIT binary patch delta 6525 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 delta 6359 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\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)),)