diff --git a/common/lib/xmodule/xmodule/css/sequence/display.scss b/common/lib/xmodule/xmodule/css/sequence/display.scss index 7d611e5bad..b15d6c27a6 100644 --- a/common/lib/xmodule/xmodule/css/sequence/display.scss +++ b/common/lib/xmodule/xmodule/css/sequence/display.scss @@ -111,7 +111,7 @@ nav.sequence-nav { outline: 0; } - &:hover { + &:hover, &:focus { background-color: #fff; background-repeat: no-repeat; background-position: center 14px; @@ -235,7 +235,7 @@ nav.sequence-nav { } } - &:hover { + &:hover, &:focus { p { display: block; margin-top: 4px; diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee index 26246644ea..686c1963aa 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee @@ -103,6 +103,7 @@ class @Sequence sequence_links = @$('#seq_content a.seqnav') sequence_links.click @goto + @$("a.active").blur() goto: (event) => event.preventDefault() diff --git a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee index 2fc31da97a..8259004893 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee @@ -95,6 +95,7 @@ if Backbone? @timer = 0 @$el.html(@template()) + $(window).bind "load", @updateSidebar $(window).bind "scroll", @updateSidebar $(window).bind "resize", @updateSidebar @@ -143,6 +144,18 @@ if Backbone? options.group_id = @group_id + lastThread = @collection.last()?.get('id') + if lastThread + # Pagination; focus the first thread after what was previously the last thread + @once("threads:rendered", -> + $(".post-list li:has(a[data-id='#{lastThread}']) + li a").focus() + ) + else + # Totally refreshing the list (e.g. from clicking a sort button); focus the first thread + @once("threads:rendered", -> + $(".post-list a").first()?.focus() + ) + @collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy}) renderThread: (thread) => diff --git a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee index 46a13db83e..ef873d961c 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee @@ -42,8 +42,10 @@ if Backbone? renderVoted: => if window.user.voted(@model) @$("[data-role=discussion-vote]").addClass("is-cast") + @$("[data-role=discussion-vote] span.sr").html("votes (click to remove your vote)") else @$("[data-role=discussion-vote]").removeClass("is-cast") + @$("[data-role=discussion-vote] span.sr").html("votes (click to vote)") renderFlagged: => if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) @@ -70,7 +72,12 @@ if Backbone? @renderVoted() @renderFlagged() @renderPinned() - @$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"]) + @$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"] + '') + if window.user.voted(@model) + @$("[data-role=discussion-vote] .votes-count-number span.sr").html("votes (click to remove your vote)") + else + @$("[data-role=discussion-vote] .votes-count-number span.sr").html("votes (click to vote)") + convertMath: -> element = @$(".post-body") diff --git a/common/static/coffee/src/discussion/views/thread_response_show_view.coffee b/common/static/coffee/src/discussion/views/thread_response_show_view.coffee index cb67125b9f..43b2d3055e 100644 --- a/common/static/coffee/src/discussion/views/thread_response_show_view.coffee +++ b/common/static/coffee/src/discussion/views/thread_response_show_view.coffee @@ -23,6 +23,7 @@ if Backbone? @delegateEvents() if window.user.voted(@model) @$(".vote-btn").addClass("is-cast") + @$(".vote-btn span.sr").html("votes (click to remove your vote)") @renderAttrs() @renderFlagged() @$el.find(".posted-details").timeago() @@ -48,12 +49,14 @@ if Backbone? @$(".vote-btn").toggleClass("is-cast") if @$(".vote-btn").hasClass("is-cast") @vote() + @$(".vote-btn span.sr").html("votes (click to remove your vote)") else @unvote() + @$(".vote-btn span.sr").html("votes (click to vote)") vote: -> url = @model.urlFor("upvote") - @$(".votes-count-number").html(parseInt(@$(".votes-count-number").html()) + 1) + @$(".votes-count-number").html((parseInt(@$(".votes-count-number").html()) + 1) + '') DiscussionUtil.safeAjax $elem: @$(".discussion-vote") url: url @@ -64,7 +67,7 @@ if Backbone? unvote: -> url = @model.urlFor("unvote") - @$(".votes-count-number").html(parseInt(@$(".votes-count-number").html()) - 1) + @$(".votes-count-number").html((parseInt(@$(".votes-count-number").html()) - 1)+'') DiscussionUtil.safeAjax $elem: @$(".discussion-vote") url: url diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index c5b1b21b52..a32217ab30 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -512,7 +512,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa def test_get_student_progress_url(self): """ Test that progress_url is in the successful response. """ url = reverse('get_student_progress_url', kwargs={'course_id': self.course.id}) - url += "?student_email={}".format( + url += "?unique_student_identifier={}".format( quote(self.students[0].email.encode("utf-8")) ) print url @@ -522,6 +522,19 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa res_json = json.loads(response.content) self.assertIn('progress_url', res_json) + def test_get_student_progress_url_from_uname(self): + """ Test that progress_url is in the successful response. """ + url = reverse('get_student_progress_url', kwargs={'course_id': self.course.id}) + url += "?unique_student_identifier={}".format( + quote(self.students[0].username.encode("utf-8")) + ) + print url + response = self.client.get(url) + print response + self.assertEqual(response.status_code, 200) + res_json = json.loads(response.content) + self.assertIn('progress_url', res_json) + def test_get_student_progress_url_noparams(self): """ Test that the endpoint 404's without the required query params. """ url = reverse('get_student_progress_url', kwargs={'course_id': self.course.id}) @@ -579,7 +592,7 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase) url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id}) response = self.client.get(url, { 'problem_to_reset': self.problem_urlname, - 'student_email': self.student.email, + 'unique_student_identifier': self.student.email, }) print response.content self.assertEqual(response.status_code, 200) @@ -608,7 +621,7 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase) url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id}) response = self.client.get(url, { 'problem_to_reset': 'robot-not-a-real-module', - 'student_email': self.student.email, + 'unique_student_identifier': self.student.email, }) print response.content self.assertEqual(response.status_code, 400) @@ -618,7 +631,7 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase) url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id}) response = self.client.get(url, { 'problem_to_reset': self.problem_urlname, - 'student_email': self.student.email, + 'unique_student_identifier': self.student.email, 'delete_module': True, }) print response.content @@ -634,11 +647,11 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase) ) def test_reset_student_attempts_nonsense(self): - """ Test failure with both student_email and all_students. """ + """ Test failure with both unique_student_identifier and all_students. """ url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id}) response = self.client.get(url, { 'problem_to_reset': self.problem_urlname, - 'student_email': self.student.email, + 'unique_student_identifier': self.student.email, 'all_students': True, }) print response.content @@ -650,7 +663,19 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase) url = reverse('rescore_problem', kwargs={'course_id': self.course.id}) response = self.client.get(url, { 'problem_to_reset': self.problem_urlname, - 'student_email': self.student.email, + 'unique_student_identifier': self.student.email, + }) + print response.content + self.assertEqual(response.status_code, 200) + self.assertTrue(act.called) + + @patch.object(instructor_task.api, 'submit_rescore_problem_for_student') + def test_rescore_problem_single_from_uname(self, act): + """ Test rescoring of a single student. """ + url = reverse('rescore_problem', kwargs={'course_id': self.course.id}) + response = self.client.get(url, { + 'problem_to_reset': self.problem_urlname, + 'unique_student_identifier': self.student.username, }) print response.content self.assertEqual(response.status_code, 200) @@ -747,7 +772,7 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id}) response = self.client.get(url, { 'problem_urlname': self.problem_urlname, - 'student_email': self.student.email, + 'unique_student_identifier': self.student.email, }) print response.content self.assertEqual(response.status_code, 200) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 8a552feb66..e0b047604e 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -33,7 +33,7 @@ import instructor_task.api from instructor_task.api_helper import AlreadyRunningError import instructor.enrollment as enrollment from instructor.enrollment import enroll_email, unenroll_email -from instructor.views.tools import strip_if_string +from instructor.views.tools import strip_if_string, get_student_from_identifier import instructor.access as access import analytics.basic import analytics.distributions @@ -456,20 +456,19 @@ def get_distribution(request, course_id): @common_exceptions_400 @require_level('staff') @require_query_params( - student_email="email of student for whom to get progress url" + unique_student_identifier="email or username of student for whom to get progress url" ) def get_student_progress_url(request, course_id): """ Get the progress url of a student. Limited to staff access. - Takes query paremeter student_email and if the student exists + Takes query paremeter unique_student_identifier and if the student exists returns e.g. { 'progress_url': '/../...' } """ - student_email = strip_if_string(request.GET.get('student_email')) - user = User.objects.get(email=student_email) + user = get_student_from_identifier(request.GET.get('unique_student_identifier')) progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': user.id}) @@ -496,7 +495,7 @@ def reset_student_attempts(request, course_id): Takes some of the following query paremeters - problem_to_reset is a urlname of a problem - - student_email is an email + - unique_student_identifier is an email or username - all_students is a boolean requires instructor access mutually exclusive with delete_module @@ -510,14 +509,17 @@ def reset_student_attempts(request, course_id): ) problem_to_reset = strip_if_string(request.GET.get('problem_to_reset')) - student_email = strip_if_string(request.GET.get('student_email')) + student_identifier = request.GET.get('unique_student_identifier', None) + student = None + if student_identifier is not None: + student = get_student_from_identifier(student_identifier) all_students = request.GET.get('all_students', False) in ['true', 'True', True] delete_module = request.GET.get('delete_module', False) in ['true', 'True', True] # parameter combinations - if all_students and student_email: + if all_students and student: return HttpResponseBadRequest( - "all_students and student_email are mutually exclusive." + "all_students and unique_student_identifier are mutually exclusive." ) if all_students and delete_module: return HttpResponseBadRequest( @@ -534,15 +536,16 @@ def reset_student_attempts(request, course_id): response_payload = {} response_payload['problem_to_reset'] = problem_to_reset - if student_email: + if student: try: - student = User.objects.get(email=student_email) enrollment.reset_student_attempts(course_id, student, module_state_key, delete_module=delete_module) except StudentModule.DoesNotExist: return HttpResponseBadRequest("Module does not exist.") + response_payload['student'] = student_identifier elif all_students: instructor_task.api.submit_reset_problem_attempts_for_all_students(request, course_id, module_state_key) response_payload['task'] = 'created' + response_payload['student'] = 'All Students' else: return HttpResponseBadRequest() @@ -561,21 +564,25 @@ def rescore_problem(request, course_id): Takes either of the following query paremeters - problem_to_reset is a urlname of a problem - - student_email is an email + - unique_student_identifier is an email or username - all_students is a boolean - all_students and student_email cannot both be present. + all_students and unique_student_identifier cannot both be present. """ problem_to_reset = strip_if_string(request.GET.get('problem_to_reset')) - student_email = strip_if_string(request.GET.get('student_email', False)) + student_identifier = request.GET.get('unique_student_identifier', None) + student = None + if student_identifier is not None: + student = get_student_from_identifier(student_identifier) + all_students = request.GET.get('all_students') in ['true', 'True', True] - if not (problem_to_reset and (all_students or student_email)): + if not (problem_to_reset and (all_students or student)): return HttpResponseBadRequest("Missing query parameters.") - if all_students and student_email: + if all_students and student: return HttpResponseBadRequest( - "Cannot rescore with all_students and student_email." + "Cannot rescore with all_students and unique_student_identifier." ) module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset) @@ -583,9 +590,8 @@ def rescore_problem(request, course_id): response_payload = {} response_payload['problem_to_reset'] = problem_to_reset - if student_email: - response_payload['student_email'] = student_email - student = User.objects.get(email=student_email) + if student: + response_payload['student'] = student_identifier instructor_task.api.submit_rescore_problem_for_student(request, course_id, module_state_key, student) response_payload['task'] = 'created' elif all_students: @@ -608,21 +614,22 @@ def list_instructor_tasks(request, course_id): Takes optional query paremeters. - With no arguments, lists running tasks. - `problem_urlname` lists task history for problem - - `problem_urlname` and `student_email` lists task + - `problem_urlname` and `unique_student_identifier` lists task history for problem AND student (intersection) """ problem_urlname = strip_if_string(request.GET.get('problem_urlname', False)) - student_email = strip_if_string(request.GET.get('student_email', False)) + student = request.GET.get('unique_student_identifier', None) + if student is not None: + student = get_student_from_identifier(student) - if student_email and not problem_urlname: + if student and not problem_urlname: return HttpResponseBadRequest( - "student_email must accompany problem_urlname" + "unique_student_identifier must accompany problem_urlname" ) if problem_urlname: module_state_key = _msk_from_problem_urlname(course_id, problem_urlname) - if student_email: - student = User.objects.get(email=student_email) + if student: tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key, student) else: tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 9634005b87..e248d47a59 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -38,7 +38,7 @@ def instructor_dashboard_2(request, course_id): raise Http404() sections = [ - _section_course_info(course_id), + _section_course_info(course_id, access), _section_membership(course_id, access), _section_student_admin(course_id, access), _section_data_download(course_id), @@ -67,18 +67,21 @@ section_display_name will be used to generate link titles in the nav bar. """ # pylint: disable=W0105 -def _section_course_info(course_id): +def _section_course_info(course_id, access): """ Provide data for the corresponding dashboard section """ course = get_course_by_id(course_id, depth=None) - section_data = {} - section_data['section_key'] = 'course_info' - section_data['section_display_name'] = _('Course Info') - section_data['course_id'] = course_id - section_data['course_display_name'] = course.display_name - section_data['enrollment_count'] = CourseEnrollment.objects.filter(course_id=course_id, is_active=1).count() - section_data['has_started'] = course.has_started() - section_data['has_ended'] = course.has_ended() + section_data = { + 'section_key': 'course_info', + 'section_display_name': _('Course Info'), + 'course_id': course_id, + 'access': access, + 'course_display_name': course.display_name, + 'enrollment_count': CourseEnrollment.objects.filter(course_id=course_id).count(), + 'has_started': course.has_started(), + 'has_ended': course.has_ended(), + 'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': course_id}), + } try: advance = lambda memo, (letter, score): "{}: {}, ".format(letter, score) + memo diff --git a/lms/djangoapps/instructor/views/tools.py b/lms/djangoapps/instructor/views/tools.py index 11fc135976..cbf6b6468a 100644 --- a/lms/djangoapps/instructor/views/tools.py +++ b/lms/djangoapps/instructor/views/tools.py @@ -1,7 +1,25 @@ """ Tools for the instructor dashboard """ +from django.contrib.auth.models import User + def strip_if_string(value): if isinstance(value, basestring): return value.strip() return value + + +def get_student_from_identifier(unique_student_identifier): + """ + Gets a student object using either an email address or username. + + Returns the student object associated with `unique_student_identifier` + + Raises User.DoesNotExist if no user object can be found. + """ + unique_student_identifier = strip_if_string(unique_student_identifier) + if "@" in unique_student_identifier: + student = User.objects.get(email=unique_student_identifier) + else: + student = User.objects.get(username=unique_student_identifier) + return student diff --git a/lms/static/coffee/src/customwmd.coffee b/lms/static/coffee/src/customwmd.coffee index 838112059e..2dabc3ed15 100644 --- a/lms/static/coffee/src/customwmd.coffee +++ b/lms/static/coffee/src/customwmd.coffee @@ -131,9 +131,11 @@ $ -> initialText = $elem.html() $elem.empty() _append = appended_id || "" + wmdInputId = "wmd-input#{_append}" $wmdPanel = $("
").addClass("wmd-panel") .append($("
").attr("id", "wmd-button-bar#{_append}")) - .append($("